diff --git a/assets/orgs/Pick-Your-AI/mark.png b/assets/orgs/Pick-Your-AI/mark.png new file mode 100644 index 0000000..b2408d2 Binary files /dev/null and b/assets/orgs/Pick-Your-AI/mark.png differ diff --git a/references/brand.lock.json b/references/brand.lock.json index 5ec2101..2e016c5 100644 --- a/references/brand.lock.json +++ b/references/brand.lock.json @@ -309,6 +309,61 @@ "title": "#F1F5F9" } }, + "pickyourai": { + "badge_accent": "115E59", + "badge_brand": "0F766E", + "canvas": { + "color": "#042F2E", + "grid": "#14B8A6", + "grid_opacity": 0.07, + "kind": "flat-grid" + }, + "company_label": "Pick Your AI", + "eyebrow_case": "upper", + "measured": false, + "tokens": { + "accent": "#14B8A6", + "accent_deep": "#115E59", + "accent_mid": "#14B8A6", + "bar": { + "from": "#115E59", + "height": 6, + "to": "#14B8A6" + }, + "body": "#CCFBF1", + "card_edge": "#CCFBF1", + "card_fill": "#F0FDFA", + "card_ink": "#042F2E", + "card_shadow": { + "blur": 12, + "color": "#000000", + "dx": 6, + "dy": 8, + "opacity": 0.35 + }, + "chip_attr": { + "bg": "#115E59", + "fg": "#F0FDFA" + }, + "chip_key": { + "bg": "#FBBF24", + "fg": "#042F2E" + }, + "edge": { + "color": "#14B8A6", + "opacity": 0.28 + }, + "eyebrow": "#5EEAD4", + "gold_trace": "#FBBF24", + "icon_ink": "#0F766E", + "icon_spark": "#B45309", + "ink": "#042F2E", + "muted": "#99F6E4", + "plate_low": "#042F2E", + "subtitle": "#CCFBF1", + "title": "#F0FDFA" + } + }, "romgenie": { "canvas": { "color": "#002C31", @@ -783,10 +838,12 @@ "description": null, "display_name": "Pick Your AI", "eyebrow": null, - "family": "completetech", + "family": "pickyourai", "family_topics": [], "footer": false, "former_slugs": [], + "hub_url": "https://pickyour.ai", + "mark": "assets/orgs/Pick-Your-AI/mark.png", "nav_name": "Pick Your AI", "tier": "T3" }, diff --git a/references/brand.schema.json b/references/brand.schema.json index 4b86cbc..9e40fc9 100644 --- a/references/brand.schema.json +++ b/references/brand.schema.json @@ -197,6 +197,12 @@ "company_label": { "type": "string" }, + "badge_brand": { + "type": "string" + }, + "badge_accent": { + "type": "string" + }, "eyebrow_case": { "enum": [ "upper", diff --git a/references/brand.yaml b/references/brand.yaml index a8954ec..fc0fc73 100644 --- a/references/brand.yaml +++ b/references/brand.yaml @@ -237,6 +237,37 @@ families: signal_core: "#FFE0E5" icon_ink: "#3A1A33" icon_spark: "#6B214F" # deep magenta: readable on pink + pickyourai: + # Pick Your AI's own palette, from Pick-Your-AI/.github assets/PROMPTS.md and build_assets.py (teal surfaces, one amber + # accent, deep teal ground). Not CompleteTech branding. Unmeasured: the layout has no Pick Your AI golden yet. + measured: false + company_label: Pick Your AI + badge_brand: "0F766E" # README badges (shields.io hex, no #): teal, not CompleteTech blue + badge_accent: "115E59" + eyebrow_case: upper + canvas: {kind: flat-grid, color: "#042F2E", grid: "#14B8A6", grid_opacity: 0.07} + tokens: + eyebrow: "#5EEAD4" + title: "#F0FDFA" + subtitle: "#CCFBF1" + body: "#CCFBF1" + muted: "#99F6E4" + ink: "#042F2E" + plate_low: "#042F2E" + card_fill: "#F0FDFA" # light icon card, as CompleteTech: the icon prompt and QA assume dark ink on a light plate + card_ink: "#042F2E" + card_edge: "#CCFBF1" + card_shadow: {dx: 6, dy: 8, blur: 12, color: "#000000", opacity: 0.35} + chip_key: {bg: "#FBBF24", fg: "#042F2E"} + chip_attr: {bg: "#115E59", fg: "#F0FDFA"} + bar: {from: "#115E59", to: "#14B8A6", height: 6} + edge: {color: "#14B8A6", opacity: 0.28} + gold_trace: "#FBBF24" + accent: "#14B8A6" + accent_mid: "#14B8A6" + accent_deep: "#115E59" + icon_ink: "#0F766E" + icon_spark: "#B45309" # deep amber: readable on the light icon card (the chip and accent stay #FBBF24) libreevolve: measured: false company_label: LibreEvolve @@ -618,7 +649,7 @@ orgs: family_topics: [] footer: true Pick-Your-AI: - family: completetech + family: pickyourai former_slugs: [] tier: T3 display_name: Pick Your AI @@ -627,6 +658,8 @@ orgs: eyebrow: null brand_topic: pick-your-ai family_topics: [] + mark: assets/orgs/Pick-Your-AI/mark.png + hub_url: https://pickyour.ai footer: false Jevelopers-neatHack-2026: family: completetech diff --git a/scripts/ctshowcase/config.py b/scripts/ctshowcase/config.py index 7f1307c..05ad8b2 100644 --- a/scripts/ctshowcase/config.py +++ b/scripts/ctshowcase/config.py @@ -40,6 +40,21 @@ def canonical(obj) -> str: return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":")) +_T3_SIGNED_OFF = False + + +def set_t3_signed_off(value: bool) -> None: + """Record the run's `--allow-t3`: set once by the CLI, read by every owner gate (check_owner, art_status, + footer_status). Off by default, so a T3 owner is refused unless the caller passes the flag after an approved + t3-metadata or t3-signoff row.""" + global _T3_SIGNED_OFF + _T3_SIGNED_OFF = bool(value) + + +def t3_signed_off() -> bool: + return _T3_SIGNED_OFF + + class Brand: """Merged view over brand.yaml.""" @@ -96,7 +111,7 @@ def check_owner(self, owner: str, *, allow_t3: bool = False) -> str | None: raise Refused(f"{owner} is an excluded owner; every command refuses", log="exempt") if status == "hold": raise Refused(f"{owner} is on HOLD", log="hold") - if status == "t3" and not allow_t3: + if status == "t3" and not (allow_t3 or t3_signed_off()): raise Refused(f"{owner} is T3; needs co-owner sign-off", log="t3-signoff") return status diff --git a/scripts/ctshowcase/context.py b/scripts/ctshowcase/context.py index 1f0e9c4..bd7083f 100644 --- a/scripts/ctshowcase/context.py +++ b/scripts/ctshowcase/context.py @@ -3,7 +3,7 @@ from pathlib import Path -from .config import Brand, load_repo_override, override_from_manifests +from .config import Brand, load_repo_override, override_from_manifests, t3_signed_off from .detect import detect from .errors import ConfigError, NeedsInput from .facts import RepoFacts @@ -150,7 +150,7 @@ def art_status(self) -> dict: no_card = "exempt:no-card" if self.card_exempt() else None # §5.4 holds whatever the owner code says if watching: # a watching-org repository that is not a fork and not its `.github`: decided explicitly return {"banner": watching, "card": no_card or watching, "icon": watching} - if status in ("t3", "hold", "excluded"): + if status in ("t3", "hold", "excluded") and not (status == "t3" and t3_signed_off()): code = {"t3": "t3-signoff", "hold": "hold", "excluded": "exempt"}[status] return {"banner": code, "card": no_card or code, "icon": code} if no_card: @@ -182,7 +182,7 @@ def footer_status(self) -> str | None: """None when the decision 18 footer applies, else why not: `exempt:watching-fork`, `hold:watching-non-fork`, `t3-signoff`, `hold` (org row `footer: hold`) or `exempt:footer` (org row `footer: false`). The watching org's `footer: false` exempts only its forks (org.md), so its `.github` needs the footer.""" - if self.brand.owner_status(self.owner) == "t3": + if self.brand.owner_status(self.owner) == "t3" and not t3_signed_off(): return "t3-signoff" watching = self.watching_status() if watching or self.brand.owner_status(self.owner) == "watching": diff --git a/scripts/ctshowcase/doctor.py b/scripts/ctshowcase/doctor.py index f2198c8..1d01fc7 100644 --- a/scripts/ctshowcase/doctor.py +++ b/scripts/ctshowcase/doctor.py @@ -8,6 +8,7 @@ from . import describe, footer as footermod, labels as labelsmod, pnglint, readmelint, svglint, topics as topicsmod from . import readme as readmemod +from .config import t3_signed_off from .context import RepoContext from .detect import standard @@ -88,7 +89,7 @@ def row(ctx: RepoContext) -> dict: logs += rl.get("logs", []) else: cells["R7"] = "unknown" if not rd else "fail" - if ctx.brand.owner_status(ctx.owner) == "t3": + if ctx.brand.owner_status(ctx.owner) == "t3" and not t3_signed_off(): cells["R7"] = "hold" # T3: no README change before co-owner sign-off (lint readme refuses) lic = ctx.meta.get("license") cells["R8"] = "pass" if lic or not ctx.public else "warn" diff --git a/scripts/ctshowcase/org.py b/scripts/ctshowcase/org.py index d528af9..804fa37 100644 --- a/scripts/ctshowcase/org.py +++ b/scripts/ctshowcase/org.py @@ -308,8 +308,11 @@ def _star_entry(estate: Estate, org: str, reg: dict) -> dict | None: return sl if sl.get("name") else None -def _star_held(estate: Estate, org: str) -> str | None: +def _star_held(estate: Estate, org: str, allow_t3: bool = False) -> str | None: + from .config import t3_signed_off st = estate.resolver.owner_status(org) + if st == "t3" and (allow_t3 or t3_signed_off()): + st = None # co-owner sign-off lifts the T3 hold (decision 12); HOLD and excluded stay held if st in ("t3", "hold", "excluded"): return st if estate.resolver.family_hold(org): @@ -1523,7 +1526,7 @@ def star_list_plan(estate: Estate, org: str, *, lists_capture: dict | None = Non login = r.canonical(org) reg = r.registry_org(org) entry = _star_entry(estate, org, reg) - held = _star_held(estate, org) + held = _star_held(estate, org, allow_t3) base = {"org": login, "reads": [{"name": "star-lists", "argv": read_command("star_lists")}], "jobs": [], "pending": [], "logs": []} if not entry: @@ -2226,7 +2229,7 @@ def register(sub) -> None: s.add_argument("--render", action="store_true", help="profile: also render the cards, badges and org banner into --out-dir") s.add_argument("--banner", help="profile: hero path relative to profile/ (default assets/banner.jpg, or assets/intro.svg)") s.add_argument("--email-approved", action="store_true", default=None, help="the org email (q26) is approved") - s.add_argument("--allow-t3", action="store_true", help="T3 org after co-owner sign-off") + s.add_argument("--allow-t3", action="store_true", default=argparse.SUPPRESS, help="T3 org after co-owner sign-off") s.add_argument("--allow-unmeasured", action="store_true") s.add_argument("--lists", help="pins/profile: star_lists.graphql capture (SOC-01)") s.add_argument("--repo-ids", help="pins: JSON {owner/repo: node id} from repo_id.graphql reads") @@ -2290,30 +2293,30 @@ def cmd_org(args): estate = estate_from_args(args) act = args.action if act == "profile" and args.user: - res, code = user_profile(estate, args.user, out_dir=args.out_dir, render=args.render, allow_t3=args.allow_t3, + res, code = user_profile(estate, args.user, out_dir=args.out_dir, render=args.render, allow_t3=getattr(args, "allow_t3", False), allow_unmeasured=args.allow_unmeasured, meta_dir=args.meta_dir) elif act == "profile": res, code = profile(estate, org, out_dir=args.out_dir, render=args.render, banner=args.banner, - email_approved=args.email_approved, allow_t3=args.allow_t3, + email_approved=args.email_approved, allow_t3=getattr(args, "allow_t3", False), allow_unmeasured=args.allow_unmeasured, lists_capture=_json_file(args.lists), meta_dir=args.meta_dir) elif act == "about": - res, code = about(estate, org, email_approved=args.email_approved, allow_t3=args.allow_t3) + res, code = about(estate, org, email_approved=args.email_approved, allow_t3=getattr(args, "allow_t3", False)) elif act == "pins": - res, code = pins(estate, org, allow_t3=args.allow_t3, lists_capture=_json_file(args.lists), + res, code = pins(estate, org, allow_t3=getattr(args, "allow_t3", False), lists_capture=_json_file(args.lists), repo_ids=_json_file(args.repo_ids)) res["jobs"] = res["jobs"] + res["star_list"].get("jobs", []) elif act == "labels": if args.repos_from_gh: - capture_label_repos(estate, org, allow_t3=args.allow_t3) - res, code = labels_plan(estate, org, allow_t3=args.allow_t3, checkouts=args.checkouts, dot_github=args.dot_github, + capture_label_repos(estate, org, allow_t3=getattr(args, "allow_t3", False)) + res, code = labels_plan(estate, org, allow_t3=getattr(args, "allow_t3", False), checkouts=args.checkouts, dot_github=args.dot_github, registry_free=args.repos_from_gh) elif act == "health": - res, code = health(estate, org, repo_dir=args.dot_github, allow_t3=args.allow_t3) + res, code = health(estate, org, repo_dir=args.dot_github, allow_t3=getattr(args, "allow_t3", False)) else: snap = _json_file(args.snapshot) or {} row = doctor(estate, org, snap, repo_dir=args.dot_github, avatar=args.avatar, email_approved=args.email_approved, - allow_t3=args.allow_t3) + allow_t3=getattr(args, "allow_t3", False)) if args.org_doctor_format != "json": print(as_tsv(row, args.header) if args.org_doctor_format == "tsv" else as_md(row, args.header)) return None, OK diff --git a/scripts/ctshowcase/project.py b/scripts/ctshowcase/project.py index 760ab7e..fd7ad24 100644 --- a/scripts/ctshowcase/project.py +++ b/scripts/ctshowcase/project.py @@ -9,6 +9,7 @@ """ from __future__ import annotations +import argparse import hashlib import json import re @@ -1153,7 +1154,7 @@ def register(sub) -> None: s.add_argument("--approved", help="decisions/approved.tsv") s.add_argument("--approved-yaml", help="decisions/APPROVED_ACTIONS.yaml (approved org renames and merges, CQ-03)") s.add_argument("--actions", help="decisions/estate-actions.json (the old and new names of approved org renames)") - s.add_argument("--allow-t3", action="store_true") + s.add_argument("--allow-t3", action="store_true", default=argparse.SUPPRESS) s.add_argument("--no-status-update", action="store_true") s.add_argument("--files-dir", help="copy-plan: write the REST view bodies under DIR (paths as in `files`)") s.add_argument("--out", help="normalize: write the template spec JSON here; readme: write the README here") @@ -1239,7 +1240,7 @@ def cmd_project(args): copied = _load(args.copied) if act == "estate-plan": res = estate_plan(r, source=args.source, copied=copied, user_id=args.user_id, approval_id=args.approval_id, - allow_t3=args.allow_t3, reviewed=args.reviewed) + allow_t3=getattr(args, "allow_t3", False), reviewed=args.reviewed) else: if not args.target: raise ShowcaseError("copy-plan needs --target ORG (or @me)", log="usage") @@ -1247,7 +1248,7 @@ def cmd_project(args): res = copy_plan(args.target, role=args.role, source=args.source, title=args.title, display_name=args.display_name, copied=copied, workstreams=ws, repositories=[x for x in (args.repos or "").split(",") if x] or None, approval_id=args.approval_id, resolver=r, user_id=args.user_id, out_of_scope=args.out_of_scope, - reviewed=args.reviewed, status_update=not args.no_status_update, allow_t3=args.allow_t3) + reviewed=args.reviewed, status_update=not args.no_status_update, allow_t3=getattr(args, "allow_t3", False)) if args.jobs and res.get("files") and not args.files_dir: # each REST view job reads its body with --input; the writer's guard refuses a body it cannot read raise ShowcaseError("copy-plan writes REST view bodies: pass --files-dir DIR with --jobs so each view job's --input " diff --git a/scripts/ctshowcase/readme.py b/scripts/ctshowcase/readme.py index 509d7bc..9e1353f 100644 --- a/scripts/ctshowcase/readme.py +++ b/scripts/ctshowcase/readme.py @@ -156,7 +156,10 @@ def banner_html(ctx: RepoContext) -> str: def badges(ctx: RepoContext) -> list[tuple[str, str, str | None]]: """[(alt, image url, link)] per §7.2: language, type, license, CI. 2-4.""" - b = ctx.brand.raw["badges"] + b = dict(ctx.brand.raw["badges"]) + for k in ("brand", "accent"): # a family outside CompleteTech carries its own badge colours (brand.yaml badge_brand, badge_accent) + if ctx.style.get(f"badge_{k}"): + b[k] = ctx.style[f"badge_{k}"] jev = ctx.style["name"] == "jevplay-games" extra = "?labelColor=1E1E1E" if jev else "" out = [] @@ -296,11 +299,14 @@ def license_section(ctx: RepoContext) -> tuple[str, str] | None: if not year: ctx.log("needs-input:license-year") return None - return "License", rv["private"].replace("", year) + private = rv["private"].replace("", year) + if ctx.org.get("family") not in (None, "completetech"): + private = private.replace("CompleteTech LLC", ctx.org.get("display_name") or ctx.owner) + return "License", private if lic == "MIT": if f.exists("BRAND_ASSETS.md"): return "License", rv["skills_mit"] - if ctx.tier.startswith("T2"): + if ctx.tier.startswith("T2") or ctx.org.get("family") not in (None, "completetech"): return "License", rv["t2_mit"].replace("", ctx.org.get("display_name") or ctx.owner) return "License", rv["t1_mit"] if lic and lic not in ("NOASSERTION",): @@ -343,6 +349,8 @@ def section_facts(ctx: RepoContext, h2: str) -> str | None: if hp and ctx.meta.get("homepage_status") == 200: return f"**[{re.sub(r'^https?://', '', hp).rstrip('/')}]({hp})**" if h2 == "Ownership": + if ctx.org.get("family") not in (None, "completetech"): + return f"Organization: {ctx.owner}" return f"Owner: @CompleteDotTech · Organization: {ctx.owner}" if h2 == "Files" and ctx.det["variant"] == "J": rows = [("profile/README.md", f"Organization profile shown at github.com/{ctx.owner}")] diff --git a/scripts/ctshowcase/readmelint.py b/scripts/ctshowcase/readmelint.py index 5eeeb8b..09dfd66 100644 --- a/scripts/ctshowcase/readmelint.py +++ b/scripts/ctshowcase/readmelint.py @@ -106,7 +106,8 @@ def lint(ctx: RepoContext, path: Path, variant: str | None = None) -> dict: warns.append(f"badges-short: {nbadges} badge (no licence or language to show yet)") for s in shields: col = re.split(r"[?)]", s.rsplit("-", 1)[-1])[0].upper() - if re.fullmatch(r"[0-9A-F]{6}", col) and col not in PALETTE: + family_cols = {str(ctx.style.get(k) or "").upper() for k in ("badge_brand", "badge_accent")} + if re.fullmatch(r"[0-9A-F]{6}", col) and col not in PALETTE and col not in family_cols: fails.append(f"R7 off-palette badge colour {col}") # tagline == description desc = (ctx.meta.get("description") or "").strip() diff --git a/scripts/ctshowcase/resolver.py b/scripts/ctshowcase/resolver.py index 458d7ee..44a540d 100644 --- a/scripts/ctshowcase/resolver.py +++ b/scripts/ctshowcase/resolver.py @@ -286,13 +286,14 @@ def owner_status(self, login: str) -> str | None: def check_owner(self, login: str, *, allow_t3: bool = False, allow_hold: bool = False) -> str | None: """Refuse excluded, hold and (unless signed off) T3 orgs under any of their logins; return the status.""" + from .config import t3_signed_off from .errors import Refused st = self.owner_status(login) if st == "excluded": raise Refused(f"{login} is an excluded owner; every command refuses", log="exempt") if st == "hold" and not allow_hold: raise Refused(f"{login} is on HOLD", log="hold") - if st == "t3" and not allow_t3: + if st == "t3" and not (allow_t3 or t3_signed_off()): raise Refused(f"{login} is T3; needs co-owner sign-off", log="t3-signoff") return st diff --git a/scripts/ctshowcase/topics.py b/scripts/ctshowcase/topics.py index 6f358bd..c561331 100644 --- a/scripts/ctshowcase/topics.py +++ b/scripts/ctshowcase/topics.py @@ -3,6 +3,7 @@ import re +from .config import t3_signed_off from .context import RepoContext from .detect import standard @@ -64,7 +65,7 @@ def membership_topics(ctx: RepoContext, also_in: list[str]) -> tuple[list[str], if status in ("excluded", "hold"): logs.append(f"exempt:also-in:{org}") continue - if status == "t3": + if status == "t3" and not t3_signed_off(): logs.append(f"t3-signoff:also-in:{org}") continue known, topic = org_topic(ctx.brand, org) diff --git a/scripts/showcase.py b/scripts/showcase.py index 3e17f58..244bfd0 100644 --- a/scripts/showcase.py +++ b/scripts/showcase.py @@ -948,6 +948,8 @@ def parser() -> argparse.ArgumentParser: p.add_argument("--type", dest="type_override", metavar="TYPE", help="§5.4 type to use instead of detection (when the file tree is not available); logged") p.add_argument("--format", choices=("json", "text"), default=None, help="output style") p.add_argument("--quiet", action="store_true") + p.add_argument("--allow-t3", action="store_true", + help="treat a T3 (co-owned) owner as signed off for this run: every repository command and generator, not just `meta`; pass only when a t3-metadata or t3-signoff row is approved (default: T3 owners are refused, exit 2)") p.add_argument("--version", action="version", version=f"%(prog)s {__version__} ({STANDARD})") sub = p.add_subparsers(dest="command", required=True, metavar="") @@ -1066,7 +1068,7 @@ def parser() -> argparse.ArgumentParser: s = sub.add_parser("meta", help="normalized metadata (read-only gh api GETs with --from-gh)") s.add_argument("--from-gh", metavar="OWNER/REPO") s.add_argument("--out") - s.add_argument("--allow-t3", action="store_true", + s.add_argument("--allow-t3", action="store_true", default=argparse.SUPPRESS, help="read a T3 (co-owned) repository; pass only when a t3-metadata or t3-signoff row is approved") s.set_defaults(func=cmd_meta) @@ -1189,6 +1191,16 @@ def parser() -> argparse.ArgumentParser: def main(argv=None) -> int: + """Run one invocation. `--allow-t3` holds only for this call: the previous value is restored on every exit.""" + from ctshowcase.config import set_t3_signed_off, t3_signed_off + previous = t3_signed_off() + try: + return _main(argv) + finally: + set_t3_signed_off(previous) + + +def _main(argv=None) -> int: # Redirected Windows streams can start as cp1252 even when their consumers # expect UTF-8. Configure before argparse, which also writes help and errors. # StringIO and embedding hosts may replace either stream without reconfigure. @@ -1208,6 +1220,8 @@ def main(argv=None) -> int: args.files.extend(remaining) elif remaining: cli.error("unrecognized arguments: " + " ".join(remaining)) + from ctshowcase.config import set_t3_signed_off + set_t3_signed_off(getattr(args, "allow_t3", False)) if args.format is None: args.format = "text" if args.command in GENERATORS else "json" try: diff --git a/tests/test_allow_t3_global.py b/tests/test_allow_t3_global.py new file mode 100644 index 0000000..c28d2a4 --- /dev/null +++ b/tests/test_allow_t3_global.py @@ -0,0 +1,82 @@ +"""A global `--allow-t3` lifts the T3 refusal for every repository command; the default still refuses (exit 2).""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SHOWCASE = [sys.executable, str(ROOT / "scripts" / "showcase.py")] +META = json.loads((ROOT / "examples" / "meta" / "northwind-support-triage-agent.json").read_text(encoding="utf-8")) + + +def _run(tmp_path, *argv, owner="Pick-Your-AI"): + meta = tmp_path / "meta.json" + meta.write_text(json.dumps(dict(META, owner=owner, repo="northwind-synthetic")), encoding="utf-8") + return subprocess.run([*SHOWCASE, "--meta", str(meta), "--no-repo-dir", *argv], cwd=ROOT, + capture_output=True, text=True, encoding="utf-8") + + +def test_t3_owner_is_refused_without_the_flag(tmp_path): + for cmd in (["describe"], ["topics"], ["readme"], + ["banner", "--out", str(tmp_path / "b.png")]): + r = _run(tmp_path, *cmd) + assert r.returncode == 2, (cmd, r.stdout, r.stderr) + assert "co-owner sign-off" in r.stdout + r.stderr or "t3-signoff" in r.stdout + r.stderr + + +def test_global_flag_lets_repository_commands_run(tmp_path): + for cmd in (["describe"], ["topics"]): + r = _run(tmp_path, "--allow-t3", *cmd) + assert r.returncode != 2, (cmd, r.stdout, r.stderr) + assert "t3-signoff" not in r.stdout + r.stderr + + +def test_global_flag_renders_art_for_a_t3_owner(tmp_path): + out = tmp_path / "banner.png" + r = _run(tmp_path, "--allow-t3", "banner", "--out", str(out), "--allow-unmeasured", "--allow-few-chips") + assert r.returncode == 0, (r.stdout, r.stderr) + assert out.is_file() and out.stat().st_size > 10_000 + assert "t3-signoff" not in r.stdout + r.stderr + + +def test_flag_does_not_lift_excluded_owners(tmp_path): + r = _run(tmp_path, "--allow-t3", "describe", owner="BunsDev") + assert r.returncode == 2, (r.stdout, r.stderr) + + +def test_doctor_reports_t3_cells_as_held_only_without_the_flag(tmp_path): + held = _run(tmp_path, "doctor", "--format", "json") + assert json.loads(held.stdout)["cells"]["R7"] == "hold", held.stdout + signed = _run(tmp_path, "--allow-t3", "doctor", "--format", "json") + assert json.loads(signed.stdout)["cells"]["R7"] != "hold", signed.stdout + + +def test_signoff_does_not_outlive_the_invocation(tmp_path, capsys): + """`--allow-t3` holds for one main() call only: a later library call in the same process is refused again.""" + import pytest + sys.path.insert(0, str(ROOT / "scripts")) + import showcase + from ctshowcase.config import load_brand, t3_signed_off + from ctshowcase.errors import Refused + assert showcase.main(["--allow-t3", "config"]) in (0, 1) + capsys.readouterr() + assert t3_signed_off() is False + with pytest.raises(Refused): + load_brand().check_owner("Pick-Your-AI") + + +def test_signed_off_t3_org_joins_also_in_and_star_lists(brand): + from ctshowcase.config import set_t3_signed_off + from ctshowcase.org import _star_held + from ctshowcase.resolver import Resolver + est = type("E", (), {"resolver": Resolver(brand, {"orgs": {}}, None)})() + assert _star_held(est, "Pick-Your-AI") == "t3" + assert _star_held(est, "Pick-Your-AI", allow_t3=True) is None + set_t3_signed_off(True) + try: + assert _star_held(est, "Pick-Your-AI") is None + assert _star_held(est, "BunsDev") == "excluded" # sign-off never lifts an excluded owner + finally: + set_t3_signed_off(False) diff --git a/tests/test_config.py b/tests/test_config.py index 1a3db2f..95699fd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -49,7 +49,7 @@ def test_owner_lists(brand): @pytest.mark.parametrize("family", ["completetech", "ai-ascension", "jev-engineering", "jevplay-games", "libreevolve", - "touchedbyanagent", "romgenie"]) + "touchedbyanagent", "romgenie", "pickyourai"]) def test_contrast(brand, family): f = brand.family(family) t, c = f["tokens"], f["canvas"]