"""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