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>
146 lines
5.7 KiB
Python
146 lines
5.7 KiB
Python
"""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"] == []
|