diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ea47d9f..31bebc4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,6 +201,28 @@ jobs: - name: Ownership, user modifications and external config run: python -m unittest tests.test_lifecycle_ownership -v + - name: Features, State V2 and the shared projection + run: python -m unittest tests.test_lifecycle_features -v + + - name: Work authority, claim grammar and the claim journal + run: python -m unittest tests.test_forge_claims -v + + - name: Release source resolution and diagnostics + run: python -m unittest tests.test_release_source -v + + - name: Release V3 contract, manifest and version chain + run: python -m unittest tests.test_release_v3 -v + + - name: Release V3 providers (GitHub, anonymous, local mirror) + run: python -m unittest tests.test_release_providers -v + + - name: Release V3 update path end to end + run: python -m unittest tests.test_lifecycle_update_v3 -v + + - name: Purity - neutral layer, contracts, distributed policy + run: | + python -m unittest tests.purity.test_dependency_purity tests.purity.test_contract_purity tests.purity.test_distributed_policy_purity -v + - name: Path traversal, link escape, tampered state, archive safety run: python -m unittest tests.test_lifecycle_security -v diff --git a/CHANGELOG.md b/CHANGELOG.md index 187d3f26..ffe6c2fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,36 @@ project adheres to [Semantic Versioning](https://semver.org/). workflow verifies a declared `V3_BRIDGE_RELEASE` through the V2 selection rules before publishing a V3 release; a missing, unpublished or bundle-less bridge blocks the release. +- **Features: profiles and capabilities are independent** (#162, ADR-0017): + `ainative feature enable | disable | switch | status` manages optional + project-scope capabilities in one lifecycle transaction. `forge-github` is + the default; it conflicts with `forge-gitlab`; a project has at most one + work forge, and `feature switch none` is the Generic Git shape. State schema + V2 records `active_features`; a V1 state projects to the compatibility + default and migrates inside the next mutation without resurrecting an + absent managed file. +- **Multi-Forge release sources and Release V3** (#165, ADR-0019): one + `resolve_release_source()` with fail-closed selector conflicts and a + machine-scope `~/.ai-native/release-providers.json`; a V3 release manifest + whose SHA-256 and size are verified (from provider metadata) *before* the + document is parsed; an exact version chain across candidate, manifest, + runtime, artifact, filename and the lifecycle protocol document; bounded + enumeration; SemVer without build metadata; duplicates refused. GitHub.com, + anonymous and local providers are wired into `update`/`update check` + (V2-era sources keep working), and `status`/`doctor` show the effective + source, its reason and whether it is authenticated. +- **GitLab provider (Release V3)** (#166): Releases for discovery, the + Generic Package Registry as the canonical integrity surface (`file_sha256` + + `size`), exact package lookup, bounded pagination, and `PRIVATE-TOKEN` + confined to the configured origin. GitLab.com live qualification is + UNTESTED and therefore not declared supported — + `docs/MULTIFORGE-QUALIFICATION.md` states exactly what was verified. +- **Work Authority observation and claims** (#163/#164, ADR-0018): + `ainative forge detect | status` (zero network, credentials, writes or + persistent trust), the doctor extension, the pure local resolver, the + canonical claim grammar and the durable + `ainative claim-attempt list | inspect | abandon` journal — written before + the remote signal, never retried blindly. ### Security diff --git a/README.fr.md b/README.fr.md index f69bd472..87b78450 100644 --- a/README.fr.md +++ b/README.fr.md @@ -577,6 +577,28 @@ ainative doctor # inclut la section Knowledge - Guides : docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md et docs/knowledge/K1-K4-MEASUREMENT-GATE.md +## Multi-Forge + +Generic Git est le produit de base ; GitHub et GitLab sont des capacités +optionnelles, et le profil de gouvernance est indépendant d'elles +(ADR-0017/0018/0019). + +```bash +ainative feature status # l'ensemble effectif de features +ainative feature switch forge-gitlab # une transaction : GitHub -> GitLab +ainative feature switch none # Generic Git : aucune forge de travail +ainative forge status # remotes observés + résolution Work Authority +ainative claim-attempt list # tentatives de claim journalisées (recovery) +``` + +Les sources de release sont résolues par une seule fonction aux sélecteurs +fail-closed (GitHub.com par défaut ; un miroir local ; une +`AINATIVE_UPDATE_URL` anonyme ; GitLab.com via un `release-providers.json` de +portée machine). La gestion du travail est provider-neutre +(`docs/FORGE-WORKFLOW.md`, avec les mappings GitHub et GitLab) ; la chaîne V3 +vérifie un manifeste ancré de l'extérieur avant tout parsing, et la matrice de +support est dans `SUPPORT.md`. + ## Démarrage rapide ### Installer sur un projet existant diff --git a/README.md b/README.md index ccd72655..87215409 100644 --- a/README.md +++ b/README.md @@ -578,6 +578,26 @@ ainative doctor # includes the Knowledge section - Guides: docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md and docs/knowledge/K1-K4-MEASUREMENT-GATE.md +## Multi-Forge + +Generic Git is the base product; GitHub and GitLab are optional capabilities, +and the governance profile is independent of them (ADR-0017/0018/0019). + +```bash +ainative feature status # the effective feature set +ainative feature switch forge-gitlab # one transaction: GitHub -> GitLab +ainative feature switch none # Generic Git: no work forge +ainative forge status # observed remotes + Work Authority resolution +ainative claim-attempt list # journaled claim attempts (recovery) +``` + +Release sources are resolved by one function with fail-closed selectors +(GitHub.com by default; a local mirror; an anonymous `AINATIVE_UPDATE_URL`; +GitLab.com through a machine-scoped `release-providers.json`). Work +management is provider-neutral (`docs/FORGE-WORKFLOW.md`, with the GitHub and +GitLab mappings); the V3 release chain verifies an externally anchored +manifest before parsing anything, and the support matrix is in `SUPPORT.md`. + ## Quick Start ### For an existing project diff --git a/SUPPORT.md b/SUPPORT.md index 02f97326..b5da57b4 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -9,6 +9,9 @@ | Operating systems | Linux, macOS and Windows (CI exercises all three) | | Profiles | Standard; Verified (adds governed Work Contracts and deterministic verification) | | Harnesses | Claude Code, Codex, OpenCode, Cursor, Gemini CLI, MiniMax/Mavis — for the shared method, skills and hooks | +| Work management | Generic Git; GitHub (mapping); GitLab (mapping). The Work Authority is resolved locally, purely and fail-closed (ADR-0018) | +| Features | Project-scope features independent of the profile (`forge-github` default, `forge-gitlab`), State schema V2 (ADR-0017) | +| Release sources | GitHub.com (built-in, qualified); a local mirror; an anonymous HTTPS release API (`AINATIVE_UPDATE_URL`, never credentialed); GitLab.com provider implemented and contract-tested, live-service qualification UNTESTED — **not declared supported**; GitLab Self-Managed and GitHub Enterprise Server UNTESTED — **not supported** | Only the latest release is supported; fixes ship as a new patch version. diff --git a/ainative/claim_cli.py b/ainative/claim_cli.py new file mode 100644 index 00000000..8f270550 --- /dev/null +++ b/ainative/claim_cli.py @@ -0,0 +1,76 @@ +"""The `ainative claim-attempt` recovery surface: list, inspect, abandon. + +Recovery is an operator act, so the surface is read-mostly: listing and +inspecting never change anything, and abandoning is one explicit transition +that requires `--confirm`. There is deliberately no "retry" here — an +uncertain POST is resolved by searching the remote for the attempt marker, +never by posting again (ADR-0018 section 6). +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from . import claims +from .cli_support import project_from, report as _report + + +def add_claim_parser(commands) -> None: + claim = commands.add_parser( + "claim-attempt", help="Inspect or abandon journaled claim attempts.") + subcommands = claim.add_subparsers(dest="claim_command", required=True) + listing = subcommands.add_parser("list", help="Every journaled claim attempt.") + _common(listing) + inspect = subcommands.add_parser("inspect", help="One attempt, in full.") + inspect.add_argument("attempt_id") + _common(inspect) + abandon = subcommands.add_parser( + "abandon", help="Give up an unresolved attempt (local transition only).") + abandon.add_argument("attempt_id") + abandon.add_argument("--confirm", action="store_true", + help="required: this is an operator decision") + _common(abandon) + + +def _common(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--project", type=Path, default=None, + help="project root (default: the current directory)") + parser.add_argument("--json", action="store_true", help="machine-readable output") + + +def _attempt_text(attempt: claims.ClaimAttempt) -> str: + lines = [f"{attempt.attempt_id}: {attempt.state}", + f" authority: {attempt.authority.get('provider')}:" + f"{attempt.authority.get('project')}", + f" item: {attempt.item}", + f" principal: {attempt.principal}", + f" marker: {attempt.marker}", + f" created: {attempt.created_at}"] + if attempt.outcome_at: + lines.append(f" outcome: {attempt.outcome_at}") + if attempt.note: + lines.append(f" note: {attempt.note}") + return "\n".join(lines) + + +def run_claim_command(args: argparse.Namespace) -> int: + project = project_from(args) + if args.claim_command == "list": + attempts = claims.list_attempts(project) + record = {"attempts": [attempt.to_record() for attempt in attempts], + "unresolved": [attempt.attempt_id for attempt in attempts + if attempt.state in claims.UNRESOLVED]} + lines = [f"{len(attempts)} attempt(s), {len(record['unresolved'])} unresolved"] + for attempt in attempts: + lines.append(f" {attempt.state:<10} {attempt.attempt_id} " + f"item {attempt.item}") + return _report(args, record, "\n".join(lines)) + if args.claim_command == "inspect": + attempt = claims.load_attempt(project, args.attempt_id) + return _report(args, attempt.to_record(), _attempt_text(attempt)) + attempt = claims.abandon(project, args.attempt_id, confirm=args.confirm) + return _report(args, attempt.to_record(), _attempt_text(attempt)) + + +__all__ = ["add_claim_parser", "run_claim_command"] diff --git a/ainative/claims.py b/ainative/claims.py new file mode 100644 index 00000000..6bed3813 --- /dev/null +++ b/ainative/claims.py @@ -0,0 +1,321 @@ +"""Canonical claim identities, and the durable journal that survives a crash. + +A claim is a remote mutation: an agent asks a work authority to recognize it +as the owner of a WorkItem. What makes a crash recoverable is that the local +record of the attempt exists *before* the remote POST — journal first, then +exactly one POST, then a re-read that searches for the attempt's own marker +(ADR-0018 sections 3–6). + +This module owns the local half: canonical identities, UTC-normalized +ordering, the PENDING → outcome journal under `.ai-native/state/claim-attempts/`, +and the explicit operator transitions. It never calls a provider API, never +holds a credential, and never infers an outcome: the harness observes, this +module records and arbitrates deterministically. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +from .lifecycle import state as statelib +from .lifecycle.errors import LifecycleError + +KIND_COMMENT = "comment" +KIND_NOTE = "note" +KIND_ASSIGNMENT = "assignment" +EVENT_KINDS = (KIND_COMMENT, KIND_NOTE, KIND_ASSIGNMENT) + +PENDING = "PENDING" +CONFIRMED = "CONFIRMED" +LOST = "LOST" +CONFLICT = "CONFLICT" +UNCERTAIN = "UNCERTAIN" +ABANDONED = "ABANDONED" +OUTCOMES = (CONFIRMED, LOST, CONFLICT, UNCERTAIN, ABANDONED) +UNRESOLVED = (PENDING, UNCERTAIN) + +ATTEMPTS_RELATIVE = Path(".ai-native") / "state" / "claim-attempts" +MARKER_PREFIX = "ainative-claim-attempt:" + +_ATTEMPT_ID = re.compile(r"^claim_[0-9a-f]{32}$") +_TOKEN = re.compile(r"^[^\s:]+$") + + +def _token(value: str, what: str) -> str: + if not isinstance(value, str) or not _TOKEN.match(value): + raise LifecycleError("CLAIM_INVALID", + f"a claim {what} must be a non-empty token without " + f"whitespace or ':', got {value!r}") + return value + + +def principal(provider: str, stable_id: str) -> str: + """`:principal:` — who is claiming.""" + + return f"{_token(provider, 'provider')}:principal:{_token(stable_id, 'principal id')}" + + +def event_identifier(provider: str, kind: str, stable_id: str) -> str: + """`::` — which claim event it is.""" + + if kind not in EVENT_KINDS: + raise LifecycleError("CLAIM_INVALID", + f"unknown claim event kind {kind!r} " + f"(known: {', '.join(EVENT_KINDS)})") + return f"{_token(provider, 'provider')}:{kind}:{_token(stable_id, 'event id')}" + + +def to_utc(value: str) -> str: + """An ISO-8601 timestamp normalized to UTC. A naive one is refused. + + Ordering claims by timestamp is only deterministic if every timestamp + carries its zone; "2026-09-16T10:00:00" cannot be compared with + "2026-09-16T09:00:00+02:00" without guessing. + """ + + try: + moment = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError as error: + raise LifecycleError("CLAIM_INVALID", + f"claim timestamp {value!r} is not ISO-8601") from error + if moment.tzinfo is None: + raise LifecycleError("CLAIM_INVALID", + f"claim timestamp {value!r} carries no timezone") + return moment.astimezone(timezone.utc).isoformat() + + +@dataclass(frozen=True) +class ClaimEvent: + """One observed claim signal, in canonical form.""" + + identifier: str # canonical event identifier + created_at: str # UTC ISO-8601 + actor: str # canonical principal + + def sort_key(self) -> tuple[str, str]: + return (self.created_at, self.identifier) + + def to_record(self) -> dict: + return {"identifier": self.identifier, "created_at": self.created_at, + "actor": self.actor} + + +def winner(events) -> ClaimEvent | None: + """The winning claim by `(created_at_utc, canonical_event_identifier)`. + + Exact duplicates are one event. Two distinct events with the same key — a + duplicate identifier carrying different actors — are unorderable and refuse + as `CLAIM_CONFLICT`: every claimant stops (ADR-0018 section 3). + """ + + unique: dict[str, ClaimEvent] = {} + for event in events: + existing = unique.get(event.identifier) + if existing is None: + unique[event.identifier] = event + elif existing != event: + raise LifecycleError( + "CLAIM_CONFLICT", + f"two different claim events share the identifier " + f"{event.identifier!r}; they cannot be ordered", + identifier=event.identifier) + if not unique: + return None + ordered = sorted(unique.values(), key=ClaimEvent.sort_key) + if len(ordered) > 1 and ordered[0].sort_key() == ordered[1].sort_key(): + raise LifecycleError("CLAIM_CONFLICT", + "two claim events are unorderable: identical " + "timestamp and identifier") + return ordered[0] + + +@dataclass +class ClaimAttempt: + """One journaled attempt to claim a WorkItem.""" + + attempt_id: str + authority: dict # WorkAuthorityRef.to_record() + item: str # the WorkItem identifier on that authority + principal: str # canonical principal + marker: str # exact marker the remote signal must carry + state: str = PENDING + created_at: str = "" + outcome_at: str | None = None + note: str = "" + history: list = field(default_factory=list) + + def to_record(self) -> dict: + return {"attempt_id": self.attempt_id, "authority": dict(self.authority), + "item": self.item, "principal": self.principal, "marker": self.marker, + "state": self.state, "created_at": self.created_at, + "outcome_at": self.outcome_at, "note": self.note, + "history": list(self.history)} + + @classmethod + def from_record(cls, raw: object) -> "ClaimAttempt": + if not isinstance(raw, dict): + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + "a claim attempt record is not an object") + attempt_id = raw.get("attempt_id") + if not isinstance(attempt_id, str) or not _ATTEMPT_ID.match(attempt_id): + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + "a claim attempt record carries an invalid id") + if not isinstance(raw.get("authority"), dict) or not isinstance(raw.get("item"), str): + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + f"claim attempt {attempt_id} is malformed") + if not isinstance(raw.get("principal"), str) or not isinstance(raw.get("marker"), str): + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + f"claim attempt {attempt_id} is malformed") + state = raw.get("state") + if state not in (PENDING, *OUTCOMES): + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + f"claim attempt {attempt_id} carries an unknown state " + f"{state!r}") + return cls(attempt_id=attempt_id, authority=dict(raw["authority"]), + item=raw["item"], principal=raw["principal"], marker=raw["marker"], + state=state, created_at=str(raw.get("created_at") or ""), + outcome_at=raw.get("outcome_at"), note=str(raw.get("note") or ""), + history=list(raw.get("history") or [])) + + +def attempts_root(project: Path) -> Path: + return Path(project) / ATTEMPTS_RELATIVE + + +def new_attempt(*, authority, item: str, principal: str, note: str = "") -> ClaimAttempt: + """A fresh attempt with its deterministic remote marker. Not yet journaled.""" + + attempt_id = statelib.new_identifier("claim") + return ClaimAttempt(attempt_id=attempt_id, authority=authority.to_record(), + item=_token(item, "item"), principal=principal, + marker=f"{MARKER_PREFIX}{attempt_id}", note=note) + + +def _path(project: Path, attempt_id: str) -> Path: + if not isinstance(attempt_id, str) or not _ATTEMPT_ID.match(attempt_id): + raise LifecycleError("CLAIM_INVALID", + f"invalid claim attempt id {attempt_id!r}") + return attempts_root(project) / f"{attempt_id}.json" + + +def _write(project: Path, attempt: ClaimAttempt) -> None: + """One durable write of an attempt record, or a fail-closed refusal.""" + + path = _path(project, attempt.attempt_id) + try: + path.parent.mkdir(parents=True, exist_ok=True) + statelib.write_atomic(path, json.dumps(attempt.to_record(), indent=2, + sort_keys=True) + "\n") + except OSError as error: + raise LifecycleError( + "CLAIM_JOURNAL_UNAVAILABLE", + f"cannot write the claim journal at {path}: {error}") from error + + +def begin(project: Path, attempt: ClaimAttempt) -> Path: + """Write the PENDING attempt durably. This happens BEFORE the remote POST. + + A local write that fails is `CLAIM_JOURNAL_UNAVAILABLE` and the caller + must not POST at all: an attempt nobody recorded cannot be recovered. + """ + + attempt.state = PENDING + attempt.created_at = attempt.created_at or statelib.now() + attempt.history.append({"state": PENDING, "at": attempt.created_at}) + _write(project, attempt) + return _path(project, attempt.attempt_id) + + +def load_attempt(project: Path, attempt_id: str) -> ClaimAttempt: + path = _path(project, attempt_id) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as error: + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + f"no claim attempt {attempt_id!r} is journaled") from error + except (OSError, ValueError) as error: + raise LifecycleError( + "CLAIM_JOURNAL_UNAVAILABLE", + f"claim attempt {attempt_id!r} is unreadable; nothing may infer " + f"its outcome: {error}") from error + return ClaimAttempt.from_record(payload) + + +def list_attempts(project: Path) -> list[ClaimAttempt]: + """Every journaled attempt, or a refusal — a corrupt journal is never skipped. + + An unreadable entry might be the one that says a POST is in flight; listing + the others and pretending it is not there is how a second POST happens. + """ + + root = attempts_root(project) + if not root.is_dir(): + return [] + attempts: list[ClaimAttempt] = [] + for path in sorted(root.glob("*.json")): + attempts.append(load_attempt(project, path.stem)) + return attempts + + +def unresolved(project: Path) -> list[ClaimAttempt]: + return [attempt for attempt in list_attempts(project) + if attempt.state in UNRESOLVED] + + +def record_outcome(project: Path, attempt_id: str, outcome: str, *, note: str = "") -> ClaimAttempt: + """Persist what the re-read proved. Never invents an outcome.""" + + if outcome not in OUTCOMES: + raise LifecycleError("CLAIM_INVALID", + f"unknown claim outcome {outcome!r} " + f"(known: {', '.join(OUTCOMES)})") + attempt = load_attempt(project, attempt_id) + if attempt.state == ABANDONED: + raise LifecycleError("CLAIM_INVALID", + f"attempt {attempt_id} was abandoned; its record is final " + "(abandonment is an operator decision, not a retry slot)") + attempt.state = outcome + attempt.outcome_at = statelib.now() + if note: + attempt.note = f"{attempt.note}\n{note}".strip() + attempt.history.append({"state": outcome, "at": attempt.outcome_at}) + _write(project, attempt) + return attempt + + +def abandon(project: Path, attempt_id: str, *, confirm: bool) -> ClaimAttempt: + """The explicit operator transition. Local only; the record is retained. + + Only an unresolved attempt (PENDING or UNCERTAIN) can be abandoned: a + CONFIRMED claim is real on the remote, and hiding it locally would not + release it. After abandonment a new attempt is allowed. + """ + + if not confirm: + raise LifecycleError("CONFIRMATION_REQUIRED", + "abandoning a claim attempt is an explicit operator " + "decision; pass --confirm after inspecting the remote state") + attempt = load_attempt(project, attempt_id) + if attempt.state not in UNRESOLVED: + raise LifecycleError("CLAIM_INVALID", + f"attempt {attempt_id} is {attempt.state}; only an " + "unresolved attempt can be abandoned") + attempt.state = ABANDONED + attempt.outcome_at = statelib.now() + attempt.history.append({"state": ABANDONED, "at": attempt.outcome_at}) + _write(project, attempt) + return attempt + + +__all__ = [ + "KIND_COMMENT", "KIND_NOTE", "KIND_ASSIGNMENT", "EVENT_KINDS", + "PENDING", "CONFIRMED", "LOST", "CONFLICT", "UNCERTAIN", "ABANDONED", + "OUTCOMES", "UNRESOLVED", "ATTEMPTS_RELATIVE", "MARKER_PREFIX", + "principal", "event_identifier", "to_utc", "ClaimEvent", "winner", + "ClaimAttempt", "attempts_root", "new_attempt", "begin", "load_attempt", + "list_attempts", "unresolved", "record_outcome", "abandon", +] diff --git a/ainative/cli.py b/ainative/cli.py index 8e77bd1a..bad5bb94 100644 --- a/ainative/cli.py +++ b/ainative/cli.py @@ -14,12 +14,14 @@ from __future__ import annotations import argparse -import json import os import sys from pathlib import Path from .lifecycle.errors import EXIT_FAILED, EXIT_INVALID_REQUEST, EXIT_OK, LifecycleError +from .cli_support import (ACTION_LABELS, action_label as _action_label, # noqa: F401 + emit as _emit, plan_text as _plan_text, + project_from as _project, report as _report) # Routed to the Verified Work Plane, unchanged. Their exit codes and output are # the Work Plane's contract and are not reinterpreted here. @@ -37,14 +39,6 @@ """ -def _emit(payload: object) -> None: - print(json.dumps(payload, indent=2, sort_keys=True, default=str)) - - -def _project(args: argparse.Namespace) -> Path: - return Path(getattr(args, "project", None) or Path.cwd()) - - def _add_common(parser: argparse.ArgumentParser, *, dry_run: bool = True, yes: bool = False) -> None: parser.add_argument("--project", type=Path, default=None, @@ -176,8 +170,14 @@ def build_parser() -> argparse.ArgumentParser: help="never prompt; choices come from flags only") from ainative.knowledge.cli import add_context_parser, add_knowledge_parser + from ainative.lifecycle.feature_cli import add_feature_parser + from ainative.claim_cli import add_claim_parser + from ainative.forge_cli import add_forge_parser add_knowledge_parser(commands) add_context_parser(commands) + add_feature_parser(commands) + add_claim_parser(commands) + add_forge_parser(commands) for name in VERIFIED_COMMANDS: commands.add_parser(name, add_help=False, @@ -224,55 +224,6 @@ def _choose_profile(args: argparse.Namespace) -> str: "Use `ainative init --profile standard` or `--profile verified`.") -def _report(args: argparse.Namespace, record: dict, text: str) -> int: - if getattr(args, "json", False): - _emit(record) - else: - print(text) - return EXIT_OK - - -# What the plan says, as opposed to what the journal records. `BLOCK_WRITE` -# read to a user as "blocked" while the operation actually proceeded; the -# internal names stay stable for journals, the labels must not lie. -ACTION_LABELS = { - "CREATE": "create", - "REPLACE": "replace", - "REMOVE": "remove", - "SKIP": "skip", - "PRESERVE": "preserve", - "CONFLICT": "conflict", - "REGION_WRITE": "configure-region", - "REGION_REMOVE": "remove-region", - "HOOK_WRITE": "configure-hook", - "HOOK_REMOVE": "remove-hook", - "BLOCK_WRITE": "configure-region", - "BLOCK_REMOVE": "remove-region", -} - - -def _action_label(action: str) -> str: - return ACTION_LABELS.get(action, action.lower()) - - -def _plan_text(result) -> str: - plan = result.plan - header = "(dry-run — nothing was written)\n" if result.dry_run else "" - counts = ", ".join(f"{_action_label(action)} {count}" - for action, count in sorted(plan.counts().items())) or "no changes" - lines = [f"{header}{plan.operation}: {plan.from_profile or 'none'} -> " - f"{plan.to_profile or 'none'}", f" {counts}"] - for change in plan.changes: - if change.action in ("SKIP",) and not result.dry_run: - continue - lines.append(f" {_action_label(change.action):<18} {change.path}" - + (f" ({change.reason})" if change.reason else "")) - for notice in result.notices: - lines.append("") - lines.append(notice) - return "\n".join(lines) - - def _cmd_init(args: argparse.Namespace) -> int: from .lifecycle import installer @@ -306,6 +257,24 @@ def _cmd_profile(args: argparse.Namespace) -> int: return _report(args, result.to_record(), _uninstall_text(result)) +def _cmd_feature(args: argparse.Namespace) -> int: + from .lifecycle.feature_cli import run_feature_command + + return run_feature_command(args) + + +def _cmd_claim_attempt(args: argparse.Namespace) -> int: + from .claim_cli import run_claim_command + + return run_claim_command(args) + + +def _cmd_forge(args: argparse.Namespace) -> int: + from .forge_cli import run_forge_command + + return run_forge_command(args) + + def _confirm_purge(args: argparse.Namespace, project: Path) -> bool: """Interactive confirmation. Without a TTY this is always False, so `--yes` is the only way through — no CI ever blocks on a prompt.""" @@ -370,16 +339,22 @@ def _doctor_collect(project: Path, check_updates: bool): def _cmd_doctor(args: argparse.Namespace) -> int: - from .lifecycle import environment, recovery, updater + from . import observation + from .lifecycle import environment, recovery, release_source, updater from .knowledge import doctor as knowledgedoctor project = _project(args) diagnosis, knowledge, checks, healthy = _doctor_collect( project, args.check_updates) + view = observation.project_view(project) if args.json: record = diagnosis.to_record() record["knowledge"] = knowledge record["environment"] = checks + record["features"] = view["features"] + record["forge"] = view["forge"] + record["claim_attempts"] = view["claim_attempts"] + record["release_source"] = release_source.describe() _emit(record) else: print(f"Project: {diagnosis.project}") @@ -398,6 +373,10 @@ def _cmd_doctor(args: argparse.Namespace) -> int: # (#131 / AUD-202). print("Updates") print(f" {updater.notice_line(diagnosis.update)}") + for line in release_source.describe_lines(): + print(line) + for line in observation.doctor_lines(view): + print(line) print("Knowledge:") print(f" status: {knowledge['status']} store: {knowledge['store']}") if knowledge["status"] != knowledgedoctor.STATUS_FAIL: @@ -700,6 +679,9 @@ def _cmd_setup(args: argparse.Namespace) -> int: "knowledge": _cmd_knowledge, "context": _cmd_context, "profile": _cmd_profile, + "feature": _cmd_feature, + "claim-attempt": _cmd_claim_attempt, + "forge": _cmd_forge, "status": _cmd_status, "doctor": _cmd_doctor, "repair": _cmd_repair, diff --git a/ainative/cli_support.py b/ainative/cli_support.py new file mode 100644 index 00000000..7dafe412 --- /dev/null +++ b/ainative/cli_support.py @@ -0,0 +1,74 @@ +"""Small shared plumbing for the `ainative` command modules. + +Split out so `cli.py` stays the dispatcher and the command modules +(`feature_cli`, `claim_cli`, `forge_cli`) render the same way without +importing `cli` back — which would be a cycle. Nothing here imports the +lifecycle or the Work Plane: it is argparse, JSON and a path. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .lifecycle.errors import EXIT_OK + +# What the plan says, as opposed to what the journal records. `BLOCK_WRITE` +# read to a user as "blocked" while the operation actually proceeded; the +# internal names stay stable for journals, the labels must not lie. +ACTION_LABELS = { + "CREATE": "create", + "REPLACE": "replace", + "REMOVE": "remove", + "SKIP": "skip", + "PRESERVE": "preserve", + "CONFLICT": "conflict", + "REGION_WRITE": "configure-region", + "REGION_REMOVE": "remove-region", + "HOOK_WRITE": "configure-hook", + "HOOK_REMOVE": "remove-hook", + "BLOCK_WRITE": "configure-region", + "BLOCK_REMOVE": "remove-region", +} + + +def emit(payload: object) -> None: + print(json.dumps(payload, indent=2, sort_keys=True, default=str)) + + +def project_from(args: argparse.Namespace) -> Path: + return Path(getattr(args, "project", None) or Path.cwd()) + + +def action_label(action: str) -> str: + return ACTION_LABELS.get(action, action.lower()) + + +def report(args: argparse.Namespace, record: dict, text: str) -> int: + if getattr(args, "json", False): + emit(record) + else: + print(text) + return EXIT_OK + + +def plan_text(result) -> str: + plan = result.plan + header = "(dry-run — nothing was written)\n" if result.dry_run else "" + counts = ", ".join(f"{action_label(action)} {count}" + for action, count in sorted(plan.counts().items())) or "no changes" + lines = [f"{header}{plan.operation}: {plan.from_profile or 'none'} -> " + f"{plan.to_profile or 'none'}", f" {counts}"] + for change in plan.changes: + if change.action in ("SKIP",) and not result.dry_run: + continue + lines.append(f" {action_label(change.action):<18} {change.path}" + + (f" ({change.reason})" if change.reason else "")) + for notice in result.notices: + lines.append("") + lines.append(notice) + return "\n".join(lines) + + +__all__ = ["ACTION_LABELS", "emit", "project_from", "action_label", "report", "plan_text"] diff --git a/ainative/forge.py b/ainative/forge.py new file mode 100644 index 00000000..8074e7cd --- /dev/null +++ b/ainative/forge.py @@ -0,0 +1,169 @@ +"""Resolve the Work Authority from locally observed facts — purely. + +Multi-Forge's Work Authority is the remote whose work records are +authoritative for a project: one provider plus a project identity on it. +Deciding *which* remote that is, is the only thing this module does, and it +decides it from facts observed elsewhere — an explicit WorkItem/ChangeRequest +reference, a harness declaration, or the Git remotes someone already read. It +never reaches the network, never looks up a credential, never writes, and never +authorizes a push (ADR-0018). + +The rules are ordered and fail closed (ADR-0018 section 2): + +1. an explicit WorkItem/ChangeRequest repository reference; +2. an explicit harness-declared authority; +3. exactly one compatible observed candidate; +4. otherwise a refusal — never a guess. + +A fork (origin and upstream disagreeing) is `AMBIGUOUS`, never "prefer +origin". A self-hosted host is `unknown`, never inferred from its name: +GitHub.com and GitLab.com are the only hosted forges V1 knows, and inferring +"gitlab" from a hostname is how a generic Git project gets claimed on a forge +it never chose. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable +from urllib.parse import urlsplit + +from .lifecycle.errors import LifecycleError + +PROVIDER_GITHUB = "github" +PROVIDER_GITLAB = "gitlab" +PROVIDER_UNKNOWN = "unknown" + +# The only hosts whose provider is a fact rather than a guess. +HOST_HINTS = {"github.com": PROVIDER_GITHUB, "gitlab.com": PROVIDER_GITLAB} + + +@dataclass(frozen=True) +class WorkAuthorityRef: + """One provider plus a stable project identity. Not a URL, not a hostname.""" + + provider: str + project: str + + def to_record(self) -> dict: + return {"provider": self.provider, "project": self.project} + + def __str__(self) -> str: + return f"{self.provider}:{self.project}" + + +@dataclass(frozen=True) +class ObservedRemote: + """A Git remote someone read locally. Evidence, never authority.""" + + name: str + url: str + + @property + def provider(self) -> str: + return provider_hint(self.url) + + @property + def identity(self) -> str | None: + return project_identity(self.url) + + def to_record(self) -> dict: + return {"name": self.name, "url": self.url, "provider": self.provider, + "project": self.identity} + + +def provider_hint(url: str) -> str: + """The provider a remote URL *names*: a hosted forge, or `unknown`.""" + + host, _ = _host_and_path(url) + return HOST_HINTS.get(host, PROVIDER_UNKNOWN) + + +def project_identity(url: str) -> str | None: + """`owner/name` (or a GitLab subgroup path) for a hosted URL, else None.""" + + host, path = _host_and_path(url) + if host not in HOST_HINTS: + return None + cleaned = path.strip("/") + if cleaned.endswith(".git"): + cleaned = cleaned[: -len(".git")] + segments = [segment for segment in cleaned.split("/") if segment] + if len(segments) < 2: + return None + return "/".join(segments) + + +def _host_and_path(url: str) -> tuple[str, str]: + """Host and path for both URL shapes Git uses, without guessing a provider.""" + + if "://" in url: + parts = urlsplit(url) + return (parts.hostname or "").lower(), parts.path + # scp-like: [user@]host:path + head, _, path = url.partition(":") + host = head.rpartition("@")[2] + return host.lower(), path + + +def observe_remotes(remotes: Iterable[tuple[str, str]]) -> tuple[ObservedRemote, ...]: + """(name, url) pairs as observed remotes; a thin adapter for callers.""" + + return tuple(ObservedRemote(name=name, url=url) for name, url in remotes) + + +def resolve_observed_work_authority(*, + explicit: WorkAuthorityRef | None = None, + declared: WorkAuthorityRef | None = None, + remotes: Iterable[ObservedRemote] = (), + ) -> WorkAuthorityRef: + """The authority the ordered rules accept, or a refusal that says why. + + `explicit` is a WorkItem/ChangeRequest repository reference (priority 1), + `declared` a harness statement (priority 2), `remotes` locally observed + evidence (priority 3). Two statements that disagree refuse as + `WORK_AUTHORITY_MISMATCH`; observation that is not unambiguous refuses as + `WORK_AUTHORITY_AMBIGUOUS` (a fork) or `WORK_AUTHORITY_UNAVAILABLE` + (nothing usable was observed). + """ + + if explicit is not None and declared is not None and explicit != declared: + raise LifecycleError( + "WORK_AUTHORITY_MISMATCH", + f"the explicit reference names {explicit} but the harness declares " + f"{declared}; reconcile them before mutating remote state", + explicit=explicit.to_record(), declared=declared.to_record()) + if explicit is not None: + return explicit + if declared is not None: + return declared + + candidates: list[WorkAuthorityRef] = [] + for remote in remotes: + if remote.provider == PROVIDER_UNKNOWN or remote.identity is None: + continue + candidate = WorkAuthorityRef(provider=remote.provider, project=remote.identity) + if candidate not in candidates: + candidates.append(candidate) + + if len(candidates) == 1: + return candidates[0] + observed = [remote.to_record() for remote in remotes] + if not candidates: + raise LifecycleError( + "WORK_AUTHORITY_UNAVAILABLE", + "no work authority could be resolved locally: no explicit reference, " + "no harness declaration, and no observed remote names a known forge", + remotes=observed) + raise LifecycleError( + "WORK_AUTHORITY_AMBIGUOUS", + "several observed remotes name different work authorities: " + + ", ".join(str(candidate) for candidate in candidates) + + "; declare the authority explicitly before mutating remote state", + candidates=[candidate.to_record() for candidate in candidates], + remotes=observed) + + +__all__ = ["WorkAuthorityRef", "ObservedRemote", "provider_hint", "project_identity", + "observe_remotes", "resolve_observed_work_authority", + "PROVIDER_GITHUB", "PROVIDER_GITLAB", "PROVIDER_UNKNOWN", "HOST_HINTS"] diff --git a/ainative/forge_cli.py b/ainative/forge_cli.py new file mode 100644 index 00000000..df503b75 --- /dev/null +++ b/ainative/forge_cli.py @@ -0,0 +1,85 @@ +"""`ainative forge detect|status` — observation only. + +Zero network, zero credentials, zero writes, zero persistent trust (PR-3, +#164). `detect` answers "what can be seen locally"; `status` adds the effective +feature set and the unresolved claim attempts. A fork renders as AMBIGUOUS and +nothing here prefers origin — the rendering is the diagnostic, the refusal +belongs to the mutation path (ADR-0018). +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from . import observation +from .cli_support import project_from, report as _report + + +def add_forge_parser(commands) -> None: + forge = commands.add_parser( + "forge", help="Observe the project's remotes and work authority.") + subcommands = forge.add_subparsers(dest="forge_command", required=True) + detect = subcommands.add_parser( + "detect", help="Observed remotes and the Work Authority resolution.") + _common(detect) + status = subcommands.add_parser( + "status", help="The forge picture: remotes, resolution, features, claims.") + _common(status) + + +def _common(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--project", type=Path, default=None, + help="project root (default: the current directory)") + parser.add_argument("--json", action="store_true", help="machine-readable output") + + +def _resolution_line(resolution: dict) -> str: + authority = resolution.get("authority") or {} + if authority: + return (f"Resolution: {resolution['state']} " + f"{authority.get('provider')}:{authority.get('project')}") + return f"Resolution: {resolution['state']}" + + +def detect_text(picture: dict) -> str: + lines = [f"Git repository: {'yes' if picture['git'] else 'no'}"] + if picture["remotes"]: + lines.append("Work authority (observed candidates):") + for remote in picture["remotes"]: + lines.append(f" {remote['name']:<10} {remote['provider']:<8} " + f"{remote['project'] or '-':<24} {remote['url']}") + else: + lines.append("Work authority (observed candidates): none") + lines.append(_resolution_line(picture["resolution"])) + if picture["resolution"].get("detail"): + lines.append(f" {picture['resolution']['detail']}") + return "\n".join(lines) + + +def status_text(view: dict) -> str: + features = view["features"] + summary = view["claim_attempts"] + lines = ["Active features: " + (", ".join(features["active"]) or "none") + + (" (projected from the V1 state)" if features["projected_from_legacy"] + else "")] + if summary["journal"] == "ok": + lines.append(f"Unresolved claim attempts: {len(summary['unresolved'])}") + else: + lines.append(f"Unresolved claim attempts: journal unavailable " + f"({summary['detail']})") + lines.append("") + lines.append(detect_text(view["forge"])) + return "\n".join(lines) + + +def run_forge_command(args: argparse.Namespace) -> int: + project = project_from(args) + if args.forge_command == "detect": + picture = observation.forge_picture(project) + return _report(args, picture, detect_text(picture)) + view = observation.project_view(project) + return _report(args, view, status_text(view)) + + +__all__ = ["add_forge_parser", "run_forge_command", "detect_text", "status_text"] diff --git a/ainative/lifecycle/data/components.json b/ainative/lifecycle/data/components.json index 5b6485a9..cee436fb 100644 --- a/ainative/lifecycle/data/components.json +++ b/ainative/lifecycle/data/components.json @@ -43,12 +43,12 @@ "engineering-method": { "version": 1, "kind": "file", - "source": "AGENTS.md", + "source": "templates/AGENTS.md", "destination": "AGENTS.md", "ownership": "MANAGED_MUTABLE", "required": true, "title": "Engineering method", - "description": "The shared cross-tool engineering rules every agent reads." + "description": "The shared cross-tool engineering rules every agent reads. The distributed copy (templates/AGENTS.md) speaks provider-neutral Work Authority; this repository's own AGENTS.md stays GitHub-specific (ADR-0018)." }, "conventions": { "version": 1, @@ -147,6 +147,21 @@ "title": "GitHub contribution templates", "description": "Generic bug/feature issue templates and PR template, installed as individually-owned managed files." }, + "gitlab-templates": { + "version": 1, + "kind": "tree", + "source": "templates/gitlab", + "destination": ".gitlab", + "include": [ + "issue_templates/bug.md", + "issue_templates/feature.md", + "merge_request_templates/default.md" + ], + "ownership": "MANAGED_MUTABLE", + "required": false, + "title": "GitLab contribution templates", + "description": "GitLab issue and merge-request templates, installed as individually-owned managed files. Owned by the forge-gitlab feature (ADR-0017)." + }, "verified-workplane": { "version": 1, "kind": "marker", diff --git a/ainative/lifecycle/data/features.json b/ainative/lifecycle/data/features.json new file mode 100644 index 00000000..3ebb9688 --- /dev/null +++ b/ainative/lifecycle/data/features.json @@ -0,0 +1,41 @@ +{ + "_comment": [ + "Declarative features. A feature is an optional project-scope capability", + "that can be enabled or disabled independently of the governance profile", + "(ADR-0017). `components` are component IDs from components.json; a component", + "belongs to at most one feature. `conflicts` are feature IDs that may not be", + "active at the same time, and the relation must be declared symmetrically.", + "`legacy_default` is the feature every V1 project projects to: the", + "compatibility default, never a write (ADR-0017 section 4)." + ], + "schema_version": 1, + "legacy_default": "forge-github", + "features": { + "forge-github": { + "version": 1, + "scope": "project", + "work_forge": true, + "title": "GitHub work forge", + "summary": "GitHub issue and pull-request templates for the project.", + "components": [ + "github-templates" + ], + "conflicts": [ + "forge-gitlab" + ] + }, + "forge-gitlab": { + "version": 1, + "scope": "project", + "work_forge": true, + "title": "GitLab work forge", + "summary": "GitLab issue and merge-request templates for the project.", + "components": [ + "gitlab-templates" + ], + "conflicts": [ + "forge-github" + ] + } + } +} diff --git a/ainative/lifecycle/data/profiles.json b/ainative/lifecycle/data/profiles.json index a62225e3..c48c6f51 100644 --- a/ainative/lifecycle/data/profiles.json +++ b/ainative/lifecycle/data/profiles.json @@ -21,8 +21,7 @@ "skills-agents", "machine-config", "gitignore-entry", - "claude-hook", - "github-templates" + "claude-hook" ] }, "verified": { diff --git a/ainative/lifecycle/errors.py b/ainative/lifecycle/errors.py index 10809b85..8498fe9b 100644 --- a/ainative/lifecycle/errors.py +++ b/ainative/lifecycle/errors.py @@ -34,12 +34,40 @@ "LOCK_HELD": EXIT_FAILED, "UPDATE_UNAVAILABLE": EXIT_FAILED, "UPDATE_CHECK_FAILED": EXIT_FAILED, + "UPDATE_SOURCE_CONFLICT": EXIT_INVALID_REQUEST, + "RELEASE_CONFIG_INVALID": EXIT_INVALID_REQUEST, + # Release V3 (ADR-0019). Enumeration, SemVer and integrity refusals. + "RELEASE_ENUMERATION_INCOMPLETE": EXIT_FAILED, + "RELEASE_NO_CANDIDATE": EXIT_FAILED, + "RELEASE_BUILD_METADATA_UNSUPPORTED": EXIT_FAILED, + "RELEASE_DUPLICATE_VERSION": EXIT_FAILED, + "RELEASE_INTEGRITY_METADATA_MISSING": EXIT_FAILED, + "RELEASE_INTEGRITY_METADATA_INVALID": EXIT_FAILED, + "RELEASE_MANIFEST_MISSING": EXIT_FAILED, + "RELEASE_MANIFEST_AMBIGUOUS": EXIT_FAILED, "UPDATE_INTEGRITY_FAILED": EXIT_FAILED, "UPDATE_INTEGRITY_METADATA_MISSING": EXIT_FAILED, "UPDATE_VERSION_MISMATCH": EXIT_FAILED, "CLI_UPDATE_REQUIRED": EXIT_FAILED, "ROLLBACK_UNAVAILABLE": EXIT_FAILED, "APPLY_FAILED": EXIT_FAILED, + # Features (ADR-0017). A project has at most one work forge; two is a + # refusal, never a silent pick. + "STATE_CONFLICTING_WORK_FORGE_FEATURES": EXIT_FAILED, + "FEATURE_UNKNOWN": EXIT_INVALID_REQUEST, + # Enabling a feature that conflicts with an active one: the remedy is an + # explicit switch, not an implicit disable. + "FEATURE_CONFLICT": EXIT_INVALID_REQUEST, + # Work Authority resolution (ADR-0018). Ambiguity stops remote mutation. + "WORK_AUTHORITY_UNAVAILABLE": EXIT_INVALID_REQUEST, + "WORK_AUTHORITY_AMBIGUOUS": EXIT_INVALID_REQUEST, + "WORK_AUTHORITY_MISMATCH": EXIT_INVALID_REQUEST, + # Claims (ADR-0018). The journal is written before the remote POST, and + # an unknown outcome is never retried automatically. + "CLAIM_INVALID": EXIT_INVALID_REQUEST, + "CLAIM_CONFLICT": EXIT_FAILED, + "CLAIM_UNCERTAIN": EXIT_FAILED, + "CLAIM_JOURNAL_UNAVAILABLE": EXIT_FAILED, # Machine-wide integration (`ainative machine`, `ainative setup`). "MACHINE_MANIFEST_UNREADABLE": EXIT_INVALID_REQUEST, "MACHINE_VAULT_PAIR_REQUIRED": EXIT_INVALID_REQUEST, diff --git a/ainative/lifecycle/feature_cli.py b/ainative/lifecycle/feature_cli.py new file mode 100644 index 00000000..5c36b1fd --- /dev/null +++ b/ainative/lifecycle/feature_cli.py @@ -0,0 +1,96 @@ +"""The `ainative feature` command surface: parser, rendering, transitions. + +Split out of `cli.py` for the size budget. The command is self-contained — its +grammar, its status rendering and its transition handler — and the dispatcher +stays the dispatcher. The two rendering callables are injected so this module +never imports `cli` back (which would be a cycle). +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from ..cli_support import plan_text as _plan_text, project_from, report as _report +from . import features as featureslib +from . import installer +from . import manifest as manifestlib +from . import state as statelib + + +def add_feature_parser(commands) -> None: + feature = commands.add_parser("feature", + help="Inspect or change optional project features.") + feature_commands = feature.add_subparsers(dest="feature_command", required=True) + status = feature_commands.add_parser("status", + help="Report the effective feature set.") + _common(status, dry_run=False) + enable = feature_commands.add_parser("enable", help="Activate a feature.") + enable.add_argument("target") + _common(enable) + disable = feature_commands.add_parser("disable", help="Deactivate a feature.") + disable.add_argument("target") + _common(disable) + switch = feature_commands.add_parser( + "switch", help="Replace the active work forge ('none' for Generic Git).") + switch.add_argument("target", help="a feature name, or 'none'") + _common(switch) + + +def _common(parser: argparse.ArgumentParser, *, dry_run: bool = True) -> None: + parser.add_argument("--project", type=Path, default=None, + help="project root (default: the current directory)") + parser.add_argument("--json", action="store_true", help="machine-readable output") + if dry_run: + parser.add_argument("--dry-run", action="store_true", + help="print the change plan; touch nothing") + parser.add_argument("--force-unlock", action="store_true", + help="take the lifecycle lock even if another one is recorded") + + +def status_record(project: Path) -> tuple[dict, str]: + """The status payload and its text rendering, from the shared projection.""" + + distribution = manifestlib.load() + effective = featureslib.project_install_state(statelib.load(project), distribution) + active = list(effective.active_features) if effective else [] + record = { + "installed": effective is not None, + "active_profile": effective.active_profile if effective else None, + "active_features": active, + "projected_from_legacy": bool(effective and effective.projected_from_legacy), + "declared": {name: {"title": value.title, "work_forge": value.work_forge, + "components": list(value.components), + "conflicts": list(value.conflicts)} + for name, value in sorted(distribution.features.items())}, + } + lines = [f"Active features: {', '.join(active) or 'none'}"] + for name in sorted(record["declared"]): + mark = "*" if name in active else " " + lines.append(f" [{mark}] {name} — {record['declared'][name]['title']}") + if record["projected_from_legacy"]: + lines.append(" (projected from the V1 state: " + f"{featureslib.legacy_default(distribution)} is the " + "compatibility default)") + return record, "\n".join(lines) + + +def run_feature_command(args: argparse.Namespace) -> int: + project = project_from(args) + if args.feature_command == "status": + record, text = status_record(project) + return _report(args, record, text) + + transition = {"enable": None, "disable": None, "switch_to": None} + if args.feature_command == "enable": + transition["enable"] = args.target + elif args.feature_command == "disable": + transition["disable"] = args.target + else: + transition["switch_to"] = args.target + result = installer.set_features(project, dry_run=args.dry_run, + force_unlock=args.force_unlock, **transition) + return _report(args, result.to_record(), _plan_text(result)) + + +__all__ = ["add_feature_parser", "run_feature_command", "status_record"] diff --git a/ainative/lifecycle/features.py b/ainative/lifecycle/features.py new file mode 100644 index 00000000..724b05dd --- /dev/null +++ b/ainative/lifecycle/features.py @@ -0,0 +1,169 @@ +"""The feature view of a project: one projection, shared by every read path. + +ADR-0017 makes profiles and features orthogonal. A profile is a governance +level; a feature is an optional project-scope capability. State V2 records the +active features, older states do not — and "which features does this project +have?" must never have two answers. Every reader therefore goes through +`project_install_state()`, which is also the single place that knows the V1 +compatibility default (a legacy project is a GitHub project until it +explicitly switches). + +The projection never writes. `migrate_state()` is its write-side counterpart: +it stamps the current schema and seeds the legacy default exactly once, inside +an existing mutation transaction (state-last, ADR-0009 section 4). It plans no +file changes of its own, so a managed file that is already absent stays +absent through the migration; only an explicit feature transition seeds files. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from . import manifest as manifestlib +from . import state as statelib +from .errors import LifecycleError + + +@dataclass(frozen=True) +class EffectiveState: + """The feature view of one project, projected from whatever is stored.""" + + schema_version: int + active_profile: str + active_features: tuple[str, ...] + projected_from_legacy: bool + + def has_feature(self, name: str) -> bool: + return name in self.active_features + + def feature_objects(self, distribution: manifestlib.Distribution) -> tuple: + return tuple(distribution.feature(name) for name in self.active_features) + + +def project_install_state(state: statelib.InstallState | None, + distribution: manifestlib.Distribution) -> EffectiveState | None: + """The features a project effectively has; None when it has no state. + + A V1 state has no `active_features` and projects to the declared legacy + default (ADR-0017 section 4). A V2 state is validated: an undeclared + feature refuses as `INSTALL_STATE_CORRUPTED`, two work forges refuse as + `STATE_CONFLICTING_WORK_FORGE_FEATURES` — fail-closed, never a silent pick. + """ + + if state is None: + return None + if state.schema_version < statelib.SCHEMA_VERSION: + return EffectiveState(schema_version=state.schema_version, + active_profile=state.active_profile, + active_features=(legacy_default(distribution),), + projected_from_legacy=True) + return EffectiveState(schema_version=state.schema_version, + active_profile=state.active_profile, + active_features=_validated(state.active_features, distribution), + projected_from_legacy=False) + + +def legacy_default(distribution: manifestlib.Distribution) -> str: + """The feature a V1 project projects to; declared, never hard-coded.""" + + default = distribution.legacy_default_feature + if not default: + raise LifecycleError("MANIFEST_INVALID", + "the catalogue declares no legacy default feature") + return default + + +def _validated(names: list[str], distribution: manifestlib.Distribution) -> tuple[str, ...]: + ordered = tuple(dict.fromkeys(names)) + for name in ordered: + try: + distribution.feature(name) + except LifecycleError as error: + if error.code != "FEATURE_UNKNOWN": + raise + # A state naming a feature this release does not know is a state + # problem, not a request problem: the same stance as a newer schema + # — upgrade the CLI rather than downgrading state. + raise LifecycleError( + "INSTALL_STATE_CORRUPTED", + f"the saved state activates feature {name!r}, which this release " + "does not know; upgrade the CLI rather than downgrading state") from error + work_forges = [name for name in ordered if distribution.feature(name).work_forge] + if len(work_forges) > 1: + raise LifecycleError( + "STATE_CONFLICTING_WORK_FORGE_FEATURES", + "the saved state activates two work forges (" + + ", ".join(sorted(work_forges)) + + "); a project has at most one — use `ainative feature switch `", + features=sorted(work_forges)) + for name in ordered: + for other in distribution.feature(name).conflicts: + if other in ordered: + raise LifecycleError( + "INSTALL_STATE_CORRUPTED", + f"the saved state activates conflicting features " + f"{name!r} and {other!r}") + return ordered + + +def migrate_state(state: statelib.InstallState, + distribution: manifestlib.Distribution) -> statelib.InstallState: + """Stamp the current schema, seeding the legacy default exactly once. + + Returns the state object it was given (every caller works on the loaded + object); a state already at the current schema is returned unchanged, so + running this twice writes the same bytes. + """ + + if state.schema_version >= statelib.SCHEMA_VERSION: + return state + if not state.active_features: + state.active_features = [legacy_default(distribution)] + state.schema_version = statelib.SCHEMA_VERSION + return state + + +def transition(distribution: manifestlib.Distribution, current: tuple[str, ...], *, + enable: str | None = None, disable: str | None = None, + switch_to: str | None = None) -> tuple[str, ...]: + """The feature set a requested transition produces, or a refusal. + + Exactly one of `enable` / `disable` / `switch_to` is given. A transition is + idempotent: asking for the set the project already has returns it + unchanged. Enabling a feature that conflicts with an active one refuses + with the remedy stated — the conflict is never resolved by an implicit + disable. `switch_to="none"` is the Generic Git shape: it removes every + work forge and nothing else. + """ + + requested = [value for value in (enable, disable) if value is not None] + if len(requested) + (1 if switch_to is not None else 0) != 1: + raise ValueError( + "a feature transition takes exactly one of enable/disable/switch_to") + + if enable is not None: + candidate = distribution.feature(enable) + for name in current: + if name in candidate.conflicts: + raise LifecycleError( + "FEATURE_CONFLICT", + f"feature {enable!r} conflicts with the active feature {name!r}; " + f"use `ainative feature switch {enable}`", + requested=enable, active=name) + return current if enable in current else current + (enable,) + + if disable is not None: + distribution.feature(disable) + return tuple(name for name in current if name != disable) + + if switch_to == "none": + return tuple(name for name in current if not distribution.feature(name).work_forge) + + distribution.feature(switch_to) + conflicts = set(distribution.feature(switch_to).conflicts) + kept = tuple(name for name in current if name not in conflicts and name != switch_to) + return kept + (switch_to,) + + +__all__ = ["EffectiveState", "project_install_state", "legacy_default", + "migrate_state", "transition"] diff --git a/ainative/lifecycle/installer.py b/ainative/lifecycle/installer.py index 9589d0ae..4c4882af 100644 --- a/ainative/lifecycle/installer.py +++ b/ainative/lifecycle/installer.py @@ -13,6 +13,7 @@ from pathlib import Path from . import environment +from . import features as featureslib from . import legacy as legacylib from . import manifest as manifestlib from . import planner as plannerlib @@ -64,6 +65,7 @@ def to_record(self) -> dict: "transaction": self.transaction, "notices": list(self.notices), "active_profile": self.state.active_profile if self.state else None, + "active_features": sorted(self.state.active_features) if self.state else None, } if self.legacy is not None and self.legacy.detected: record["legacy_adoption"] = self.legacy.to_record() @@ -93,19 +95,32 @@ def _blocking_transaction(project: Path) -> None: transactions=txnlib.summarise(pending)) -def _fresh_state(source: DistributionSource, profile: str) -> InstallState: +def _fresh_state(source: DistributionSource, profile: str, + distribution: Distribution) -> InstallState: + """A new project starts on the catalogue's legacy default feature. + + That default is the compatibility rule ADR-0017 section 4 fixes for V1 + projects; without it a fresh Standard install would stop shipping the + GitHub templates every install has shipped so far — a behavior change + nobody asked for. `feature switch none` opts out explicitly. + """ + return InstallState(stack_version=source.version, source_version=source.version, - active_profile=profile) + active_profile=profile, + active_features=[featureslib.legacy_default(distribution)]) -def _commit_state(project: Path, state: InstallState, plan: Plan, - distribution: Distribution, source: DistributionSource, - journal_id: str) -> None: - """Fold a successfully applied plan into the install state.""" +def _fold_plan(state: InstallState, plan: Plan, distribution: Distribution, + target_features: tuple[str, ...] | None) -> None: + """Record a plan's outcome in the install state (no write, no metadata).""" for identifier in plan.components_removed: state.drop_component(identifier) - for identifier in distribution.effective_component_ids(plan.to_profile or state.active_profile): + if target_features is not None: + state.active_features = sorted(target_features) + features = tuple(state.active_features) + for identifier in plannerlib.wanted_component_ids( + distribution, plan.to_profile or state.active_profile, features): component = distribution.component(identifier) entries = plannerlib.managed_entries(component, plan.changes) # A CONFLICT leaves the file alone; keep whatever we already knew about @@ -123,6 +138,22 @@ def _commit_state(project: Path, state: InstallState, plan: Plan, if identifier not in state.installed_components: state.installed_components.append(identifier) + +def _commit_state(project: Path, state: InstallState, plan: Plan, + distribution: Distribution, source: DistributionSource, + journal_id: str, *, + target_features: tuple[str, ...] | None = None) -> None: + """Fold a successfully applied plan into the install state.""" + + _fold_plan(state, plan, distribution, target_features) + if plan.to_profile and plan.to_profile != state.active_profile: + state.previous_profile = state.active_profile + state.active_profile = plan.to_profile + state.stack_version = source.version + state.source_version = source.version + state.last_transaction = journal_id + statelib.save(project, state) + if plan.to_profile and plan.to_profile != state.active_profile: state.previous_profile = state.active_profile state.active_profile = plan.to_profile @@ -140,12 +171,19 @@ def plan_profile(project: Path, distribution: Distribution, source: Distribution distribution.profile(target_profile) # refuse an unknown profile up front current = state if state is not None else statelib.load(project) adoption = legacylib.Adoption(False, (), (), (), ()) - if current is None: - current = _fresh_state(source, target_profile) + fresh = current is None + if fresh: + current = _fresh_state(source, target_profile, distribution) adoption = legacylib.adopt(project, distribution, source, target_profile) current = legacylib.apply_to_state(current, adoption, stack_version=source.version) + else: + # A V1 state migrates inside this operation (state-last, ADR-0017 + # section 5). The migration itself plans no file change: a managed file + # that is already absent stays absent. + current = featureslib.migrate_state(current, distribution) plan = plannerlib.build_install_plan(project, distribution, source, current, - target_profile, operation=operation) + target_profile, operation=operation, + seed_feature_files=fresh) return plan, current, adoption @@ -169,11 +207,14 @@ def install(project: Path, target_profile: str, *, dry_run: bool = False, if dry_run or plan.is_noop: if plan.is_noop and not dry_run: - # Nothing had to change on disk, but the profile may still need - # recording. That is a write to the install state, so it takes the - # lock like every other write — an unlocked commit here could - # interleave with another operation's own commit. - if statelib.load(project) is None or state.active_profile != target_profile: + # Nothing had to change on disk, but the profile — or the schema a + # V1 state is still on — may need recording. That is a write to the + # install state, so it takes the lock like every other write — an + # unlocked commit here could interleave with another operation's + # own commit. + loaded = statelib.load(project) + if (loaded is None or state.active_profile != target_profile + or loaded.schema_version < statelib.SCHEMA_VERSION): with locklib.acquire(project, operation, force=force_unlock): state.active_profile = target_profile for identifier in distribution.effective_component_ids(target_profile): @@ -247,5 +288,70 @@ def switch(project: Path, target_profile: str, **kwargs) -> OperationResult: return install(project, target_profile, operation="profile-switch", **kwargs) -__all__ = ["OperationResult", "install", "switch", "plan_profile", "require_project", - "trust_anchor_present", "TRUST_ANCHOR_RELATIVE", "VERIFIED_BOOTSTRAP_NOTICE"] +def set_features(project: Path, *, enable: str | None = None, disable: str | None = None, + switch_to: str | None = None, dry_run: bool = False, + distribution: Distribution | None = None, + source: DistributionSource | None = None, + force_unlock: bool = False) -> OperationResult: + """Enable, disable or switch features in one lifecycle transaction. + + The sequence is the profile path's, unchanged (ADR-0017 section 6): take + the project lifecycle lock, reload the state, project the effective V2 set, + plan, apply, verify, write the state last, commit. A V1 state migrates + inside the same transaction and the migration plans no file change of its + own, so a managed file that is already absent stays absent. + """ + + from . import features as featureslib + from . import lock as locklib + + project = require_project(project) + distribution = distribution or manifestlib.load() + source = source or sourcelib.resolve() + _blocking_transaction(project) + + with locklib.acquire(project, "feature", force=force_unlock): + _blocking_transaction(project) + state = statelib.load(project) + if state is None: + raise LifecycleError("NOT_INSTALLED", + f"no AI Native installation recorded in {project}") + was_legacy = state.schema_version < statelib.SCHEMA_VERSION + state = featureslib.migrate_state(state, distribution) + effective = featureslib.project_install_state(state, distribution) + current = effective.active_features + target = featureslib.transition(distribution, current, enable=enable, + disable=disable, switch_to=switch_to) + enabling = [name for name in target if name not in current] + notices = [f"feature {name!r} enabled" for name in enabling] + notices += [f"feature {name!r} disabled" for name in current + if name not in target] + plan = plannerlib.build_install_plan(project, distribution, source, state, + state.active_profile, operation="feature", + target_features=target, + seed_feature_files=bool(enabling)) + if plan.is_noop or dry_run: + if not dry_run: + # A no-op transition changes nothing — including the state + # bytes — unless it still has something to record: the schema + # migration, a feature set that moved, or component records the + # plan proved are gone (a disable whose files were all + # preserved must still drop its records). + needs_recording = (was_legacy or tuple(state.active_features) != target + or bool(plan.components_removed)) + if needs_recording: + _fold_plan(state, plan, distribution, target) + statelib.save(project, state) + return OperationResult("feature", plan, applied=False, dry_run=dry_run, + state=state, notices=notices) + applier = txnlib.Applier(project, distribution, source, plan) + journal = applier.run(lambda: _commit_state(project, state, plan, distribution, + source, applier.journal.identifier, + target_features=target)) + return OperationResult("feature", plan, applied=True, dry_run=False, state=state, + transaction=journal.identifier, notices=notices) + + +__all__ = ["OperationResult", "install", "switch", "set_features", "plan_profile", + "require_project", "trust_anchor_present", "TRUST_ANCHOR_RELATIVE", + "VERIFIED_BOOTSTRAP_NOTICE"] diff --git a/ainative/lifecycle/manifest.py b/ainative/lifecycle/manifest.py index 5632d098..61ed7e55 100644 --- a/ainative/lifecycle/manifest.py +++ b/ainative/lifecycle/manifest.py @@ -82,13 +82,35 @@ class Profile: components: tuple[str, ...] +# The only feature scope V1 knows: a feature is an optional project-scope +# capability. Machine-scope integration stays with `ainative machine` and is not +# a feature (ADR-0017 section 9). +FEATURE_SCOPE_PROJECT = "project" +FEATURE_SCOPES = (FEATURE_SCOPE_PROJECT,) + + +@dataclass(frozen=True) +class Feature: + """An optional capability, declared independently of any profile.""" + + name: str + scope: str + work_forge: bool + title: str + summary: str + components: tuple[str, ...] + conflicts: tuple[str, ...] + + @dataclass(frozen=True) class Distribution: - """The component and profile catalogue, already validated.""" + """The component, profile and feature catalogue, already validated.""" components: Mapping[str, Component] profiles: Mapping[str, Profile] default_profile: str + features: Mapping[str, Feature] = field(default_factory=dict) + legacy_default_feature: str = "" schema_version: int = 1 _cache: dict = field(default_factory=dict, compare=False, repr=False) @@ -100,6 +122,14 @@ def profile(self, name: str) -> Profile: raise LifecycleError("PROFILE_INVALID", f"unknown profile {name!r} (known: {known})") from None + def feature(self, name: str) -> Feature: + try: + return self.features[name] + except KeyError: + known = ", ".join(sorted(self.features)) + raise LifecycleError("FEATURE_UNKNOWN", + f"unknown feature {name!r} (known: {known})") from None + def component(self, identifier: str) -> Component: try: return self.components[identifier] @@ -222,6 +252,67 @@ def _build_profile(name: str, raw: Any) -> Profile: ) +def _build_feature(name: str, raw: Any, + components: Mapping[str, Component]) -> Feature: + if not isinstance(raw, dict): + raise LifecycleError("MANIFEST_INVALID", f"feature {name!r} is not an object") + scope = raw.get("scope") + if scope not in FEATURE_SCOPES: + raise LifecycleError( + "MANIFEST_INVALID", + f"feature {name!r}: scope {scope!r} is not a project feature; " + "machine-scope integration is not a feature (ADR-0017 section 9)") + identifiers = _strings(raw.get("components"), "components", name) + for identifier in identifiers: + if identifier not in components: + raise LifecycleError( + "MANIFEST_INVALID", + f"feature {name!r} references unknown component {identifier!r}") + return Feature( + name=name, + scope=scope, + work_forge=bool(raw.get("work_forge", False)), + title=str(raw.get("title", name)), + summary=str(raw.get("summary", "")), + components=identifiers, + conflicts=_strings(raw.get("conflicts"), "conflicts", name), + ) + + +def _link_features(features: Mapping[str, Feature]) -> None: + """Refuse a catalogue whose conflicts or component claims cannot be honored. + + Two features sharing a component would make disable/enable ambiguous (whose + file is it?); an asymmetric conflict would let one side activate what the + other refuses; both are catalogue defects, not states a later stage could + resolve. + """ + + claimed: dict[str, str] = {} + for name, feature in features.items(): + if name in feature.conflicts: + raise LifecycleError("MANIFEST_INVALID", + f"feature {name!r} conflicts with itself") + for other in feature.conflicts: + if other not in features: + raise LifecycleError( + "MANIFEST_INVALID", + f"feature {name!r} conflicts with unknown feature {other!r}") + if name not in features[other].conflicts: + raise LifecycleError( + "MANIFEST_INVALID", + f"the conflict between {name!r} and {other!r} must be " + "declared symmetrically") + for identifier in feature.components: + if identifier in claimed: + raise LifecycleError( + "MANIFEST_INVALID", + f"component {identifier!r} is claimed by features " + f"{claimed[identifier]!r} and {name!r}; a component belongs " + "to exactly one feature") + claimed[identifier] = name + + def _reject_collisions(components: Mapping[str, Component]) -> None: """Two destinations that differ only in case are one path on Windows. @@ -266,7 +357,21 @@ def load(data_dir: Path | None = None) -> Distribution: if default not in profiles: raise LifecycleError("MANIFEST_INVALID", f"default profile {default!r} is not declared") - distribution = Distribution(components=components, profiles=profiles, default_profile=default, + feature_payload = _read_json(directory / "features.json") + raw_features = feature_payload.get("features") + if not isinstance(raw_features, dict) or not raw_features: + raise LifecycleError("MANIFEST_INVALID", "features.json declares no features") + features = {name: _build_feature(name, raw, components) + for name, raw in sorted(raw_features.items())} + _link_features(features) + legacy = feature_payload.get("legacy_default") + if not isinstance(legacy, str) or legacy not in features: + raise LifecycleError("MANIFEST_INVALID", + f"legacy_default {legacy!r} is not a declared feature") + + distribution = Distribution(components=components, profiles=profiles, + default_profile=default, features=features, + legacy_default_feature=legacy, schema_version=int(profile_payload.get("schema_version", 1))) for name in profiles: distribution.effective_component_ids(name) # proves the graph resolves @@ -274,8 +379,9 @@ def load(data_dir: Path | None = None) -> Distribution: __all__ = [ - "Component", "Profile", "Distribution", "load", "DATA_DIR", + "Component", "Profile", "Feature", "Distribution", "load", "DATA_DIR", "KIND_TREE", "KIND_FILE", "KIND_TEMPLATE", "KIND_EXTERNAL_BLOCK", "KIND_JSON_HOOK", "KIND_MARKER", "KIND_DATA_ROOT", "KINDS", "MANAGED_IMMUTABLE", "MANAGED_MUTABLE", "USER_DATA", "EXTERNAL_CONFIG", "OWNERSHIPS", + "FEATURE_SCOPE_PROJECT", "FEATURE_SCOPES", ] diff --git a/ainative/lifecycle/planner.py b/ainative/lifecycle/planner.py index 63ebdcbb..fa637cea 100644 --- a/ainative/lifecycle/planner.py +++ b/ainative/lifecycle/planner.py @@ -14,6 +14,7 @@ from . import digest as digestlib from . import external_json +from . import features as featureslib from . import hooks as hookslib from . import manifest as manifestlib from .errors import LifecycleError @@ -180,8 +181,14 @@ def component_files(component: Component, def _install_change(project: Path, destination: str, source_relative: str | None, component: Component, source: DistributionSource, - state: InstallState, payload: bytes | None) -> Change: - """One file's decision, taken from ownership and the recorded digest.""" + state: InstallState, payload: bytes | None, *, + seed_missing: bool) -> Change: + """One file's decision, taken from ownership and the recorded digest. + + `seed_missing=False` is the feature-component policy: a feature seeds its + files when it is enabled, so a file the user removed stays removed through + ordinary maintenance (ADR-0017 section 5). Profile components always seed. + """ target = resolve_within(project, destination) known = state.file_for(destination) @@ -203,6 +210,10 @@ def _install_change(project: Path, destination: str, source_relative: str | None "seeded from the shipped template", source_relative, new_digest) if not target.exists(): + if not seed_missing: + return Change(SKIP, destination, component.identifier, component.ownership, + "absent — a feature seeds its files when it is enabled", + source_relative, None) return Change(CREATE, destination, component.identifier, component.ownership, "absent", source_relative, new_digest) @@ -259,7 +270,8 @@ def _prune_changes(project: Path, component: Component, wanted: Iterable[str], def plan_component_install(project: Path, component: Component, source: DistributionSource, - state: InstallState, profile: str) -> list[Change]: + state: InstallState, profile: str, *, + seed_missing: bool = True) -> list[Change]: if component.kind == manifestlib.KIND_DATA_ROOT: return [Change(SKIP, path, component.identifier, component.ownership, "user data root — declared, never written", kind="data_root") @@ -301,7 +313,7 @@ def plan_component_install(project: Path, component: Component, source: Distribu if component.kind == manifestlib.KIND_MARKER: payload = marker_payload(component, source, profile).encode("utf-8") changes.append(_install_change(project, destination, source_relative, component, - source, state, payload)) + source, state, payload, seed_missing=seed_missing)) wanted.append(destination) changes.extend(_prune_changes(project, component, wanted, state)) return changes @@ -389,12 +401,47 @@ def plan_component_removal(project: Path, component: Component, state: InstallSt for entry in state.files_for_component(component.identifier)] +def wanted_component_ids(distribution: Distribution, profile: str, + features: tuple[str, ...]) -> list[str]: + """Profile components first, then the active features' components. + + One owner for the wanted set: the planner builds the plan from it and the + installer records the outcome from it, so the two cannot drift into + planning one set and recording another. + """ + + wanted = list(distribution.effective_component_ids(profile)) + for name in features: + for identifier in distribution.feature(name).components: + if identifier not in wanted: + wanted.append(identifier) + return wanted + + def build_install_plan(project: Path, distribution: Distribution, source: DistributionSource, state: InstallState, target_profile: str, *, - operation: str) -> Plan: - """Install or switch to `target_profile`, changing only what differs.""" + operation: str, + target_features: tuple[str, ...] | None = None, + seed_feature_files: bool = False) -> Plan: + """Install or switch to `target_profile`, changing only what differs. + + `target_features` names the features the project must end up with; None + keeps the effective set the state projects to (which is how a V1 state's + legacy default keeps its files maintained instead of being removed as + "no longer wanted"). Feature components are planned like any other managed + file, with one difference: their absent files are seeded only when a + feature is being enabled (`seed_feature_files`), so a file the user removed + stays removed through ordinary maintenance. + """ + + if target_features is None: + effective = featureslib.project_install_state(state, distribution) + target_features = effective.active_features if effective else () + feature_components: set[str] = set() + for name in target_features: + feature_components.update(distribution.feature(name).components) - wanted = distribution.effective_component_ids(target_profile) + wanted = wanted_component_ids(distribution, target_profile, target_features) present = list(state.installed_components) plan = Plan(operation=operation, project=project, from_profile=state.active_profile if present else None, @@ -402,8 +449,9 @@ def build_install_plan(project: Path, distribution: Distribution, source: Distri for identifier in wanted: component = distribution.component(identifier) + seed = identifier not in feature_components or seed_feature_files plan.changes.extend(plan_component_install(project, component, source, state, - target_profile)) + target_profile, seed_missing=seed)) if identifier not in present: plan.components_added.append(identifier) diff --git a/ainative/lifecycle/provider.py b/ainative/lifecycle/provider.py index f77ee3dd..47fc943b 100644 --- a/ainative/lifecycle/provider.py +++ b/ainative/lifecycle/provider.py @@ -30,6 +30,10 @@ from . import version as versionlib from .digest import digest_bytes from .errors import LifecycleError +# The selector names live with their owner (release_source); re-exported here +# because they have always been importable through the provider module. +from .release_source import (GITLAB_TOKEN_ENV, LOCAL_SOURCE_ENV, PROVIDER_ENV, + RELEASE_URL_ENV) def _update_token() -> str: @@ -41,10 +45,23 @@ def _update_token() -> str: return value return "" + +def environment_token() -> str: + """The token this environment offers, if any. Never logged anywhere. + + Public for the V3 providers, which attach it only when the transport's + endpoint rule allows the origin (PR-0A). + """ + + return _update_token() + + +def environment_gitlab_token() -> str: + """The GitLab token this environment offers, if any. Never logged anywhere.""" + + return (os.environ.get(GITLAB_TOKEN_ENV) or "").strip() + DEFAULT_RELEASE_URL = "https://api.github.com/repos/Rwanbt/ai-native-dev-stack/releases/latest" -PROVIDER_ENV = "AINATIVE_UPDATE_PROVIDER" # "github" (default) | "local" -LOCAL_SOURCE_ENV = "AINATIVE_UPDATE_LOCAL_DIR" -RELEASE_URL_ENV = "AINATIVE_UPDATE_URL" # Authenticated checks, when the environment provides a token. Never logged, # never persisted: NAT-shared users hit the anonymous rate limit otherwise. TOKEN_ENVS = ("GITHUB_TOKEN", "GH_TOKEN") @@ -419,18 +436,47 @@ def verify_archive(payload: bytes, expected: str | None) -> str: def build(channel: str = "stable") -> UpdateProvider: - """The provider this environment selects. One variable, no discovery.""" + """The provider this environment selects. One resolver, no discovery. - selected = (os.environ.get(PROVIDER_ENV) or "").strip().lower() - if selected == "local": - root = os.environ.get(LOCAL_SOURCE_ENV) - if not root: - raise LifecycleError("UPDATE_CHECK_FAILED", - f"{PROVIDER_ENV}=local requires {LOCAL_SOURCE_ENV}") - return LocalDirectoryProvider(Path(root)) - if selected in ("", "github", "release-api"): - return ReleaseApiProvider() - raise LifecycleError("UPDATE_CHECK_FAILED", f"unknown update provider {selected!r}") + The decision lives in `release_source.resolve_release_source()` so + `update`, `update check`, `status` and `doctor` cannot disagree about it + (ADR-0019 section 1); this function only constructs the selected provider. + """ + + from . import release_source as release_sourcelib + + source = release_sourcelib.resolve_release_source() + if source.kind == release_sourcelib.KIND_GITLAB: + raise LifecycleError("UPDATE_CHECK_FAILED", + "the gitlab provider speaks Release V3 only") + if source.kind == release_sourcelib.KIND_LOCAL: + return LocalDirectoryProvider(source.directory) + return ReleaseApiProvider(url=source.metadata_url, endpoint=source.endpoint) + + +def build_v3(channel: str = "stable"): + """The V3 provider for the resolved source (ADR-0019 section 11). + + The second generation of the same seam: the V2 path speaks a `Release` + listing, the V3 path speaks bounded enumeration plus an anchored manifest. + The updater prefers V3 and falls back to V2 only when a complete + enumeration proves the channel is V2-era. + """ + + from . import release_providers as release_providerslib + from . import release_source as release_sourcelib + + source = release_sourcelib.resolve_release_source() + if source.kind == release_sourcelib.KIND_LOCAL: + return release_providerslib.LocalReleaseProvider(source.directory) + if source.kind == release_sourcelib.KIND_ANONYMOUS: + return release_providerslib.AnonymousReleaseApiProvider(source.metadata_url, + source.endpoint) + if source.kind == release_sourcelib.KIND_GITLAB: + return release_providerslib.GitLabReleaseProvider(source.endpoint, + source.project_ref) + # github and named providers are both GitHub-Release-API-compatible. + return release_providerslib.GitHubReleaseProvider(source.endpoint) def copy_tree(source: Path, destination: Path) -> None: @@ -447,5 +493,6 @@ def copy_tree(source: Path, destination: Path) -> None: "LIFECYCLE_BUNDLE_PREFIX", "LIFECYCLE_BUNDLE_SUFFIX", "NETWORK_TIMEOUT_SECONDS", "MAX_ARCHIVE_BYTES", "MAX_METADATA_BYTES", "ReleaseProviderEndpointConfig", - "upgrade_command", "UPGRADE_COMMAND_TEMPLATE", + "upgrade_command", "UPGRADE_COMMAND_TEMPLATE", "environment_token", + "environment_gitlab_token", ] \ No newline at end of file diff --git a/ainative/lifecycle/release_providers.py b/ainative/lifecycle/release_providers.py new file mode 100644 index 00000000..e72d890d --- /dev/null +++ b/ainative/lifecycle/release_providers.py @@ -0,0 +1,521 @@ +"""The V3 providers: GitHub.com, the anonymous release API, and a local mirror. + +Each provider implements the contract in `release_v3` and nothing else: it +enumerates what its source publishes, fetches the manifest bytes, fetches an +artifact. Every trust decision stays in `release_v3` — the provider that +fetched a manifest never decides whether to believe it (ADR-0019 sections 9–11). + +GitHub and the anonymous release document share the asset-selection rules of +the V2 path: an `assets[].url` API locator is preferred, `browser_download_url` +is the fallback, and neither is ever authenticated off the endpoint's declared +origin (PR-0A). The local mirror executes the same logical chain as a network +provider — its index supplies the manifest locator, size and SHA-256, and +nothing is trusted because "it is on disk". + +Enumeration is bounded. A provider that could not exhaust its listing says so +(`complete=False`), and `release_v3.select_candidate` then refuses rather than +picking the best of what it happened to see. +""" + +from __future__ import annotations + +import json +import urllib.parse +from pathlib import Path + +from . import release_v3 as release_v3lib +from . import transport as transportlib +from . import version as versionlib +from .errors import LifecycleError +from .paths import validate_relative + +MANIFEST_ASSET_NAME = "ainative-release-v3.json" +MAX_MANIFEST_BYTES = 1 << 20 # 1 MiB of release manifest is already absurd +MAX_ARCHIVE_BYTES = 256 << 20 # 256 MiB — mirrors the V2 bound +MAX_ENUMERATION_ENTRIES = 30 # one API page; beyond it, incomplete +GITLAB_PACKAGE_NAME = "ai-native-dev-stack" + + +def _json_object(payload: bytes, source: str) -> dict: + try: + document = json.loads(payload.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as error: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"release metadata from {source} is not valid JSON: " + f"{error}") from error + if not isinstance(document, dict): + raise LifecycleError("UPDATE_CHECK_FAILED", + f"release metadata from {source} is not an object") + return document + + +def _json_list(payload: bytes, source: str) -> list: + try: + document = json.loads(payload.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as error: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"release listing from {source} is not valid JSON: " + f"{error}") from error + if not isinstance(document, list): + raise LifecycleError("UPDATE_CHECK_FAILED", + f"release listing from {source} is not a list") + return document + + +def _asset_url(asset: dict) -> str | None: + for key in ("url", "browser_download_url"): + value = asset.get(key) + if isinstance(value, str) and value.lower().startswith("https://"): + return value + return None + + +def _manifest_asset(document: dict) -> dict | None: + for asset in document.get("assets") or []: + if isinstance(asset, dict) and asset.get("name") == MANIFEST_ASSET_NAME: + return asset + return None + + +def _anchor(asset: dict) -> tuple[str | None, int | None]: + raw_digest = asset.get("digest") + if isinstance(raw_digest, str) and raw_digest.startswith("sha256:"): + digest: str | None = raw_digest.split(":", 1)[1].strip().lower() + elif isinstance(raw_digest, str) and raw_digest.strip(): + # Present but not sha256: — kept so verification refuses as + # malformed metadata instead of missing metadata. + digest = raw_digest.strip() + else: + digest = None + size = asset.get("size") + if isinstance(size, bool) or not isinstance(size, int): + size = None + return digest, size + + +def _document_candidate(document: object) -> release_v3lib.ReleaseCandidate | None: + """A V3 candidate from a GitHub-shaped release document, or None. + + Not a candidate: a draft, a tag that is not SemVer (nothing installable), + or a release that publishes no V3 manifest asset — that last one is a + V2-era release, not a broken V3 one. + """ + + from . import version as versionlib + + if not isinstance(document, dict) or document.get("draft"): + return None + tag = document.get("tag_name") + if not isinstance(tag, str): + return None + parsed = versionlib.parse(tag) + if parsed is None: + return None + asset = _manifest_asset(document) + if asset is None: + return None + digest, size = _anchor(asset) + return release_v3lib.ReleaseCandidate( + version=str(parsed), identity=tag, + channel="beta" if document.get("prerelease") else "stable", + manifest_sha256=digest, manifest_size=size, + manifest_locator=_asset_url(asset)) + + +class _AssetDocumentProvider(release_v3lib.ReleaseProvider): + """Shared fetching for the providers whose releases are GitHub-shaped.""" + + def __init__(self) -> None: + self._documents: dict[str, dict] = {} + + def _remember(self, candidate: release_v3lib.ReleaseCandidate, + document: dict) -> None: + self._documents[candidate.identity] = document + + def _document(self, candidate: release_v3lib.ReleaseCandidate) -> dict: + try: + return self._documents[candidate.identity] + except KeyError: + raise LifecycleError( + "UPDATE_CHECK_FAILED", + "the provider must enumerate before it fetches") from None + + def _token(self) -> str: + return "" + + def _get(self, url: str, limit: int) -> bytes: + return transportlib.get(url, limit=limit, endpoint=self.endpoint, + kind=transportlib.ARTIFACT, + accept=transportlib.ACCEPT_OCTET_STREAM, + token=self._token()) + + def fetch_manifest(self, candidate: release_v3lib.ReleaseCandidate) -> bytes: + locator = candidate.manifest_locator or _asset_url( + _manifest_asset(self._document(candidate)) or {}) + if not locator: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"release {candidate.identity} publishes no " + f"fetchable {MANIFEST_ASSET_NAME}") + return self._get(locator, MAX_MANIFEST_BYTES) + + def fetch_artifact(self, candidate: release_v3lib.ReleaseCandidate, + artifact: release_v3lib.ManifestArtifact) -> bytes: + document = self._document(candidate) + for asset in document.get("assets") or []: + if isinstance(asset, dict) and asset.get("name") == artifact.name: + url = _asset_url(asset) + if url: + return self._get(url, MAX_ARCHIVE_BYTES) + raise LifecycleError( + "UPDATE_UNAVAILABLE", + f"release {candidate.identity} publishes no asset {artifact.name!r}") + + +class GitHubReleaseProvider(_AssetDocumentProvider): + """GitHub Releases as a V3 source. Auth only at the configured origin.""" + + name = "github" + + def __init__(self, endpoint, repository: str = "Rwanbt/ai-native-dev-stack", + page_bound: int = MAX_ENUMERATION_ENTRIES) -> None: + super().__init__() + self.endpoint = endpoint + self.repository = repository + self.page_bound = page_bound + + def _token(self) -> str: + if self.endpoint.auth_origin is None: + return "" + from . import provider as providerlib + + return providerlib.environment_token() + + def enumerate(self, query: release_v3lib.ReleaseQuery) -> release_v3lib.EnumerationResult: + url = (f"{self.endpoint.api_base_url}/repos/{self.repository}" + f"/releases?per_page={self.page_bound}") + payload = transportlib.get(url, limit=MAX_MANIFEST_BYTES, endpoint=self.endpoint, + kind=transportlib.METADATA, + accept=transportlib.ACCEPT_GITHUB_JSON, + token=self._token()) + documents = _json_list(payload, url) + candidates = [] + for document in documents: + candidate = _document_candidate(document) + if candidate is None: + continue + self._remember(candidate, document) + candidates.append(candidate) + return release_v3lib.EnumerationResult(tuple(candidates), + complete=len(documents) < self.page_bound) + + +class AnonymousReleaseApiProvider(_AssetDocumentProvider): + """`AINATIVE_UPDATE_URL`: one release document, anonymous, GitHub-shaped.""" + + name = "anonymous" + + def __init__(self, url: str, + endpoint: transportlib.ReleaseProviderEndpointConfig | None = None) -> None: + super().__init__() + self.url = url + self.endpoint = endpoint or transportlib.anonymous_endpoint(url) + + def enumerate(self, query: release_v3lib.ReleaseQuery) -> release_v3lib.EnumerationResult: + payload = transportlib.get(self.url, limit=MAX_MANIFEST_BYTES, + endpoint=self.endpoint, + kind=transportlib.METADATA, + accept=transportlib.ACCEPT_GITHUB_JSON, + token=self._token()) + document = _json_object(payload, self.url) + candidate = _document_candidate(document) + if candidate is not None: + self._remember(candidate, document) + return release_v3lib.EnumerationResult((candidate,) if candidate else (), + complete=True) + + +class LocalReleaseProvider(release_v3lib.ReleaseProvider): + """A local mirror that executes the same logical chain as a network source. + + `releases.json` names, per channel, the version and the manifest locator + with its size and SHA-256 (`{"manifest": {"file": ..., "size": ..., + "sha256": ...}}`); the manifest then names the artifacts. A channel entry + without a V3 manifest paragraph is a V2-era entry and is not a candidate. + """ + + name = "local" + + def __init__(self, root: Path) -> None: + self.root = Path(root) + + def _index(self) -> dict: + path = self.root / "releases.json" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"cannot read {path}: {error}") from error + if not isinstance(payload, dict): + raise LifecycleError("UPDATE_CHECK_FAILED", f"{path} is not a JSON object") + return payload + + def _candidate(self, channel: str, entry: object) -> release_v3lib.ReleaseCandidate | None: + if not isinstance(entry, dict): + return None + version = entry.get("version") + manifest = entry.get("manifest") + if not isinstance(version, str) or not version.strip() or not isinstance(manifest, dict): + return None + file = manifest.get("file") + locator = f"{version}/{file}" if isinstance(file, str) and file else None + sha = manifest.get("sha256") + size = manifest.get("size") + return release_v3lib.ReleaseCandidate( + version=version.strip(), identity=f"local:{channel}", channel=channel, + manifest_sha256=sha if isinstance(sha, str) else None, + manifest_size=size if isinstance(size, int) and not isinstance(size, bool) else None, + manifest_locator=locator) + + def enumerate(self, query: release_v3lib.ReleaseQuery) -> release_v3lib.EnumerationResult: + index = self._index() + channels = index.get("channels") + if not isinstance(channels, dict) or not channels: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"{self.root / 'releases.json'} declares no channels") + candidates = [] + for channel, entry in sorted(channels.items()): + candidate = self._candidate(str(channel), entry) + if candidate is not None: + candidates.append(candidate) + return release_v3lib.EnumerationResult(tuple(candidates), complete=True) + + def _release_file(self, relative: str, *, limit: int, + description: str) -> bytes: + parts = validate_relative(relative) + path = self.root.joinpath(*parts.parts) + try: + size = path.stat().st_size + except OSError as error: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"{description} is missing: {error}") from error + if size > limit: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"{description} is {size} bytes, over the " + f"{limit} limit") + try: + return path.read_bytes() + except OSError as error: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"cannot read {description}: {error}") from error + + def fetch_manifest(self, candidate: release_v3lib.ReleaseCandidate) -> bytes: + if not candidate.manifest_locator: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"{candidate.identity} declares no manifest locator") + return self._release_file(candidate.manifest_locator, limit=MAX_MANIFEST_BYTES, + description=f"manifest for {candidate.identity}") + + def fetch_artifact(self, candidate: release_v3lib.ReleaseCandidate, + artifact: release_v3lib.ManifestArtifact) -> bytes: + return self._release_file(f"{candidate.version}/{artifact.name}", + limit=MAX_ARCHIVE_BYTES, + description=f"artifact {artifact.name!r}") + + +class GitLabReleaseProvider(release_v3lib.ReleaseProvider): + """GitLab.com as a V3 source: Releases for discovery, the Generic Package + Registry as the canonical distribution and integrity surface (ADR-0019 + section 11). + + Release Link URLs are presentation, never integrity roots: the anchor is + the package file API's `file_sha256` and `size`. One project identity + (`release_project_ref`) is used for Releases, packages and package files — + there is no second project configuration. GitLab is V3-only: it has no V2 + fallback, and a complete channel without a V3 candidate refuses. + """ + + name = "gitlab" + supports_v2_fallback = False + + def __init__(self, endpoint, project_ref: str, + page_bound: int = MAX_ENUMERATION_ENTRIES) -> None: + self.endpoint = endpoint + self.project_ref = project_ref + self.page_bound = page_bound + self._downloads: dict[str, dict[str, str]] = {} + + # --- requests ------------------------------------------------------- + + def _token(self) -> str: + if self.endpoint.auth_origin is None: + return "" + from . import provider as providerlib + + return providerlib.environment_gitlab_token() + + def _project_url(self) -> str: + encoded = urllib.parse.quote(self.project_ref, safe="") + return f"{self.endpoint.api_base_url}/projects/{encoded}" + + def _get_json(self, url: str) -> object: + payload = transportlib.get(url, limit=MAX_MANIFEST_BYTES, endpoint=self.endpoint, + kind=transportlib.METADATA, + accept=transportlib.ACCEPT_GITHUB_JSON, + token=self._token()) + try: + return json.loads(payload.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as error: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"GitLab answered {url} with invalid JSON: " + f"{error}") from error + + def _get_bytes(self, url: str, limit: int) -> bytes: + return transportlib.get(url, limit=limit, endpoint=self.endpoint, + kind=transportlib.ARTIFACT, + accept=transportlib.ACCEPT_OCTET_STREAM, + token=self._token()) + + # --- enumeration ---------------------------------------------------- + + def enumerate(self, query: release_v3lib.ReleaseQuery) -> release_v3lib.EnumerationResult: + releases, releases_complete = self._releases() + packages, packages_complete = self._packages() + candidates = self._candidates(releases, packages) + return release_v3lib.EnumerationResult( + tuple(candidates), complete=releases_complete and packages_complete) + + def _releases(self) -> tuple[list, bool]: + url = f"{self._project_url()}/releases?per_page={self.page_bound}" + document = self._get_json(url) + if not isinstance(document, list): + raise LifecycleError("UPDATE_CHECK_FAILED", + f"GitLab releases from {url} are not a list") + return document, len(document) < self.page_bound + + def _packages(self) -> tuple[dict, bool]: + url = (f"{self._project_url()}/packages?package_type=generic" + f"&package_name={GITLAB_PACKAGE_NAME}&per_page={self.page_bound}") + document = self._get_json(url) + if not isinstance(document, list): + raise LifecycleError("UPDATE_CHECK_FAILED", + f"GitLab packages from {url} are not a list") + by_version: dict[str, list] = {} + for record in document: + if not isinstance(record, dict): + continue + if (record.get("package_type") != "generic" + or record.get("name") != GITLAB_PACKAGE_NAME): + continue + parsed = versionlib.parse(record.get("version")) \ + if isinstance(record.get("version"), str) else None + if parsed is None: + continue + by_version.setdefault(str(parsed), []).append(record) + return by_version, len(document) < self.page_bound + + def _candidates(self, releases: list, packages: dict) -> list: + candidates = [] + for release in releases: + parsed = versionlib.parse(release.get("tag_name")) \ + if isinstance(release, dict) else None + if parsed is None or release.get("upcoming_release"): + continue + records = packages.get(str(parsed)) + if not records: + continue + if len(records) > 1: + raise LifecycleError( + "RELEASE_DUPLICATE_VERSION", + f"project {self.project_ref} publishes {len(records)} generic " + f"packages for version {parsed}; exactly one is required") + identity = str(release.get("tag_name")) + package_id = records[0].get("id") + files = self._package_files(package_id) + digest, size = self._anchor(files, identity) + self._downloads[identity] = self._download_urls(package_id, files) + candidates.append(release_v3lib.ReleaseCandidate( + version=str(parsed), identity=identity, + channel="beta" if parsed.pre else "stable", + manifest_sha256=digest, manifest_size=size)) + return candidates + + def _package_files(self, package_id: object) -> list: + url = (f"{self._project_url()}/packages/{package_id}/package_files" + f"?per_page={self.page_bound}") + document = self._get_json(url) + if not isinstance(document, list): + raise LifecycleError("UPDATE_CHECK_FAILED", + f"GitLab package files from {url} are not a list") + if len(document) >= self.page_bound: + raise LifecycleError( + "RELEASE_ENUMERATION_INCOMPLETE", + f"package {package_id} answered a full page of files; the " + "listing bounds were reached before exhaustion") + return [entry for entry in document if isinstance(entry, dict)] + + def _download_urls(self, package_id: object, files: list) -> dict: + return {str(entry.get("file_name")): + f"{self._project_url()}/packages/{package_id}/package_files/" + f"{entry.get('id')}/download" + for entry in files if entry.get("id") is not None} + + def _anchor(self, files: list, identity: str) -> tuple[str, int]: + manifests = [entry for entry in files + if entry.get("file_name") == MANIFEST_ASSET_NAME] + if not manifests: + raise LifecycleError("RELEASE_MANIFEST_MISSING", + f"release {identity} carries no {MANIFEST_ASSET_NAME}") + if len(manifests) > 1: + raise LifecycleError("RELEASE_MANIFEST_AMBIGUOUS", + f"release {identity} carries {len(manifests)} " + f"{MANIFEST_ASSET_NAME} files") + entry = manifests[0] + digest = entry.get("file_sha256") + if not isinstance(digest, str) or not digest.strip(): + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_MISSING", + f"package file of release {identity} publishes no file_sha256") + cleaned = digest.strip().lower() + if len(cleaned) != 64 or any(character not in "0123456789abcdef" + for character in cleaned): + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_INVALID", + f"package file of release {identity} carries a malformed file_sha256") + size = entry.get("size") + if size is None: + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_MISSING", + f"package file of release {identity} publishes no size") + if isinstance(size, bool) or not isinstance(size, int) or size <= 0: + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_INVALID", + f"package file of release {identity} carries a malformed size") + return cleaned, size + + # --- fetching ------------------------------------------------------- + + def _download(self, identity: str, name: str, limit: int) -> bytes: + downloads = self._downloads.get(identity) + if downloads is None: + raise LifecycleError("UPDATE_CHECK_FAILED", + "the provider must enumerate before it fetches") + url = downloads.get(name) + if not url: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"release {identity} publishes no package file " + f"{name!r}") + return self._get_bytes(url, limit) + + def fetch_manifest(self, candidate: release_v3lib.ReleaseCandidate) -> bytes: + return self._download(candidate.identity, MANIFEST_ASSET_NAME, + MAX_MANIFEST_BYTES) + + def fetch_artifact(self, candidate: release_v3lib.ReleaseCandidate, + artifact: release_v3lib.ManifestArtifact) -> bytes: + return self._download(candidate.identity, artifact.name, MAX_ARCHIVE_BYTES) + + +__all__ = ["GitHubReleaseProvider", "AnonymousReleaseApiProvider", + "LocalReleaseProvider", "GitLabReleaseProvider", "MANIFEST_ASSET_NAME", + "GITLAB_PACKAGE_NAME", "MAX_MANIFEST_BYTES", "MAX_ARCHIVE_BYTES", + "MAX_ENUMERATION_ENTRIES"] diff --git a/ainative/lifecycle/release_source.py b/ainative/lifecycle/release_source.py new file mode 100644 index 00000000..7b3a4ed3 --- /dev/null +++ b/ainative/lifecycle/release_source.py @@ -0,0 +1,306 @@ +"""Where a release comes from: one resolver, one conflict policy, no guessing. + +`resolve_release_source()` is the only function that decides. `update`, +`update check`, `status` and `doctor` all consume it, so the answer cannot +differ between commands (ADR-0019 sections 1–3). Validation happens before +precedence: contradictory selectors are refused, never ordered, and nothing +falls back silently. + +Selectors (environment): + +```text +AINATIVE_UPDATE_PROVIDER=local + AINATIVE_UPDATE_LOCAL_DIR -> local mirror +AINATIVE_UPDATE_LOCAL_DIR without provider=local -> UPDATE_SOURCE_CONFLICT +AINATIVE_UPDATE_URL mixed with a provider/local selector -> UPDATE_SOURCE_CONFLICT +AINATIVE_UPDATE_URL alone -> anonymous release API +AINATIVE_UPDATE_PROVIDER= -> machine config or built-in +nothing selected -> machine default, else GitHub.com +``` + +Machine configuration lives at `~/.ai-native/release-providers.json` — machine +scope, deliberately: a cloned repository must never be able to redirect a +user's updates or name its own credential origin. The built-in names +(`github`, `gitlab`, `local`) cannot be redefined there, and V1 supports no +custom `auth_origin`: a named provider is anonymous. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path + +from . import transport as transportlib +from .errors import LifecycleError + +PROVIDER_ENV = "AINATIVE_UPDATE_PROVIDER" +LOCAL_SOURCE_ENV = "AINATIVE_UPDATE_LOCAL_DIR" +RELEASE_URL_ENV = "AINATIVE_UPDATE_URL" +CONFIG_RELATIVE = Path(".ai-native") / "release-providers.json" + +RESERVED_PROVIDERS = ("github", "gitlab", "local") +CONFIG_SCHEMA_VERSION = 1 + +GITLAB_API_BASE_URL = "https://gitlab.com/api/v4" +GITLAB_AUTH_ORIGIN = "https://gitlab.com" +GITLAB_TOKEN_ENV = "GITLAB_TOKEN" + +KIND_GITHUB = "github" +KIND_GITLAB = "gitlab" +KIND_LOCAL = "local" +KIND_ANONYMOUS = "anonymous" +KIND_NAMED = "named" + + +@dataclass(frozen=True) +class ReleaseSource: + """The resolved source, with the reason and the trust facts to display.""" + + kind: str + provider_name: str + reason: str + endpoint: transportlib.ReleaseProviderEndpointConfig | None = None + directory: Path | None = None + metadata_url: str | None = None + project_ref: str | None = None + + @property + def authenticated(self) -> bool: + return bool(self.endpoint and self.endpoint.auth_origin is not None) + + def to_record(self) -> dict: + return {"kind": self.kind, "provider": self.provider_name, + "reason": self.reason, + "authenticated": self.authenticated, + "auth_origin": self.endpoint.auth_origin if self.endpoint else None, + "api_base_url": self.endpoint.api_base_url if self.endpoint else None, + "release_project_ref": self.project_ref, + "directory": str(self.directory) if self.directory else None} + + +def config_path(home: Path | None = None) -> Path: + return (Path(home) if home else Path.home()) / CONFIG_RELATIVE + + +def load_machine_config(home: Path | None = None) -> dict: + """The machine-scope provider configuration, validated, or {} when absent.""" + + path = config_path(home) + if not path.is_file(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise LifecycleError("RELEASE_CONFIG_INVALID", + f"cannot read {path}: {error}") from error + if not isinstance(payload, dict): + raise LifecycleError("RELEASE_CONFIG_INVALID", f"{path} is not a JSON object") + version = payload.get("schema_version") + if version != CONFIG_SCHEMA_VERSION: + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + f"{path}: schema_version {version!r} is not {CONFIG_SCHEMA_VERSION}; " + "upgrade the CLI rather than guessing") + providers = payload.get("providers", {}) + if not isinstance(providers, dict): + raise LifecycleError("RELEASE_CONFIG_INVALID", + f"{path}: providers must be an object") + for name in providers: + # `gitlab` is a built-in provider that *is* configured here (its + # project reference has to live somewhere machine-scoped); the other + # built-ins cannot be redefined. + if name in RESERVED_PROVIDERS and name != "gitlab": + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + f"{path}: provider {name!r} is built in and cannot be redefined") + default = payload.get("default_provider") + if default is not None and (not isinstance(default, str) or not default.strip()): + raise LifecycleError("RELEASE_CONFIG_INVALID", + f"{path}: default_provider must be a provider name") + return payload + + +def _named_source(name: str, entry: object) -> ReleaseSource: + if not isinstance(entry, dict): + raise LifecycleError("RELEASE_CONFIG_INVALID", + f"provider {name!r} must be an object") + if entry.get("auth_origin"): + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + f"provider {name!r}: custom auth_origin is not supported in V1; " + "a named provider is anonymous") + base = entry.get("api_base_url") + if not isinstance(base, str) or not base.lower().startswith("https://"): + raise LifecycleError("RELEASE_CONFIG_INVALID", + f"provider {name!r}: api_base_url must be an HTTPS URL") + return ReleaseSource(kind=KIND_NAMED, provider_name=name, + endpoint=transportlib.anonymous_endpoint(base), + metadata_url=base.rstrip("/") + "/releases/latest", + reason=f"named provider {name!r} (machine configuration)") + + +def _gitlab_source(entry: object) -> ReleaseSource: + """The built-in GitLab provider, configured per machine (ADR-0019 §61). + + The project reference has to live somewhere machine-scoped; it is a + numeric project id or a namespace path, never a URL. + """ + + if not isinstance(entry, dict): + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + "provider 'gitlab' requires a machine configuration entry with " + "release_project_ref") + project_ref = entry.get("release_project_ref") + if not isinstance(project_ref, str) or not project_ref.strip(): + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + "provider 'gitlab': release_project_ref is required (a numeric " + "project id or namespace/project)") + if project_ref.strip().lower().startswith(("http://", "https://")): + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + "provider 'gitlab': release_project_ref is a project reference, " + "not a URL") + api_base = str(entry.get("api_base_url") or GITLAB_API_BASE_URL).strip() + if not api_base.lower().startswith("https://"): + raise LifecycleError("RELEASE_CONFIG_INVALID", + "provider 'gitlab': api_base_url must be an HTTPS URL") + origin = str(entry.get("auth_origin") or GITLAB_AUTH_ORIGIN).strip() + endpoint = transportlib.ReleaseProviderEndpointConfig( + provider="gitlab", api_base_url=api_base.rstrip("/"), auth_origin=origin, + api_version=str(entry.get("api_version") or "v4"), + credential_source=str(entry.get("credential_source") + or f"environment:{GITLAB_TOKEN_ENV}"), + auth_header="PRIVATE-TOKEN", auth_prefix="") + return ReleaseSource(kind=KIND_GITLAB, provider_name="gitlab", + endpoint=endpoint, project_ref=project_ref.strip(), + reason="gitlab provider (machine configuration)") + + +def _builtin_github(reason: str) -> ReleaseSource: + # Deferred: provider owns the default URL and imports this module inside + # `build()`, so the import direction stays release_source -> provider. + from . import provider as providerlib + + return ReleaseSource(kind=KIND_GITHUB, provider_name="github", + endpoint=transportlib.GITHUB_ENDPOINT, + metadata_url=providerlib.DEFAULT_RELEASE_URL, + reason=reason) + + +def resolve_release_source(environ=None, home: Path | None = None) -> ReleaseSource: + """The one source decision. Conflicts refuse; nothing is ordered silently.""" + + environment = dict(os.environ if environ is None else environ) + config = load_machine_config(home) + + provider = (environment.get(PROVIDER_ENV) or "").strip().lower() + local_dir = (environment.get(LOCAL_SOURCE_ENV) or "").strip() + url = (environment.get(RELEASE_URL_ENV) or "").strip() + _reject_conflicts(provider=provider, local_dir=local_dir, url=url) + + if url: + return ReleaseSource(kind=KIND_ANONYMOUS, provider_name="anonymous", + endpoint=transportlib.anonymous_endpoint(url), + metadata_url=url, + reason=f"anonymous release API ({RELEASE_URL_ENV})") + if provider == "local": + return _local_mirror(local_dir) + if provider: + return _selected_provider(provider, config) + return _default_source(config) + + +def _reject_conflicts(*, provider: str, local_dir: str, url: str) -> None: + if url and (provider or local_dir): + raise LifecycleError( + "UPDATE_SOURCE_CONFLICT", + f"{RELEASE_URL_ENV} cannot be combined with {PROVIDER_ENV}/" + f"{LOCAL_SOURCE_ENV}; declare exactly one source", + selectors={RELEASE_URL_ENV: url, PROVIDER_ENV: provider, + LOCAL_SOURCE_ENV: local_dir}) + if local_dir and provider != "local": + raise LifecycleError( + "UPDATE_SOURCE_CONFLICT", + f"{LOCAL_SOURCE_ENV} requires an explicit {PROVIDER_ENV}=local; " + "selectors are never ordered", + selectors={LOCAL_SOURCE_ENV: local_dir, PROVIDER_ENV: provider}) + + +def _local_mirror(local_dir: str) -> ReleaseSource: + if not local_dir: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"{PROVIDER_ENV}=local requires {LOCAL_SOURCE_ENV}") + return ReleaseSource(kind=KIND_LOCAL, provider_name="local", + directory=Path(local_dir).expanduser(), + reason=f"local mirror ({LOCAL_SOURCE_ENV})") + + +def _selected_provider(provider: str, config: dict) -> ReleaseSource: + if provider in ("github", "release-api"): + return _builtin_github(f"built-in GitHub.com ({PROVIDER_ENV})") + if provider == "gitlab": + return _gitlab_source((config.get("providers") or {}).get("gitlab")) + entry = (config.get("providers") or {}).get(provider) + if entry is None: + # Preserves the long-standing refusal for a name nothing declares; a + # declared-but-malformed entry is a config problem instead. + raise LifecycleError("UPDATE_CHECK_FAILED", + f"unknown update provider {provider!r}") + return _named_source(provider, entry) + + +def _default_source(config: dict) -> ReleaseSource: + default = str(config.get("default_provider") or "").strip().lower() + if default == "local": + entry = (config.get("providers") or {}).get("local") or {} + directory = entry.get("directory") if isinstance(entry, dict) else None + if not isinstance(directory, str) or not directory.strip(): + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + "default_provider 'local' requires providers.local.directory " + "in the machine configuration") + return ReleaseSource(kind=KIND_LOCAL, provider_name="local", + directory=Path(directory).expanduser(), + reason="machine default provider 'local'") + if default == "gitlab": + return _gitlab_source((config.get("providers") or {}).get("gitlab")) + if default == "github": + return _builtin_github("machine default provider 'github'") + if default: + return _named_source(default, (config.get("providers") or {}).get(default)) + return _builtin_github("built-in default (GitHub.com)") + + +def describe(environ=None, home: Path | None = None) -> dict: + """The source as diagnostics display it; a refusal is a state, not a crash.""" + + try: + return resolve_release_source(environ=environ, home=home).to_record() + except LifecycleError as error: + return {"kind": "refused", "state": error.code, "detail": error.message, + "authenticated": False, "auth_origin": None} + + +def describe_lines(record: dict | None = None) -> list[str]: + """`describe()`'s text rendering, shared by `status` and `doctor`.""" + + record = record if record is not None else describe() + if record.get("kind") == "refused": + return ["Release source", + f" refused: {record.get('state')} — {record.get('detail')}"] + lines = ["Release source", + f" {record.get('kind')} ({record.get('provider')}) — {record.get('reason')}"] + suffix = f" (auth origin {record.get('auth_origin')})" if record.get("auth_origin") else "" + lines.append(f" authenticated: {'yes' if record.get('authenticated') else 'no'}{suffix}") + return lines + + +__all__ = ["ReleaseSource", "resolve_release_source", "load_machine_config", + "config_path", "describe", "describe_lines", "PROVIDER_ENV", + "LOCAL_SOURCE_ENV", "RELEASE_URL_ENV", "CONFIG_RELATIVE", + "RESERVED_PROVIDERS", "KIND_GITHUB", "KIND_GITLAB", "KIND_LOCAL", + "KIND_ANONYMOUS", "KIND_NAMED", "GITLAB_API_BASE_URL", + "GITLAB_AUTH_ORIGIN", "GITLAB_TOKEN_ENV"] diff --git a/ainative/lifecycle/release_v3.py b/ainative/lifecycle/release_v3.py new file mode 100644 index 00000000..92d74b5e --- /dev/null +++ b/ainative/lifecycle/release_v3.py @@ -0,0 +1,413 @@ +"""Release V3: the provider contract, the manifest, and the chains that bind them. + +ADR-0019 sections 7–10. Three ideas, each fail-closed: + +* A candidate is installable only when its version is SemVer without build + metadata, and two candidates must not claim the same precedence with + different identities (`RELEASE_DUPLICATE_VERSION`). +* An enumeration that stopped at its bounds is incomplete + (`RELEASE_ENUMERATION_INCOMPLETE`); a complete channel with nothing is + `RELEASE_NO_CANDIDATE`. Partial results are never used silently. +* The manifest's SHA-256 and size come from provider metadata, never from the + manifest itself: they are verified **before** the document is parsed, so + nothing a hostile manifest says can influence whether its own bytes are + trusted. The version chain then requires one version across candidate, + manifest, compatibility.runtime_version, artifact, artifact filename and the + lifecycle protocol document. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import Iterable + +from . import version as versionlib +from .digest import digest_bytes +from .errors import LifecycleError + +MANIFEST_SCHEMA = "ainative.release" +MANIFEST_PROTOCOL = "v3" +# The `lifecycle-protocol.json` a V3 bundle carries at its root; the V2 bundle +# keeps protocol 2 (provider.UPDATE_PROTOCOL_VERSION). +BUNDLE_PROTOCOL_VERSION = 3 +ARTIFACT_KIND_LIFECYCLE = "lifecycle" +ARTIFACT_KINDS = (ARTIFACT_KIND_LIFECYCLE,) + +_V3_BUNDLE_PREFIX = "ainative-lifecycle-v3-" +_V3_BUNDLE_SUFFIX = ".zip" +_SHA256 = re.compile(r"^[0-9a-f]{64}$") + + +@dataclass(frozen=True) +class ReleaseQuery: + """What a caller asks a provider for: one compatible channel.""" + + channel: str = "stable" + + +@dataclass(frozen=True) +class ReleaseCandidate: + """One enumerated release, with the anchor of its manifest. + + `manifest_sha256` and `manifest_size` are the external integrity anchor: + they come from the provider's own metadata (a release asset listing, a + mirror index), never from the manifest bytes themselves. + `manifest_locator` is the provider-internal way to fetch the manifest + (an asset API URL, a mirror path); it is opaque to this module. + """ + + version: str + identity: str + channel: str = "stable" + manifest_sha256: str | None = None + manifest_size: int | None = None + manifest_locator: str | None = None + + def to_record(self) -> dict: + return {"version": self.version, "identity": self.identity, + "channel": self.channel, + "manifest_sha256": self.manifest_sha256, + "manifest_size": self.manifest_size} + + +@dataclass(frozen=True) +class EnumerationResult: + """Everything the provider could see, and whether that was everything.""" + + candidates: tuple[ReleaseCandidate, ...] + complete: bool + + def to_record(self) -> dict: + return {"complete": self.complete, + "candidates": [candidate.to_record() for candidate in self.candidates]} + + +@dataclass(frozen=True) +class ManifestArtifact: + """One artifact the manifest declares. Verified, never trusted.""" + + name: str + kind: str + version: str + sha256: str + size: int + + def to_record(self) -> dict: + return {"name": self.name, "kind": self.kind, "version": self.version, + "sha256": self.sha256, "size": self.size} + + +@dataclass(frozen=True) +class ReleaseManifest: + """The parsed ReleaseManifest V3, already validated.""" + + version: str + channel: str + runtime_version: str + artifacts: tuple[ManifestArtifact, ...] + provenance: dict = field(default_factory=dict) + schema: str = MANIFEST_SCHEMA + protocol: str = MANIFEST_PROTOCOL + + def lifecycle_artifact(self) -> ManifestArtifact: + matches = [artifact for artifact in self.artifacts + if artifact.kind == ARTIFACT_KIND_LIFECYCLE] + if len(matches) != 1: + raise LifecycleError( + "UPDATE_INTEGRITY_FAILED", + f"the manifest declares {len(matches)} lifecycle artifacts; " + "exactly one is required") + return matches[0] + + def to_record(self) -> dict: + return {"schema": self.schema, "protocol": self.protocol, + "version": self.version, "channel": self.channel, + "compatibility": {"runtime_version": self.runtime_version}, + "artifacts": [artifact.to_record() for artifact in self.artifacts], + "provenance": dict(self.provenance)} + + +class ReleaseProvider: + """The V3 provider contract. Implementations fetch; this module decides. + + `supports_v2_fallback` says whether a complete channel without a V3 + candidate is a V2-era source (GitHub.com and mirrors are; GitLab is + V3-only and refuses instead). + """ + + name = "abstract" + supports_v2_fallback = True + + def enumerate(self, query: ReleaseQuery) -> EnumerationResult: + raise NotImplementedError + + def fetch_manifest(self, candidate: ReleaseCandidate) -> bytes: + raise NotImplementedError + + def fetch_artifact(self, candidate: ReleaseCandidate, + artifact: ManifestArtifact) -> bytes: + raise NotImplementedError + + +def canonical_version(value: object) -> str: + """A SemVer version string without build metadata, or a refusal. + + `1.2.3` and `1.2.3-rc.1` are installable; `1.2.3+build1` is refused with + `RELEASE_BUILD_METADATA_UNSUPPORTED` because two artifacts must never share + an installable version identity. A value that is not SemVer at all keeps + the historical `UPDATE_CHECK_FAILED` refusal. + """ + + parsed = versionlib.parse(value) if isinstance(value, str) else None + if parsed is None: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"release version {value!r} is not a SemVer version") + if "+" in parsed.raw: + raise LifecycleError( + "RELEASE_BUILD_METADATA_UNSUPPORTED", + f"release version {parsed.raw!r} carries build metadata; installable " + "versions are exactly major.minor.patch with an optional pre-release", + version=parsed.raw) + return str(parsed) + + +def select_candidate(result: EnumerationResult, query: ReleaseQuery) -> ReleaseCandidate: + """The newest installable candidate of the channel, or a refusal.""" + + if not result.complete: + raise LifecycleError( + "RELEASE_ENUMERATION_INCOMPLETE", + "the provider stopped enumerating at its bounds; partial results " + "are never used to pick a release") + compatible = [candidate for candidate in result.candidates + if candidate.channel == query.channel] + if not compatible: + raise LifecycleError( + "RELEASE_NO_CANDIDATE", + f"the {query.channel!r} channel is complete and holds no release") + by_precedence: dict[str, str] = {} + for candidate in compatible: + canonical = canonical_version(candidate.version) + previous = by_precedence.get(canonical) + if previous is not None and previous != candidate.identity: + raise LifecycleError( + "RELEASE_DUPLICATE_VERSION", + f"two releases claim version {canonical}: {previous!r} and " + f"{candidate.identity!r}; duplicates are never resolved by order", + version=canonical, identities=sorted({previous, candidate.identity})) + by_precedence[canonical] = candidate.identity + ordered = sorted(compatible, key=lambda candidate: versionlib.parse(candidate.version)) + return ordered[-1] + + +def verify_external_anchor(payload: bytes, *, sha256: str | None, size: int | None) -> None: + """Verify bytes against the anchor that travelled with the candidate. + + Absent anchor: `RELEASE_INTEGRITY_METADATA_MISSING`; malformed: + `RELEASE_INTEGRITY_METADATA_INVALID`; anything that does not match: + `UPDATE_INTEGRITY_FAILED`. This runs BEFORE parsing, always. + """ + + if sha256 is None and size is None: + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_MISSING", + "the provider published no manifest digest and size; a manifest " + "nobody anchored is not one this stack parses") + if not (isinstance(sha256, str) and _SHA256.match(sha256.strip().lower()) + and isinstance(size, int) and not isinstance(size, bool) and size > 0): + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_INVALID", + f"the manifest anchor is malformed (sha256={sha256!r}, size={size!r})") + if len(payload) != size: + raise LifecycleError( + "UPDATE_INTEGRITY_FAILED", + f"the manifest is {len(payload)} bytes but the provider declared {size}") + actual = digest_bytes(payload) + if actual != sha256.strip().lower(): + raise LifecycleError( + "UPDATE_INTEGRITY_FAILED", + f"the manifest digest {actual} does not match the anchored " + f"{sha256.strip().lower()}", + expected=sha256.strip().lower(), actual=actual) + + +def parse_manifest(payload: bytes) -> ReleaseManifest: + """Parse and validate a manifest whose bytes were already verified.""" + + try: + document = json.loads(payload.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as error: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"the manifest is not valid JSON: {error}") from error + if not isinstance(document, dict): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", "the manifest is not an object") + if document.get("schema") != MANIFEST_SCHEMA: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"unknown manifest schema {document.get('schema')!r}") + protocol = document.get("protocol") + if protocol != MANIFEST_PROTOCOL: + newer = _newer_protocol(protocol) + if newer is not None: + raise LifecycleError( + "CLI_UPDATE_REQUIRED", + f"the release manifest speaks protocol {protocol}; this runtime " + "speaks v3 and the release must be applied by a newer CLI runtime") + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"unknown manifest protocol {protocol!r}") + + raw_version = document.get("version") + if not isinstance(raw_version, str) or not raw_version.strip(): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", "the manifest declares no version") + version = canonical_version(raw_version) + channel = document.get("channel") + if not isinstance(channel, str) or not channel.strip(): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", "the manifest declares no channel") + compatibility = document.get("compatibility") + if not isinstance(compatibility, dict): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + "the manifest declares no compatibility object") + raw_runtime = compatibility.get("runtime_version") + if not isinstance(raw_runtime, str) or not raw_runtime.strip(): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + "the manifest declares no compatibility.runtime_version") + runtime_version = canonical_version(raw_runtime) + artifacts = _parse_artifacts(document.get("artifacts")) + provenance = document.get("provenance", {}) + if not isinstance(provenance, dict): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", "provenance must be an object") + return ReleaseManifest(version=version, channel=channel.strip(), + runtime_version=runtime_version, artifacts=artifacts, + provenance=dict(provenance)) + + +def _newer_protocol(protocol: object) -> int | None: + """The protocol number when it is a v newer than this runtime's, else None.""" + + match = re.match(r"^v(?P\d+)$", str(protocol)) + if match is None: + return None + number = int(match.group("number")) + return number if number > int(MANIFEST_PROTOCOL.lstrip("v")) else None + + +def _parse_artifacts(raw: object) -> tuple[ManifestArtifact, ...]: + if not isinstance(raw, list) or not raw: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + "the manifest declares no artifacts") + artifacts = [] + for entry in raw: + artifacts.append(_parse_artifact(entry)) + return tuple(artifacts) + + +def _parse_artifact(entry: object) -> ManifestArtifact: + if not isinstance(entry, dict): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", "an artifact entry is not an object") + name = entry.get("name") + if not isinstance(name, str) or not name or "/" in name or "\\" in name \ + or name in (".", ".."): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"artifact name {name!r} is not a plain filename") + kind = entry.get("kind") + if kind not in ARTIFACT_KINDS: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"artifact {name!r} declares unknown kind {kind!r}") + version = canonical_version(entry.get("version")) + sha = entry.get("sha256") + if not (isinstance(sha, str) and _SHA256.match(sha.strip().lower())): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"artifact {name!r} declares no valid sha256") + size = entry.get("size") + if not (isinstance(size, int) and not isinstance(size, bool) and size > 0): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"artifact {name!r} declares no valid size") + return ManifestArtifact(name=name, kind=kind, version=version, + sha256=sha.strip().lower(), size=size) + + +def lifecycle_bundle_name(version: str) -> str: + """The one filename a V3 lifecycle bundle of `version` may wear.""" + + return f"{_V3_BUNDLE_PREFIX}{version}{_V3_BUNDLE_SUFFIX}" + + +def require_exact_version_chain(candidate: ReleaseCandidate, manifest: ReleaseManifest, + artifact: ManifestArtifact, *, + protocol_release_version: str | None = None) -> None: + """One version across every trust-bearing identity, or `UPDATE_VERSION_MISMATCH`. + + candidate.version == manifest.version == manifest.compatibility.runtime_version + == artifact.version == the artifact filename version == (after extraction) + lifecycle-protocol.json.release_version. + """ + + links = [ + ("candidate version", candidate.version), + ("manifest version", manifest.version), + ("compatibility.runtime_version", manifest.runtime_version), + ("artifact version", artifact.version), + ("artifact filename version", _filename_version(artifact)), + ] + if protocol_release_version is not None: + links.append(("lifecycle-protocol.json release_version", + protocol_release_version)) + distinct = {value for _, value in links} + if len(distinct) != 1: + raise LifecycleError( + "UPDATE_VERSION_MISMATCH", + "; ".join(f"{label}={value!r}" for label, value in links), + chain={label: value for label, value in links}) + if artifact.name != lifecycle_bundle_name(artifact.version): + raise LifecycleError( + "UPDATE_VERSION_MISMATCH", + f"the artifact is named {artifact.name!r} but its version " + f"{artifact.version!r} requires {lifecycle_bundle_name(artifact.version)!r}", + published=artifact.name, + expected=lifecycle_bundle_name(artifact.version)) + + +def _filename_version(artifact: ManifestArtifact) -> str: + name = artifact.name + inner = name[len(_V3_BUNDLE_PREFIX):-len(_V3_BUNDLE_SUFFIX)] \ + if name.startswith(_V3_BUNDLE_PREFIX) and name.endswith(_V3_BUNDLE_SUFFIX) \ + else name + return inner + + +def fetch_manifest_for(provider: ReleaseProvider, + candidate: ReleaseCandidate) -> ReleaseManifest: + """Fetch, anchor-verify, parse and chain-check one candidate's manifest.""" + + payload = provider.fetch_manifest(candidate) + verify_external_anchor(payload, sha256=candidate.manifest_sha256, + size=candidate.manifest_size) + manifest = parse_manifest(payload) + artifact = manifest.lifecycle_artifact() + require_exact_version_chain(candidate, manifest, artifact) + return manifest + + +def resolve_manifest(provider: ReleaseProvider, query: ReleaseQuery + ) -> tuple[ReleaseCandidate, ReleaseManifest]: + """enumerate -> select -> anchor-verified manifest -> exact chain. + + The order is the trust order (ADR-0019 section 9): the manifest's bytes are + verified against provider metadata before the document is parsed, and the + version chain is checked before anything is downloaded. + """ + + result = provider.enumerate(query) + candidate = select_candidate(result, query) + return candidate, fetch_manifest_for(provider, candidate) + + +__all__ = [ + "ReleaseQuery", "ReleaseCandidate", "EnumerationResult", "ManifestArtifact", + "ReleaseManifest", "ReleaseProvider", "canonical_version", "select_candidate", + "verify_external_anchor", "parse_manifest", "lifecycle_bundle_name", + "require_exact_version_chain", "resolve_manifest", "fetch_manifest_for", + "MANIFEST_SCHEMA", "MANIFEST_PROTOCOL", "BUNDLE_PROTOCOL_VERSION", + "ARTIFACT_KIND_LIFECYCLE", +] diff --git a/ainative/lifecycle/state.py b/ainative/lifecycle/state.py index c680e1c3..573ae310 100644 --- a/ainative/lifecycle/state.py +++ b/ainative/lifecycle/state.py @@ -25,7 +25,7 @@ from .errors import LifecycleError -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 LIFECYCLE_DIRNAME = Path(".ai-native") / "lifecycle" STATE_RELATIVE = LIFECYCLE_DIRNAME / "state.json" @@ -152,6 +152,11 @@ class InstallState: source_version: str = "0.0.0" source_revision: str | None = None installed_components: list[str] = field(default_factory=list) + # V2 (ADR-0017): the optional project-scope features this project activates. + # Empty on a V1 state (the schema that predates the field) and migrated + # explicitly; never derived here, because the catalogue of features is not + # this module's to know. + active_features: list[str] = field(default_factory=list) managed_files: list[ManagedFile] = field(default_factory=list) last_transaction: str | None = None update_channel: str = "stable" @@ -189,6 +194,7 @@ def to_record(self) -> dict[str, Any]: sorted(self.managed_files, key=lambda item: (item.component, item.path))] record["installed_components"] = sorted(set(self.installed_components)) + record["active_features"] = sorted(set(self.active_features)) return record @classmethod @@ -210,6 +216,14 @@ def from_record(cls, raw: Any) -> "InstallState": if not isinstance(components, list) or not all(isinstance(i, str) for i in components): raise LifecycleError("INSTALL_STATE_CORRUPTED", "installed_components must be a list of strings") + # V1 (the schema before features) has no `active_features` and must keep + # loading; anything that is present but not a list of strings is corrupt, + # not "an old state": guessing is how a state naming nothing becomes a + # state pretending to name something. + features = raw.get("active_features", []) if version >= 2 else [] + if not isinstance(features, list) or not all(isinstance(item, str) for item in features): + raise LifecycleError("INSTALL_STATE_CORRUPTED", + "active_features must be a list of strings") files = raw.get("managed_files", []) if not isinstance(files, list): raise LifecycleError("INSTALL_STATE_CORRUPTED", "managed_files must be a list") @@ -227,6 +241,7 @@ def from_record(cls, raw: Any) -> "InstallState": source_version=str(raw.get("source_version", "0.0.0")), source_revision=raw.get("source_revision"), installed_components=list(components), + active_features=list(features), managed_files=[ManagedFile.from_record(item) for item in files], last_transaction=raw.get("last_transaction"), update_channel=str(raw.get("update_channel", "stable")), diff --git a/ainative/lifecycle/status.py b/ainative/lifecycle/status.py index 3442be31..55a054a6 100644 --- a/ainative/lifecycle/status.py +++ b/ainative/lifecycle/status.py @@ -12,6 +12,7 @@ from . import manifest as manifestlib from . import recovery as recoverylib +from . import release_source as release_sourcelib from . import state as statelib from . import updater as updaterlib from .manifest import Distribution @@ -28,6 +29,9 @@ class Status: active_profile: str | None previous_profile: str | None components: list[dict] = field(default_factory=list) + features: list[str] = field(default_factory=list) + features_projected: bool = False + release_source: dict = field(default_factory=dict) healthy: bool = True lifecycle_notes: list[str] = field(default_factory=list) verified: dict = field(default_factory=dict) @@ -42,6 +46,9 @@ def to_record(self) -> dict: "profile": self.active_profile, "previous_profile": self.previous_profile, "components": self.components, + "features": self.features, + "features_projected_from_legacy": self.features_projected, + "release_source": self.release_source, "lifecycle": {"healthy": self.healthy, "notes": list(self.lifecycle_notes), "findings": self.counts}, "verified": self.verified, @@ -55,6 +62,12 @@ def render(self) -> str: return "\n".join(lines) lines.append("Profile") lines.append(f" {self.active_profile}") + if self.installed: + lines.append("") + lines.append("Features") + lines.append(f" {', '.join(self.features) or 'none'}" + + (" (projected from the V1 state)" + if self.features_projected else "")) lines.append("") lines.append("Components") for item in self.components: @@ -75,6 +88,9 @@ def render(self) -> str: lines.append("") lines.append("Updates") lines.append(f" {updaterlib.notice_line(self.update)}") + if self.release_source: + lines.append("") + lines.extend(release_sourcelib.describe_lines(self.release_source)) return "\n".join(lines) @@ -158,10 +174,20 @@ def build(project: Path, *, distribution: Distribution | None = None, if diagnosis.lock and diagnosis.lock.get("stale_suspect"): notes.append("a lifecycle lock is present and may be stale") + from . import features as featureslib + from . import release_source as release_sourcelib + + # One projection for every reader (ADR-0017 section 4): `status` reports + # the effective feature set, exactly as `feature status` does. + effective = featureslib.project_install_state(state, distribution) + return Status( project=project, installed=True, stack_version=state.stack_version, active_profile=state.active_profile, previous_profile=state.previous_profile, components=_component_rows(distribution, state, diagnosis), + features=list(effective.active_features) if effective else [], + features_projected=bool(effective and effective.projected_from_legacy), + release_source=release_sourcelib.describe(), healthy=diagnosis.healthy, lifecycle_notes=notes, verified=_verified_section(project, distribution, state), update=updaterlib.cached_notice(project, allow_network=check_updates, diff --git a/ainative/lifecycle/transport.py b/ainative/lifecycle/transport.py index 193491a7..30272b73 100644 --- a/ainative/lifecycle/transport.py +++ b/ainative/lifecycle/transport.py @@ -66,6 +66,10 @@ class ReleaseProviderEndpointConfig: where the credential comes from and is resolved by the provider, not here. An endpoint with `auth_origin=None` is anonymous by construction: no credential can be attached whatever the environment holds. + + `auth_header` and `auth_prefix` exist because providers disagree on the + shape of authentication: GitHub wants `Authorization: Bearer `, + GitLab wants `PRIVATE-TOKEN: `. The default is GitHub's. """ provider: str @@ -73,6 +77,8 @@ class ReleaseProviderEndpointConfig: auth_origin: str | None = None api_version: str = "" credential_source: str = "" + auth_header: str = "Authorization" + auth_prefix: str = "Bearer" GITHUB_ENDPOINT = ReleaseProviderEndpointConfig( @@ -136,7 +142,8 @@ def _request_headers(endpoint: ReleaseProviderEndpointConfig, origin: str, *, if endpoint.api_version: headers["X-GitHub-Api-Version"] = endpoint.api_version if token and _may_receive(endpoint, origin): - headers["Authorization"] = f"Bearer {token}" + value = f"{endpoint.auth_prefix} {token}".strip() if endpoint.auth_prefix else token + headers[endpoint.auth_header] = value return headers diff --git a/ainative/lifecycle/updater.py b/ainative/lifecycle/updater.py index 5f61513c..6072e5a1 100644 --- a/ainative/lifecycle/updater.py +++ b/ainative/lifecycle/updater.py @@ -40,6 +40,7 @@ from . import manifest as manifestlib from . import planner as plannerlib from . import provider as providerlib +from . import release_v3 as release_v3lib from . import source as sourcelib from . import state as statelib from . import version as versionlib @@ -196,6 +197,55 @@ def checks_disabled(state: statelib.InstallState | None) -> bool: return not preferences.get("enabled", True) or not preferences.get("auto_check", True) +def _check_refusal(project: Path, current: str, error: LifecycleError, + record: bool) -> CheckResult: + """The check outcome for a refused source. Never fatal, never a traceback.""" + + if error.code == "CLI_UPDATE_REQUIRED": + # A future-protocol release IS available; this runtime cannot apply it. + target = error.detail.get("target_version") + result = _runtime_fields(CheckResult( + UPDATE_AVAILABLE if isinstance(target, str) else CHECK_FAILED, + current, latest=target if isinstance(target, str) else None, + detail=error.message, checked_at=statelib.now())) + else: + status = OFFLINE if error.code == "UPDATE_CHECK_FAILED" else CHECK_FAILED + result = _runtime_fields(CheckResult(status, current, detail=error.message, + checked_at=statelib.now())) + if record: + _write_cache(project, result) + return result + + +def resolve_v3_candidate(channel: str): + """(candidate, provider) for a V3 source, or None when the channel is V2-era. + + `None` means the source was exhausted and publishes no V3 candidate for the + channel — a V2-era mirror — and the caller speaks the V2 path to it. A + source that hit its enumeration bounds refuses instead: partial results are + never a reason to change generation (ADR-0019 section 7). + """ + + provider = providerlib.build_v3(channel) + if provider is None: + return None + result = provider.enumerate(release_v3lib.ReleaseQuery(channel)) + if not result.complete: + raise LifecycleError( + "RELEASE_ENUMERATION_INCOMPLETE", + "the release source answered a full page; its enumeration bounds " + "were reached before exhaustion") + if not any(candidate.channel == channel for candidate in result.candidates): + if not provider.supports_v2_fallback: + raise LifecycleError( + "RELEASE_NO_CANDIDATE", + f"the {channel!r} channel of this source is complete and holds " + "no V3 release") + return None + candidate = release_v3lib.select_candidate(result, release_v3lib.ReleaseQuery(channel)) + return candidate, provider + + def check(project: Path, *, force: bool = False, allow_network: bool = True, record: bool = True, state: statelib.InstallState | None = None, source: DistributionSource | None = None, @@ -230,26 +280,19 @@ def check(project: Path, *, force: bool = False, allow_network: bool = True, if release is None: try: - release = providerlib.build(channel).latest(channel) + resolved = resolve_v3_candidate(channel) except LifecycleError as error: - if error.code == "CLI_UPDATE_REQUIRED": - # A future-protocol release IS available; this runtime cannot - # apply it. The old "no bundle to verify" answer sent users - # looking for a publishing defect instead of upgrading (#157). - target = error.detail.get("target_version") - result = _runtime_fields(CheckResult( - UPDATE_AVAILABLE if isinstance(target, str) else CHECK_FAILED, - current, latest=target if isinstance(target, str) else None, - detail=error.message, checked_at=statelib.now())) - if record: - _write_cache(project, result) - return result - status = OFFLINE if error.code == "UPDATE_CHECK_FAILED" else CHECK_FAILED - result = _runtime_fields(CheckResult(status, current, detail=error.message, - checked_at=statelib.now())) - if record: - _write_cache(project, result) - return result + return _check_refusal(project, current, error, record) + if resolved is not None: + # The version is all a check needs; the manifest is fetched only + # when an update is actually applied. + release = providerlib.Release(version=resolved[0].version, url=None, + digest=None) + else: + try: + release = providerlib.build(channel).latest(channel) + except LifecycleError as error: + return _check_refusal(project, current, error, record) newer = versionlib.is_newer(release.version, current) result = _runtime_fields(CheckResult(UPDATE_AVAILABLE if newer else UP_TO_DATE, current, @@ -379,7 +422,8 @@ def _safe_extract(payload: bytes, destination: Path) -> Path: return root -def _distribution_root(extracted: Path, release: providerlib.Release | None = None) -> Path: +def _distribution_root(extracted: Path, release: providerlib.Release | None = None, + *, expected_protocol: int | None = None) -> Path: """The payload root of an extracted bundle. A v2 bundle carries `lifecycle-protocol.json` at its root and the payload @@ -404,11 +448,13 @@ def _distribution_root(extracted: Path, release: providerlib.Release | None = No raise LifecycleError("UPDATE_INTEGRITY_FAILED", f"{providerlib.PROTOCOL_MANIFEST} is not an object") protocol_version = document.get("protocol_version") - if protocol_version != providerlib.UPDATE_PROTOCOL_VERSION: + expected = (expected_protocol if expected_protocol is not None + else providerlib.UPDATE_PROTOCOL_VERSION) + if protocol_version != expected: raise LifecycleError( "UPDATE_VERSION_MISMATCH", f"bundle declares lifecycle protocol {protocol_version!r}; this runtime " - f"speaks protocol {providerlib.UPDATE_PROTOCOL_VERSION}") + f"speaks protocol {expected}") declared = str(document.get("release_version", "")) if release is not None and declared != release.version: raise LifecycleError( @@ -491,6 +537,18 @@ def _require_matching_runtime(release: providerlib.Release) -> None: upgrade_command=upgrade_command(release.version)) +def _degraded_apply(project: Path, state: statelib.InstallState, + error: LifecycleError, dry_run: bool) -> UpdateResult: + """The update outcome when the source could not be consulted.""" + + outcome = _runtime_fields(CheckResult( + OFFLINE if error.code == "UPDATE_CHECK_FAILED" else CHECK_FAILED, + state.stack_version, detail=error.message, checked_at=statelib.now())) + if not dry_run: + _write_cache(project, outcome) + return UpdateResult(False, dry_run, state.stack_version, None, outcome) + + def apply(project: Path, *, dry_run: bool = False, force: bool = False, distribution: manifestlib.Distribution | None = None) -> UpdateResult: """resolve -> runtime gate -> check -> fetch -> verify -> apply -> commit. @@ -509,22 +567,26 @@ def apply(project: Path, *, dry_run: bool = False, force: bool = False, f"no AI Native installation recorded in {project}") channel = state.update_preferences.get("channel", "stable") or "stable" - provider = providerlib.build(channel) + v3 = None try: - release = provider.latest(channel) + v3 = resolve_v3_candidate(channel) except LifecycleError as error: - # An unreachable or empty source degrades gracefully, exactly as - # `check` reports it. A source that answered with something this stack - # refuses (mismatched bundle version, missing digest) is not degraded - # connectivity - it is a refusal, and it propagates. + # An unreachable source degrades gracefully, exactly as `check` + # reports it; anything a source answered with something this stack + # refuses is a refusal, and it propagates. if force or error.code not in ("UPDATE_CHECK_FAILED", "UPDATE_UNAVAILABLE"): raise - outcome = _runtime_fields(CheckResult( - OFFLINE if error.code == "UPDATE_CHECK_FAILED" else CHECK_FAILED, - state.stack_version, detail=error.message, checked_at=statelib.now())) - if not dry_run: - _write_cache(project, outcome) - return UpdateResult(False, dry_run, state.stack_version, None, outcome) + return _degraded_apply(project, state, error, dry_run) + if v3 is not None: + release = providerlib.Release(version=v3[0].version, url=None, digest=None) + else: + provider = providerlib.build(channel) + try: + release = provider.latest(channel) + except LifecycleError as error: + if force or error.code not in ("UPDATE_CHECK_FAILED", "UPDATE_UNAVAILABLE"): + raise + return _degraded_apply(project, state, error, dry_run) # Availability first, runtime contract second: a project already at the # newest release has nothing to apply, so a runtime that differs is not @@ -543,13 +605,24 @@ def apply(project: Path, *, dry_run: bool = False, force: bool = False, return UpdateResult(applied=False, dry_run=dry_run, from_version=state.stack_version, to_version=outcome.latest, check=outcome) - payload = provider.fetch(release) - providerlib.verify_archive(payload, release.digest) + protocol_version = providerlib.UPDATE_PROTOCOL_VERSION + if v3 is not None: + candidate, v3provider = v3 + manifest = release_v3lib.fetch_manifest_for(v3provider, candidate) + artifact = manifest.lifecycle_artifact() + payload = v3provider.fetch_artifact(candidate, artifact) + release_v3lib.verify_external_anchor(payload, sha256=artifact.sha256, + size=artifact.size) + protocol_version = release_v3lib.BUNDLE_PROTOCOL_VERSION + else: + payload = provider.fetch(release) + providerlib.verify_archive(payload, release.digest) staging = Path(tempfile.mkdtemp(prefix="ainative-update-", dir=str(_staging_root(project)))) try: - root = _distribution_root(_safe_extract(payload, staging), release) + root = _distribution_root(_safe_extract(payload, staging), release, + expected_protocol=protocol_version) staged_source = DistributionSource(root=root.resolve(), origin="update", version=sourcelib.read_version(root)) # The release named a version; the bytes name another one. SHA-256 diff --git a/ainative/observation.py b/ainative/observation.py new file mode 100644 index 00000000..2878a5e5 --- /dev/null +++ b/ainative/observation.py @@ -0,0 +1,131 @@ +"""Locally observed forge facts: remotes, and what they support. + +Read-only by construction: the only subprocess is `git remote`, the only inputs +are the pure resolver, the install state and the claim journal. No network, no +credentials, no writes, no persistent trust (PR-3, #164). Diagnostic only — an +ambiguity is rendered, never resolved by preference. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from . import claims +from . import forge as forgelib +from .lifecycle import environment +from .lifecycle import features as featureslib +from .lifecycle import manifest as manifestlib +from .lifecycle import state as statelib +from .lifecycle.errors import LifecycleError + +RESOLVED = "RESOLVED" +AMBIGUOUS = "AMBIGUOUS" +UNAVAILABLE = "UNAVAILABLE" +MISMATCH = "MISMATCH" + +_RESOLUTION_STATES = { + "WORK_AUTHORITY_AMBIGUOUS": AMBIGUOUS, + "WORK_AUTHORITY_UNAVAILABLE": UNAVAILABLE, + "WORK_AUTHORITY_MISMATCH": MISMATCH, +} + + +def read_remotes(project: Path) -> tuple[forgelib.ObservedRemote, ...]: + """The project's Git remotes, or () when there are none / it is not a repo.""" + + names = _git(project, "remote") + if not names: + return () + remotes = [] + for name in names.splitlines(): + name = name.strip() + if not name: + continue + url = _git(project, "remote", "get-url", name) + if url: + remotes.append(forgelib.ObservedRemote(name=name, url=url.strip())) + return tuple(remotes) + + +def _git(project: Path, *arguments: str) -> str | None: + try: + completed = subprocess.run(["git", "-C", str(project), *arguments], + capture_output=True, text=True, timeout=10) + except (OSError, subprocess.SubprocessError): + return None + if completed.returncode != 0: + return None + return completed.stdout.strip() + + +def forge_picture(project: Path, *, explicit=None, declared=None) -> dict: + """Observed remotes plus the resolver's answer, as a renderable state.""" + + observed = read_remotes(project) + record = {"git": environment.git_root(project) is not None, + "remotes": [remote.to_record() for remote in observed], + "resolution": {"state": UNAVAILABLE, "authority": None, "detail": ""}} + try: + authority = forgelib.resolve_observed_work_authority( + explicit=explicit, declared=declared, remotes=observed) + except LifecycleError as refusal: + record["resolution"]["state"] = _RESOLUTION_STATES.get(refusal.code, UNAVAILABLE) + record["resolution"]["detail"] = refusal.message + return record + record["resolution"] = {"state": RESOLVED, "authority": authority.to_record(), + "detail": ""} + return record + + +def project_view(project: Path) -> dict: + """What `forge status` and doctor display: features, forge, claim attempts.""" + + distribution = manifestlib.load() + effective = featureslib.project_install_state(statelib.load(project), distribution) + return { + "features": {"active": list(effective.active_features) if effective else [], + "projected_from_legacy": bool( + effective and effective.projected_from_legacy)}, + "forge": forge_picture(project), + "claim_attempts": _claim_summary(project), + } + + +def doctor_lines(view: dict) -> list[str]: + """The doctor extension as text lines (PR-3 / #164). Never a credential.""" + + features = view["features"] + resolution = view["forge"]["resolution"] + authority = resolution.get("authority") or {} + summary = view["claim_attempts"] + lines = [ + "Features", + " active: " + (", ".join(features["active"]) or "none") + + (" (projected from the V1 state)" if features["projected_from_legacy"] else ""), + "Work authority (observed)", + " state: " + resolution["state"] + + (f" {authority.get('provider')}:{authority.get('project')}" if authority else ""), + ] + for remote in view["forge"]["remotes"]: + lines.append(f" {remote['name']:<10} {remote['provider']:<8} " + f"{remote['project'] or '-'}") + if summary["journal"] == "ok": + lines.append(f"Claim attempts: {len(summary['unresolved'])} unresolved") + else: + lines.append(f"Claim attempts: journal unavailable ({summary['detail']})") + return lines + + +def _claim_summary(project: Path) -> dict: + try: + pending = [attempt.to_record() for attempt in claims.unresolved(project)] + except LifecycleError as error: + # An unreadable journal is a fact to display, not a crash: doctor must + # keep diagnosing the rest, and the refusal is visible in the output. + return {"journal": "unavailable", "unresolved": [], "detail": error.message} + return {"journal": "ok", "unresolved": pending, "detail": ""} + + +__all__ = ["RESOLVED", "AMBIGUOUS", "UNAVAILABLE", "MISMATCH", + "read_remotes", "forge_picture", "project_view", "doctor_lines"] diff --git a/docs/DISTRIBUTION-LIFECYCLE.md b/docs/DISTRIBUTION-LIFECYCLE.md index 0a01c9ab..72670b7a 100644 --- a/docs/DISTRIBUTION-LIFECYCLE.md +++ b/docs/DISTRIBUTION-LIFECYCLE.md @@ -731,3 +731,36 @@ report whether machine integration is present. The machine surface has no project ownership state and no transaction journal; re-running the installer *is* the update path. + + +## 18. Features (project-scope capabilities) + +A profile is a governance level; a feature is an optional project-scope +capability. They are independent (ADR-0017): a `verified` project can be +Generic Git, and a `standard` project can work with GitLab. + +```bash +ainative feature status +ainative feature enable forge-gitlab +ainative feature disable forge-gitlab +ainative feature switch forge-gitlab # one transaction: disable + enable +ainative feature switch none # Generic Git: no work forge +``` + +At most one **work forge** may be active. `forge-github` (the default) conflicts +with `forge-gitlab`; enabling a conflicting feature refuses with the remedy +(`feature switch`), and a state that names two work forges refuses as +`STATE_CONFLICTING_WORK_FORGE_FEATURES` rather than picking one. + +State schema V2 records `active_features`. A V1 state has no such field and +projects to `forge-github` — the compatibility default, read-only. The +migration happens inside the next mutation, state-last like every other state +write, and plans no file change of its own: a previously managed file that is +already absent stays absent. Only an explicit feature transition seeds files. + +Feature files follow the same ownership rules as everything else: enabling +seeds absent files, disabling removes only unchanged files and preserves files +the user modified, and re-enabling never overwrites a user file. One difference +from profile components is deliberate: ordinary `init`/`update` maintenance +never re-seeds an absent feature file — a file you removed stays removed until +a feature transition puts it back. diff --git a/docs/FORGE-WORKFLOW.md b/docs/FORGE-WORKFLOW.md new file mode 100644 index 00000000..b6bc9706 --- /dev/null +++ b/docs/FORGE-WORKFLOW.md @@ -0,0 +1,262 @@ +# Forge Workflow — provider-neutral work management + +How AI agents and humans turn ideas into merged, verified work when the work +authority may be GitHub, GitLab, or nothing at all (Generic Git). This file is +the canonical statement of the *generic* rules; each provider's mechanics live +in its mapping — [`GITHUB-WORKFLOW.md`](GITHUB-WORKFLOW.md), +[`GITLAB-WORKFLOW.md`](GITLAB-WORKFLOW.md) — and where a mapping is silent, this +file decides. The architecture review for this document is closed; change it +through an Issue, not by silently editing policy. + +## Vocabulary + +```text +Work Authority the remote whose work records are authoritative for a project: + one provider + one project identity. Not a URL, not a hostname. +WorkItem the unit of work on that authority (GitHub Issue, GitLab WorkItem) +ChangeRequest the reviewable change (GitHub PR, GitLab merge request) +WorkAuthorityRef provider + stable project identity (never guessed from a hostname) +Claim an agent's remote signal that it owns a WorkItem +``` + +One provider, one project, at most: a project has **one** work authority. Two +candidates that disagree (a fork's `origin` and `upstream`) are *ambiguity*, and +ambiguity stops remote mutation — see "Work Authority resolution" below. + +## Where state lives + +```text +Work authority records = canonical actionable backlog (WorkItems and their state) +Provider metadata = labels, milestones, review state (per mapping) +ADRs = accepted architecture (in the repository) +Work Contracts = deterministic verification when policy requires them +Vault / Obsidian = historical context only +Skills = procedures +``` + +AI Native never stores project-specific backlog state: no `issues.json`, no +current-milestone file, no vault board mirroring the authority. If the work +authority is unreachable, the backlog is unreachable with it — that is the +point: one authoritative source per fact. + +### WorkItem vs ADR + +An ADR records an architecture decision already made. A WorkItem records work +that should happen. "Refactor X per ADR-0007" may be a WorkItem; disagreeing +with ADR-0007 is not — it is a proposal to amend the ADR, handled like any +architectural change (WorkItem first, then a deliberate ADR update). + +### WorkItem vs Work Contract + +A Verified Work Contract is the deterministic proof side of one unit of work: +constrained verification, evidence provenance, convergence. It does not +replace the WorkItem; it verifies it. When repository policy requires Verified +work, the issue-to-implementation skill creates or updates the contract after +the claim and binds the WorkItem's acceptance criteria into it. + +### WorkItem vs Vault memory + +The Vault keeps session summaries, research, postmortems, decision context and +links (WorkItem, ChangeRequest, ADR). It must never hold canonical current +state: no current backlog, no status mirrors, no live Kanban, no assignee +snapshots. No bidirectional Vault ↔ authority synchronization exists or should +exist. + +## Lifecycle of one unit of work + +```text +finding --> triage --> WorkItem (or rejected / Discussion / ADR candidate) + | + v + issue-to-implementation + claim -> branch -> implement -> validate + | + v + ChangeRequest with "Refs #N" + | + v + MERGE_READY (all conditions) + | + v + Refs #N -> Closes #N --> FINAL_MERGE_FRESHNESS + | + v + merge (authorized process) + | + v + WorkItem closed as completed --> provider state = Done +``` + +### Triage + +Ideas, bugs, audit findings and research observations are candidates, not +backlog. Triage validates, deduplicates, classifies and decides: WorkItem, +Discussion, ADR candidate, or reject. Only useful, actionable work is +persisted. Triage never implements code. + +### Implementation + +One skill owns the path from a claimed WorkItem to a merged ChangeRequest. Its +invariants: + +1. **Claim before working.** See "Claims" below. +2. **Scope is the WorkItem.** Implement only its canonical scope. +3. **Acceptance Criteria are protected.** An implementation agent MUST NOT + weaken, remove, replace or materially reinterpret acceptance criteria to + make its implementation pass. It MAY detect ambiguity, identify + infeasibility, propose a change or request clarification — but a material + change becomes authoritative only after explicit approval from an + authorized maintainer **and** persistence in the WorkItem. Material = + required behavior, functional scope, security requirements, performance + thresholds, supported platforms, failure behavior, public API/contract, + acceptance thresholds. If uncertain, treat it as material and ask. There is + no separate AC database: the WorkItem body is the only home of the AC. +4. **Policy conflicts stop work.** Root `AGENTS.md` is the repository baseline; + local `AGENTS.md` files specialize or strengthen it and must never silently + weaken it. A real contradiction is surfaced as a visible conflict — never + resolved silently. +5. **Refs while working, Closes when ready.** Open and update ChangeRequests + with `Refs #N` during development. `Closes #N` appears only once + MERGE_READY is satisfied, and only immediately before the merge. + +After canonical scope and AC are bound, implementation choices use +`implementation-economy`; it cannot alter scope or acceptance criteria. + +### MERGE_READY + +`MERGE_READY` is a checklist, not a merge: + +```text +MERGE_READY = + current WorkItem acceptance criteria satisfied + + required tests/checks pass + + documentation updated where required + + review requirements satisfied + + no relevant blocker + + Verified CONVERGED when policy requires it + + WorkItem scope/AC freshness confirmed +``` + +### FINAL_MERGE_FRESHNESS + +Immediately before the actual merge, re-read the WorkItem and its current +acceptance criteria and compare against the latest validated snapshot (Verified +work: compare the canonical AC digest with the digest bound in the Work +Contract). A material difference invalidates MERGE_READY: `ISSUE_CHANGED` — do +not merge, do not close, reconcile scope and verify again. This is not an +atomic remote transaction; it is the freshest possible pre-merge check. It +exists because the WorkItem is shared state and another actor may have changed +it while the ChangeRequest was in review. + +### DONE + +```text +DONE = MERGE_READY was valid + + ChangeRequest merged + + WorkItem closed as completed + + provider state = Done (when the mapping defines one) +``` + +Closed is not Done: WorkItems are closed as duplicate, not-planned, invalid or +superseded too. Only "closed as completed" after a merged ChangeRequest counts. +If the provider exposes a completion state but the automation lacks permission +to update it, report `PROJECT_STATUS_SYNC_REQUIRED` instead of claiming Done +silently. + +## Work Authority resolution + +Reading and mutating remote state is the harness's job (it holds the provider +tooling and the credentials). `ainative` adds one pure local resolver, +`resolve_observed_work_authority()` (`ainative/forge.py`): no network, no +credentials, no writes, no push authorization. Priority: + +```text +1. an explicit WorkItem/ChangeRequest repository reference +2. an explicit harness-declared authority +3. exactly one compatible observed candidate +4. otherwise: WORK_AUTHORITY_UNAVAILABLE | WORK_AUTHORITY_AMBIGUOUS | WORK_AUTHORITY_MISMATCH +``` + +Observed candidates are diagnostic only. `github.com` and `gitlab.com` are the +only hosts whose provider is a fact; a self-hosted host is `unknown`, never +inferred from its name. When the resolution is ambiguous, remote mutation stops +— diagnostics may continue. + +## Claims + +There is no distributed lock and no lock service. A claim is a remote signal +and a local record: + +- **Preferred signal:** assignment. **Fallback:** an explicit claim comment or + note. The mapping names the provider's exact signals. +- **Identity is canonical**, never local formatting: + `:principal:` for the claimant, + `::` for the event (`comment`, `note`, + `assignment`). Timestamps are normalized to UTC. Winner ordering is the total + order `(created_at_utc, canonical_event_identifier)`; an unorderable pair is + `CLAIM_CONFLICT` and every claimant stops. +- **Before claiming**, read the WorkItem and any linked active ChangeRequest: a + linked open implementation ChangeRequest is a claim-level conflict + (`ACTIVE_PR_CONFLICT`) — stop, unless a maintainer requested a competing + attempt or the ChangeRequest was explicitly abandoned. +- **Journal before POST.** `ainative claim-attempt` writes a PENDING record + under `.ai-native/state/claim-attempts/` *before* the remote signal, with a + deterministic marker the signal must carry. A journal that cannot be written + is `CLAIM_JOURNAL_UNAVAILABLE` and no signal is sent. +- **Outcomes:** `CONFIRMED`, `LOST`, `CONFLICT`, `UNCERTAIN`, `ABANDONED`. An + unknown POST result is `UNCERTAIN` and is **never** retried automatically: + the marker search on the remote is the only resolution. A corrupt journal + fails closed. +- **Abandonment is explicit:** `ainative claim-attempt abandon --confirm` + is a local transition that keeps the record; only then may a new attempt be + created. No leases, no heartbeats, no age-based expiration: time is not + authority. + +## Review scope + +A ChangeRequest review checks: the WorkItem's acceptance criteria, the relevant +`AGENTS.md` policy, the relevant ADRs, and regressions the change introduces or +worsens. Anything else found during review is handled by priority and never +expands the current change: + +```text +P0/P1 -> separate WorkItem / escalation; may block a release globally, + does not expand the change +P2/P3 -> backlog candidate; does not expand the change +``` + +Only a finding caused or worsened by the current change — or one that directly +makes its merge unsafe — may block that change. + +## Deferred work + +Significant deferred work must not live only in chat, a temporary plan, or +historical memory. If it is actionable and worth retaining, it becomes a +WorkItem; if it was explicitly rejected, no WorkItem is created. + +## Branch naming + +Recommended, where the provider allows free branch names: + +```text +feat/-description fix/-description +docs/-description refactor/-description +chore/description # truly trivial internal work, no WorkItem required +``` + +Naming is a convention, not an enforcement mechanism. + +## Mappings + +| Concept | GitHub | GitLab | +|---|---|---| +| WorkItem | Issue | WorkItem (issue) | +| ChangeRequest | Pull request | Merge request | +| Claim signal | assignment, issue comment | assignee, note | +| Claim event id | comment/assignment event id | note/assignment id | +| Review state | PR review | MR approval | +| Templates | `templates/github/` → `.github/` | `templates/gitlab/` → `.gitlab/` | + +Each mapping document records its provider's exact mechanics and points back +here for the rules. `github-templates` and `gitlab-templates` are the two +feature components that install them (ADR-0017). diff --git a/docs/GITHUB-WORKFLOW.md b/docs/GITHUB-WORKFLOW.md index 71663569..1fc3f617 100644 --- a/docs/GITHUB-WORKFLOW.md +++ b/docs/GITHUB-WORKFLOW.md @@ -1,26 +1,26 @@ -# GitHub Workflow — AI-centered work management +# GitHub Workflow — the GitHub.com mapping -How AI agents and humans turn ideas into merged, verified work using GitHub as -the canonical active state. The architecture review for this document is -closed; change it through an issue, not by silently editing policy. +The generic rules — vocabulary, lifecycle, claims, MERGE_READY, +FINAL_MERGE_FRESHNESS, DONE, review scope, deferred work, branch naming — live +in [`FORGE-WORKFLOW.md`](FORGE-WORKFLOW.md), with the GitLab mapping in +[`GITLAB-WORKFLOW.md`](GITLAB-WORKFLOW.md). This file records only what is +GitHub-specific. The architecture review for this document is closed; change +it through an Issue, not by silently editing policy. -## Where state lives +## Where GitHub state lives ```text GitHub Issues = canonical actionable backlog GitHub Project = operational visualization when configured Milestones = delivery grouping -ADRs = accepted architecture -Work Contracts = deterministic verification when policy requires them -Vault / Obsidian = historical context only -Skills = procedures (github-triage, issue-to-implementation, - implementation-economy) ``` AI Native never stores project-specific backlog state. There is no -`issues.json`, no `current-milestone.json`, no vault board that mirrors GitHub. -If GitHub is unreachable, the backlog is unreachable with it — that is the -point: one authoritative source per fact. +`issues.json`, no `current-milestone.json`, no vault board that mirrors +GitHub. If GitHub is unreachable, the backlog is unreachable with it — that is +the point: one authoritative source per fact. (The generic statement, and the +WorkItem vs ADR / Work Contract / Vault memory rules, are in +`FORGE-WORKFLOW.md`.) ### Issue vs Project @@ -33,9 +33,9 @@ canonical operational fields: | Status | `Inbox`, `Backlog`, `Ready`, `In Progress`, `Done` | | Area | project-specific metadata (subsystem, domain) | -Do not add `Priority`, `Type`, `Effort`, or `Review` fields in v1. Type and -priority are Issue labels (`type:bug`, `priority:P1`, ...) — labels travel with -the Issue everywhere, Project fields do not. PR state stays GitHub's native PR +Do not add `Priority`, `Type`, `Effort`, or `Review` fields: Type and priority +are Issue labels (`type:bug`, `priority:P1`, ...) — labels travel with the +Issue everywhere, Project fields do not. PR state stays GitHub's native PR state; it is not a Project column. A repository without a Project loses nothing but the visualization. Every @@ -43,157 +43,26 @@ skill must degrade gracefully when no Project exists: Issues, labels and PRs are the whole workflow for small repositories. Never fail because a Project is absent. -### Issue vs ADR - -An ADR records an architecture decision already made. An Issue records work -that should happen. "Refactor X per ADR-0007" may be an Issue; disagreeing -with ADR-0007 is not — it is a proposal to amend the ADR, handled like any -architectural change (issue first, then a deliberate ADR update). - -### Issue vs Work Contract - -A Verified Work Contract is the deterministic proof side of one unit of work: -constrained verification, evidence provenance, convergence. It does not -replace the Issue; it verifies it. When repository policy requires Verified -work, the issue-to-implementation skill creates or updates the contract after -the claim and binds the Issue's acceptance criteria into it. - -### Issue vs Vault memory - -The Vault keeps session summaries, research, postmortems, decision context and -links (Issue, PR, ADR). It must never hold canonical current state: no current -backlog, no Issue status mirrors, no live Kanban, no assignee snapshots. No -bidirectional Vault <-> GitHub synchronization exists or should exist. - -## Lifecycle of one unit of work +## Claim signals ```text -finding --> github-triage --> Issue (or rejected / Discussion / ADR candidate) - | - v - issue-to-implementation - claim -> branch -> implement -> validate - | - v - PR with "Refs #N" - | - v - MERGE_READY (all conditions) - | - v - Refs #N -> Closes #N --> FINAL_MERGE_FRESHNESS - | - v - merge (authorized process) - | - v - Issue closed as completed --> Project Status = Done +preferred: assign the Issue to the claiming account +fallback: an explicit Issue comment: + "Claiming this Issue for implementation." ``` -### Triage (skills/github-triage) - -Ideas, bugs, audit findings and research observations are candidates, not -backlog. Triage validates, deduplicates, classifies (`type:*`, `priority:P*`) -and decides: Issue, Discussion, ADR candidate, or reject. Only useful, -actionable work is persisted. P0/P1 validated findings may be escalated into -Issues when authorized; P2 findings may become Issues when useful; P3 and -research notes are not automatically created as Issues. Triage never -implements code. - -### Implementation (skills/issue-to-implementation) - -One skill owns the path from a claimed Issue to a merged PR. Its invariants: - -1. **Claim before working.** See "Multi-agent claims" below. -2. **Scope is the Issue.** Implement only the Issue's canonical scope. -3. **Acceptance Criteria are protected.** An implementation agent MUST NOT - weaken, remove, replace or materially reinterpret an Issue's Acceptance - Criteria to make its implementation pass. It MAY detect ambiguity, - identify infeasibility, propose a change or request clarification — but a - material AC change becomes authoritative only after explicit approval from - an authorized maintainer/user **and** persistence in the GitHub Issue. - Material changes include: required behavior, functional scope, security - requirements, performance thresholds, supported platforms, failure - behavior, public API/contract, acceptance thresholds. If uncertain, treat - the change as material and ask. There is no separate AC database: the - Issue body is the only home of the AC. -4. **Policy conflicts stop work.** Root AGENTS.md is the repository baseline; - local AGENTS.md files specialize or strengthen it and must never silently - weaken it. A real contradiction is surfaced as a visible conflict (on the - Issue when working from one: conflicting files, conflicting rules, why - implementation cannot proceed safely) — never resolved silently. ADRs and - AGENTS.md govern different domains; a genuine contradiction between them - requires reconciliation, not arbitrary precedence. -5. **Refs while working, Closes when ready.** Open and update PRs with - `Refs #N` during development. `Closes #N` appears only once MERGE_READY is - satisfied, and only immediately before the merge. - -After canonical scope and AC are bound, implementation choices use -`implementation-economy`; it cannot alter Issue scope or Acceptance Criteria. - -### MERGE_READY - -`MERGE_READY` is a checklist, not a merge: - -```text -MERGE_READY = - current Issue acceptance criteria satisfied - + required tests/checks pass - + documentation updated where required - + review requirements satisfied - + no relevant blocker - + Verified CONVERGED when policy requires it - + Issue scope/AC freshness confirmed -``` - -### FINAL_MERGE_FRESHNESS - -Immediately before the actual merge, re-read the Issue and its current -acceptance criteria and compare against the latest validated snapshot -(Verified work: compare the canonical AC digest with the digest bound in the -Work Contract). A material difference invalidates MERGE_READY: -`ISSUE_CHANGED` — do not merge, do not close, reconcile scope and verify -again. - -This is not an atomic GitHub transaction; it is the freshest possible -pre-merge check. It exists because the Issue is shared state and another -actor may have changed it while the PR was in review. - -### DONE - -```text -DONE = MERGE_READY was valid - + PR merged - + Issue closed as completed - + Project Status = Done (when a Project is configured) -``` - -Closed is not Done. Issues are closed as `duplicate`, `not planned`, -`invalid` or `superseded` too — only "closed as completed" after a merged PR -counts as Done. If a Project exists but the automation lacks permission to -update it, report `PROJECT_STATUS_SYNC_REQUIRED` instead of claiming Done -silently. - -## Multi-agent claims - -There is no distributed lock and no lock service. The claim is GitHub state: - -- **Preferred:** GitHub Issue assignment. -- **Fallback** (actor cannot assign): an explicit Issue claim comment, e.g. - "Claiming this Issue for implementation." - Valid claim signals normalize to: `actor`, `created_at`, `stable_github_identifier` (comment or assignment event ID), `claim_kind`. -Before claiming, also read the open PRs that reference or implement the -Issue (PR body `Refs`/`Closes`/`Fixes`, issue timeline links, or an explicit +Before claiming, also read the open PRs that reference or implement the Issue +(PR body `Refs`/`Closes`/`Fixes`, issue timeline links, or an explicit "implements #N"): **an active linked implementation PR is a claim-level conflict** (`ACTIVE_PR_CONFLICT` -> STOP). It lifts only when explicit: a maintainer requested a competing implementation or collaboration, the PR was explicitly abandoned or superseded, or the same actor is continuing their own -implementation. Age alone never proves abandonment; an ambiguous -relationship is surfaced, not duplicated around. After claiming, re-read the -Issue — the claim is only as good as the freshest read, and a PR that -appeared during the race re-triggers the conflict. +implementation. Age alone never proves abandonment; an ambiguous relationship +is surfaced, not duplicated around. After claiming, re-read the Issue — the +claim is only as good as the freshest read, and a PR that appeared during the +race re-triggers the conflict. Resolution (implemented by `skills/issue-to-implementation/bin/claim_resolution.py`): @@ -211,53 +80,42 @@ unorderable -> CLAIM_CONFLICT -> every claimant STOPs ``` Assignment preference is an acquisition rule, not an arbitration override. -Stale-claim expiration, stale-PR expiration, heartbeats and leases are out -of scope in v1. - -## Review scope - -A PR review checks: the Issue's acceptance criteria, the relevant AGENTS.md -policy, the relevant ADRs, and regressions the PR introduces or worsens. -Anything else found during review is handled by priority and never expands -the current PR: +Stale-claim expiration, stale-PR expiration, heartbeats and leases are out of +scope in v1 (issue #33 stays independent). The journal-before-POST rule and +the recovery surface (`ainative claim-attempt`) are in `FORGE-WORKFLOW.md`. -```text -P0/P1 -> separate Issue / escalation; may block a release globally, - does not expand the PR -P2/P3 -> backlog candidate (github-triage); does not expand the PR -``` - -Only a finding caused or worsened by the current PR — or one that directly -makes its merge unsafe — may block that PR. - -## Deferred work - -Significant deferred work must not live only in chat, a temporary plan, or -historical memory. If it is actionable and worth retaining, it becomes a -GitHub Issue; if it was explicitly rejected, no Issue is created. +## Implementation, on GitHub -## Branch naming +The generic invariants — claim before working, scope is the Issue, protected +acceptance criteria, policy conflicts stop work, `Refs` while working and +`Closes` only when ready — are stated once in `FORGE-WORKFLOW.md`. After scope +and AC are bound, implementation choices use `implementation-economy`; its +limits are stated there too. -Recommended: +## MERGE_READY and DONE, on GitHub -```text -feat/123-description fix/123-description -docs/123-description refactor/123-description -chore/description # truly trivial internal work, no Issue required -``` +`MERGE_READY` and `FINAL_MERGE_FRESHNESS` are defined once in +`FORGE-WORKFLOW.md`; on GitHub the linkage mechanics are: -Naming is a convention, not an enforcement mechanism, in v1. +- PRs start with `Refs #N`; `Closes #N` appears only once MERGE_READY holds, + only immediately before the merge; +- DONE additionally requires the Project Status to be `Done` when a Project is + configured; +- if the automation lacks permission to update the Project, report + `PROJECT_STATUS_SYNC_REQUIRED` instead of claiming Done silently. ## Templates Generic templates ship with the stack (`templates/github/`) and are installed -as managed files (individual files, never the whole `.github/` directory): +as managed files (individual files, never the whole `.github/` directory) by +the `github-templates` component, owned by the `forge-github` feature +(ADR-0017): - `.github/ISSUE_TEMPLATE/bug.md` — Problem, Reproduction, Expected outcome, Acceptance criteria, Affected surface (triage hint only; Project Area is canonical when a Project is used). -- `.github/ISSUE_TEMPLATE/feature.md` — Problem, Expected outcome, - Acceptance criteria, Out of scope. +- `.github/ISSUE_TEMPLATE/feature.md` — Problem, Expected outcome, Acceptance + criteria, Out of scope. - `.github/PULL_REQUEST_TEMPLATE.md` — What changed, Why, Refs #, Validation, Risk. It deliberately contains no `Closes`: development starts with `Refs`. @@ -266,3 +124,9 @@ is preserved; a stack-installed template the user modified is preserved and reported as a conflict; a stack-installed, unmodified template may be updated or removed by update/uninstall. See `tests/test_lifecycle_github_templates.py` for the executable contract. + +## Review scope + +The generic rule (only findings caused or worsened by the current change may +block it; P0/P1 escalate separately, P2/P3 become backlog candidates) is stated +once in `FORGE-WORKFLOW.md`. diff --git a/docs/GITLAB-WORKFLOW.md b/docs/GITLAB-WORKFLOW.md new file mode 100644 index 00000000..33dddbf0 --- /dev/null +++ b/docs/GITLAB-WORKFLOW.md @@ -0,0 +1,55 @@ +# GitLab Workflow — the GitLab.com mapping + +The generic rules — vocabulary, lifecycle, claims, MERGE_READY, DONE, review +scope, deferred work — live in [`FORGE-WORKFLOW.md`](FORGE-WORKFLOW.md). This +file records only what is GitLab-specific. GitLab Self-Managed is not a +declared V1 target: it stays `UNTESTED` until qualified (ADR-0019). + +## Records + +| Generic concept | GitLab record | +|---|---| +| WorkItem | issue (work item) | +| ChangeRequest | merge request | +| Backlog state | labels (e.g. `type:*`, `priority:*`), milestones, boards | +| Review state | MR approval; merge when pipeline succeeds, per project settings | +| Completion | issue closed; board/list membership | + +GitLab calls its unit of work a *work item* natively; the generic vocabulary +uses the same word deliberately — the mapping is nearly identity for records, +and the interesting differences are claim signals and templates. + +## Claim signals + +```text +preferred: assign the issue to the claiming account +fallback: a note on the issue: "Claiming this work item for implementation." +``` + +The canonical event kinds are `note` (GitLab notes are comments) and +`assignment`. A claim event's stable identifier is the note's id or the +assignee-change event id; the claimant principal is +`gitlab:principal:` (the numeric user id, not the username — +a username can be renamed). Timestamps arrive with a zone; normalize with +`to_utc`. + +The resolution rule itself (earliest valid claim, deterministic ordering) is in +`FORGE-WORKFLOW.md`; no GitLab-specific resolution exists. + +## Templates + +`gitlab-templates` (feature `forge-gitlab`, ADR-0017) installs individual +managed files from `templates/gitlab/`: + +```text +.gitlab/issue_templates/bug.md +.gitlab/issue_templates/feature.md +.gitlab/merge_request_templates/default.md +``` + +They are `MANAGED_MUTABLE`: a re-install updates a file still holding the +bytes the stack wrote, preserves a file the user edited (as a conflict), and +never adopts a file it did not write. The GitLab provider itself (release +distribution through the GitLab Releases API and the Generic Package Registry) +is defined by ADR-0019 and implemented by PR-5; this document covers work +management, not release distribution. diff --git a/docs/MULTIFORGE-QUALIFICATION.md b/docs/MULTIFORGE-QUALIFICATION.md new file mode 100644 index 00000000..4b97e19a --- /dev/null +++ b/docs/MULTIFORGE-QUALIFICATION.md @@ -0,0 +1,100 @@ +# Multi-Forge qualification report + +**Base commit:** `multiforge` head `6f4946b` plus the documentation commit that +carries this report. **Architecture:** frozen (Multi-Forge v1.3.2-final; +ADR-0017/0018/0019). **Execution:** local, issue-driven; the full CI matrix +runs when the branch is proposed for `dev`. + +This report states what was verified, with which evidence, and what was not. +Declared support never exceeds qualified support (`SUPPORT.md`, ADR-0019 §13). + +## What was delivered + +| Stream | Deliverable | Evidence | +|---|---|---| +| PR-0A | Release transport credential confinement (`transport.py`) | 23 tests; characterization probe recorded pre-fix | +| ADR-MF-01/02/03 | ADR-0017/0018/0019 accepted | merged documents | +| PR-0B | V2 forward bridge (`CLI_UPDATE_REQUIRED`) + publication gate | 5 tests; `scripts/check_bridge_release.py` + 9 gate tests | +| PR-1 | Feature model, State V2, atomic switching, GitLab templates | 34 tests + 309-suite regression | +| PR-2 | Work Authority resolver, claim grammar, journal, policy docs, distributed template | 27 tests; policy purity tests | +| PR-3 | `forge detect/status`, doctor extension, status parity | 7 observation tests | +| PR-4 | Source resolver, ReleaseManifest V3, providers, updater wiring | 19 + 32 + 16 + 5 tests | +| PR-5 | GitLab provider (V3 contract), purity tests | 10 provider tests + 7 purity tests | + +## Platform matrix + +| Platform | Status | +|---|---| +| Windows (local) | GREEN — the full local suite (314 lifecycle + CLI/knowledge/machine suites) | +| Linux, macOS | PENDING — CI runs on the `multiforge` → `dev` proposal; not yet executed | + +## Forge qualification + +| Tuple | Status | +|---|---| +| Generic Git | GREEN — `feature switch none`, zero-feature states valid, observation resolves `UNAVAILABLE` | +| GitHub.com | GREEN for the release source (existing qualified releases + provider contract tests); GREEN for work management (mapping + observation) | +| GitLab.com | Provider implemented and contract-tested against a scripted GitLab API. **Live-service qualification UNTESTED — not declared supported** | +| GitLab Self-Managed | UNTESTED — not supported | +| GitHub Enterprise Server | UNTESTED — not supported | + +## E2E scenarios A–Q (plan §76) + +| # | Scenario | Evidence | Status | +|---|---|---|---| +| A | Generic Git | `test_lifecycle_features` (switch none, zero features), `test_forge_claims` (UNAVAILABLE) | GREEN | +| B | New GitLab project | feature switch to `forge-gitlab` + GitLab provider tests (scripted) | PARTIAL — no live GitLab project exists to qualify | +| C | Legacy GitHub project | V1 projection + migration tests | GREEN | +| D | GitHub → GitLab → none → GitHub | `test_a_round_trip_loses_no_user_data` | GREEN | +| E | Fork origin/upstream ambiguity | resolver + `forge detect` rendering (AMBIGUOUS, exit 0) | GREEN | +| F | Credential exfiltration attack | PR-0A tests (evil URL, artifact URL, gate: zero credentials at non-approved origins) | GREEN | +| G | Private GitHub asset → anonymous CDN | asset API flow test (scripted transport) | GREEN (scripted) | +| H | Tampered ReleaseManifest V3 | `test_release_v3` + `test_lifecycle_update_v3` (zero writes) | GREEN | +| I | Duplicate GitLab package/manifest | provider tests (`RELEASE_DUPLICATE_VERSION`, `RELEASE_MANIFEST_AMBIGUOUS`) | GREEN | +| J | GitLab object-storage blob | shared transport policy test (API → CDN strips credentials); no GitLab-specific 302 fixture yet | PARTIAL | +| K | Claim crash after POST | journal tests (`UNCERTAIN` unresolved, no retry, explicit `abandon`) | GREEN | +| L | Planner refusal during V1 projection | two-work-forge state refuses; projection never writes | GREEN | +| M | Legacy GitLab remote keeps GitHub compatibility default | projection test; the *warning* surfacing is not implemented | PARTIAL | +| N | Read-only V1 projection == persisted V2 | migration parity test + `status` parity test | GREEN | +| O | Missing legacy managed template not resurrected | `test_the_migration_does_not_resurrect_an_absent_template` | GREEN | +| P | Selector conflict identical across commands | one resolver consumed by update/check/status/doctor; conflicts tested at the resolver; no single cross-command E2E test | PARTIAL | +| Q | Mandatory secret patterns survive operator configuration | **not implemented** — the anti-debt scanner contract (`extra_secret_patterns`) is still pending | PENDING | + +## Security, migration, claims, integrity + +- **Transport:** zero provider credentials observed at non-approved origins + across the metadata + artifact flow; `https → http` downgrades, userinfo URLs + and over-long redirect chains refused; `AINATIVE_UPDATE_URL` anonymous. +- **State migration:** V1→V2 inside the next mutation, state-last, idempotent; + absent managed files stay absent; two-work-forge states refuse. +- **Claims:** journal written durably before the remote signal; corrupt or + unwritable journals fail closed; an uncertain POST is never retried; + abandonment is an explicit operator transition that keeps the record. +- **Release integrity:** the manifest is parsed only after its external anchor + (SHA-256 + size from provider metadata) is verified; the exact version chain + holds across six identities; enumeration incompleteness and duplicates + refuse instead of resolving by order. + +## Known limitations and remaining work + +**P1 — blocks the declared-support gate:** +- GitLab.com live qualification (requires a real GitLab project publishing + `ai-native-dev-stack` generic packages and an `ainative-release-v3.json` + manifest). +- Scenario Q: mandatory secret patterns (`MANDATORY_SECRET_PATTERNS` ∪ + operator extras) and the GitLab token-prefix qualification tuple. +- Full CI matrix (Linux/Windows/macOS, py3.11/3.13) — runs on the `dev` + proposal. +- EN/FR documentation parity automation (plan §75). + +**P2 — known gaps, documented rather than hidden:** +- Scenario J: no GitLab-specific object-storage 302 fixture (the shared + transport policy covers the behavior). +- Scenario M: the "legacy GitLab remote" case projects correctly but no + warning line is rendered yet. +- Scenario P: no single cross-command selector-conflict E2E test. +- The V2 bridge release and the V3 release have not been published: the plan's + release choreography (§92) starts after the branch is promoted to `dev`, and + the first V3 publication stays gated by `V3_BRIDGE_RELEASE`. + +**P0:** none known. diff --git a/scripts/lifecycle_upgrade_e2e.py b/scripts/lifecycle_upgrade_e2e.py index 311d66cb..fb0893b6 100644 --- a/scripts/lifecycle_upgrade_e2e.py +++ b/scripts/lifecycle_upgrade_e2e.py @@ -123,11 +123,16 @@ def relabel_tree(source_root: Path, destination: Path, version: str) -> Path: start = text.index(marker) + len(marker) end = text.index('"', start) init.write_text(text[:start] + version + text[end:], encoding="utf-8") - agents = destination / "AGENTS.md" - text = agents.read_text(encoding="utf-8") - relabelled = re.sub(r"(stack-version:\s*)[0-9][0-9A-Za-z.\-]*", - rf"\g<1>{version}", text, count=1) - agents.write_text(relabelled, encoding="utf-8") + # Both policy files carry the version label: the repository's own AGENTS.md + # and the distributed template the engineering-method component installs + # (ADR-0018). Relabelling only one of them left the managed payload + # byte-identical between N and N+1, and the update plan became a no-op. + for relative in ("AGENTS.md", "templates/AGENTS.md"): + agents = destination / relative + text = agents.read_text(encoding="utf-8") + relabelled = re.sub(r"(stack-version:\s*)[0-9][0-9A-Za-z.\-]*", + rf"\g<1>{version}", text, count=1) + agents.write_text(relabelled, encoding="utf-8") return destination @@ -258,7 +263,9 @@ def main() -> int: if state["stack_version"] != to_version or state["source_version"] != to_version: raise Failure(f"update did not land: {state['stack_version']}/" f"{state['source_version']}, expected {to_version}") - expected_agents = (probe_tree / "AGENTS.md").read_bytes() + # The engineering-method component installs the distributed template + # (ADR-0018), not the repository's own AGENTS.md. + expected_agents = (probe_tree / "templates" / "AGENTS.md").read_bytes() if (project / "AGENTS.md").read_bytes() != expected_agents: raise Failure("project assets are not the probe release's assets") ok(f"project updated to {to_version}: state, source_version and assets agree") diff --git a/templates/AGENTS.md b/templates/AGENTS.md new file mode 100644 index 00000000..2d357dfc --- /dev/null +++ b/templates/AGENTS.md @@ -0,0 +1,686 @@ +# Universal Engineering Rules + + + + + + +## Primary bias to correct + +Working code is not clean code. Small pieces are not simple. Familiar patterns are not correct patterns. +Own the result beyond the edit — local changes have system-level consequences. + +--- + +## Code structure + +- **File size**: flag >500 LOC new file; propose extraction >800 LOC existing; mandatory refactor >1500 LOC +- **Function size**: ≤50 LOC target; >100 alert; >200 blocking — extract sub-functions, never keep adding +- **Cyclomatic complexity**: ≤10 target; >15 alert; >25 blocking +- **Single responsibility**: before adding to a file — "does this belong here?", "am I adding a second responsibility?", "is this helper reusable elsewhere?" +- **No global state**: no `static` globals, no singletons (`getInstance()`). Prefer injection via parameter or owner member. If unavoidable: `// WHY: [precise technical reason]` +- **Dependency direction**: UI → Core → Types. Never reverse. Use forward declarations or interfaces to break upward deps. +- **No circular dependencies**: a dependency that "climbs" the hierarchy is a circular dep in formation. Resolve by forward declaration or interface extraction. + +--- + +## Error handling + +This is the single statement of the error-handling policy. Other sections point +here rather than restating it. + +- Never swallow errors silently: no empty `catch {}`, no ignored `Result`, no `_ =` +- **Rust**: `?`, `map_err()`, or `anyhow::bail!` — `unwrap()`/`expect()` forbidden in production code except on a proven invariant carrying `// SAFETY: [reason]` +- **C++**: return codes or `std::optional`/`std::expected` over exceptions in hot paths and critical code; never `catch(...) {}` +- At system boundaries (I/O, HTTP, network, user input, external parsing): always handle explicitly +- Internal trusted boundaries may `assert`/`debug_assert` in debug, panic in Rust + +--- + +## Naming & comments + +- **Language**: English everywhere — code, comments, commits, PR descriptions. One language per repo. +- **Names**: explicit over short — `processAudioFrame()` > `process()`, `userEmailAddress` > `email`. One term per concept across the codebase. +- **No cryptic abbreviations**: `idx→index`, `cnt→count`, `mgr→manager` (exceptions: `ptr`, `id`, `num`) +- **Comments**: WHY only — hidden constraint, subtle invariant, workaround for a specific bug. Never describe WHAT the code does, and never to explain confusing code: simplify the code instead. (One exception, in §Senior reflexes: public interface contracts.) +- **Dead code** — never comment it out. Remove pre-existing dead code only when + removal belongs to accepted scope; deletion safety follows + `skills/implementation-economy/SKILL.md`. `git log -S "functionName"` + recovers any deleted code. + +--- + +## Constants & resources + +- No magic numbers or strings appearing more than once → named constant +- **Rust**: `const` at module level or in `impl` block +- **C++**: `constexpr` or `static constexpr`; never bare `#define` for typed values +- **C++ resources**: no naked `new`/`delete` — `std::unique_ptr`, `std::make_unique`, RAII always. Every acquired resource is released via RAII. + +--- + +## Git & collaboration + +- Commit format: `(): ` — types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore` +- PR size: ≤400 LOC changed. Beyond: split into sequential autonomous PRs, each independently buildable +- Squash merge preferred: one clean commit per PR in main history; never merge-commit noise in `main` +- **Pre-commit** (before every non-trivial commit): + - Rust: `cargo clippy --all-targets -- -D warnings && cargo test` + - C++: `cmake --build build/ --config Release` + - TS/JS: `tsc --noEmit && eslint src/` + +--- + +## Work management + + + +- **The project's Work Authority is the canonical actionable backlog.** + WorkItem state, review state and completion live there — never in this + repository, never in the Vault. Skills = procedures; ADRs = accepted + architecture; Work Contracts = deterministic verification when policy + requires them; Vault = historical context only. This repository never stores + project-specific backlog state. +- **Claim before working.** A visible claim first (the provider's preferred + signal — assignment/assignee — or an explicit claim message as fallback), + then deterministic resolution: each actor stands at their earliest valid + claim event, actors ordered by `created_at` then stable identifier; losers + STOP. Journal the attempt locally before the remote signal + (`ainative claim-attempt`); an uncertain outcome is never retried + automatically. No lock service, ever. An active linked implementation + ChangeRequest is a claim-level conflict (`ACTIVE_PR_CONFLICT`): no parallel + implementation unless explicitly requested. +- **Protect Acceptance Criteria.** An implementation agent MUST NOT weaken, + remove, replace or materially reinterpret a WorkItem's Acceptance Criteria to + make its implementation pass. It may detect ambiguity, identify + infeasibility, propose a change, or ask — a material AC change becomes + authoritative only after explicit maintainer approval AND persistence in the + WorkItem. Material = required behavior, functional scope, security, + performance thresholds, platforms, failure behavior, public API/contract, + acceptance thresholds. Uncertain -> treat as material -> ask. +- **Scope is the WorkItem.** Unrelated findings never expand the current + ChangeRequest: P0/P1 -> separate WorkItem/escalation (may block a release, + not this change); P2/P3 -> backlog candidate. +- **Deferred work leaves the chat.** Significant deferred work that is + actionable and worth retaining becomes a WorkItem; explicitly rejected work + gets no WorkItem. +- **Refs while working, Closes when ready.** ChangeRequests start with a + reference to the WorkItem (`Refs #N`). The closing keyword appears only once + MERGE_READY holds, and only immediately before the merge — after one final + re-read of the WorkItem and its current AC (FINAL_MERGE_FRESHNESS). A + material difference is `ISSUE_CHANGED` -> stop. +- **MERGE_READY is not DONE.** DONE = MERGE_READY was valid + ChangeRequest + merged + WorkItem closed *as completed* (+ provider completion state = Done + when configured). +- **Policy conflicts stop work.** A local AGENTS.md may specialize or + strengthen this policy, never silently weaken it. A real contradiction is + surfaced visibly (POLICY_CONFLICT), never resolved by silent choice. + +Details, flow diagram and the skill-level procedures: the AI Native Dev Stack +repository's `docs/FORGE-WORKFLOW.md`, with the GitHub and GitLab mappings. + +## Engineering discipline + + +- One authoritative source per piece of system knowledge (DRY). When knowledge is copied, choose one owner and derive or trace the rest. +- Orthogonality: unrelated concerns, business rules, and volatile details don't change together. When changes fan out widely, restore ownership. +- Keep important decisions reversible until evidence justifies commitment. When uncertain or hard to reverse, seek feedback or make the step smaller. +- Automate repeatable work; keep automation versioned. +- Debug from reproduced facts and measured behavior — never coincidence or blame. +- Leave touched code, docs, tests, and tooling in a condition you can stand behind. + +--- + +## Clean code discipline + + +- Preserve behavior, write for the next reader, leave touched code cleaner within scope. +- Split boolean flags and mixed abstraction levels out of functions. (Naming itself: see §Naming & comments.) +- Separate commands from queries. No hidden side effects. +- When touching code: remove the smell most likely to make the next change risky or unclear. + +--- + +## Refactoring discipline + + +- Preserve observable behavior; isolate feature changes, migrations, and cleanup into separate steps. +- Small buildable, testable, reviewable steps — split if too large to reason about locally. +- Get a safety net (tests) before risky structural edits; characterize current behavior before modifying legacy code. +- Refactor the smell blocking the current change, not every smell nearby. +- When the same edit appears for a third time: centralize ownership instead of copying again. +- Stop when the change is easy, the code is clearer, and further cleanup would be speculative. + +--- + +## Design complexity + + +- Optimize for lower cognitive load — not shorter files, familiar patterns, or clever compactness. +- Prefer deep modules: small interfaces hiding significant internal complexity. Reject wrappers that don't hide real complexity. +- Hide volatile decisions, representations, protocol facts, and messy edge handling in one owning module. +- When naming is hard or comments get long: treat it as design evidence, not a comment problem. +- When one change spreads widely: look for duplicated knowledge, hidden dependencies, or the wrong owner. +- Add complexity for performance or patterns only when evidence justifies it. +- **Implementation Economy** — after accepted scope is established, use + `skills/implementation-economy/SKILL.md` for implementation-time + simplification and ownership-first mechanism selection. It never changes + accepted scope, AC, architecture authority or review criteria. + +--- + +## Codebase analysis strategy + +Before any analysis, audit, or review, estimate scope and classify intent. + +**Estimate scope** (run this first): +```bash +git ls-files | grep -E "\.(py|rs|cpp|c|h|hpp|ts|js|go|sh)$" | xargs wc -c 2>/dev/null | tail -1 +# → divide by 4 = estimated tokens (±20% heuristic) +git ls-files | grep -E "\.(py|rs|cpp|c|h|hpp|ts|js|go|sh)$" | wc -l +# → file count +``` + +**Classify intent**: + +| Signal in the request | Mode | Strategy | +|---|---|---| +| "Where is X?", "find Y" | **Lookup** | Explore sub-agent | +| "How does X work?" | **Understanding** | Sub-agent + targeted read | +| "Review", "analyse the architecture" | **Review** | Central synthesis + list of read/unread files | +| "Exhaustive", "nothing missing", "audit" | **Audit** | Manifest-driven direct read + verified coverage | + +**Secondary complexity signal**: if `tokens < 50k` but `files > 100` → prefer a clarification round even for Audit (many small files = complex dependency graph). + +**Strategy by size**: +``` +< 50 000 tokens → read ALL files directly in the main context (100% coverage guaranteed) +50k – 150k → deterministic cartography (ctags/AST) + layered reads +> 150 000 tokens → multi-phase workflow (cartography → parallel reads → synthesis) +``` + +**Verified coverage (mandatory in Audit mode)**: +1. Before starting: generate the complete file list — `git ls-files | grep -E "..."` — this is the execution contract +2. Declare legitimate exclusions upfront by path (`generated/`, `vendor/`, `build/`) +3. Read every remaining file in sequence +4. Report at the end: + +``` +Coverage — Audit [repo] +Files: 11 total | Excluded (generated): 0 | Excluded (vendor): 0 | To read: 11 +Read: 11/11 (100%) ✅ + +Unread business-logic files: none +Central unread modules (>5 incoming imports): none +``` + +**Confidence rule derived from coverage**: +- `≥ 80%` → conclusions without qualifier +- `60–80%` → prefix each conclusion with "Partial analysis:" +- `< 60%` → prefix with "⚠️ Provisional — insufficient coverage" + +**Note**: `ctags`/AST tools give structural exhaustiveness (all symbols), NOT behavioral exhaustiveness (same signature ≠ same logic). Direct read remains necessary for behavioral audits. Never use a sub-agent for Audit mode. + +--- + +### This project — ai-native-dev-stack + +Measured 2026-09-14 (v2.4.3: OpenCode plugin runtime fix, adapters in the published payload, opencode-plugin CI gate) via `git ls-files` (image excluded). Re-measure +with `python3 scripts/measure_scope.py`; CI fails when these figures drift. + +| Scope | Tokens (÷4) | Files | Strategy | +|---|---|---|---| +| Core stack (excl. anti-debt) | ~824 494 | 477 | **Layered read** — cartography first then targeted reads | +| Anti-debt agent | ~133 902 | 117 | Read its `AI_CONTEXT.md` and ADRs before its sources | +| Whole repo | ~958 397 | 594 | **Multi-phase workflow** — never a single direct read | + +Do **not** read the whole repo in one pass: at ~958k tokens it does not fit, +and the strategy table above applies in full. Pick the scope the task needs — +most work touches only one of the three halves below. + +The core stack is itself two independent halves, and almost no task needs both: + +| Half | Entry points | What it decides | +|---|---|---| +| **Distribution & lifecycle** (`ainative/`) | `docs/DISTRIBUTION-LIFECYCLE.md`, ADR-0009 | what is installed, and what may be replaced or deleted | +| **Verified Work Plane** (`ainative_workplane/`) | `docs/VERIFIED-WORK-PLANE.md`, ADR-0001…0008 | whether declared work has converged | + +The dependency runs one way: lifecycle may invoke the Work Plane, never the +reverse. Reading either half without the other is correct. + +> This block said "~22 000 tokens, 11 files, direct read always" until +> 2026-08-27, measured ten weeks and 178 files earlier. Every agent read that +> instruction at session start and would have blown its context following it. +> A measurement in an instruction file is a fact with an expiry date: when you +> add one, add the check that fails when it expires. + +--- + +## Senior engineering reflexes + +The rules above are the always-on core. The reflexes below are the full senior playbook — apply them proactively, without being asked, scaled to the project's language and risk. They are the canonical source: per-tool configs (`CLAUDE.md`, MiniMax `agent.md`, `.cursorrules`) should reference this file rather than re-state these rules. + +### Documentation & decisions + +- **ADR** (Architecture Decision Record) — documents a decision *already made*. Retrospective, in `docs/adr/NNNN-short-title.md`: Context · Decision · Rejected alternatives · Consequences. Triggers: new central pattern, lib choice, thread-model constraint, public-API change. +- **RFC** (Request for Comments) — requests feedback *before* a major change. Prospective, in `docs/rfcs/`: Motivation · Detailed proposal · Alternatives · Open questions · Review deadline. +- **`// See ADR-NNNN`** in code — when a block implements a documented decision, link it so a reader reaches the "why" without searching the docs. +- **Documentation proportional to size**: >10 source files → `CLAUDE.md`; >3,000 LOC → `ARCHITECTURE.md` (thread model, data flow, ownership, red zones); >5,000 LOC → `CONTRIBUTING.md` (conventions, how to add a module, PR checklist). +- **Domain glossary** — for any jargon-dense domain (audio, finance, medical, network, games), create `docs/glossary.md` defining terms *operationally* (precise definition + link to the implementing module + concrete in-project example). A dev without domain background introduces subtle bugs by misreading a technical term. +- **Data format versioning & migrations** — every persisted format carries an explicit version + one migration function per delta (`upgradeProjectV6toV7()`). Without migrations a refactor that changes the format makes all old files unreadable. +- **CHANGELOG.md** — on any project with releases, maintain it from Conventional Commits: `## [VERSION] - YYYY-MM-DD` with `### Added/Fixed/Changed/Removed`. + +### Testing + +- **Propose tests at service creation** — when a stateless service / pure business logic is created or extracted, proactively offer a test (don't wait to be asked). Stateless free functions are the highest-priority, easiest wins. +- **Test naming**: `Component_Scenario_ExpectedBehavior` (e.g. `ProjectReader_LoadCorruptedJson_DoesNotCrash`). +- **Three classified suites**: `*_Unit` (pre-commit + CI, zero I/O, <100ms) · `*_Integration` (CI nightly, mocked devices/files) · `*_Device`/`*_AudioDevice` (manual, real hardware). +- **Integration & golden tests** on deterministic outputs: golden (render a known output, compare checksum/RMS), replay (import → edit → undo → render → verify), session-load (load N historical projects → migrations still work). +- **Fuzz & property-based**: fuzz every parser of external data (libFuzzer / `cargo-fuzz`) — malformed input must fail cleanly, never corrupt state silently. Property-based test algorithms with math invariants (`proptest`/`quickcheck`/`rapidcheck`) — e.g. "audio output stays within [-1.0, 1.0] for any input". +- **Invariants as runtime asserts** — every critical invariant documented in ARCHITECTURE.md has a matching `assert()`/`debug_assert!()` in code. An unverified invariant is just a promise. Free in release, immediate detection in debug. +- **Zero-alloc CI check** — any real-time thread has a test asserting `heap_alloc_count == 0` after N iterations. An accidental allocation in a hot path is invisible until user reports ("crash after 2h"). + +### Concurrency & systems + +- **Ownership graph = DAG** — never an ownership cycle. Upward (child→parent) or lateral (sibling→sibling) references use `weak_ptr`/observer/callback, never a strong ref. Destruction order = reverse of construction. +- **Shutdown sequence** — in any multi-threaded system, document in ARCHITECTURE.md which thread is joined first, in what order queues drain, when OS handles are released. A service destroyed while the audio thread holds a reference = guaranteed crash. +- **Lock hierarchy** — document the mandatory acquisition order (e.g. `ProjectMutex → AudioGraphMutex → TrackMutex`). Never acquire a level-N lock while holding level-N+1. Prevents deadlocks; TSan detects violations. +- **Thread annotations** — comment every method with `// THREAD: audio | ui | any` so the model is explicit in code, not only in ARCHITECTURE.md. +- **RT threads** (audio callback, video decode) — no logging, no mutex, no I/O, no allocation. Communicate via a lock-free ring buffer: RT thread pushes `(EventId, timestamp, value)` with atomics; a low-priority thread drains to log/UI. Without it, "it crackles sometimes" reports are undebuggable. +- **Structured logging** — 4 levels (ERROR irrecoverable · WARN degraded · INFO session events · DEBUG off in release). Per-domain macros when justified (`LOG_AUDIO_WARN`). RT threads log only via the ring buffer above. + +### Safety & static analysis + +- **Error handling policy** — see §Error handling above. It is stated once, there. +- **RAII (C++)** — no naked `new`/`delete`; `make_unique`/`make_shared`/stack. FFI opaque handles wrapped in a RAII type immediately (no naked handle circulating). +- **`using namespace` banned at file scope** — in headers (0 exceptions, fully qualify) and production `.cpp` (function scope or explicit alias `namespace fs = std::filesystem;` only). +- **Sanitizers** in dedicated CI builds: ASan (use-after-free, overflow) + UBSan (signed overflow, null deref) can combine; TSan (data races) separate build; MSan (uninit reads). Rust FFI modules: `cargo miri test` (nightly) catches UB at the `extern "C"` boundary that C++ sanitizers miss. +- **Clang-Tidy (C++)** — beyond cppcheck. Priority checks: `bugprone-use-after-move`, `bugprone-dangling-handle`, `performance-unnecessary-copy-initialization`, `modernize-use-override/make-unique`, `readability-function-size`. Ship a `.clang-tidy` + run in pre-commit/CI. +- **Hardware abstraction for testability** — any service touching OS resources consumes an interface, never the hardware directly. Priority interfaces: `IFileSystem`, `IClock` (timers/autosave), `IAudioSink`. Lets CI simulate disk errors / latency without real hardware. + +### Supply chain + +- **`cargo audit --deny warnings`** (RustSec CVE scan of `Cargo.lock`) and **`cargo-deny`** (crate bans, license policy, duplicate versions) on any serious Rust project. +- **`osv-scanner --recursive .`** or **`trivy fs .`** for vendored/system C++ deps (SDL3, ImGui, FFmpeg, codecs). C++ CVEs are rarer but graver (codec overflow = RCE). Nightly CI. +- **CODEOWNERS** — `.github/CODEOWNERS` assigning ownership by domain + mandatory reviewer on frozen cores / public APIs / CI. Create it even solo: it prepares a second dev with zero ambiguity. + +### Process & collaboration + +- **Code review checklist** (before approving any PR): Correctness · Security (secret/injection/missing validation) · Thread safety (shared data protected, atomics correct) · Resources (no leak) · Performance (no alloc in hot path, no avoidable O(n²)) · Readability (a senior understands it in 30s) · Tests (logic covered / no broken test). +- **Performance budgets** — document per subsystem and check in CI: audio callback <2ms · UI frame <16.6ms (60fps) · undo/redo <50ms · project load <3s · heavy ops (scan, waveform) async non-blocking. +- **Tech debt SLA** — build/clippy warning: immediate (don't commit) · race condition: 24h · architecture violation: 7 days · legacy TODO: next sprint. "Stop-the-line" on the first two. +- **Feature flags** — isolate unfinished/experimental code behind a runtime flag (preferred, `config.json`) or compile-time `#ifdef` with `// FEATURE: ... — remove when: ...`. Do not introduce `#if 0`; use a real feature flag. Pre-existing `#if 0` cleanup follows accepted scope and Implementation Economy deletion safety. +- **Public interface contracts** (exception to "comments = WHY only") — public interface headers document non-inferable contracts in one line: `// @pre Must NOT be called from audio thread`, `// @thread-safety lock-free, MT-safe`, `// @throws never (noexcept)`. +- **FFI conventions (C++ ↔ Rust)** — the most dangerous boundary. Every `extern "C"`: return an `int32_t`/`ResultCode` error code (never implicit); complex errors via a thread-local `get_last_error_str()`; ownership documented explicitly (`Box::into_raw()` → C++ `unique_ptr` with a deleter calling back into Rust; never `free()` C++-side on Rust-allocated memory). Capture conventions in an "Interop Error Handling + Memory Ownership" ADR. +- **Scoped cleanup** — leave touched code cleaner only when the cleanup belongs + to the accepted work. Unrelated neighbouring debt does not enter the current + PR because it is quick; route it through normal triage. + +--- + +## Architectural change discipline (vs routine fixes) + +Routine fixes (lint, typo, single-line, doc, test): +- Use existing AGENTS.md rules (Senior reflexes, Clean code, Refactoring) +- No extra overhead +- Existing pre-commit checks per language (§Git & collaboration) are sufficient + +Architectural changes — any of these touched: +- `context/` or `providers/` directory (DI, scope, hierarchy) +- `routes?/` (route nesting, layout, navigation flow) +- Dependency order in DI chain +- Exported types/interfaces from `types/` or `exports/` +- Module-level singletons/state +- Root app component (`app.tsx`, `App.tsx`) + +REQUIRED before proposing the change: +1. Run `graphify path ` and cite the consumer tree in your commit message +2. Read ≥ 3 call sites of the changed symbol directly (not grep summary) +3. State scope explicitly in the commit message: + "Affects: [list]. Does not affect: [list]." +4. If `git diff` touches ≥ 2 files in architectural scope → ask user to confirm scope before applying +5. Tag the commit with `[arch-change]` for review priority + +Rationale (from 2026-06-26 incident): +The FileStoreProvider bug was introduced by a fix that placed a directory-scoped +provider inside session-scoped providers. The fix author (Rwanbt) understood the +immediate wiring (viewer → FileStore.markClean) but missed the directory-vs-session +scope distinction. This rule forces scope reasoning via graphify + call-site reading ++ explicit user confirmation before arch changes are applied. + +Enforcement: +- Portable guarantee on every harness: the manual steps above. Public + distribution supplies no automatic gate — never assume one will run. +- Mavis-only enhancement, machine-local and optional: the + `pretool-arch-change-detect` hook and the `arch-change-gate` skill exist + only in the maintainer's Mavis setup, are not public distribution, and are + not installed by `scripts/install_agents.py`. Where present they warn; they + never replace the manual evidence above. + +See also: `/projects/ai-native-dev-stack/AGENTS.md` +for project-specific context. It is not an enforcement mirror and does not +provide the Mavis hook/skill implementations. The legacy +`Systeme-Agentique/` path is historical and is no longer part of the v4 +vault layout. + +--- + +## Pre-commit checklist + +Before marking any task done: + +- [ ] Behavior preserved (or intentionally changed with tests)? +- [ ] One authoritative source per fact modified? +- [ ] Local reasoning clear without external context? +- [ ] No silent errors, no magic numbers, no dead code? +- [ ] Named accurately? Comments WHY only? +- [ ] File/function within size budget? +- [ ] Pre-commit checks pass (lint + tests)? +- [ ] PR ≤400 LOC or split planned? + +--- + +## Cross-model agent operating rules + +Cross-model operating rules for autonomous analysis, planning, implementation, +debugging, code review and architecture. Designed to hold on Claude, Gemini, GPT, +DeepSeek, Qwen, GLM and Minimax — including under long context and weaker +self-control. + +Written in English on purpose: it is the most reliably-followed instruction +language across all of the above. Layer project-specific rules (LOC gates, frozen +core, naming, stack) on top in a separate file. + +--- + +## Core principle + +Reliability, not speed, and not volume of text. + +A plausible explanation is not the goal. An explanation whose alternatives you have +actively ruled out, with cited evidence, is. + +Structured prose that merely *looks* thorough is the exact failure mode this file +exists to prevent. Every claim of completeness, safety, or confidence must be backed +by a located file, an executed command, or an explicit `UNVERIFIED` label. Nothing +else counts. + +Rigor scales with stakes (§1). Heavy process on a trivial task is waste, not +diligence. The real bottleneck is reviewable, trustworthy output — not how much +the agent generates. + +--- + +## 0. How to use this file + +- **At session start:** read this file. If `state.md` already exists, read it too — + that is how context carries across sessions. +- **Before your first action,** state the task TIER (§1) in one line. This is not proof + you read the file; it is what makes the proportionality gate actually fire. Skip it + and you default to over- or under-doing the task. +- **Tooling dependency — read this.** These rules reach full strength only with + execution + search/file tools. Without them you cannot produce `VERIFIED` evidence: + downgrade every such claim to `INFERRED` or `UNVERIFIED` and say so — never fabricate + a command output or `file:line` to satisfy a rule. If you cannot write files, keep + `state.md` inline as a structured block in your reply and re-quote it instead of + re-reading it. +- **Precedence.** Project/user instructions override these defaults for non-safety + matters. Never silently override a safety guard (irreversibility, §6) — surface the + conflict and confirm first. +- These rules override default tendencies toward premature conclusions, shallow + review, optimism bias, and unverified claims. +- Apply rules **in proportion to TIER**. Applying CRITICAL rigor to a TRIVIAL task + is itself a rule violation. + +--- + +## 1. Triage first — always (proportionality gate) + +Classify every task before acting. State the tier explicitly. If unsure between two +tiers, pick the higher one. + +**TRIVIAL** — typo, comment, formatting, rename a local symbol, one-line doc, an +obvious single-file change with no behavior change. +→ Just do it. Run only: §3 (no hallucinated claims), the irreversibility guard +(§6 — **all tiers, never skipped**), and §8's grep-the-pattern. Skip the targeted path +analysis (§5), the adversarial self-review (§6), and the confidence report (§7). + +**STANDARD** — bug fix, small feature, refactor inside one module, any change with +local behavior impact. +→ Full epistemic core (§3), bounded investigation (§4), targeted path analysis (§5), +single-pass adversarial review (§6), confidence report (§7). + +**CRITICAL** — touches concurrency, money/payments, auth, data persistence, a public +API contract, a cross-module or cross-platform boundary, a migration, or anything +irreversible. +→ All of STANDARD **plus** mandatory invariant verification, escalation thresholds, +and no destructive action without explicit confirmation. + +Never silently upgrade scope: do not turn a typo fix into a refactor. + +--- + +## 2. State file — one file, verifiable, reloaded + +Maintain **one** file: `state.md`. Not four. Create it if absent. + +Three sections, nothing else: + +``` +### DECISIONS +- decision | rationale | rejected alternative | status(active/superseded) + +### UNCERTAINTIES (P0 blocks correctness | P1 blocks completeness | P2 cosmetic) +- question | current hypothesis | the exact test/command that will resolve it + +### VERIFIED FINDINGS +- finding | location(file:line) | proof: exact command run + observed result, OR file:line read | status(confirmed/rejected) +``` + +Discipline: + +- **Confirmed requires proof.** Never write a finding as `confirmed` without citing + the exact command + observed result, or the exact `file:line` read. A static read + is `INFERRED`, never `confirmed`. +- **Reject, don't delete.** A finding shown false is marked `REJECTED` and kept. +- **Event-driven, not per-step.** Write only on (a) a new verified finding, + (b) a new P0/P1 uncertainty, (c) a decision. Do not narrate trivia into the log. +- **Cap at ~50 active entries.** When exceeded, compact: fold confirmed findings into + `DECISIONS`/invariants, archive the rest. A 5000-line log is noise you will ignore. +- **Mandatory reload.** Before any final answer, plan, or review, re-read `state.md`. + Externalizing without reloading is wasted I/O. If a final conclusion contradicts a + `confirmed` finding, resolve the contradiction explicitly — never silently pick one. +- **Single writer.** `state.md` assumes one writer. With parallel agents, give each its + own state file or a shared store with explicit merge — never let two agents clobber one. + +Use this machine-parseable form for updates (keeps weaker models disciplined): + +``` + +finding: ... | loc: path/file.rs:142 | proof: `cargo test foo` -> 0 passed, panic at :142 + +``` + +--- + +## 3. Epistemic core (every load-bearing claim) + +Tag every claim that an action or conclusion depends on: + +- **VERIFIED** — observed directly (ran it / read the exact lines / saw the output). + Cite the evidence. +- **INFERRED** — deduced from observed evidence, not directly seen. State the chain. +- **ASSUMED** — neither observed nor inferred. Must never be the *silent* sole basis of + an action. If acting on one is genuinely unavoidable (info inaccessible, no fallback), + label it, flag it as the primary risk, and cap confidence accordingly — do not present + it as settled. + +Anti-hallucination: + +- **Never assert the existence** of a file, function, type, test, flag, API, or + behavior you have not located. Not located → locate it now, or label it + `UNVERIFIED` and treat it as a P0 uncertainty. +- "Located" means a tool returned it or you read it — **not** that the name is + plausible. + +Evidence hierarchy (higher beats lower on conflict): + +``` +1. Executed result (test output, run, reproduction) +2. Source you read directly +3. Tests +4. Documentation / comments +5. Inference +6. Assumption +``` + +A disagreement between two levels **is itself a finding** — flag it, do not silently +trust the higher rank. When code does X but a test or doc expects Y, the conflict is +the bug until proven otherwise. + +--- + +## 4. Investigation — bounded, with an escape hatch + +Default: inspect, search, trace, verify **before** asking. Do not interrupt for +anything obtainable independently. + +But autonomy has hard bounds — these prevent rabbit holes and infinite loops +(the numbers below are floors weak models can count; tune them to your context window): + +- **Progress cap.** If ~8–10 search/inspection steps pass without resolving the active + P0 hypothesis, STOP. Write current state, list the blind spots, and either change + strategy or ask one targeted question. +- **Loop cap.** If 3 distinct fix attempts fail, STOP repeating. Switch strategy or + escalate. Re-running the same approach is not investigation. +- **Depth bound.** Trace dependencies up/down only until impact is nil or documented. + Do **not** descend into stdlib / kernel / third-party internals chasing certainty. + +**Tool-failure awareness:** an empty result is **not** proof of absence. If a search +returns nothing, verify the query, the path, and that the tool actually ran before +concluding "none exist." A `grep` with a wrong path returning 0 is a tool failure, +not a clean repo. + +**Escape hatch** (overrides "don't interrupt"). Declare the task `BLOCKED` and list +exactly what is missing when: + +- required info is genuinely inaccessible (private dep, missing file, behind auth), OR +- more than 2 unverified assumptions would have to be chained to proceed, OR +- multiple valid business/architectural decisions exist. + +Never hallucinate to escape a blocked state. + +--- + +## 5. Path & impact analysis — targeted, not ritual *(STANDARD / CRITICAL)* + +Do **not** list 8 paths each with a one-line "looks safe." That is theater. + +For each modified or critical function, pick the paths that actually apply and **show +the trace**: + +- **Always** consider: invalid input, and the **error/exception path** — a commonly + missed one. Inspect `catch` / `except` / error branches for unreleased + resources: locks, files, connections, transactions. +- **Only if the code touches them:** timeout, cancellation, shutdown, concurrency, + recovery. +- **UI / presentation / pure-leaf code:** these mostly don't apply — say so, move on. + +Rule of result, not form: for each scenario claimed safe, cite the line that handles +it or the test that exercises it. If you cannot execute, mark it `UNVERIFIED` and +raise it as a risk — do not assert safety. + +**Change impact:** + +- Name affected callers (trace ≥1 level up), affected tests, and any invariant the + change touches. +- **CRITICAL:** state the rollback strategy, and identify/run impacted tests **before** + declaring the change correct. + +--- + +## 6. Self-review & action safety + +**Single-pass adversarial review** (replaces any "double pass / reason from scratch" — +autoregressive models cannot truly forget pass 1): + +- After reaching a conclusion, take a **hostile reviewer** stance and write the + strongest concrete reasons it could be wrong — at least one, taken seriously. If a + genuine attempt finds none, state *why* the conclusion is robust rather than inventing + weak objections to hit a quota. +- For each, check whether collected evidence already rules it out. If not, investigate. +- A counterexample is "handled" **only** if you cite the test/line/run that refutes it. + An untested "what if input is empty?" counts for nothing. + +**Irreversibility guard** (all tiers): + +- Before any destructive or lasting-side-effect action — delete, migration, schema + change, force-push, production mutation, bulk write, shell command with lasting + effect — **STOP and confirm explicitly**. Default to a reversible path (branch, + dry-run, backup) when one exists. + +**Invariant verification** (CRITICAL): + +- Every proposal touching an invariant must **name** it and cite the exact line or + test proving it still holds. Listing invariants in a report without checking them is + "invariant theater" and is forbidden. + +--- + +## 7. Closing — confidence report *(STANDARD / CRITICAL; skip for TRIVIAL)* + +End with a tight report. **Confidence is bounded, not invented:** + +> Confidence is capped by the **weakest load-bearing claim**, not by a headcount. If the +> decisive claim is `ASSUMED`, confidence stays low even if ten peripheral claims are +> `VERIFIED`. A single `VERIFIED` claim that fully settles the question can justify a +> high score. + +``` +Confidence: X/10 (justified by the weakest load-bearing claim, not a ratio) +Verified (with evidence): ... +Inferred / Assumed: ... +Inspected (cite key lines): ... +Not inspected — why safe, or flagged as risk: ... +Open P0/P1 uncertainties: ... (if any P0/P1 remain → task is INCOMPLETE) +Irreversible / risky actions taken or proposed: ... +``` + +--- + +## 8. Recovery & correction + +- **User contradicts a prior decision:** do not defend it. Update `state.md` with the + pivot and its reason, then proceed on the new basis. +- **A past `confirmed` finding turns out wrong:** mark it `REJECTED` (don't delete) and + propagate the correction to everything that depended on it. +- **After any fix, before declaring it done:** grep the whole codebase for the same + pattern / root cause elsewhere. One instance fixed is not the class fixed. + +--- + +## Completeness self-check (the one gate that always runs — kept cheap) + +Before concluding any **non-trivial** task, answer in 4 lines: + +1. What did I inspect (with evidence)? +2. What could this impact that I did **not** inspect? +3. Why is that safe — or is it an open risk? +4. What is the single thing I'm most likely to have gotten wrong? + +If you cannot answer these, the task is not done. + +--- + +## Anti-patterns — NEVER (quick reference) + +- ❌ "This is probably safe." → verify, or label `UNVERIFIED`. +- ❌ "The file looks fine" after opening it. → cite the line, or you didn't review it. +- ❌ Marking `confirmed` from a static read. → that's `INFERRED`, not confirmed. +- ❌ "I checked for counterexamples" with no test. → ritual, not verification. +- ❌ Listing invariants without citing what proves them. → theater. +- ❌ "Scope exhausted" with no bound. → name what you didn't inspect and why. +- ❌ Empty search result → "nothing exists." → check the tool / path first. +- ❌ Heavy analysis on a typo. → wrong tier. +- ❌ "I'll remember this." → you won't. Write it to `state.md`. +- ❌ Hallucinating an API to escape a blocked state. → declare `BLOCKED` instead. diff --git a/templates/gitlab/issue_templates/bug.md b/templates/gitlab/issue_templates/bug.md new file mode 100644 index 00000000..9295ac27 --- /dev/null +++ b/templates/gitlab/issue_templates/bug.md @@ -0,0 +1,21 @@ +## Problem + + + +## Reproduction + + + +## Expected outcome + + + +## Acceptance criteria + + + +- [ ] ... + +## Affected surface + + diff --git a/templates/gitlab/issue_templates/feature.md b/templates/gitlab/issue_templates/feature.md new file mode 100644 index 00000000..eda86a11 --- /dev/null +++ b/templates/gitlab/issue_templates/feature.md @@ -0,0 +1,17 @@ +## Problem + + + +## Expected outcome + + + +## Acceptance criteria + + + +- [ ] ... + +## Out of scope + + diff --git a/templates/gitlab/merge_request_templates/default.md b/templates/gitlab/merge_request_templates/default.md new file mode 100644 index 00000000..f252570b --- /dev/null +++ b/templates/gitlab/merge_request_templates/default.md @@ -0,0 +1,13 @@ +## What changed + +## Why + +Refs # + +## Validation + +- [ ] Relevant validation completed +- [ ] Documentation updated if required +- [ ] No unrelated scope included + +## Risk diff --git a/tests/lifecycle_support.py b/tests/lifecycle_support.py index 74da725a..50e339c4 100644 --- a/tests/lifecycle_support.py +++ b/tests/lifecycle_support.py @@ -72,6 +72,9 @@ def build_distribution_tree(root: Path, version: str = "1.0.0", *, templates = root / "templates" templates.mkdir(parents=True, exist_ok=True) write_text(templates / "AI_CONTEXT_template.md", f"# context {version}\n") + # The engineering-method component installs from the distributed template, + # not from the repository's own AGENTS.md (ADR-0018). + write_text(templates / "AGENTS.md", f"# Engineering method {version}\n") skills = root / "skills" for name in ["demo-skill"] + ([extra_skill] if extra_skill else []): diff --git a/tests/purity/test_contract_purity.py b/tests/purity/test_contract_purity.py new file mode 100644 index 00000000..f1414101 --- /dev/null +++ b/tests/purity/test_contract_purity.py @@ -0,0 +1,68 @@ +"""Provider-neutral helpers behave identically for GitHub and GitLab (§74). + +The claim grammar, the Work Authority resolver and the release version policy +are parameterized by provider. Nothing may behave differently because the +provider word is `gitlab` instead of `github`: same identity shape, same +normalization, same selection rules. +""" + +from __future__ import annotations + +import unittest + +from ainative import claims +from ainative import forge as forgelib +from ainative.lifecycle import release_v3 +from ainative.lifecycle.errors import LifecycleError + +PROVIDERS = ("github", "gitlab") + + +class ContractPurity(unittest.TestCase): + + def test_canonical_identities_share_one_shape_across_providers(self): + for provider in PROVIDERS: + with self.subTest(provider=provider): + self.assertEqual(claims.principal(provider, "actor-1"), + f"{provider}:principal:actor-1") + self.assertEqual(claims.event_identifier(provider, "note", "42"), + f"{provider}:note:42") + self.assertEqual(claims.event_identifier(provider, "comment", "42"), + f"{provider}:comment:42") + + def test_remote_reading_understands_both_hosted_forges(self): + github = forgelib.ObservedRemote(name="origin", + url="git@github.com:org/project.git") + gitlab = forgelib.ObservedRemote(name="origin", + url="https://gitlab.com/org/sub/project.git") + self.assertEqual(github.provider, "github") + self.assertEqual(github.identity, "org/project") + self.assertEqual(gitlab.provider, "gitlab") + self.assertEqual(gitlab.identity, "org/sub/project") + for remote in (github, gitlab): + resolved = forgelib.resolve_observed_work_authority(remotes=(remote,)) + self.assertEqual(resolved.provider, remote.provider) + self.assertEqual(resolved.project, remote.identity) + + def test_the_version_policy_is_provider_independent(self): + for provider in PROVIDERS: + with self.subTest(provider=provider): + self.assertEqual(release_v3.canonical_version("v2.5.0"), "2.5.0") + with self.assertRaises(LifecycleError) as raised: + release_v3.canonical_version("2.5.0+build1") + self.assertEqual(raised.exception.code, + "RELEASE_BUILD_METADATA_UNSUPPORTED") + candidates = ( + release_v3.ReleaseCandidate(version="2.5.0", + identity=f"{provider}:v2.5.0"), + release_v3.ReleaseCandidate(version="2.5.0-rc.1", + identity=f"{provider}:v2.5.0-rc.1"), + ) + chosen = release_v3.select_candidate( + release_v3.EnumerationResult(candidates, complete=True), + release_v3.ReleaseQuery("stable")) + self.assertEqual(chosen.version, "2.5.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/purity/test_dependency_purity.py b/tests/purity/test_dependency_purity.py new file mode 100644 index 00000000..4c8279e6 --- /dev/null +++ b/tests/purity/test_dependency_purity.py @@ -0,0 +1,61 @@ +"""Neutral modules must not depend on provider implementations (ADR-0019 §74). + +`ainative.forge`, `ainative.claims` and `ainative.cli_support` are the neutral +layer: provider-neutral facts, identities and plumbing. They may import the +lifecycle's errors and state (neutral infrastructure), never a provider +implementation, the release transport, the updater or the observation module — +a neutral module that reaches a provider is a coupling that makes the neutral +layer untestable without a network or a forge. +""" + +from __future__ import annotations + +import ast +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] + +NEUTRAL_MODULES = ("ainative/forge.py", "ainative/claims.py", "ainative/cli_support.py") +BANNED_PREFIXES = ( + "ainative.lifecycle.provider", + "ainative.lifecycle.release_providers", + "ainative.lifecycle.transport", + "ainative.lifecycle.updater", + "ainative.lifecycle.release_source", + "ainative.observation", +) + + +def imported_modules(path: Path) -> set[str]: + """Every module name `path` imports, with relative imports resolved.""" + + tree = ast.parse(path.read_text(encoding="utf-8")) + found: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + found.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + if node.level: + package = "ainative" if path.parent.name == "ainative" \ + else "ainative.lifecycle" + found.add(f"{package}.{node.module}" if node.module else package) + elif node.module: + found.add(node.module) + return found + + +class DependencyPurity(unittest.TestCase): + + def test_neutral_modules_never_import_a_provider_or_the_transport(self): + for relative in NEUTRAL_MODULES: + with self.subTest(module=relative): + imports = imported_modules(REPO / relative) + offenders = {name for name in imports + if any(name == banned or name.startswith(banned + ".") + for banned in BANNED_PREFIXES)} + self.assertEqual(offenders, set(), relative) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/purity/test_distributed_policy_purity.py b/tests/purity/test_distributed_policy_purity.py new file mode 100644 index 00000000..c3a608a6 --- /dev/null +++ b/tests/purity/test_distributed_policy_purity.py @@ -0,0 +1,49 @@ +"""The distributed policy speaks provider-neutral Work Authority (§74). + +`templates/AGENTS.md` is what every project receives; the repository's own +`AGENTS.md` may stay GitHub-specific. The distributed file may name the +mappings, but it must not carry GitHub-only *authority*: no GitHub-only claim +procedure, no GitHub-only credential, no GitHub-only canonical-backlog +statement. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +DISTRIBUTED = REPO / "templates" / "AGENTS.md" + +FORBIDDEN_AUTHORITY = ( + "GitHub Issues are the canonical actionable backlog", + "docs/GITHUB-WORKFLOW.md", + "GITHUB_TOKEN", + "GH_TOKEN", +) + + +class DistributedPolicyPurity(unittest.TestCase): + + def setUp(self) -> None: + self.text = DISTRIBUTED.read_text(encoding="utf-8") + + def test_no_github_only_authority_survives_in_the_distributed_policy(self): + for phrase in FORBIDDEN_AUTHORITY: + with self.subTest(phrase=phrase): + self.assertNotIn(phrase, self.text) + + def test_the_distributed_policy_names_the_neutral_rules(self): + self.assertIn("Work Authority", self.text) + self.assertIn("WorkItem", self.text) + self.assertIn("FORGE-WORKFLOW.md", self.text) + self.assertIn("ainative claim-attempt", self.text) + self.assertIn("ACTIVE_PR_CONFLICT", self.text) + + def test_the_repository_own_policy_may_stay_github_specific(self): + own = (REPO / "AGENTS.md").read_text(encoding="utf-8") + self.assertIn("GitHub work management", own) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_forge_claims.py b/tests/test_forge_claims.py new file mode 100644 index 00000000..87c75afd --- /dev/null +++ b/tests/test_forge_claims.py @@ -0,0 +1,347 @@ +"""The pure Work Authority resolver, the claim grammar, and the claim journal. + +ADR-0018 splits work management in two: the harness observes remote facts, and +the local layer records them deterministically. These tests pin the local half +— resolution priority and refusals, canonical identities, UTC ordering, the +journal-before-POST invariant, the outcomes, and the explicit abandonment +transition. Nothing here touches the network, and nothing may infer an outcome: +an unreadable journal fails closed. +""" + +from __future__ import annotations + +import json +import subprocess +import unittest +from pathlib import Path + +from tests.lifecycle_support import LifecycleTestCase, write_text +from ainative import claims +from ainative import forge as forgelib +from ainative.lifecycle import state as statelib +from ainative.lifecycle.errors import LifecycleError + + +class RemoteReading(unittest.TestCase): + + def test_https_and_scp_urls_read_the_same(self): + self.assertEqual(forgelib.provider_hint("https://github.com/o/r.git"), "github") + self.assertEqual(forgelib.provider_hint("git@github.com:o/r.git"), "github") + self.assertEqual(forgelib.provider_hint("ssh://git@gitlab.com/g/s/p.git"), "gitlab") + self.assertEqual(forgelib.project_identity("git@github.com:o/r.git"), "o/r") + self.assertEqual(forgelib.project_identity("https://gitlab.com/g/s/p.git"), "g/s/p") + + def test_a_self_hosted_host_is_unknown_never_guessed(self): + self.assertEqual(forgelib.provider_hint("https://git.example.com/o/r.git"), "unknown") + self.assertIsNone(forgelib.project_identity("https://git.example.com/o/r.git")) + + def test_an_url_without_a_project_is_not_an_identity(self): + self.assertIsNone(forgelib.project_identity("https://github.com")) + self.assertIsNone(forgelib.project_identity("https://github.com/only-owner")) + + +class WorkAuthorityResolution(unittest.TestCase): + + def ref(self, provider: str, project: str) -> forgelib.WorkAuthorityRef: + return forgelib.WorkAuthorityRef(provider=provider, project=project) + + def test_an_explicit_reference_wins(self): + resolved = forgelib.resolve_observed_work_authority( + explicit=self.ref("github", "o/explicit"), + remotes=forgelib.observe_remotes([("origin", "https://gitlab.com/o/other.git")])) + self.assertEqual(resolved, self.ref("github", "o/explicit")) + + def test_a_harness_declaration_wins_over_observation(self): + resolved = forgelib.resolve_observed_work_authority( + declared=self.ref("gitlab", "g/declared"), + remotes=forgelib.observe_remotes([("origin", "https://github.com/o/other.git")])) + self.assertEqual(resolved, self.ref("gitlab", "g/declared")) + + def test_contradicting_statements_refuse_as_mismatch(self): + with self.assertRaises(LifecycleError) as raised: + forgelib.resolve_observed_work_authority( + explicit=self.ref("github", "o/a"), declared=self.ref("gitlab", "g/b")) + self.assertEqual(raised.exception.code, "WORK_AUTHORITY_MISMATCH") + + def test_one_compatible_candidate_is_an_authority(self): + resolved = forgelib.resolve_observed_work_authority( + remotes=forgelib.observe_remotes([("origin", "git@github.com:o/r.git")])) + self.assertEqual(resolved, self.ref("github", "o/r")) + + def test_two_names_for_the_same_authority_are_not_ambiguous(self): + resolved = forgelib.resolve_observed_work_authority( + remotes=forgelib.observe_remotes([ + ("origin", "https://github.com/o/r.git"), + ("upstream", "git@github.com:o/r")])) + self.assertEqual(resolved, self.ref("github", "o/r")) + + def test_a_fork_refuses_as_ambiguous_without_preferring_origin(self): + with self.assertRaises(LifecycleError) as raised: + forgelib.resolve_observed_work_authority( + remotes=forgelib.observe_remotes([ + ("origin", "https://github.com/me/fork.git"), + ("upstream", "https://github.com/org/project.git")])) + self.assertEqual(raised.exception.code, "WORK_AUTHORITY_AMBIGUOUS") + self.assertEqual(len(raised.exception.detail["candidates"]), 2) + + def test_nothing_usable_refuses_as_unavailable(self): + with self.assertRaises(LifecycleError) as raised: + forgelib.resolve_observed_work_authority(remotes=()) + self.assertEqual(raised.exception.code, "WORK_AUTHORITY_UNAVAILABLE") + with self.assertRaises(LifecycleError) as raised: + forgelib.resolve_observed_work_authority( + remotes=forgelib.observe_remotes( + [("origin", "https://git.example.com/o/r.git")])) + self.assertEqual(raised.exception.code, "WORK_AUTHORITY_UNAVAILABLE") + + +class ClaimGrammar(unittest.TestCase): + + def event(self, identifier: str, created_at: str, actor: str = "github:principal:alice"): + return claims.ClaimEvent(identifier=identifier, created_at=created_at, actor=actor) + + def test_canonical_identities(self): + self.assertEqual(claims.principal("github", "alice"), + "github:principal:alice") + self.assertEqual(claims.event_identifier("gitlab", "note", "42"), + "gitlab:note:42") + with self.assertRaises(LifecycleError) as raised: + claims.event_identifier("gitlab", "telepathy", "42") + self.assertEqual(raised.exception.code, "CLAIM_INVALID") + for bad in ("", "two words", "co:lon"): + with self.assertRaises(LifecycleError): + claims.principal("github", bad) + + def test_timestamps_normalize_to_utc_and_naive_ones_refuse(self): + self.assertEqual(claims.to_utc("2026-09-16T12:00:00+02:00"), + "2026-09-16T10:00:00+00:00") + self.assertEqual(claims.to_utc("2026-09-16T10:00:00Z"), + "2026-09-16T10:00:00+00:00") + for bad in ("2026-09-16T10:00:00", "not-a-date"): + with self.assertRaises(LifecycleError) as raised: + claims.to_utc(bad) + self.assertEqual(raised.exception.code, "CLAIM_INVALID") + + def test_winner_is_ordered_by_timestamp_then_identifier(self): + early = self.event("github:comment:2", "2026-09-16T09:00:00+00:00") + late = self.event("github:comment:1", "2026-09-16T10:00:00+00:00") + self.assertEqual(claims.winner([late, early]), early) + self.assertIsNone(claims.winner([])) + + def test_a_tie_is_broken_by_the_canonical_identifier(self): + first = self.event("github:comment:1", "2026-09-16T10:00:00+00:00") + second = self.event("github:comment:2", "2026-09-16T10:00:00+00:00") + self.assertEqual(claims.winner([second, first]), first) + + def test_duplicate_observations_are_one_event(self): + event = self.event("github:comment:1", "2026-09-16T10:00:00+00:00") + self.assertEqual(claims.winner([event, event]), event) + + def test_an_identifier_carrying_two_actors_is_a_conflict(self): + with self.assertRaises(LifecycleError) as raised: + claims.winner([ + self.event("github:comment:1", "2026-09-16T10:00:00+00:00", + actor="github:principal:alice"), + self.event("github:comment:1", "2026-09-16T10:00:00+00:00", + actor="github:principal:bob")]) + self.assertEqual(raised.exception.code, "CLAIM_CONFLICT") + + +class ClaimJournal(LifecycleTestCase): + + def authority(self): + return forgelib.WorkAuthorityRef(provider="github", project="o/r") + + def attempt(self, item: str = "42"): + return claims.new_attempt(authority=self.authority(), item=item, + principal=claims.principal("github", "alice")) + + def test_begin_writes_pending_before_anything_else(self): + attempt = self.attempt() + path = claims.begin(self.project, attempt) + self.assertTrue(path.is_file()) + record = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual(record["state"], claims.PENDING) + self.assertIn(attempt.attempt_id, attempt.marker) + self.assertEqual(record["marker"], attempt.marker) + + def test_an_unwritable_journal_refuses_instead_of_posting(self): + journal = claims.attempts_root(self.project) + write_text(journal, "not a directory\n") + with self.assertRaises(LifecycleError) as raised: + claims.begin(self.project, self.attempt()) + self.assertEqual(raised.exception.code, "CLAIM_JOURNAL_UNAVAILABLE") + + def test_a_corrupt_entry_fails_closed(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + write_text(claims.attempts_root(self.project) / f"{attempt.attempt_id}.json", + "{not json\n") + with self.assertRaises(LifecycleError) as raised: + claims.list_attempts(self.project) + self.assertEqual(raised.exception.code, "CLAIM_JOURNAL_UNAVAILABLE") + + def test_outcomes_are_recorded_and_uncertain_stays_unresolved(self): + confirmed = self.attempt() + uncertain = self.attempt(item="43") + claims.begin(self.project, confirmed) + claims.begin(self.project, uncertain) + claims.record_outcome(self.project, confirmed.attempt_id, claims.CONFIRMED) + claims.record_outcome(self.project, uncertain.attempt_id, claims.UNCERTAIN, + note="POST answer was lost") + states = {attempt.attempt_id: attempt.state + for attempt in claims.list_attempts(self.project)} + self.assertEqual(states[confirmed.attempt_id], claims.CONFIRMED) + self.assertEqual(states[uncertain.attempt_id], claims.UNCERTAIN) + self.assertEqual([attempt.attempt_id for attempt in claims.unresolved(self.project)], + [uncertain.attempt_id]) + self.assertEqual(claims.load_attempt(self.project, uncertain.attempt_id).note, + "POST answer was lost") + + def test_an_unknown_outcome_is_refused(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + with self.assertRaises(LifecycleError) as raised: + claims.record_outcome(self.project, attempt.attempt_id, "MAYBE") + self.assertEqual(raised.exception.code, "CLAIM_INVALID") + + def test_abandon_needs_an_explicit_confirmation(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + with self.assertRaises(LifecycleError) as raised: + claims.abandon(self.project, attempt.attempt_id, confirm=False) + self.assertEqual(raised.exception.code, "CONFIRMATION_REQUIRED") + self.assertEqual(claims.load_attempt(self.project, attempt.attempt_id).state, + claims.PENDING) + + def test_abandon_is_local_and_the_record_is_retained(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + abandoned = claims.abandon(self.project, attempt.attempt_id, confirm=True) + self.assertEqual(abandoned.state, claims.ABANDONED) + self.assertTrue((claims.attempts_root(self.project) + / f"{attempt.attempt_id}.json").is_file()) + self.assertEqual(claims.unresolved(self.project), []) + # after abandonment, a new attempt is allowed + replacement = self.attempt() + claims.begin(self.project, replacement) + self.assertEqual(claims.load_attempt(self.project, replacement.attempt_id).state, + claims.PENDING) + + def test_a_confirmed_claim_cannot_be_abandoned(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + claims.record_outcome(self.project, attempt.attempt_id, claims.CONFIRMED) + with self.assertRaises(LifecycleError) as raised: + claims.abandon(self.project, attempt.attempt_id, confirm=True) + self.assertEqual(raised.exception.code, "CLAIM_INVALID") + + def test_an_abandoned_record_is_final(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + claims.abandon(self.project, attempt.attempt_id, confirm=True) + with self.assertRaises(LifecycleError) as raised: + claims.record_outcome(self.project, attempt.attempt_id, claims.LOST) + self.assertEqual(raised.exception.code, "CLAIM_INVALID") + + def test_a_pending_attempt_survives_a_lifecycle_update_byte_identically(self): + from ainative.lifecycle.digest import digest_file + + self.install("standard") + attempt = self.attempt() + path = claims.begin(self.project, attempt) + before = digest_file(path) + from ainative.lifecycle import installer + + installer.install(self.project, "standard", operation="update", + distribution=self.distribution, source=self.source) + self.assertEqual(digest_file(path), before, + "a lifecycle update touched a pending claim attempt") + self.assertEqual(statelib.SCHEMA_VERSION, + statelib.load(self.project).schema_version) + + def test_the_cli_lists_inspects_and_abandons(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + listed = json.loads(self.cli("claim-attempt", "list", "--json").stdout) + self.assertEqual(listed["unresolved"], [attempt.attempt_id]) + inspected = json.loads(self.cli("claim-attempt", "inspect", + attempt.attempt_id, "--json").stdout) + self.assertEqual(inspected["marker"], attempt.marker) + refused = self.cli("claim-attempt", "abandon", attempt.attempt_id) + self.assertEqual(refused.returncode, 2, "abandon without --confirm must refuse") + abandoned = json.loads(self.cli("claim-attempt", "abandon", attempt.attempt_id, + "--confirm", "--json").stdout) + self.assertEqual(abandoned["state"], claims.ABANDONED) + self.assertEqual(json.loads(self.cli("claim-attempt", "list", "--json").stdout) + ["unresolved"], []) + + +class ForgeObservation(LifecycleTestCase): + """`forge detect|status` and the doctor extension: observe, never mutate.""" + + def snapshot(self) -> dict: + from ainative.lifecycle.digest import digest_file + + return {path.relative_to(self.project).as_posix(): digest_file(path) or "" + for path in self.project.rglob("*") if path.is_file()} + + def remote(self, name: str, url: str) -> None: + subprocess.run(["git", "-C", str(self.project), "remote", "add", name, url], + check=True, capture_output=True) + + def test_detect_resolves_a_single_hosted_remote(self): + self.remote("origin", "git@github.com:o/r.git") + record = json.loads(self.cli("forge", "detect", "--json").stdout) + self.assertEqual(record["resolution"]["state"], "RESOLVED") + self.assertEqual(record["resolution"]["authority"], + {"provider": "github", "project": "o/r"}) + + def test_a_fork_renders_ambiguous_without_preferring_origin(self): + self.remote("origin", "https://github.com/me/fork.git") + self.remote("upstream", "https://github.com/org/project.git") + completed = self.cli("forge", "detect", "--json") + self.assertEqual(completed.returncode, 0, "detection is diagnostic, not a failure") + record = json.loads(completed.stdout) + self.assertEqual(record["resolution"]["state"], "AMBIGUOUS") + self.assertEqual([item["name"] for item in record["remotes"]], + ["origin", "upstream"]) + self.assertIn("Resolution: AMBIGUOUS", self.cli("forge", "detect").stdout) + + def test_an_unknown_host_is_unavailable_not_guessed(self): + self.remote("origin", "https://git.example.com/o/r.git") + record = json.loads(self.cli("forge", "detect", "--json").stdout) + self.assertEqual(record["remotes"][0]["provider"], "unknown") + self.assertEqual(record["resolution"]["state"], "UNAVAILABLE") + + def test_forge_status_reports_features_and_claim_attempts(self): + self.install("standard") + claims.begin(self.project, claims.new_attempt( + authority=forgelib.WorkAuthorityRef(provider="github", project="o/r"), + item="42", principal=claims.principal("github", "alice"))) + record = json.loads(self.cli("forge", "status", "--json").stdout) + self.assertEqual(record["features"]["active"], ["forge-github"]) + self.assertEqual(len(record["claim_attempts"]["unresolved"]), 1) + + def test_observation_commands_never_write(self): + self.install("standard") + self.remote("origin", "git@github.com:o/r.git") + before = self.snapshot() + self.cli("forge", "detect") + self.cli("forge", "status") + self.cli("doctor") + self.assertEqual(self.snapshot(), before, + "an observation command touched the project") + + def test_doctor_reports_features_forge_and_claims(self): + self.install("standard") + self.remote("origin", "git@gitlab.com:g/p.git") + record = json.loads(self.cli("doctor", "--json").stdout) + self.assertEqual(record["features"]["active"], ["forge-github"]) + self.assertEqual(record["forge"]["resolution"]["authority"]["provider"], "gitlab") + self.assertEqual(record["claim_attempts"]["journal"], "ok") + self.assertEqual(record["claim_attempts"]["unresolved"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_github_work_skills.py b/tests/test_github_work_skills.py index 3a8b0bda..0a073de0 100644 --- a/tests/test_github_work_skills.py +++ b/tests/test_github_work_skills.py @@ -458,8 +458,12 @@ def test_boy_scout_under_15_min_rule_is_gone(self) -> None: self.assertIn('Scoped cleanup', self.AGENTS) def test_github_workflow_lists_implementation_economy(self) -> None: + # The generic rule moved to the provider-neutral policy (ADR-0018); + # the GitHub mapping must still name the skill and point at it. self.assertIn('implementation-economy', self.WORKFLOW) - self.assertIn('it cannot alter Issue scope or Acceptance Criteria', self.WORKFLOW) + neutral = (REPO / 'docs' / 'FORGE-WORKFLOW.md').read_text(encoding='utf-8') + self.assertIn('implementation-economy', neutral) + self.assertIn('it cannot alter scope or acceptance criteria', neutral) def test_preexisting_deletion_imperatives_are_scoped(self) -> None: diff --git a/tests/test_lifecycle_features.py b/tests/test_lifecycle_features.py new file mode 100644 index 00000000..742f9957 --- /dev/null +++ b/tests/test_lifecycle_features.py @@ -0,0 +1,452 @@ +"""The feature model, State V2, and the one projection every reader shares. + +ADR-0017 makes profiles (governance) and features (optional project-scope +capabilities) orthogonal. A V1 state predates `active_features`; it projects to +the declared legacy default — a GitHub project until it explicitly switches — +and migrates inside a normal mutation, state-last. These tests pin the model, +the projection and the migration, including every refusal that must never be a +silent pick: two work forges, an undeclared feature, a malformed catalogue. +""" + +from __future__ import annotations + +import json +import subprocess +import unittest +from pathlib import Path + +from tests.lifecycle_support import LifecycleTestCase, write_text +from ainative.lifecycle import features as featureslib +from ainative.lifecycle import manifest as manifestlib +from ainative.lifecycle import state as statelib +from ainative.lifecycle.errors import LifecycleError + +LEGACY = "forge-github" +GITLAB = "forge-gitlab" + + +def write_catalogue(directory: Path, *, features: dict, legacy: str = "forge") -> Path: + """A minimal catalogue: one component, one profile, the given features.""" + + directory.mkdir(parents=True, exist_ok=True) + write_text(directory / "components.json", json.dumps({ + "schema_version": 1, + "components": {"tpl": {"kind": "file", "ownership": "MANAGED_MUTABLE", + "source": "AGENTS.md", "destination": "TEMPLATE.md"}}})) + write_text(directory / "profiles.json", json.dumps({ + "schema_version": 1, "default": "standard", + "profiles": {"standard": {"extends": None, "components": []}}})) + write_text(directory / "features.json", json.dumps({ + "schema_version": 1, "legacy_default": legacy, "features": features})) + return directory + + +class FeatureCatalogue(unittest.TestCase): + + def setUp(self) -> None: + import shutil + import tempfile + + self.root = Path(tempfile.mkdtemp(prefix="ainative-features-")) + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) + self.distribution = manifestlib.load() + + def feature(self, name: str, **fields) -> dict: + base = {"scope": "project", "work_forge": False, "components": [], "conflicts": []} + base.update(fields) + return base + + def test_the_real_catalogue_declares_both_work_forges_and_a_default(self): + github = self.distribution.feature(LEGACY) + gitlab = self.distribution.feature(GITLAB) + self.assertEqual(self.distribution.legacy_default_feature, LEGACY) + for feature in (github, gitlab): + self.assertEqual(feature.scope, manifestlib.FEATURE_SCOPE_PROJECT) + self.assertTrue(feature.work_forge) + self.assertIn(GITLAB, github.conflicts) + self.assertIn(LEGACY, gitlab.conflicts) + self.assertEqual(github.components, ("github-templates",)) + self.assertEqual(gitlab.components, ("gitlab-templates",)) + + def test_an_undeclared_feature_id_is_refused(self): + with self.assertRaises(LifecycleError) as raised: + self.distribution.feature("forge-sourceforge") + self.assertEqual(raised.exception.code, "FEATURE_UNKNOWN") + + def test_a_machine_scope_entry_is_not_a_feature(self): + directory = write_catalogue( + self.root / "machine", + features={"hal": self.feature("hal", scope="machine")}) + with self.assertRaises(LifecycleError) as raised: + manifestlib.load(directory) + self.assertEqual(raised.exception.code, "MANIFEST_INVALID") + + def test_asymmetric_conflicts_are_refused(self): + directory = write_catalogue( + self.root / "asymmetric", + features={"a": self.feature("a", components=["tpl"], conflicts=["b"]), + "b": self.feature("b", components=[])}) + with self.assertRaises(LifecycleError) as raised: + manifestlib.load(directory) + self.assertEqual(raised.exception.code, "MANIFEST_INVALID") + + def test_a_component_claimed_by_two_features_is_refused(self): + directory = write_catalogue( + self.root / "claimed", + features={"a": self.feature("a", components=["tpl"]), + "b": self.feature("b", components=["tpl"])}) + with self.assertRaises(LifecycleError) as raised: + manifestlib.load(directory) + self.assertEqual(raised.exception.code, "MANIFEST_INVALID") + + def test_a_feature_referencing_an_unknown_component_is_refused(self): + directory = write_catalogue( + self.root / "unknown-component", + features={"a": self.feature("a", components=["ghost"])}) + with self.assertRaises(LifecycleError) as raised: + manifestlib.load(directory) + self.assertEqual(raised.exception.code, "MANIFEST_INVALID") + + def test_the_legacy_default_must_be_declared(self): + directory = write_catalogue( + self.root / "legacy", legacy="ghost", + features={"a": self.feature("a")}) + with self.assertRaises(LifecycleError) as raised: + manifestlib.load(directory) + self.assertEqual(raised.exception.code, "MANIFEST_INVALID") + + +class StateProjection(LifecycleTestCase): + """One projection, one answer — whatever schema is on disk.""" + + def setUp(self) -> None: + super().setUp() + self.install("standard") + + def v1_record(self) -> dict: + """Rewrite the state as the release before V2 wrote it.""" + + path = statelib.state_path(self.project) + record = json.loads(path.read_text(encoding="utf-8")) + record["schema_version"] = 1 + record.pop("active_features", None) + statelib.write_atomic(path, json.dumps(record, indent=2, sort_keys=True) + "\n") + return record + + def effective(self): + return featureslib.project_install_state( + statelib.load(self.project), manifestlib.load()) + + def test_a_v1_project_projects_to_the_legacy_default(self): + self.v1_record() + effective = self.effective() + self.assertEqual(effective.schema_version, 1) + self.assertEqual(effective.active_features, (LEGACY,)) + self.assertTrue(effective.projected_from_legacy) + self.assertTrue(effective.has_feature(LEGACY)) + + def test_a_v1_verified_project_projects_to_the_legacy_default(self): + self.switch("verified") + self.v1_record() + self.assertEqual(self.effective().active_features, (LEGACY,)) + + def test_a_v1_project_with_a_gitlab_remote_still_projects_to_the_legacy_default(self): + subprocess.run(["git", "-C", str(self.project), "remote", "add", "origin", + "https://gitlab.com/org/project.git"], check=True, + capture_output=True) + self.v1_record() + self.assertEqual(self.effective().active_features, (LEGACY,)) + + def test_a_v1_project_with_no_remote_still_projects_to_the_legacy_default(self): + remotes = subprocess.run(["git", "-C", str(self.project), "remote"], + check=True, capture_output=True, text=True) + self.assertEqual(remotes.stdout.strip(), "") + self.v1_record() + self.assertEqual(self.effective().active_features, (LEGACY,)) + + def test_the_projection_is_read_only(self): + from ainative.lifecycle.digest import digest_file + + self.v1_record() + path = statelib.state_path(self.project) + before = digest_file(path) + self.effective() + self.effective() + self.assertEqual(digest_file(path), before) + + def test_a_v2_state_with_one_forge_projects_it(self): + state = statelib.load(self.project) + state.active_features = [GITLAB] + statelib.save(self.project, state) + self.assertEqual(self.effective().active_features, (GITLAB,)) + self.assertFalse(self.effective().projected_from_legacy) + + def test_a_v2_state_with_no_forge_is_valid(self): + state = statelib.load(self.project) + state.active_features = [] + statelib.save(self.project, state) + self.assertEqual(self.effective().active_features, ()) + + def test_a_v2_state_with_two_work_forges_is_refused(self): + state = statelib.load(self.project) + state.active_features = [LEGACY, GITLAB] + statelib.save(self.project, state) + from ainative.lifecycle.digest import digest_file + + before = digest_file(statelib.state_path(self.project)) + with self.assertRaises(LifecycleError) as raised: + self.effective() + self.assertEqual(raised.exception.code, "STATE_CONFLICTING_WORK_FORGE_FEATURES") + self.assertEqual(digest_file(statelib.state_path(self.project)), before, + "a refused projection rewrote the state") + + def test_a_v2_state_naming_an_undeclared_feature_is_refused(self): + state = statelib.load(self.project) + state.active_features = ["forge-sourceforge"] + statelib.save(self.project, state) + with self.assertRaises(LifecycleError) as raised: + self.effective() + self.assertEqual(raised.exception.code, "INSTALL_STATE_CORRUPTED") + + def test_the_projection_is_idempotent(self): + self.v1_record() + first = self.effective() + second = self.effective() + self.assertEqual(first, second) + state = statelib.load(self.project) + statelib.save(self.project, state) + self.assertEqual(self.effective(), second) + + +class StateMigration(LifecycleTestCase): + + def snapshot(self, *, skip_state: bool = True) -> dict: + from ainative.lifecycle.digest import digest_file + + state_path = statelib.state_path(self.project) + return {path.relative_to(self.project).as_posix(): digest_file(path) or "" + for path in self.project.rglob("*") + if path.is_file() and not (skip_state and path == state_path)} + + def v1_record(self) -> dict: + path = statelib.state_path(self.project) + record = json.loads(path.read_text(encoding="utf-8")) + record["schema_version"] = 1 + record.pop("active_features", None) + statelib.write_atomic(path, json.dumps(record, indent=2, sort_keys=True) + "\n") + return record + + def test_a_no_op_install_migrates_a_v1_state(self): + self.install("standard") + self.v1_record() + before = self.snapshot() + self.install("standard") + state = statelib.load(self.project) + self.assertEqual(state.schema_version, statelib.SCHEMA_VERSION) + self.assertEqual(state.active_features, [LEGACY]) + self.assertEqual(self.snapshot(), before, + "the migration changed managed files") + + def test_migration_is_idempotent(self): + from ainative.lifecycle.digest import digest_file + + self.install("standard") + self.v1_record() + self.install("standard") + path = statelib.state_path(self.project) + migrated = digest_file(path) + self.install("standard") + self.assertEqual(digest_file(path), migrated, + "a second install rewrote the migrated state") + self.assertEqual(statelib.load(self.project).active_features, [LEGACY]) + + def test_migrate_state_leaves_a_current_state_untouched(self): + state = statelib.InstallState(active_features=[GITLAB]) + migrated = featureslib.migrate_state(state, manifestlib.load()) + self.assertIs(migrated, state) + self.assertEqual(migrated.active_features, [GITLAB]) + + def test_the_effective_feature_set_is_identical_across_the_migration(self): + """Read-only parity: projection before == projection after (ADR-0017 §4).""" + + self.install("standard") + self.v1_record() + before = featureslib.project_install_state(statelib.load(self.project), + manifestlib.load()) + self.install("standard") + after = featureslib.project_install_state(statelib.load(self.project), + manifestlib.load()) + self.assertEqual(before.active_features, after.active_features) + self.assertEqual(before.active_profile, after.active_profile) + + +class FeatureSwitching(LifecycleTestCase): + """One transaction per transition; ownership decides every file.""" + + TEMPLATES = { + "github": {"ISSUE_TEMPLATE/bug.md": "# bug\n", + "ISSUE_TEMPLATE/feature.md": "# feature\n", + "PULL_REQUEST_TEMPLATE.md": "# pr\n"}, + "gitlab": {"issue_templates/bug.md": "# bug\n", + "issue_templates/feature.md": "# feature\n", + "merge_request_templates/default.md": "# mr\n"}, + } + + def setUp(self) -> None: + super().setUp() + for forge, files in self.TEMPLATES.items(): + for relative, content in files.items(): + write_text(self.distribution_root / "templates" / forge / relative, content) + self.install("standard") + + def switch_features(self, **kwargs): + from ainative.lifecycle import installer + + return installer.set_features(self.project, distribution=self.distribution, + source=self.source, **kwargs) + + def features_now(self) -> list[str]: + return statelib.load(self.project).active_features + + def state_digest(self) -> str: + from ainative.lifecycle.digest import digest_file + + return digest_file(statelib.state_path(self.project)) + + def test_switch_replaces_the_work_forge_in_one_transaction(self): + result = self.switch_features(switch_to=GITLAB) + self.assertTrue(result.applied) + self.assertEqual(self.features_now(), [GITLAB]) + self.assertFalse(self.exists(".github/PULL_REQUEST_TEMPLATE.md"), + "the previous forge's template survived the switch") + self.assertTrue(self.exists(".gitlab/issue_templates/bug.md")) + self.assertEqual(self.read(".gitlab/issue_templates/bug.md"), "# bug\n") + state = statelib.load(self.project) + self.assertEqual([entry.path for entry + in state.files_for_component("gitlab-templates")], + [".gitlab/issue_templates/bug.md", + ".gitlab/issue_templates/feature.md", + ".gitlab/merge_request_templates/default.md"]) + + def test_switch_none_leaves_generic_git(self): + self.switch_features(switch_to="none") + self.assertEqual(self.features_now(), []) + self.assertFalse(self.exists(".github/PULL_REQUEST_TEMPLATE.md")) + + def test_disable_preserves_a_modified_file(self): + self.write(".github/PULL_REQUEST_TEMPLATE.md", "# mine\n") + self.switch_features(disable=LEGACY) + self.assertEqual(self.features_now(), []) + self.assertEqual(self.read(".github/PULL_REQUEST_TEMPLATE.md"), "# mine\n") + self.assertFalse(self.exists(".github/ISSUE_TEMPLATE/bug.md"), + "an unchanged template was not removed by disable") + + def test_enable_seeds_the_absent_files(self): + self.switch_features(switch_to="none") + self.switch_features(enable=GITLAB) + self.assertTrue(self.exists(".gitlab/merge_request_templates/default.md")) + + def test_enable_of_an_active_feature_is_a_no_op(self): + before = self.state_digest() + result = self.switch_features(enable=LEGACY) + self.assertFalse(result.applied) + self.assertTrue(result.plan.is_noop) + self.assertEqual(self.state_digest(), before) + + def test_enable_of_a_conflicting_feature_names_the_remedy(self): + before = self.state_digest() + with self.assertRaises(LifecycleError) as raised: + self.switch_features(enable=GITLAB) + self.assertEqual(raised.exception.code, "FEATURE_CONFLICT") + self.assertIn(f"feature switch {GITLAB}", raised.exception.message) + self.assertEqual(self.features_now(), [LEGACY]) + self.assertEqual(self.state_digest(), before) + + def test_an_unknown_feature_is_refused(self): + with self.assertRaises(LifecycleError) as raised: + self.switch_features(enable="forge-sourceforge") + self.assertEqual(raised.exception.code, "FEATURE_UNKNOWN") + + def test_a_feature_command_needs_an_installed_project(self): + empty = self.root / "empty" + empty.mkdir() + from ainative.lifecycle import installer + + with self.assertRaises(LifecycleError) as raised: + installer.set_features(empty, disable=LEGACY, distribution=self.distribution, + source=self.source) + self.assertEqual(raised.exception.code, "NOT_INSTALLED") + + def test_an_absent_feature_file_stays_absent_through_an_install(self): + self.switch_features(switch_to=GITLAB) + (self.project / ".gitlab" / "issue_templates" / "bug.md").unlink() + self.install("standard") + self.assertFalse(self.exists(".gitlab/issue_templates/bug.md"), + "an install re-seeded a feature file the user removed") + self.switch_features(switch_to="none") + self.switch_features(enable=GITLAB) + self.assertTrue(self.exists(".gitlab/issue_templates/bug.md"), + "an explicit enable did not seed the feature's files") + + def test_the_migration_does_not_resurrect_an_absent_template(self): + """ADR-0017 section 5: the fixture pinned by PR-1B.""" + + path = statelib.state_path(self.project) + record = json.loads(path.read_text(encoding="utf-8")) + record["schema_version"] = 1 + record.pop("active_features", None) + statelib.write_atomic(path, json.dumps(record, indent=2, sort_keys=True) + "\n") + (self.project / ".github" / "PULL_REQUEST_TEMPLATE.md").unlink() + self.install("standard") + self.assertFalse(self.exists(".github/PULL_REQUEST_TEMPLATE.md"), + "the migration resurrected an absent managed file") + state = statelib.load(self.project) + self.assertEqual(state.schema_version, statelib.SCHEMA_VERSION) + self.assertEqual(state.active_features, [LEGACY]) + + def test_a_round_trip_loses_no_user_data(self): + self.write(".github/ISSUE_TEMPLATE/bug.md", "# mine\n") + self.switch_features(switch_to=GITLAB) + self.switch_features(switch_to="none") + self.switch_features(enable=LEGACY) + self.assertEqual(self.read(".github/ISSUE_TEMPLATE/bug.md"), "# mine\n") + self.assertEqual(self.features_now(), [LEGACY]) + self.assertTrue(self.exists(".github/PULL_REQUEST_TEMPLATE.md"), + "the unmodified templates were not restored by the enable") + + def test_a_user_file_where_a_feature_file_belongs_is_never_adopted(self): + self.write(".gitlab/issue_templates/bug.md", "# mine\n") + result = self.switch_features(switch_to=GITLAB) + self.assertEqual(self.read(".gitlab/issue_templates/bug.md"), "# mine\n") + conflicts = [change.path for change in result.plan.changes + if change.action == "CONFLICT"] + self.assertEqual(conflicts, [".gitlab/issue_templates/bug.md"]) + self.assertTrue(self.exists(".gitlab/issue_templates/feature.md"), + "the other files of the enabled feature were not seeded") + state = statelib.load(self.project) + self.assertIsNone(state.file_for(".gitlab/issue_templates/bug.md"), + "the user's file was adopted into the state") + + def test_the_cli_switches_and_reports(self): + status = json.loads(self.cli("feature", "status", "--json").stdout) + self.assertEqual(status["active_features"], [LEGACY]) + self.assertTrue(status["installed"]) + switched = json.loads(self.cli("feature", "switch", GITLAB, "--json").stdout) + self.assertTrue(switched["applied"]) + self.assertEqual(switched["active_features"], [GITLAB]) + self.assertEqual(self.features_now(), [GITLAB]) + + + def test_status_reports_the_same_effective_features_as_the_projection(self): + path = statelib.state_path(self.project) + record = json.loads(path.read_text(encoding="utf-8")) + record["schema_version"] = 1 + record.pop("active_features", None) + statelib.write_atomic(path, json.dumps(record, indent=2, sort_keys=True) + "\n") + report = json.loads(self.cli("status", "--json").stdout) + self.assertEqual(report["features"], [LEGACY]) + self.assertTrue(report["features_projected_from_legacy"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lifecycle_security.py b/tests/test_lifecycle_security.py index ad1424c4..5dd32b5e 100644 --- a/tests/test_lifecycle_security.py +++ b/tests/test_lifecycle_security.py @@ -212,6 +212,13 @@ def _write(self, directory: Path, components: dict, profiles: dict) -> Path: (directory / "profiles.json").write_text( json.dumps({"schema_version": 1, "default": "standard", "profiles": profiles}), encoding="utf-8") + # The feature catalogue is a third required manifest (ADR-0017); these + # fixtures are about components and profiles, so it is minimal. + (directory / "features.json").write_text( + json.dumps({"schema_version": 1, "legacy_default": "forge", + "features": {"forge": {"scope": "project", "work_forge": True, + "components": [], "conflicts": []}}}), + encoding="utf-8") return directory def setUp(self) -> None: diff --git a/tests/test_lifecycle_update_integrity.py b/tests/test_lifecycle_update_integrity.py index 564dc613..b5a7d255 100644 --- a/tests/test_lifecycle_update_integrity.py +++ b/tests/test_lifecycle_update_integrity.py @@ -133,7 +133,8 @@ def test_one_flipped_byte_refuses_before_any_write(self): mutated[-1] ^= 0xFF provider = self.provider(self.document(), bytes(mutated)) before = self.snapshot() - with mock.patch.object(providerlib, "build", lambda channel="stable": provider): + with mock.patch.object(providerlib, "build", lambda channel="stable": provider), \ + mock.patch.object(providerlib, "build_v3", lambda channel="stable": None): with self.assertRaises(LifecycleError) as raised: updaterlib.apply(self.project, distribution=self.distribution) self.assertEqual(raised.exception.code, "UPDATE_INTEGRITY_FAILED") @@ -142,7 +143,8 @@ def test_one_flipped_byte_refuses_before_any_write(self): def test_the_official_path_updates_end_to_end(self): provider = self.provider(self.document(), self.archive_bytes) - with mock.patch.object(providerlib, "build", lambda channel="stable": provider): + with mock.patch.object(providerlib, "build", lambda channel="stable": provider), \ + mock.patch.object(providerlib, "build_v3", lambda channel="stable": None): result = updaterlib.apply(self.project, distribution=self.distribution) self.assertTrue(result.applied) self.assertEqual(result.to_version, "2.0.0") diff --git a/tests/test_lifecycle_update_transport.py b/tests/test_lifecycle_update_transport.py index 6ccaef3c..a597b062 100644 --- a/tests/test_lifecycle_update_transport.py +++ b/tests/test_lifecycle_update_transport.py @@ -368,7 +368,8 @@ def test_a_bridge_style_release_stays_selectable_by_the_v2_runtime(self): def test_check_reports_a_future_release_as_available_with_a_cli_upgrade(self): self.install("standard") provider = self.provider(self.v3_document()) - with mock.patch.object(providerlib, "build", lambda channel="stable": provider): + with mock.patch.object(providerlib, "build", lambda channel="stable": provider), \ + mock.patch.object(providerlib, "build_v3", lambda channel="stable": None): result = updaterlib.check(self.project, force=True, record=False) self.assertEqual(result.status, updaterlib.UPDATE_AVAILABLE) self.assertEqual(result.latest, "3.0.0") @@ -378,7 +379,8 @@ def test_apply_refuses_a_future_release_before_any_write(self): self.install("standard") provider = self.provider(self.v3_document()) before = self.snapshot() - with mock.patch.object(providerlib, "build", lambda channel="stable": provider): + with mock.patch.object(providerlib, "build", lambda channel="stable": provider), \ + mock.patch.object(providerlib, "build_v3", lambda channel="stable": None): with self.assertRaises(LifecycleError) as raised: updaterlib.apply(self.project, distribution=self.distribution) self.assertEqual(raised.exception.code, "CLI_UPDATE_REQUIRED") diff --git a/tests/test_lifecycle_update_v3.py b/tests/test_lifecycle_update_v3.py new file mode 100644 index 00000000..e73e0e82 --- /dev/null +++ b/tests/test_lifecycle_update_v3.py @@ -0,0 +1,122 @@ +"""The V3 update path, end to end on a local mirror. + +The updater prefers V3 when the source publishes an anchored manifest, and +falls back to the V2 path only when a complete enumeration proves the channel +is V2-era. Everything that can refuse happens before the first project write. +""" + +from __future__ import annotations + +import json +import unittest +import zipfile +from hashlib import sha256 +from pathlib import Path + +from tests.lifecycle_support import LifecycleTestCase, build_distribution_tree, write_text +from ainative.lifecycle import provider as providerlib +from ainative.lifecycle import release_v3 as release_v3lib +from ainative.lifecycle import state as statelib +from ainative.lifecycle import updater as updaterlib +from ainative.lifecycle.errors import LifecycleError + +TARGET = "2.0.0" + + +def make_v3_bundle(directory: Path, version: str, *, protocol: int = 3) -> Path: + tree = build_distribution_tree(directory / f"dist-{version}", version) + path = directory / release_v3lib.lifecycle_bundle_name(version) + protocol_document = {"schema_name": "lifecycle_protocol", + "protocol_version": protocol, "release_version": version, + "payload_root": "stack"} + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("lifecycle-protocol.json", json.dumps(protocol_document)) + for item in sorted(tree.rglob("*")): + if item.is_file(): + archive.write(item, f"stack/{item.relative_to(tree).as_posix()}") + return path + + +class V3MirrorUpdate(LifecycleTestCase): + + def setUp(self) -> None: + super().setUp() + self.releases = self.root / "releases" + (self.releases / TARGET).mkdir(parents=True) + self.bundle = make_v3_bundle(self.releases / TARGET, TARGET) + self.manifest_document = { + "schema": "ainative.release", "protocol": "v3", "version": TARGET, + "channel": "stable", + "compatibility": {"runtime_version": TARGET}, + "artifacts": [{"name": release_v3lib.lifecycle_bundle_name(TARGET), + "kind": "lifecycle", "version": TARGET, + "sha256": "", "size": 0}], + "provenance": {"source": "test"}, + } + self.record_bundle() + self.publish() + self.set_env(providerlib.PROVIDER_ENV, "local") + self.set_env(providerlib.LOCAL_SOURCE_ENV, str(self.releases)) + self.install("standard") + self.assume_runtime(TARGET) + + def record_bundle(self) -> None: + payload = self.bundle.read_bytes() + self.manifest_document["artifacts"][0]["sha256"] = sha256(payload).hexdigest() + self.manifest_document["artifacts"][0]["size"] = len(payload) + + def publish(self, *, sha: str | None = None, size: int | None = None) -> None: + manifest = json.dumps(self.manifest_document).encode("utf-8") + write_text(self.releases / TARGET / "ainative-release-v3.json", + manifest.decode()) + index = {"channels": {"stable": {"version": TARGET, "manifest": { + "file": "ainative-release-v3.json", + "size": size if size is not None else len(manifest), + "sha256": sha if sha is not None else sha256(manifest).hexdigest()}}}} + write_text(self.releases / "releases.json", json.dumps(index)) + + def snapshot(self) -> dict: + from ainative.lifecycle.digest import digest_file + + return {path.relative_to(self.project).as_posix(): digest_file(path) or "" + for path in self.project.rglob("*") if path.is_file()} + + def test_check_sees_the_v3_release(self): + result = updaterlib.check(self.project, force=True, record=False) + self.assertEqual(result.status, updaterlib.UPDATE_AVAILABLE) + self.assertEqual(result.latest, TARGET) + + def test_a_v3_update_applies_end_to_end(self): + result = updaterlib.apply(self.project, distribution=self.distribution) + self.assertTrue(result.applied) + self.assertEqual(result.to_version, TARGET) + self.assertEqual(self.read("AGENTS.md"), f"# Engineering method {TARGET}\n") + self.assertEqual(statelib.load(self.project).stack_version, TARGET) + self.assertTrue(result.rollback_available) + + def test_a_tampered_v3_manifest_is_refused_before_any_write(self): + self.publish(sha="a" * 64) + before = self.snapshot() + with self.assertRaises(LifecycleError) as raised: + updaterlib.apply(self.project, distribution=self.distribution) + self.assertEqual(raised.exception.code, "UPDATE_INTEGRITY_FAILED") + self.assertEqual(self.snapshot(), before, + "a refused V3 update touched the project") + + def test_a_v3_bundle_carrying_the_v2_protocol_is_refused(self): + self.bundle.unlink() + self.bundle = make_v3_bundle(self.releases / TARGET, TARGET, protocol=2) + self.record_bundle() + self.publish() + with self.assertRaises(LifecycleError) as raised: + updaterlib.apply(self.project, distribution=self.distribution) + self.assertEqual(raised.exception.code, "UPDATE_VERSION_MISMATCH") + + def test_the_cli_reports_the_v3_check(self): + record = json.loads(self.cli("update", "check", "--force", "--json").stdout) + self.assertEqual(record["status"], updaterlib.UPDATE_AVAILABLE) + self.assertEqual(record["latest"], TARGET) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_providers.py b/tests/test_release_providers.py new file mode 100644 index 00000000..7851604b --- /dev/null +++ b/tests/test_release_providers.py @@ -0,0 +1,484 @@ +"""The V3 providers: GitHub, the anonymous document, and the local mirror. + +Providers fetch; release_v3 decides. These tests pin the fetching contract: +what is a candidate (drafts, non-SemVer tags and V2-era releases are not), how +the anchor travels from provider metadata, that a bounded listing says so, that +credentials follow the endpoint rule, and that the local mirror runs the same +logical verification chain as a network source. +""" + +from __future__ import annotations + +import json +import unittest +import urllib.error +import urllib.request +from hashlib import sha256 +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import mock + +from tests.lifecycle_support import write_text +from ainative.lifecycle import release_providers as providerslib +from ainative.lifecycle import release_v3 as release_v3lib +from ainative.lifecycle import transport as transportlib +from ainative.lifecycle.errors import LifecycleError + + +class FakeResponse: + def __init__(self, payload: bytes) -> None: + self.payload = payload + + def read(self, limit: int) -> bytes: + return self.payload[:limit] + + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, *args: object) -> bool: + return False + + +class ScriptedServer: + def __init__(self, hops: dict) -> None: + self.hops = hops + self.observed: list[tuple[str, str | None, str | None]] = [] + + def send(self, request: urllib.request.Request): + url = request.full_url + headers = {key.lower(): value for key, value in request.headers.items()} + self.observed.append((url, + headers.get("authorization") + or headers.get("private-token"), + headers.get("accept"))) + if url not in self.hops: + raise AssertionError(f"unexpected request: {url}") + answer = self.hops[url] + if isinstance(answer, tuple) and answer[0] == "redirect": + raise urllib.error.HTTPError(url, 302, "Found", + {"Location": answer[1]}, None) + return FakeResponse(answer) + + +LIST_URL = "https://api.github.com/repos/o/r/releases?per_page=30" +MANIFEST_URL = "https://api.github.com/repos/o/r/releases/assets/1" + + +def release_document(version: str = "2.5.0", *, tag: str | None = None, + prerelease: bool = False, draft: bool = False, + manifest: bool = True, digest: str | None = "sha256:" + "a" * 64, + size: int | None = 10, artifact: bool = True) -> dict: + tag = tag or f"v{version}" + assets = [] + if manifest: + asset = {"name": "ainative-release-v3.json", "url": MANIFEST_URL, + "browser_download_url": f"https://github.com/o/r/releases/download/{tag}/" + "ainative-release-v3.json"} + if digest is not None: + asset["digest"] = digest + if size is not None: + asset["size"] = size + assets.append(asset) + if artifact: + assets.append({"name": release_v3lib.lifecycle_bundle_name(version), + "url": "https://api.github.com/repos/o/r/releases/assets/2", + "digest": "sha256:" + "b" * 64, "size": 20}) + return {"tag_name": tag, "prerelease": prerelease, "draft": draft, "assets": assets} + + +def anchored_manifest(version: str = "2.5.0") -> bytes: + document = { + "schema": "ainative.release", "protocol": "v3", "version": version, + "channel": "stable", + "compatibility": {"runtime_version": version}, + "artifacts": [{"name": release_v3lib.lifecycle_bundle_name(version), + "kind": "lifecycle", "version": version, + "sha256": "b" * 64, "size": 20}], + "provenance": {"source": "test"}, + } + return json.dumps(document).encode("utf-8") + + +def github_provider(server: ScriptedServer, **kwargs): + provider = providerslib.GitHubReleaseProvider( + transportlib.GITHUB_ENDPOINT, repository="o/r", **kwargs) + return provider, mock.patch.object(transportlib, "_send", server.send) + + +class GitHubEnumerate(unittest.TestCase): + + def enumerate(self, documents: list, **kwargs): + bound = kwargs.get("page_bound", 30) + server = ScriptedServer({ + f"https://api.github.com/repos/o/r/releases?per_page={bound}": + json.dumps(documents).encode("utf-8")}) + provider, patcher = github_provider(server, **kwargs) + with patcher: + return provider.enumerate(release_v3lib.ReleaseQuery("stable")) + + def test_a_v3_release_becomes_a_candidate_with_its_anchor(self): + result = self.enumerate([release_document()]) + self.assertTrue(result.complete) + candidate = result.candidates[0] + self.assertEqual(candidate.version, "2.5.0") + self.assertEqual(candidate.identity, "v2.5.0") + self.assertEqual(candidate.channel, "stable") + self.assertEqual(candidate.manifest_sha256, "a" * 64) + self.assertEqual(candidate.manifest_size, 10) + self.assertEqual(candidate.manifest_locator, MANIFEST_URL) + + def test_drafts_prereleases_and_non_v3_releases_are_not_candidates(self): + result = self.enumerate([ + release_document(draft=True), + release_document(version="2.4.0", prerelease=True), + release_document(version="2.3.0", manifest=False), + release_document(version="2.2.0", tag="nightly"), + ]) + self.assertEqual([candidate.version for candidate in result.candidates], ["2.4.0"]) + self.assertEqual(result.candidates[0].channel, "beta") + + def test_a_full_page_is_an_incomplete_enumeration(self): + result = self.enumerate([release_document(), release_document(version="2.4.0")], + page_bound=2) + self.assertFalse(result.complete) + + def test_a_missing_digest_is_carried_as_a_missing_anchor(self): + result = self.enumerate([release_document(digest=None, size=None)]) + self.assertIsNone(result.candidates[0].manifest_sha256) + self.assertIsNone(result.candidates[0].manifest_size) + + +class GitHubFetching(unittest.TestCase): + + def provider(self, server: ScriptedServer): + return github_provider(server) + + def test_fetch_manifest_uses_the_locator_with_the_artifact_policy(self): + manifest = anchored_manifest() + server = ScriptedServer({ + LIST_URL: json.dumps([release_document()]).encode("utf-8"), + MANIFEST_URL: manifest}) + provider, patcher = self.provider(server) + with patcher, mock.patch.dict("os.environ", {"GITHUB_TOKEN": "ghp_" + "x" * 36}): + candidate = provider.enumerate(release_v3lib.ReleaseQuery("stable")).candidates[0] + payload = provider.fetch_manifest(candidate) + self.assertEqual(payload, manifest) + self.assertEqual(server.observed[1][0], MANIFEST_URL) + self.assertEqual(server.observed[1][1], "Bearer ghp_" + "x" * 36) + self.assertEqual(server.observed[1][2], transportlib.ACCEPT_OCTET_STREAM) + + def test_fetch_artifact_picks_the_named_asset(self): + server = ScriptedServer({ + LIST_URL: json.dumps([release_document()]).encode("utf-8"), + "https://api.github.com/repos/o/r/releases/assets/2": b"bundle-bytes"}) + provider, patcher = self.provider(server) + with patcher: + candidate = provider.enumerate(release_v3lib.ReleaseQuery("stable")).candidates[0] + payload = provider.fetch_artifact( + candidate, release_v3lib.ManifestArtifact( + name=release_v3lib.lifecycle_bundle_name("2.5.0"), kind="lifecycle", + version="2.5.0", sha256="b" * 64, size=20)) + self.assertEqual(payload, b"bundle-bytes") + + def test_an_unknown_asset_is_refused(self): + server = ScriptedServer({ + LIST_URL: json.dumps([release_document()]).encode("utf-8")}) + provider, patcher = self.provider(server) + with patcher: + candidate = provider.enumerate(release_v3lib.ReleaseQuery("stable")).candidates[0] + with self.assertRaises(LifecycleError) as raised: + provider.fetch_artifact( + candidate, release_v3lib.ManifestArtifact( + name="other.zip", kind="lifecycle", version="2.5.0", + sha256="b" * 64, size=1)) + self.assertEqual(raised.exception.code, "UPDATE_UNAVAILABLE") + + def test_fetching_before_enumerating_is_a_programming_refusal(self): + provider = providerslib.GitHubReleaseProvider(transportlib.GITHUB_ENDPOINT, + repository="o/r") + with self.assertRaises(LifecycleError) as raised: + provider.fetch_manifest(release_v3lib.ReleaseCandidate( + version="2.5.0", identity="v2.5.0")) + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + + def test_resolve_manifest_runs_the_whole_chain(self): + manifest = anchored_manifest() + server = ScriptedServer({ + LIST_URL: json.dumps([release_document()]).encode("utf-8"), + MANIFEST_URL: manifest}) + provider, patcher = self.provider(server) + # The candidate's anchor must match the served manifest for the chain + # to pass: rebuild the listing with the real digest and size. + document = release_document(digest="sha256:" + sha256(manifest).hexdigest(), + size=len(manifest)) + server.hops[LIST_URL] = json.dumps([document]).encode("utf-8") + with patcher: + candidate, parsed = release_v3lib.resolve_manifest( + provider, release_v3lib.ReleaseQuery("stable")) + self.assertEqual(candidate.version, "2.5.0") + self.assertEqual(parsed.version, "2.5.0") + + +class AnonymousDocument(unittest.TestCase): + + URL = "https://mirror.example/releases/latest" + + def provider(self, document: object): + server = ScriptedServer({self.URL: json.dumps(document).encode("utf-8")}) + provider = providerslib.AnonymousReleaseApiProvider(self.URL) + return provider, mock.patch.object(transportlib, "_send", server.send), server + + def test_a_v3_document_yields_one_candidate_and_no_credential(self): + provider, patcher, server = self.provider(release_document()) + with patcher, mock.patch.dict("os.environ", {"GITHUB_TOKEN": "ghp_" + "x" * 36}): + result = provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertTrue(result.complete) + self.assertEqual(len(result.candidates), 1) + self.assertEqual(server.observed[0][1], None, + "the anonymous source received a credential") + + def test_a_v2_document_yields_no_candidate(self): + provider, patcher, _server = self.provider(release_document(manifest=False)) + with patcher: + result = provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual(result.candidates, ()) + with self.assertRaises(LifecycleError) as raised: + release_v3lib.select_candidate(result, release_v3lib.ReleaseQuery("stable")) + self.assertEqual(raised.exception.code, "RELEASE_NO_CANDIDATE") + + +class LocalMirror(unittest.TestCase): + + VERSION = "2.5.0" + + def setUp(self) -> None: + self.directory = TemporaryDirectory(prefix="ainative-mirror-") + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.manifest = anchored_manifest(self.VERSION) + write_text(self.root / self.VERSION / "ainative-release-v3.json", self.manifest.decode()) + write_text(self.root / self.VERSION / release_v3lib.lifecycle_bundle_name(self.VERSION), + "bundle-bytes\n") + self.publish() + + def publish(self, *, sha: str | None = None, size: int | None = None, + version: str = "2.5.0") -> None: + payload = {"channels": {"stable": { + "version": version, + "manifest": { + "file": "ainative-release-v3.json", + "size": size if size is not None else len(self.manifest), + "sha256": sha if sha is not None else sha256(self.manifest).hexdigest(), + }}}} + write_text(self.root / "releases.json", json.dumps(payload)) + + def provider(self) -> providerslib.LocalReleaseProvider: + return providerslib.LocalReleaseProvider(self.root) + + def test_the_whole_chain_runs_on_a_local_mirror(self): + candidate, manifest = release_v3lib.resolve_manifest( + self.provider(), release_v3lib.ReleaseQuery("stable")) + self.assertEqual(candidate.version, self.VERSION) + self.assertEqual(manifest.version, self.VERSION) + payload = self.provider().fetch_artifact(candidate, manifest.lifecycle_artifact()) + self.assertEqual(payload, b"bundle-bytes\n") + + def test_a_tampered_manifest_is_refused_by_the_anchor(self): + write_text(self.root / self.VERSION / "ainative-release-v3.json", + self.manifest.decode().replace("2.5.0", "2.5.1")) + with self.assertRaises(LifecycleError) as raised: + release_v3lib.resolve_manifest(self.provider(), + release_v3lib.ReleaseQuery("stable")) + self.assertEqual(raised.exception.code, "UPDATE_INTEGRITY_FAILED") + + def test_a_v2_era_index_entry_is_not_a_candidate(self): + result = self.provider().enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual(len(result.candidates), 1) + write_text(self.root / "releases.json", + json.dumps({"channels": {"stable": {"version": "2.5.0", + "archive": "stack.zip", + "sha256": "0" * 64}}})) + result = self.provider().enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual(result.candidates, ()) + with self.assertRaises(LifecycleError) as raised: + release_v3lib.select_candidate(result, release_v3lib.ReleaseQuery("stable")) + self.assertEqual(raised.exception.code, "RELEASE_NO_CANDIDATE") + + def test_a_traversing_locator_cannot_leave_the_mirror(self): + candidate = release_v3lib.ReleaseCandidate( + version=self.VERSION, identity="local:stable", + manifest_locator="../outside/ainative-release-v3.json") + with self.assertRaises(LifecycleError) as raised: + self.provider().fetch_manifest(candidate) + self.assertEqual(raised.exception.code, "PATH_ESCAPE") + + def test_a_missing_artifact_is_refused(self): + candidate = release_v3lib.ReleaseCandidate( + version=self.VERSION, identity="local:stable") + with self.assertRaises(LifecycleError) as raised: + self.provider().fetch_artifact( + candidate, release_v3lib.ManifestArtifact( + name="never-shipped.zip", kind="lifecycle", version=self.VERSION, + sha256="b" * 64, size=1)) + self.assertEqual(raised.exception.code, "UPDATE_UNAVAILABLE") + + +GITLAB_ENDPOINT = transportlib.ReleaseProviderEndpointConfig( + provider="gitlab", api_base_url="https://gitlab.com/api/v4", + auth_origin="https://gitlab.com", auth_header="PRIVATE-TOKEN", auth_prefix="") + +RELEASES_URL = "https://gitlab.com/api/v4/projects/group%2Fproject/releases?per_page=30" +PACKAGES_URL = ("https://gitlab.com/api/v4/projects/group%2Fproject/packages" + "?package_type=generic&package_name=ai-native-dev-stack&per_page=30") +MANIFEST_DOWNLOAD = ("https://gitlab.com/api/v4/projects/group%2Fproject/packages/77" + "/package_files/101/download") +BUNDLE_DOWNLOAD = ("https://gitlab.com/api/v4/projects/group%2Fproject/packages/77" + "/package_files/102/download") + + +def package_files(manifest_bytes: bytes, *, duplicate_manifest: bool = False, + digest: bool = True, digest_value: str | None = None, + size: bool = True) -> list: + manifest_entry = {"id": 101, "file_name": "ainative-release-v3.json"} + if digest: + manifest_entry["file_sha256"] = digest_value or sha256(manifest_bytes).hexdigest() + if size: + manifest_entry["size"] = len(manifest_bytes) + files = [manifest_entry, {"id": 102, "file_name": release_v3lib.lifecycle_bundle_name("2.5.0"), + "file_sha256": "b" * 64, "size": 20}] + if duplicate_manifest: + files.append({"id": 103, "file_name": "ainative-release-v3.json", + "file_sha256": "c" * 64, "size": 10}) + return files + + +class GitLabProvider(unittest.TestCase): + + def server(self, *, files: list | None = None, releases: list | None = None, + packages: list | None = None, manifest: bytes | None = None, + page_bound: int = 30) -> ScriptedServer: + manifest = manifest if manifest is not None else anchored_manifest() + releases_url = (f"https://gitlab.com/api/v4/projects/group%2Fproject/releases" + f"?per_page={page_bound}") + packages_url = ("https://gitlab.com/api/v4/projects/group%2Fproject/packages" + f"?package_type=generic&package_name=ai-native-dev-stack" + f"&per_page={page_bound}") + files_url = ("https://gitlab.com/api/v4/projects/group%2Fproject/packages/77" + f"/package_files?per_page={page_bound}") + hops = { + releases_url: json.dumps(releases if releases is not None else [ + {"tag_name": "v2.6.0", "upcoming_release": True}, + {"tag_name": "v2.5.0", "upcoming_release": False}, + {"tag_name": "nightly", "upcoming_release": False}, + ]).encode("utf-8"), + packages_url: json.dumps(packages if packages is not None else [ + {"id": 77, "package_type": "generic", "name": "ai-native-dev-stack", + "version": "2.5.0"}, + {"id": 78, "package_type": "generic", "name": "other", "version": "9.9.9"}, + ]).encode("utf-8"), + files_url: json.dumps(files if files is not None else package_files(manifest) + ).encode("utf-8"), + MANIFEST_DOWNLOAD: manifest, + BUNDLE_DOWNLOAD: b"bundle-bytes", + } + return ScriptedServer(hops) + + def provider(self, server: ScriptedServer, **kwargs): + return providerslib.GitLabReleaseProvider(GITLAB_ENDPOINT, "group/project", **kwargs) + + def test_enumeration_matches_release_and_package_identities(self): + server = self.server() + provider = self.provider(server) + with mock.patch.object(transportlib, "_send", server.send): + result = provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertTrue(result.complete) + self.assertEqual([candidate.version for candidate in result.candidates], ["2.5.0"]) + candidate = result.candidates[0] + self.assertEqual(candidate.identity, "v2.5.0") + self.assertEqual(candidate.channel, "stable") + self.assertEqual(candidate.manifest_size, len(anchored_manifest())) + + def test_the_token_travels_as_a_private_token_only_at_the_origin(self): + server = self.server() + provider = self.provider(server) + with mock.patch.object(transportlib, "_send", server.send), \ + mock.patch.dict("os.environ", {"GITLAB_TOKEN": "glpat-" + "x" * 20}): + provider.enumerate(release_v3lib.ReleaseQuery("stable")) + headers = {url: token for url, token, _accept in server.observed} + self.assertEqual(headers[RELEASES_URL], "glpat-" + "x" * 20) + + def test_an_anonymous_endpoint_wears_no_gitlab_token(self): + endpoint = transportlib.anonymous_endpoint("https://mirror.example/api/v4") + provider = providerslib.GitLabReleaseProvider(endpoint, "group/project") + server = ScriptedServer({}) + server.hops = {RELEASES_URL.replace("https://gitlab.com/api/v4", "https://mirror.example/api/v4"): b"[]", + PACKAGES_URL.replace("https://gitlab.com/api/v4", "https://mirror.example/api/v4"): b"[]"} + with mock.patch.object(transportlib, "_send", server.send), \ + mock.patch.dict("os.environ", {"GITLAB_TOKEN": "glpat-" + "x" * 20}): + provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual([token for _url, token, _a in server.observed], [None, None]) + + def test_a_duplicate_package_for_one_version_is_refused(self): + server = self.server(packages=[ + {"id": 77, "package_type": "generic", "name": "ai-native-dev-stack", + "version": "2.5.0"}, + {"id": 78, "package_type": "generic", "name": "ai-native-dev-stack", + "version": "2.5.0"}]) + provider = self.provider(server) + with mock.patch.object(transportlib, "_send", server.send): + with self.assertRaises(LifecycleError) as raised: + provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual(raised.exception.code, "RELEASE_DUPLICATE_VERSION") + + def test_manifest_lookup_refusals(self): + cases = ( + (package_files(anchored_manifest())[1:], "RELEASE_MANIFEST_MISSING"), + (package_files(anchored_manifest(), duplicate_manifest=True), + "RELEASE_MANIFEST_AMBIGUOUS"), + (package_files(anchored_manifest(), digest=False), + "RELEASE_INTEGRITY_METADATA_MISSING"), + (package_files(anchored_manifest(), digest_value="nope"), + "RELEASE_INTEGRITY_METADATA_INVALID"), + (package_files(anchored_manifest(), size=False), + "RELEASE_INTEGRITY_METADATA_MISSING"), + ) + for files, code in cases: + with self.subTest(code=code): + server = self.server(files=files) + provider = self.provider(server) + with mock.patch.object(transportlib, "_send", server.send): + with self.assertRaises(LifecycleError) as raised: + provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual(raised.exception.code, code) + + def test_a_full_release_page_is_an_incomplete_enumeration(self): + server = self.server(releases=[{"tag_name": "v2.5.0", "upcoming_release": False}, + {"tag_name": "v2.4.0", "upcoming_release": False}, + {"tag_name": "v2.3.0", "upcoming_release": False}], + page_bound=3) + provider = self.provider(server, page_bound=3) + with mock.patch.object(transportlib, "_send", server.send): + result = provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertFalse(result.complete) + + def test_the_whole_chain_runs_against_a_scripted_gitlab(self): + manifest = anchored_manifest() + server = self.server(manifest=manifest) + provider = self.provider(server) + with mock.patch.object(transportlib, "_send", server.send): + candidate, parsed = release_v3lib.resolve_manifest( + provider, release_v3lib.ReleaseQuery("stable")) + payload = provider.fetch_artifact(candidate, parsed.lifecycle_artifact()) + self.assertEqual(candidate.version, "2.5.0") + self.assertEqual(payload, b"bundle-bytes") + + def test_fetching_before_enumerating_is_a_programming_refusal(self): + provider = providerslib.GitLabReleaseProvider(GITLAB_ENDPOINT, "group/project") + with self.assertRaises(LifecycleError) as raised: + provider.fetch_manifest(release_v3lib.ReleaseCandidate( + version="2.5.0", identity="v2.5.0")) + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_source.py b/tests/test_release_source.py new file mode 100644 index 00000000..a7f9f48c --- /dev/null +++ b/tests/test_release_source.py @@ -0,0 +1,225 @@ +"""One source resolver: selector conflicts refuse, and every command agrees. + +ADR-0019 sections 1–3: `resolve_release_source()` is the only decision, and it +validates before precedence — contradictory selectors are refused, never +ordered. The machine configuration is machine scope (a repository cannot +redirect a user's updates), reserved names cannot be redefined, and a named +provider is anonymous in V1. +""" + +from __future__ import annotations + +import json +import os +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from tests.lifecycle_support import LifecycleTestCase, write_text +from ainative.lifecycle import provider as providerlib +from ainative.lifecycle import release_source as sourcelib +from ainative.lifecycle.errors import LifecycleError + + +class TempHome(unittest.TestCase): + + def setUp(self) -> None: + self.root = Path(tempfile.mkdtemp(prefix="ainative-source-")) + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) + self.home = self.root / "home" + self.home.mkdir() + self.mirror = self.root / "mirror" + self.mirror.mkdir() + + def write_config(self, payload: dict) -> Path: + return write_text(sourcelib.config_path(self.home), json.dumps(payload) + "\n") + + +class SelectorMatrix(TempHome): + + def resolve(self, **environment): + return sourcelib.resolve_release_source(environ=environment, home=self.home) + + def test_nothing_selected_is_the_builtin_github(self): + source = self.resolve() + self.assertEqual(source.kind, sourcelib.KIND_GITHUB) + self.assertTrue(source.authenticated) + self.assertEqual(source.endpoint.auth_origin, "https://api.github.com") + self.assertEqual(source.metadata_url, providerlib.DEFAULT_RELEASE_URL) + + def test_the_local_pair_resolves_to_the_mirror(self): + source = self.resolve(AINATIVE_UPDATE_PROVIDER="local", + AINATIVE_UPDATE_LOCAL_DIR=str(self.mirror)) + self.assertEqual(source.kind, sourcelib.KIND_LOCAL) + self.assertEqual(source.directory, self.mirror) + self.assertFalse(source.authenticated) + + def test_a_local_dir_without_the_explicit_provider_is_a_conflict(self): + with self.assertRaises(LifecycleError) as raised: + self.resolve(AINATIVE_UPDATE_LOCAL_DIR=str(self.mirror)) + self.assertEqual(raised.exception.code, "UPDATE_SOURCE_CONFLICT") + + def test_the_url_mixed_with_a_selector_is_a_conflict(self): + cases = ({"AINATIVE_UPDATE_PROVIDER": "github"}, + {"AINATIVE_UPDATE_LOCAL_DIR": str(self.mirror)}, + {"AINATIVE_UPDATE_PROVIDER": "local", + "AINATIVE_UPDATE_LOCAL_DIR": str(self.mirror)}) + for extra in cases: + with self.subTest(extra=extra): + with self.assertRaises(LifecycleError) as raised: + self.resolve(AINATIVE_UPDATE_URL="https://mirror.example/releases/latest", + **extra) + self.assertEqual(raised.exception.code, "UPDATE_SOURCE_CONFLICT") + + def test_the_url_alone_is_anonymous(self): + source = self.resolve(AINATIVE_UPDATE_URL="https://mirror.example/releases/latest") + self.assertEqual(source.kind, sourcelib.KIND_ANONYMOUS) + self.assertFalse(source.authenticated) + self.assertIsNone(source.endpoint.auth_origin) + + def test_the_builtin_name_and_its_alias_resolve_to_github(self): + for name in ("github", "release-api"): + self.assertEqual(self.resolve(AINATIVE_UPDATE_PROVIDER=name).kind, + sourcelib.KIND_GITHUB) + + def test_an_unknown_provider_name_is_refused(self): + with self.assertRaises(LifecycleError) as raised: + self.resolve(AINATIVE_UPDATE_PROVIDER="carrier-pigeon") + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + + def test_local_without_a_directory_is_refused(self): + with self.assertRaises(LifecycleError) as raised: + self.resolve(AINATIVE_UPDATE_PROVIDER="local") + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + + +class MachineConfig(TempHome): + + def test_a_named_provider_resolves_anonymously(self): + self.write_config({"schema_version": 1, "providers": { + "internal": {"api_base_url": "https://releases.internal.example/api"}}}) + source = sourcelib.resolve_release_source( + environ={"AINATIVE_UPDATE_PROVIDER": "internal"}, home=self.home) + self.assertEqual(source.kind, sourcelib.KIND_NAMED) + self.assertFalse(source.authenticated) + self.assertEqual(source.metadata_url, + "https://releases.internal.example/api/releases/latest") + + def test_the_default_provider_applies_when_nothing_is_selected(self): + self.write_config({"schema_version": 1, "default_provider": "internal", + "providers": {"internal": { + "api_base_url": "https://releases.internal.example/api"}}}) + source = sourcelib.resolve_release_source(environ={}, home=self.home) + self.assertEqual(source.provider_name, "internal") + + def test_the_default_local_requires_a_directory(self): + self.write_config({"schema_version": 1, "default_provider": "local"}) + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source(environ={}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_a_reserved_name_cannot_be_redefined(self): + self.write_config({"schema_version": 1, "providers": { + "github": {"api_base_url": "https://evil.example/api"}}}) + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source(environ={}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_the_gitlab_provider_requires_a_project_reference(self): + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source( + environ={"AINATIVE_UPDATE_PROVIDER": "gitlab"}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_the_gitlab_provider_resolves_with_its_configured_project(self): + self.write_config({"schema_version": 1, "providers": { + "gitlab": {"release_project_ref": "group/sub/project"}}}) + source = sourcelib.resolve_release_source( + environ={"AINATIVE_UPDATE_PROVIDER": "gitlab"}, home=self.home) + self.assertEqual(source.kind, sourcelib.KIND_GITLAB) + self.assertEqual(source.project_ref, "group/sub/project") + self.assertTrue(source.authenticated) + self.assertEqual(source.endpoint.auth_header, "PRIVATE-TOKEN") + self.assertEqual(source.endpoint.auth_origin, "https://gitlab.com") + self.assertEqual(source.endpoint.api_base_url, "https://gitlab.com/api/v4") + + def test_a_gitlab_project_reference_is_not_a_url(self): + self.write_config({"schema_version": 1, "providers": { + "gitlab": {"release_project_ref": "https://gitlab.com/group/project"}}}) + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source( + environ={"AINATIVE_UPDATE_PROVIDER": "gitlab"}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_a_custom_auth_origin_is_refused_in_v1(self): + self.write_config({"schema_version": 1, "providers": { + "internal": {"api_base_url": "https://x.example/api", + "auth_origin": "https://x.example"}}}) + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source( + environ={"AINATIVE_UPDATE_PROVIDER": "internal"}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_a_future_config_schema_is_refused_not_guessed(self): + self.write_config({"schema_version": 2, "providers": {}}) + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source(environ={}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_describe_turns_a_conflict_into_a_state(self): + record = sourcelib.describe(environ={"AINATIVE_UPDATE_LOCAL_DIR": "x"}, + home=self.home) + self.assertEqual(record["kind"], "refused") + self.assertEqual(record["state"], "UPDATE_SOURCE_CONFLICT") + self.assertIn("Release source", sourcelib.describe_lines(record)[0]) + + +class ProviderBuild(TempHome): + + def test_build_delegates_to_the_resolver(self): + with mock.patch.object(sourcelib, "load_machine_config", lambda home=None: {}), \ + mock.patch.dict(os.environ, { + "AINATIVE_UPDATE_PROVIDER": "local", + "AINATIVE_UPDATE_LOCAL_DIR": str(self.mirror)}, clear=False): + built = providerlib.build("stable") + self.assertIsInstance(built, providerlib.LocalDirectoryProvider) + self.assertEqual(built.root, self.mirror) + + def test_build_of_the_default_is_the_github_endpoint_provider(self): + for name in (providerlib.PROVIDER_ENV, providerlib.LOCAL_SOURCE_ENV, + providerlib.RELEASE_URL_ENV): + os.environ.pop(name, None) + with mock.patch.object(sourcelib, "load_machine_config", lambda home=None: {}): + built = providerlib.build("stable") + self.assertIsInstance(built, providerlib.ReleaseApiProvider) + self.assertEqual(built.endpoint.auth_origin, "https://api.github.com") + self.assertEqual(built.url, providerlib.DEFAULT_RELEASE_URL) + + def test_build_of_an_anonymous_url_wears_no_credential(self): + with mock.patch.object(sourcelib, "load_machine_config", lambda home=None: {}), \ + mock.patch.dict(os.environ, { + "AINATIVE_UPDATE_URL": "https://mirror.example/releases/latest"}): + built = providerlib.build("stable") + self.assertIsNone(built.endpoint.auth_origin) + self.assertEqual(built._token(), "") + + +class Diagnostics(LifecycleTestCase): + + def test_status_and_doctor_display_the_source_without_secrets(self): + self.install("standard") + report = json.loads(self.cli("status", "--json").stdout) + self.assertEqual(report["release_source"]["kind"], "github") + self.assertTrue(report["release_source"]["authenticated"]) + text = self.cli("status").stdout + self.assertIn("Release source", text) + self.assertIn("authenticated: yes", text) + doctor = json.loads(self.cli("doctor", "--json").stdout) + self.assertEqual(doctor["release_source"]["reason"], + report["release_source"]["reason"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_v3.py b/tests/test_release_v3.py new file mode 100644 index 00000000..a0366476 --- /dev/null +++ b/tests/test_release_v3.py @@ -0,0 +1,319 @@ +"""Release V3: candidate policy, the anchored manifest, and the exact chain. + +ADR-0019 sections 7–10, pinned: build metadata is not an installable version, +duplicates are never resolved by order, an incomplete enumeration refuses +instead of picking the best of what it saw, the manifest is verified against +provider metadata BEFORE it is parsed, and one version must hold across every +trust-bearing identity. +""" + +from __future__ import annotations + +import json +import unittest +from hashlib import sha256 + +from ainative.lifecycle import release_v3 +from ainative.lifecycle.errors import LifecycleError + + +def manifest_payload(version: str = "2.5.0", *, runtime: str | None = None, + artifact_version: str | None = None, + artifact_name: str | None = None, + protocol: str = "v3", channel: str = "stable", + extra_artifacts: list | None = None, + **document_overrides) -> bytes: + artifact_version = artifact_version or version + artifacts = [{ + "name": artifact_name or release_v3.lifecycle_bundle_name(artifact_version), + "kind": "lifecycle", "version": artifact_version, + "sha256": "a" * 64, "size": 10, + }] + artifacts.extend(extra_artifacts or []) + document = { + "schema": "ainative.release", + "protocol": protocol, + "version": version, + "channel": channel, + "compatibility": {"runtime_version": runtime or version}, + "artifacts": artifacts, + "provenance": {"source": "test"}, + } + document.update(document_overrides) + return json.dumps(document).encode("utf-8") + + +def anchor(payload: bytes) -> tuple[str, int]: + return sha256(payload).hexdigest(), len(payload) + + +def candidate(version: str, *, identity: str | None = None, channel: str = "stable", + payload: bytes | None = None, anchored: bool = True): + digest, size = anchor(payload) if payload is not None else ("a" * 64, 10) + return release_v3.ReleaseCandidate( + version=version, identity=identity or f"release-{version}", channel=channel, + manifest_sha256=digest if anchored else None, + manifest_size=size if anchored else None) + + +def expect(code: str, action) -> LifecycleError: + try: + action() + except LifecycleError as error: + if error.code != code: + raise AssertionError(f"expected {code}, got {error.code}: " + f"{error.message}") from error + return error + raise AssertionError(f"expected a {code} refusal; nothing was raised") + + +class SemVerPolicy(unittest.TestCase): + + def test_canonical_versions_are_semver_without_build_metadata(self): + self.assertEqual(release_v3.canonical_version("1.2.3"), "1.2.3") + self.assertEqual(release_v3.canonical_version("v1.2.3"), "1.2.3") + self.assertEqual(release_v3.canonical_version("1.2.3-rc.1"), "1.2.3-rc.1") + + def test_build_metadata_is_not_an_installable_version(self): + expect("RELEASE_BUILD_METADATA_UNSUPPORTED", + lambda: release_v3.canonical_version("1.2.3+build1")) + + def test_a_non_semver_candidate_keeps_the_historical_refusal(self): + expect("UPDATE_CHECK_FAILED", lambda: release_v3.canonical_version("nightly")) + + +class CandidateSelection(unittest.TestCase): + + def select(self, candidates, *, complete=True, channel="stable"): + result = release_v3.EnumerationResult(tuple(candidates), complete) + return release_v3.select_candidate(result, release_v3.ReleaseQuery(channel)) + + def test_order_is_version_order_not_string_order(self): + chosen = self.select([candidate("1.9.0"), candidate("1.10.0")]) + self.assertEqual(chosen.version, "1.10.0") + + def test_a_release_outranks_its_pre_releases(self): + chosen = self.select([candidate("1.2.3-rc.1"), candidate("1.2.3")]) + self.assertEqual(chosen.version, "1.2.3") + chosen = self.select([candidate("1.2.3-rc.1"), candidate("1.2.3-rc.2")]) + self.assertEqual(chosen.version, "1.2.3-rc.2") + + def test_another_channel_is_not_a_candidate(self): + expect("RELEASE_NO_CANDIDATE", + lambda: self.select([candidate("1.2.3", channel="beta")])) + + def test_an_incomplete_enumeration_refuses_even_with_candidates(self): + expect("RELEASE_ENUMERATION_INCOMPLETE", + lambda: self.select([candidate("1.2.3")], complete=False)) + + def test_a_complete_channel_with_nothing_refuses(self): + expect("RELEASE_NO_CANDIDATE", lambda: self.select([])) + + def test_two_identities_on_one_version_refuse_as_duplicates(self): + error = expect("RELEASE_DUPLICATE_VERSION", + lambda: self.select([candidate("1.2.3", identity="tag-a"), + candidate("1.2.3", identity="tag-b")])) + self.assertEqual(error.detail["version"], "1.2.3") + + def test_the_same_identity_listed_twice_is_one_candidate(self): + chosen = self.select([candidate("1.2.3", identity="tag-a"), + candidate("1.2.3", identity="tag-a")]) + self.assertEqual(chosen.identity, "tag-a") + + +class TrustAnchor(unittest.TestCase): + + def verify(self, payload, *, sha=None, size=None): + release_v3.verify_external_anchor(payload, sha256=sha, size=size) + + def test_an_absent_anchor_is_missing_metadata(self): + expect("RELEASE_INTEGRITY_METADATA_MISSING", + lambda: self.verify(b"{}", sha=None, size=None)) + + def test_a_malformed_anchor_is_invalid_metadata(self): + for sha, size in (("zz", 2), ("a" * 64, 0), ("a" * 64, "2")): + with self.subTest(sha=sha, size=size): + expect("RELEASE_INTEGRITY_METADATA_INVALID", + lambda sha=sha, size=size: self.verify(b"{}", sha=sha, size=size)) + + def test_a_size_mismatch_is_refused(self): + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.verify(b"{}", sha="a" * 64, size=99)) + + def test_a_digest_mismatch_is_refused(self): + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.verify(b"{}", sha="a" * 64, size=2)) + + def test_a_matching_anchor_passes(self): + digest, size = anchor(b'{"ok": true}') + self.verify(b'{"ok": true}', sha=digest, size=size) + + def test_verification_happens_before_parsing(self): + # Invalid JSON with a WRONG digest must fail on the digest, proving the + # bytes were verified before being parsed; with a correct anchor the + # same bytes reach the parser and fail there. + error = expect("UPDATE_INTEGRITY_FAILED", + lambda: self._verify_then_parse(b"{not json")) + self.assertIn("does not match", error.message) + error = expect("UPDATE_INTEGRITY_FAILED", + lambda: release_v3.parse_manifest(b"{not json")) + self.assertIn("not valid JSON", error.message) + + def _verify_then_parse(self, payload: bytes) -> None: + self.verify(payload, sha="a" * 64, size=len(payload)) + release_v3.parse_manifest(payload) + + +class ManifestValidation(unittest.TestCase): + + def parse(self, payload: bytes): + return release_v3.parse_manifest(payload) + + def test_a_valid_manifest_round_trips(self): + manifest = self.parse(manifest_payload()) + self.assertEqual(manifest.version, "2.5.0") + self.assertEqual(manifest.runtime_version, "2.5.0") + self.assertEqual(manifest.channel, "stable") + self.assertEqual(manifest.lifecycle_artifact().name, + "ainative-lifecycle-v3-2.5.0.zip") + self.assertEqual(manifest.to_record()["provenance"], {"source": "test"}) + + def test_a_newer_protocol_asks_for_a_newer_cli(self): + expect("CLI_UPDATE_REQUIRED", + lambda: self.parse(manifest_payload(protocol="v4"))) + + def test_an_unknown_protocol_is_an_integrity_refusal(self): + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(manifest_payload(protocol="v2"))) + + def test_an_unknown_schema_is_refused(self): + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(manifest_payload(schema="something.else"))) + + def test_a_build_metadata_manifest_version_is_refused(self): + expect("RELEASE_BUILD_METADATA_UNSUPPORTED", + lambda: self.parse(manifest_payload(version="2.5.0+build1"))) + + def test_missing_pieces_are_refused(self): + for overrides in ({"channel": ""}, {"compatibility": None}, + {"artifacts": []}, {"provenance": []}, + {"version": None}): + with self.subTest(overrides=overrides): + expect("UPDATE_INTEGRITY_FAILED", + lambda overrides=overrides: self.parse( + manifest_payload(**overrides))) + + def test_a_traversing_artifact_name_is_refused(self): + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(manifest_payload(artifact_name="../../evil.zip"))) + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(manifest_payload(artifact_name="sub/evil.zip"))) + + def test_an_unknown_artifact_kind_is_refused(self): + digest, size = anchor(b"x") + extra = [{"name": "wheel.whl", "kind": "wheel", "version": "2.5.0", + "sha256": "a" * 64, "size": 10}] + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(manifest_payload(extra_artifacts=extra))) + + def test_a_malformed_artifact_digest_is_refused(self): + payload = manifest_payload() + document = json.loads(payload) + document["artifacts"][0]["sha256"] = "nope" + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(json.dumps(document).encode())) + + def test_two_lifecycle_artifacts_are_ambiguous(self): + second = {"name": "ainative-lifecycle-v3-2.5.0.zip", "kind": "lifecycle", + "version": "2.5.0", "sha256": "b" * 64, "size": 11} + manifest = self.parse(manifest_payload(extra_artifacts=[second])) + expect("UPDATE_INTEGRITY_FAILED", manifest.lifecycle_artifact) + + +class VersionChain(unittest.TestCase): + + def chain(self, *, candidate_version="2.5.0", manifest_payload_args=None, + protocol_release_version=None): + payload = manifest_payload(**(manifest_payload_args or {})) + manifest = release_v3.parse_manifest(payload) + release_v3.require_exact_version_chain( + candidate(candidate_version), manifest, manifest.lifecycle_artifact(), + protocol_release_version=protocol_release_version) + + def test_the_exact_chain_passes(self): + self.chain(protocol_release_version="2.5.0") + + def test_every_broken_link_is_refused(self): + cases = ( + {"candidate_version": "2.5.1"}, + {"manifest_payload_args": {"version": "2.5.1"}}, + {"manifest_payload_args": {"runtime": "2.4.9"}}, + {"manifest_payload_args": {"artifact_version": "2.4.9"}}, + {"manifest_payload_args": {"artifact_name": "ainative-lifecycle-v3-2.4.9.zip"}}, + {"protocol_release_version": "2.4.9"}, + ) + for case in cases: + with self.subTest(case=case): + error = expect("UPDATE_VERSION_MISMATCH", lambda case=case: self.chain(**case)) + self.assertIn("chain", error.detail) + + def test_the_chain_detail_names_every_link(self): + error = expect("UPDATE_VERSION_MISMATCH", + lambda: self.chain(candidate_version="2.5.1", + protocol_release_version="2.5.0")) + self.assertEqual(set(error.detail["chain"]), + {"candidate version", "manifest version", + "compatibility.runtime_version", "artifact version", + "artifact filename version", + "lifecycle-protocol.json release_version"}) + + +class ResolutionOrder(unittest.TestCase): + + class FakeProvider(release_v3.ReleaseProvider): + name = "fake" + + def __init__(self, candidates, payload, *, complete=True): + self._result = release_v3.EnumerationResult(tuple(candidates), complete) + self._payload = payload + self.calls: list[str] = [] + + def enumerate(self, query): + self.calls.append("enumerate") + return self._result + + def fetch_manifest(self, candidate): + self.calls.append(f"fetch_manifest:{candidate.version}") + return self._payload + + def fetch_artifact(self, candidate, artifact): + raise AssertionError("resolve_manifest must not download artifacts") + + def test_a_complete_chain_resolves(self): + payload = manifest_payload() + provider = self.FakeProvider([candidate("2.5.0", payload=payload)], payload) + resolved, manifest = release_v3.resolve_manifest( + provider, release_v3.ReleaseQuery("stable")) + self.assertEqual(resolved.version, "2.5.0") + self.assertEqual(manifest.version, "2.5.0") + self.assertEqual(provider.calls, ["enumerate", "fetch_manifest:2.5.0"]) + + def test_an_incomplete_enumeration_never_fetches_a_manifest(self): + payload = manifest_payload() + provider = self.FakeProvider([candidate("2.5.0", payload=payload)], payload, + complete=False) + expect("RELEASE_ENUMERATION_INCOMPLETE", + lambda: release_v3.resolve_manifest(provider, + release_v3.ReleaseQuery("stable"))) + self.assertEqual(provider.calls, ["enumerate"]) + + def test_an_unanchored_candidate_never_reaches_the_parser(self): + payload = manifest_payload() + provider = self.FakeProvider([candidate("2.5.0", anchored=False)], payload) + expect("RELEASE_INTEGRITY_METADATA_MISSING", + lambda: release_v3.resolve_manifest(provider, + release_v3.ReleaseQuery("stable"))) + + +if __name__ == "__main__": + unittest.main()