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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:51:28 -04:00
co-authored by Claude Opus 5.5
parent e68ccc5884
commit 07d2542479
27 changed files with 1638 additions and 51 deletions
+2
View File
@@ -32,6 +32,7 @@ from scribe.routes.trash import trash_bp
from scribe.routes.dashboard import dashboard_bp
from scribe.routes.systems import systems_bp
from scribe.routes.canonical_systems import canonical_systems_bp
from scribe.routes.platforms import platforms_bp
from scribe.routes.lessons import lessons_bp
from scribe.routes.snippets import snippets_bp
from scribe.routes.webhooks import webhooks_bp
@@ -101,6 +102,7 @@ def create_app() -> Quart:
app.register_blueprint(dashboard_bp)
app.register_blueprint(systems_bp)
app.register_blueprint(canonical_systems_bp)
app.register_blueprint(platforms_bp)
app.register_blueprint(snippets_bp)
app.register_blueprint(webhooks_bp)
+4
View File
@@ -160,6 +160,9 @@ _READ_ONLY_TOOLS = frozenset({
# retrieval_telemetry's reason, and needed by a read key so that a line
# naming a moment can be understood by whoever was shown it.
"list_moments",
# The platform catalog and a project's answers (milestone 463). A pure
# read; set_project_platforms is the write.
"list_platforms",
# The pass over the corpus and its queue (milestone 458 step 7): which
# rules are unjudged and which proposals wait. Reads of the caller's own
# rules, as list_rules is.
@@ -183,6 +186,7 @@ _WRITE_TOOLS = frozenset({
# projects, Systems, repos
"create_project", "update_project", "delete_project", "decide_project_inception",
"create_system", "update_system", "delete_system", "map_system_to_canonical",
"set_project_platforms",
"bind_repo", "unbind_repo",
# snippets, processes, the shape ledger
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
+2 -1
View File
@@ -6,7 +6,7 @@ from `mcp.server.build_mcp_server`.
"""
from scribe.mcp.tools import (
design_systems, lessons, milestones, notes, processes, projects, recent, repos,
moments, retrieval_review, retrieval_tuning,
moments, platforms, retrieval_review, retrieval_tuning,
wide_net,
rulebooks, search, shapes, snippets, systems, tags, tasks, trash,
)
@@ -24,6 +24,7 @@ def register_all(mcp) -> None:
projects.register(mcp)
milestones.register(mcp)
systems.register(mcp)
platforms.register(mcp)
design_systems.register(mcp)
tags.register(mcp)
recent.register(mcp)
+78
View File
@@ -0,0 +1,78 @@
"""Platform MCP tools — what a project is built on or ships as (milestone 463).
Thin wrappers over services/platforms.py. The catalog is global; a project's
platforms decide which family ideas reach it.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import platforms as platforms_svc
async def list_platforms(project_id: int = 0) -> dict:
"""The GLOBAL platform catalog — runtimes, delivery channels and
toolchains a project can be built on or ship as (Android app, container
image, Go, …) — and, with a project_id, that project's answer for each.
A project's platforms decide which family ideas reach it: an idea is for
some platforms, and every project that is one of them answers it. Pass
slugs from here to set_project_platforms and decide_project_inception.
Each platform's `markers` are the file patterns that let the coverage
refresh DETECT it in a bound repo. A project's `state` per platform is
`declared` (a person said so), `detected` (a marker said so) or `rejected`
(a person said no — kept so detection cannot add it back). Only declared
and detected are membership.
Args:
project_id: also return this project's answers. 0 = catalog only.
"""
catalog = await platforms_svc.list_platforms()
out: dict = {"platforms": [p.to_dict() for p in catalog]}
if project_id:
rows = await platforms_svc.project_platforms(current_user_id(), project_id)
if rows is None:
raise ValueError(f"project {project_id} not found")
out["project_platforms"] = rows
return out
async def set_project_platforms(
project_id: int,
declared: list[str] | None = None,
rejected: list[str] | None = None,
withdrawn: list[str] | None = None,
) -> dict:
"""Say which platforms a project is — or is not. Only the platforms named
change; everything else is left exactly as it is.
Use it when the operator corrects what detection found, or when a project
starts or stops shipping something (it gains an Android client; it drops
its container image). At project creation, decide_project_inception's
`platforms` is the place instead — it records the answer with the rest of
what the project inherits.
Args:
project_id: the project.
declared: slugs the project IS (list_platforms).
rejected: slugs it is NOT — recorded as a "no", so the coverage
refresh never detects it back.
withdrawn: slugs whose answer to drop entirely, so detection may
decide again on the next refresh.
"""
updates: dict[str, str | None] = {}
for slug in withdrawn or []:
updates[slug] = None
for slug in rejected or []:
updates[slug] = "rejected"
for slug in declared or []:
updates[slug] = "declared"
if not updates:
raise ValueError("name at least one platform to declare, reject or withdraw")
rows = await platforms_svc.set_project_platforms(current_user_id(), project_id, updates)
return {"project_id": project_id, "project_platforms": rows}
def register(mcp) -> None:
for fn in (list_platforms, set_project_platforms):
mcp.tool(name=fn.__name__)(fn)
+32 -9
View File
@@ -23,6 +23,7 @@ from scribe.services import design_systems as design_systems_svc
from scribe.services import inception as inception_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import platforms as platforms_svc
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
@@ -125,9 +126,15 @@ async def enter_project(project_id: int) -> dict:
create it with create_system rather than leaving the area unmodelled. Read
a subsystem's accumulated records with list_system_records. Each is id and name; get_system has the charter.
`platforms` (milestone 463) is what the project is built on or ships as
— Android app, container image, Go, … — each with how it is known
(`declared` by a person, `detected` from a bound repo). They decide which
family ideas reach this project. An empty list on a project that plainly
ships something is worth correcting with set_project_platforms.
`inception` (milestone 297) appears ONLY when the project is yours and
nobody has decided what it inherits: it carries the current defaults
(design system, Systems), what to ask the
(design system, Systems, platforms), what to ask the
operator — once — and the decide_project_inception call that answers it;
it repeats on every enter until a decision is recorded.
@@ -188,6 +195,9 @@ async def enter_project(project_id: int) -> dict:
# three days of the feature landing (#2546's audit). Untagged writes now
# also ask with the vocabulary listed; this copy lets the first write tag.
systems = await systems_svc.list_systems(uid, project_id)
platforms = platforms_svc.members(
await platforms_svc.project_platforms(uid, project_id) or []
)
# The arrival-moment half of the bootstrap ask (#2683): session start is
# when the agent has just read the project map and is not yet deep in a
@@ -258,6 +268,7 @@ async def enter_project(project_id: int) -> dict:
},
"pattern_coverage": coverage_svc.coverage_line(coverage) if coverage else None,
"systems": [{"id": s.id, "name": s.name} for s in systems],
"platforms": platforms,
"design_system": design_system,
"milestone_summary": milestone_summary,
**rulebooks_svc.rules_payload(
@@ -302,12 +313,15 @@ async def get_project(project_id: int) -> dict:
rules (project_rules), and applicable_rules: the
global rules tagged to an area this project works in. Every other global
rule applies too and arrives by retrieval when the work matches it.
`platforms` is every platform the project has an answer for, with its
state — rejected ones included, unlike enter_project's brief list.
"""
uid = current_user_id()
project = await projects_svc.get_project(uid, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
data = project.to_dict()
data["platforms"] = await platforms_svc.project_platforms(uid, project_id) or []
rows = await milestones_svc.get_project_milestone_summary(uid, project_id)
data["milestone_summary"], _ = milestones_svc.brief_milestone_summary(rows)
applicable = await rulebooks_svc.get_applicable_rules(
@@ -317,17 +331,21 @@ async def get_project(project_id: int) -> dict:
return data
def _inception_choices(design_system_id, seed_systems) -> dict | None:
def _inception_choices(design_system_id, seed_systems, platforms=None) -> dict | None:
"""The tool args → an inception choices object, or None when no inception
arg was given at all (a bare create stays undecided and enter_project
asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
system."""
if not design_system_id and seed_systems is None:
system. platforms: None = not stated (memberships untouched), a list =
the whole answer."""
if not design_system_id and seed_systems is None and platforms is None:
return None
return {
choices = {
"design_system_id": None if design_system_id in (0, -1) else design_system_id,
"seed_systems": bool(seed_systems),
}
if platforms is not None:
choices["platforms"] = list(platforms)
return choices
async def create_project(
@@ -338,11 +356,12 @@ async def create_project(
color: str = "",
design_system_id: int = 0,
seed_systems: bool | None = None,
platforms: list[str] | None = None,
) -> dict:
"""Create a new project in Scribe — and decide what it inherits.
A project's inheritance is a decision, not a default (milestone 297):
before calling, ask the operator the two inception questions and pass
before calling, ask the operator the three inception questions and pass
the answers; a project created without either is UNDECIDED and
enter_project will ask until decide_project_inception records it.
Defaults if nobody decides: no design system, no Systems. Rules are not
@@ -359,6 +378,9 @@ async def create_project(
(list_design_systems); -1 = explicitly none; 0 = not stated.
seed_systems: true mints the standard starter Systems (CI & Release,
Auth & Access, …) so records can be tagged from day one.
platforms: slugs of what the project is built on or ships as
(list_platforms) — they decide which family ideas reach it. The
list is the whole answer; omit it to leave the question open.
"""
uid = current_user_id()
project = await projects_svc.create_project(
@@ -370,7 +392,7 @@ async def create_project(
color=color or None,
)
data = project.to_dict()
choices = _inception_choices(design_system_id, seed_systems)
choices = _inception_choices(design_system_id, seed_systems, platforms)
if choices is not None:
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
data["inception"] = decided["inception"]
@@ -388,6 +410,7 @@ async def decide_project_inception(
project_id: int,
design_system_id: int = 0,
seed_systems: bool | None = None,
platforms: list[str] | None = None,
) -> dict:
"""Record what a project inherits — answer enter_project's `inception` ask,
or re-decide later (milestone 297).
@@ -400,10 +423,10 @@ async def decide_project_inception(
Args: as create_project's inception args. Passing nothing records a
decision to take nothing (no design system, no seed) — a valid answer,
stated.
stated — and leaves the project's platforms as they are.
"""
uid = current_user_id()
choices = _inception_choices(design_system_id, seed_systems) or {}
choices = _inception_choices(design_system_id, seed_systems, platforms) or {}
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
return {"project_id": project_id, **decided}
+96
View File
@@ -0,0 +1,96 @@
"""Platform routes — the GLOBAL platform catalog, and which platforms a
project is (milestone 463 step 2).
Two shapes, as with the canonical areas:
- `/api/platforms` — the catalog. Readable by any signed-in user; writable
only by an admin, since a vocabulary anyone extends stops being shared.
- `/api/projects/<id>/platforms` — a project's answers, authorised by the
PROJECT (read to see them, write to change them). The service enforces
both; these are thin wrappers.
"""
import logging
from quart import Blueprint, jsonify, request
from scribe.auth import admin_required, get_current_user_id, login_required
from scribe.routes.utils import not_found
from scribe.services import platforms as platforms_svc
logger = logging.getLogger(__name__)
platforms_bp = Blueprint("platforms", __name__, url_prefix="/api")
_EDITABLE = ("name", "description", "order_index", "markers")
@platforms_bp.route("/platforms", methods=["GET"])
@login_required
async def list_platforms_route():
entries = await platforms_svc.list_platforms()
return jsonify({"platforms": [e.to_dict() for e in entries]})
@platforms_bp.route("/platforms", methods=["POST"])
@admin_required
async def create_platform_route():
uid = get_current_user_id()
data = await request.get_json() or {}
if not (data.get("name") or "").strip():
return jsonify({"error": "name is required"}), 400
try:
entry = await platforms_svc.create_platform(
uid, data["name"], description=data.get("description"),
markers=data.get("markers"),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if entry is None:
return jsonify({"error": "Permission denied"}), 403
# Duplicate-gated on the slug: the entry that already is this platform
# comes back as a 409 rather than a second spelling of it.
if isinstance(entry, dict):
return jsonify(entry), 409
return jsonify(entry.to_dict()), 201
@platforms_bp.route("/platforms/<int:platform_id>", methods=["PATCH"])
@admin_required
async def update_platform_route(platform_id: int):
uid = get_current_user_id()
data = await request.get_json() or {}
fields = {k: v for k, v in data.items() if k in _EDITABLE}
try:
entry = await platforms_svc.update_platform(uid, platform_id, **fields)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if entry is None:
return not_found("Platform")
return jsonify(entry.to_dict())
@platforms_bp.route("/projects/<int:project_id>/platforms", methods=["GET"])
@login_required
async def project_platforms_route(project_id: int):
"""Every platform the project has an answer for — rejected included, so
a screen can show "no" as well as "yes"."""
rows = await platforms_svc.project_platforms(get_current_user_id(), project_id)
if rows is None:
return not_found("Project")
return jsonify({"project_platforms": rows})
@platforms_bp.route("/projects/<int:project_id>/platforms", methods=["PUT"])
@login_required
async def set_project_platforms_route(project_id: int):
"""Body: {"platforms": {<slug>: "declared" | "rejected" | null}}. Only
the slugs named change; null withdraws the answer. Applies whole or not
at all."""
data = await request.get_json() or {}
try:
rows = await platforms_svc.set_project_platforms(
get_current_user_id(), project_id, data.get("platforms"),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"project_platforms": rows})
+9
View File
@@ -103,6 +103,15 @@ async def can_admin_project(user_id: int, project_id: int) -> bool:
return perm in ("admin", "owner")
async def is_instance_admin(user_id: int) -> bool:
"""Whether the user administers the INSTANCE (users.role == "admin") —
the gate on the global catalogs (canonical areas, platforms), which belong
to no user and no project, so no share can grant a write to them."""
async with async_session() as session:
role = await session.scalar(select(User.role).where(User.id == user_id))
return role == "admin"
# ---------------------------------------------------------------------------
# Note / task permissions
# ---------------------------------------------------------------------------
+1 -4
View File
@@ -30,7 +30,6 @@ from sqlalchemy import select
from scribe.models import async_session
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.system import System
from scribe.models.user import User
from scribe.services import access
logger = logging.getLogger(__name__)
@@ -60,9 +59,7 @@ def _tokens(slug: str) -> frozenset[str]:
async def _is_admin(user_id: int) -> bool:
async with async_session() as session:
role = await session.scalar(select(User.role).where(User.id == user_id))
return role == "admin"
return await access.is_instance_admin(user_id)
async def list_canonical_systems() -> list[CanonicalSystem]:
+22 -1
View File
@@ -660,6 +660,11 @@ class ArchiveScan(NamedTuple):
definitions: list[ArchiveShape]
references: dict[str, dict[str, int]] # path → class token → count
# EVERY file's repo-relative path, scannable or not (milestone 463). The
# platform markers are files this scan otherwise skips — go.mod,
# AndroidManifest.xml, a Dockerfile — so detection reads the names here
# rather than re-walking the tarball.
paths: tuple[str, ...] = ()
def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
@@ -678,11 +683,14 @@ def scan_archive(blob: bytes) -> ArchiveScan:
"""
shapes: list[ArchiveShape] = []
references: dict[str, dict[str, int]] = {}
paths: list[str] = []
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
for member in tar:
if not member.isfile() or "/" not in member.name:
continue
path = member.name.split("/", 1)[1]
if path:
paths.append(path)
if not path or not scannable(path) or member.size > _MAX_FILE_BYTES:
continue
handle = tar.extractfile(member)
@@ -708,7 +716,7 @@ def scan_archive(blob: bytes) -> ArchiveScan:
)
if refs:
references[path] = refs
return ArchiveScan(shapes, references)
return ArchiveScan(shapes, references, tuple(paths))
# --- matching shapes against recorded locations ------------------------------
@@ -806,6 +814,8 @@ async def compute_coverage(
return None
served: list[tuple[str, str]] = []
# Every file path across the project's repos, for platform detection.
tree_paths: list[str] = []
recorded = await _recorded_locations(user_id, project_id)
# The proposer's canon catalog, read once per refresh and shared across
# the project's repos (#2792).
@@ -822,6 +832,7 @@ async def compute_coverage(
ref = binding.ref or await forge.default_branch(api_repo)
scan = scan_archive(await forge.archive(api_repo, ref))
definitions = scan.definitions
tree_paths.extend(scan.paths)
# The head commit is provenance sugar on the ledger rows; failing to
# learn it must not fail the sync — the ref names the point well
# enough and the row timestamps carry the when.
@@ -857,6 +868,16 @@ async def compute_coverage(
if not served:
return None
# Platform detection (milestone 463) rides the same walk: the paths are in
# hand once. It only ever ADDS membership the project has no answer for,
# and it must not be able to fail the refresh it rides on.
try:
from scribe.services import platforms as platforms_svc
await platforms_svc.detect_for_project(project_id, tree_paths)
except Exception:
logger.warning("platform detection failed for project %s", project_id, exc_info=True)
await shape_ledger.mark_canonicals(project_id, recorded)
try:
await shape_ledger.apply_derive_groups(project_id)
+84 -12
View File
@@ -8,7 +8,8 @@ A project's inheritance is a decision, not a default. The record lives on
"via": "mcp" | "ui" | "legacy",
"choices": {
"design_system_id": <id> | null,
"seed_systems": bool
"seed_systems": bool,
"platforms": [<slug>, ...] | null
}
}
@@ -29,6 +30,15 @@ applies the effects (each idempotent), and writes the record LAST, so a
half-applied decision is re-runnable rather than recorded as done.
``current_defaults`` is what the enter_project ask shows: what binds today
if nobody decides.
``platforms`` (milestone 463) is which platforms the project IS — the answer
that decides which family ideas reach it. A list of catalog SLUGS, not ids:
the record is JSON, and a slug survives a backup restore onto an install whose
ids differ, where an id inside JSON would come back naming another platform.
NULL means the question was not answered here, and the project's memberships
are left exactly as they are; a list is the full answer — every platform in it
is declared, and any platform detection had added that is NOT in it is
recorded as a "no", so the next refresh cannot put it back.
"""
from __future__ import annotations
@@ -38,7 +48,7 @@ from scribe.models import async_session
from scribe.models.project import Project
INCEPTION_VIAS = ("mcp", "ui", "legacy")
CHOICE_KEYS = ("design_system_id", "seed_systems")
CHOICE_KEYS = ("design_system_id", "seed_systems", "platforms")
def validate_inception(choices) -> str | None:
@@ -46,8 +56,9 @@ def validate_inception(choices) -> str | None:
None. Pure and checked BEFORE any effect is applied: a decision either
applies whole or errors whole (the StrictArgs lesson, #2709).
Accepts two keys, each optional: ``design_system_id`` an int or None,
``seed_systems`` a bool. Unknown keys are an error — a typo, or a choice
Accepts three keys, each optional: ``design_system_id`` an int or None,
``seed_systems`` a bool, ``platforms`` a list of slugs or None. Unknown
keys are an error — a typo, or a choice
the product no longer offers, must not become a silently ignored one."""
if not isinstance(choices, dict):
return "choices must be an object"
@@ -60,16 +71,26 @@ def validate_inception(choices) -> str | None:
seed = choices.get("seed_systems", False)
if not isinstance(seed, bool):
return "seed_systems must be true or false"
platforms = choices.get("platforms")
if platforms is not None and (
not isinstance(platforms, list)
or not all(isinstance(p, str) and p.strip() for p in platforms)
):
return "platforms must be a list of platform slugs (list_platforms), or null"
return None
def normalize_choices(choices: dict | None) -> dict:
"""Both keys, always present, in canonical form — what gets stored
"""Every key, always present, in canonical form — what gets stored
and what the UI/agent reads back. Call after validate_inception."""
choices = choices or {}
platforms = choices.get("platforms")
return {
"design_system_id": choices.get("design_system_id"),
"seed_systems": bool(choices.get("seed_systems", False)),
"platforms": (
sorted({p.strip() for p in platforms}) if platforms is not None else None
),
}
@@ -81,11 +102,15 @@ def is_decided(project) -> bool:
async def current_defaults(user_id: int, project_id: int) -> dict:
"""What the project inherits if nobody decides — the ask's payload.
{design_system_id, design_systems: [{id,title}], systems: <count>}.
{design_system_id, design_systems: [{id,title}], systems: <count>,
platforms: [{slug,name}], project_platforms: [{slug,name,state}]}.
Instance-agnostic: an install with no design systems shows an empty list,
and the ask says so rather than inventing a default.
and the ask says so rather than inventing a default. `project_platforms`
is what detection has already found (and anything already answered), so
the form can start from it.
"""
from scribe.services import design_systems as design_systems_svc
from scribe.services import platforms as platforms_svc
from scribe.services import projects as projects_svc
from scribe.services import systems as systems_svc
@@ -94,10 +119,13 @@ async def current_defaults(user_id: int, project_id: int) -> dict:
raise ValueError(f"project {project_id} not found")
designs = await design_systems_svc.list_design_systems(user_id)
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
catalog = await platforms_svc.list_platforms()
return {
"design_system_id": project.design_system_id,
"design_systems": [{"id": d.id, "title": d.title} for d in designs],
"systems": len(systems),
"platforms": [{"slug": p.slug, "name": p.name} for p in catalog],
"project_platforms": await platforms_svc.project_platforms(user_id, project_id) or [],
}
@@ -106,9 +134,15 @@ async def _check_targets(user_id: int, choices: dict) -> None:
effect lands — a decision applies whole or errors whole."""
from scribe.services import access
from scribe.services import platforms as platforms_svc
ds = choices["design_system_id"]
if ds is not None and not await access.can_read_design_system(user_id, ds):
raise ValueError(f"design system {ds} not found (or not readable)")
if choices["platforms"]:
_, unknown = await platforms_svc.resolve_slugs(choices["platforms"])
if unknown:
raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)")
async def decide(
@@ -127,7 +161,8 @@ async def decide(
replaces the design system and re-seeds nothing a project already has.
Returns {"inception": <record>, "effects": {design_system_id,
systems_seeded}}.
systems_seeded, platforms}} — `platforms` is the project's answers after
the decision, or None when the choice was left unstated.
"""
from scribe.services import design_systems as design_systems_svc
from scribe.services import projects as projects_svc
@@ -152,6 +187,9 @@ async def decide(
await systems_svc.seed_standard_systems(user_id, project_id)
if choices["seed_systems"] else []
)
platforms = None
if choices["platforms"] is not None:
platforms = await _apply_platforms(user_id, project_id, choices["platforms"])
record = {
"decided_at": datetime.now(timezone.utc).isoformat(),
@@ -169,10 +207,41 @@ async def decide(
"effects": {
"design_system_id": choices["design_system_id"],
"systems_seeded": [sy.name for sy in seeded],
"platforms": platforms,
},
}
async def _apply_platforms(user_id: int, project_id: int, slugs: list[str]) -> list[dict]:
"""The platforms answer as membership. The list is the WHOLE answer:
every slug in it is declared, and every platform the project was a member
of (declared or detected) that is left out becomes rejected — so the next
refresh cannot detect back what the person just said the project isn't.
Platforms already rejected stay rejected; platforms never answered stay
unanswered."""
from scribe.services import platforms as platforms_svc
named = set(slugs)
current = await platforms_svc.project_platforms(user_id, project_id) or []
updates: dict[str, str | None] = {slug: "declared" for slug in named}
for row in current:
if row["slug"] not in named and row["state"] in platforms_svc.MEMBER_STATES:
updates[row["slug"]] = "rejected"
return await platforms_svc.set_project_platforms(user_id, project_id, updates)
def _platform_line(defaults: dict) -> str:
"""What the ask says about platforms: what detection already found, and
the catalog to choose from."""
found = [
p["slug"] for p in defaults.get("project_platforms", [])
if p["state"] in ("declared", "detected")
]
catalog = ", ".join(p["slug"] for p in defaults.get("platforms", [])) or "none"
lead = f"detected so far: {', '.join(found)}; " if found else ""
return f"{lead}catalog: {catalog}"
async def inception_ask(user_id: int, project_id: int) -> dict:
"""The enter_project ask for an undecided project (milestone 297) — the
sibling of the systems-bootstrap ask (#2683): the project's OWN current
@@ -190,13 +259,16 @@ async def inception_ask(user_id: int, project_id: int) -> dict:
"inherits. Design system — "
f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} "
f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
"once: which design system (or none), and whether to seed "
"the standard starter Systems — then record the answers. This ask repeats on "
"every enter_project until a decision is recorded."
"once: which design system (or none), whether to seed "
"the standard starter Systems, and which platforms the project is "
f"built on or ships as ({_platform_line(defaults)}) — then record the "
"answers. This ask repeats on every enter_project until a decision "
"is recorded."
),
"call": (
f"decide_project_inception(project_id={project_id}, "
"design_system_id=<id | -1 for none>, seed_systems=<true|false>)"
"design_system_id=<id | -1 for none>, seed_systems=<true|false>, "
"platforms=[<slug>, ...])"
),
}
+335
View File
@@ -0,0 +1,335 @@
"""Platforms — what a project is built on or ships as, and which projects are
which (milestone 463 step 2).
Membership is what makes "is this idea in family for that project?" a lookup
rather than a judgment: a family idea is for some platforms, and it reaches
every project that is a member of one of them.
Three ways a project becomes, or refuses to become, a member:
- **declared** — a person said so: at inception, or in the project's settings.
- **detected** — a marker file in a bound repo said so, found by the coverage
refresh that already walks the repo archive.
- **rejected** — a person said NO. Kept as a row, so the next refresh does not
detect it straight back.
The one invariant everything here protects: **detection only ever ADDS, and
only where nobody has answered.** It never overwrites a declared or rejected
row, and it never removes anything — not even a detected row whose marker has
since disappeared, because membership is what adoption rows hang off and a
platform flickering in and out with a repo's file tree would churn a ledger
of decisions nobody re-made.
The catalog itself is global, like the canonical area catalog, and for the
same reason writes to it are admin-only: a shared vocabulary anyone can extend
stops being shared. Reads are open to any signed-in user.
"""
from __future__ import annotations
import fnmatch
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.family import Platform, ProjectPlatform
from scribe.services import access
from scribe.services.canonical_systems import canonical_slug
logger = logging.getLogger(__name__)
# The states that make a project a member. `rejected` is an answer, not
# membership.
MEMBER_STATES = ("declared", "detected")
# What a person may set from a door. `detected` is the refresh's to write.
SETTABLE_STATES = ("declared", "rejected")
# --- the catalog -------------------------------------------------------------
async def list_platforms() -> list[Platform]:
"""The whole live catalog, in display order. Global — no owner filter."""
async with async_session() as session:
result = await session.execute(
select(Platform)
.where(Platform.deleted_at.is_(None))
.order_by(Platform.order_index.asc(), Platform.name.asc())
)
return list(result.scalars().all())
def _clean_markers(markers) -> list[str]:
"""Markers as stored: stripped, non-empty strings, de-duplicated in order.
Anything else is refused by the caller before it gets here."""
seen: list[str] = []
for m in markers or []:
m = str(m).strip()
if m and m not in seen:
seen.append(m)
return seen
def validate_markers(markers) -> str | None:
"""The error a markers value would earn, or None. Pure."""
if markers is None:
return None
if not isinstance(markers, list) or not all(isinstance(m, str) for m in markers):
return "markers must be a list of glob patterns"
if any(m.strip().startswith("/") for m in markers):
return "markers are repo-relative — no leading slash"
return None
async def create_platform(
user_id: int, name: str, *, description: str | None = None,
markers: list[str] | None = None,
) -> Platform | dict | None:
"""Add a platform to the global catalog. Admin only.
Duplicate-gated on the slug, so "Android App" cannot be added beside
"Android app": the existing entry comes back instead of a second spelling
of it. None means not permitted, or no usable name.
"""
if not await access.is_instance_admin(user_id):
return None
slug = canonical_slug(name)
if not slug:
return None
error = validate_markers(markers)
if error:
raise ValueError(error)
async with async_session() as session:
existing = await session.scalar(
select(Platform).where(Platform.slug == slug, Platform.deleted_at.is_(None))
)
if existing is not None:
return {
"duplicate": True,
"existing_id": existing.id,
"message": (
f"'{existing.name}' (#{existing.id}) is already this platform — "
f"both names reduce to '{slug}'."
),
}
highest = await session.scalar(
select(Platform.order_index).order_by(Platform.order_index.desc()).limit(1)
)
entry = Platform(
name=" ".join(name.split()),
slug=slug,
description=description,
markers=_clean_markers(markers),
order_index=(highest or 0) + 1,
)
session.add(entry)
await session.commit()
await session.refresh(entry)
return entry
async def update_platform(user_id: int, platform_id: int, **fields: object) -> Platform | None:
"""Rename, re-describe, re-order or re-mark a catalog entry. Admin only.
A rename recomputes the slug — the display name and the match key must not
disagree. Changing the slug of a platform projects already belong to is
safe: membership is by id; only a backup carries the slug.
"""
if not await access.is_instance_admin(user_id):
return None
if "markers" in fields:
error = validate_markers(fields["markers"])
if error:
raise ValueError(error)
async with async_session() as session:
entry = await session.get(Platform, platform_id)
if entry is None or entry.deleted_at is not None:
return None
if fields.get("name"):
entry.name = " ".join(str(fields["name"]).split())
entry.slug = canonical_slug(entry.name)
if fields.get("description") is not None:
entry.description = fields["description"] or None
if fields.get("order_index") is not None:
entry.order_index = int(fields["order_index"])
if fields.get("markers") is not None:
entry.markers = _clean_markers(fields["markers"])
entry.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(entry)
return entry
async def resolve_slugs(slugs: list[str]) -> tuple[dict[str, int], list[str]]:
"""{slug: id} for the slugs the live catalog knows, and the ones it does
not. Doors take slugs — readable, stable across installs, and the form an
inception record and a backup both carry."""
wanted = [s for s in dict.fromkeys(slugs or [])]
if not wanted:
return {}, []
async with async_session() as session:
rows = (await session.execute(
select(Platform.slug, Platform.id).where(
Platform.slug.in_(wanted), Platform.deleted_at.is_(None),
)
)).all()
known = {slug: pid for slug, pid in rows}
return known, [s for s in wanted if s not in known]
# --- detection (pure) --------------------------------------------------------
def marker_matches(marker: str, path: str) -> bool:
"""Whether one marker matches one repo-relative path.
A marker with no slash matches a file's BASENAME anywhere in the tree
(`go.mod`, `AndroidManifest.xml`, `vite.config.*`). A marker with a slash
matches the whole repo-relative path (`.github/workflows/*`), so a
directory-shaped marker cannot be satisfied by a same-named file
somewhere else.
"""
marker = marker.strip()
if not marker:
return False
if "/" in marker:
return fnmatch.fnmatchcase(path, marker)
return fnmatch.fnmatchcase(path.rsplit("/", 1)[-1], marker)
def detect(catalog: list, paths: list[str]) -> list[int]:
"""The ids of every catalog platform at least one of whose markers matches
at least one path. Pure, so it is testable against a fixture tree with no
forge and no database. A platform with no markers is never detected —
that is what declare-only means."""
hits: list[int] = []
for platform in catalog:
markers = list(getattr(platform, "markers", None) or [])
if markers and any(marker_matches(m, p) for m in markers for p in paths):
hits.append(platform.id)
return hits
# --- membership ----------------------------------------------------------------
async def project_platforms(user_id: int, project_id: int) -> list[dict] | None:
"""Every platform the project has an answer for, joined to the catalog.
None when the caller cannot read the project.
Rejected rows are included — a settings screen has to show "no" as well
as "yes", or it cannot let anyone change their mind.
"""
if not await access.can_read_project(user_id, project_id):
return None
async with async_session() as session:
rows = (await session.execute(
select(ProjectPlatform, Platform)
.join(Platform, Platform.id == ProjectPlatform.platform_id)
.where(ProjectPlatform.project_id == project_id, Platform.deleted_at.is_(None))
.order_by(Platform.order_index.asc(), Platform.name.asc())
)).all()
return [
{"id": p.id, "slug": p.slug, "name": p.name, "state": m.state}
for m, p in rows
]
def members(platforms: list[dict]) -> list[dict]:
"""The rows of `project_platforms` that are membership — the brief form
enter_project and get_project carry."""
return [
{"slug": p["slug"], "name": p["name"], "state": p["state"]}
for p in platforms if p["state"] in MEMBER_STATES
]
def validate_updates(updates) -> str | None:
"""The error a set of membership answers would earn, or None. Pure.
`updates` is {slug: "declared" | "rejected" | None}. None withdraws the
answer — the row goes, and detection may add the platform again later.
`detected` is not settable: it is what the refresh writes, and a person
claiming it would erase the difference between the two."""
if not isinstance(updates, dict):
return "platforms must be an object of {slug: state}"
for slug, state in updates.items():
if not isinstance(slug, str) or not slug:
return "each platform is named by its slug"
if state is not None and state not in SETTABLE_STATES:
return (
f"'{state}' is not a state a person sets — use one of "
f"{', '.join(SETTABLE_STATES)}, or null to withdraw the answer"
)
return None
async def set_project_platforms(
user_id: int, project_id: int, updates: dict[str, str | None],
) -> list[dict]:
"""Apply a person's answers about a project's platforms. Write-gated on
the project (rule 78). Platforms not named are left exactly as they are.
Raises ValueError, naming the problem, on a malformed update, an unknown
slug, or no write access — before anything is written, so the update
applies whole or not at all.
"""
error = validate_updates(updates)
if error:
raise ValueError(error)
if not await access.can_write_project(user_id, project_id):
raise ValueError(f"project {project_id} not found or no write access")
known, unknown = await resolve_slugs(list(updates))
if unknown:
raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)")
async with async_session() as session:
existing = {
row.platform_id: row for row in (await session.execute(
select(ProjectPlatform).where(ProjectPlatform.project_id == project_id)
)).scalars().all()
}
for slug, state in updates.items():
pid = known[slug]
row = existing.get(pid)
if state is None:
if row is not None:
await session.delete(row)
elif row is None:
session.add(ProjectPlatform(project_id=project_id, platform_id=pid, state=state))
else:
row.state = state
await session.commit()
return await project_platforms(user_id, project_id) or []
async def record_detected(project_id: int, platform_ids: list[int]) -> list[int]:
"""Add `detected` membership for each platform the project has NO answer
for. Returns the ids actually added.
The refresh's writer, so it takes no user: it runs on the owner's behalf
inside a sync the owner's keyring authorised. It never updates or deletes
a row — see the module docstring for why that is the whole contract.
"""
if not platform_ids:
return []
async with async_session() as session:
answered = set((await session.execute(
select(ProjectPlatform.platform_id).where(
ProjectPlatform.project_id == project_id,
)
)).scalars().all())
added = [pid for pid in dict.fromkeys(platform_ids) if pid not in answered]
for pid in added:
session.add(ProjectPlatform(project_id=project_id, platform_id=pid, state="detected"))
await session.commit()
return added
async def detect_for_project(project_id: int, paths: list[str]) -> list[int]:
"""Run detection over a refresh's paths and record what is new. The
coverage refresh's single call — it must not be able to fail that
refresh, so the caller wraps it."""
hits = detect(await list_platforms(), paths)
added = await record_detected(project_id, hits)
if added:
logger.info("project %s: detected platform(s) %s", project_id, added)
return added