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
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user