diff --git a/.claude/skills/report/SKILL.md b/.claude/skills/report/SKILL.md index 03a2e98..a75e5a0 100644 --- a/.claude/skills/report/SKILL.md +++ b/.claude/skills/report/SKILL.md @@ -39,6 +39,13 @@ Structure, in order: 6. **Open items** — split by who acts: owner (dashboard/env/merge), orchestrator (cross-repo), this repo's next pass. +Where the pass consumed a sync spec, per-item dispositions use +exactly these five words: `applied` / `ported-as-contract` / +`already-present` / `not-applicable-because` / `open`. `open` means +the detect fires but the item is deliberately out of this session's +scope — name it under Open items with who acts. Do not invent a +sixth word; the orchestrator's tooling reads these five. + Anti-patterns, all observed in the fleet and all rejected on receipt: "should work" (test it or mark it unverified); summary claims without artifacts; green CI presented as deploy proof when diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 59cff66..5c8a736 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,39 +1,29 @@ -# Version drift is the network's chronic disease — satellites were still -# running a 2.0-era artifact the day 2.3.4 shipped, and nothing about a stale -# host looks broken from the outside. This is the standing fix. +# TEMPLATE FILE: satellites copy this verbatim. # -# TEMPLATE FILE: satellites copy this verbatim. The `dash-network` group is -# the point — a package release lands as ONE reviewable pull request per repo -# instead of five, which is the difference between a rollout and a chore. +# Version updates for pip are OFF BY DESIGN (2026-08-25). On range +# requirements dependabot can only propose FLOOR RAISES, so the old +# `dash-network` allow-list group structurally produced the exact PR class +# the allow-list existed to suppress — 18 of the fleet's 38 open dependabot +# PRs were pip floor-raises. Floors encode minimum-compatibility knowledge +# (a gunicorn floor IS the CVE fact), the resolver already installs latest +# under them, and a raise erases the floor's meaning while adding none. +# Floors move deliberately, through sync specs, every encoding at once; +# drift detection — the group's stated purpose — is already answered on the +# wire by the contract battery (healthz `dash_version` against the floor). +# +# SECURITY updates are unaffected. They ride GitHub's security-update +# channel — dependency graph + Dependabot alerts, enabled per repo in +# Settings → Advanced Security — which needs no entry in this file: +# "There is no interaction between the settings specified in the +# dependabot.yml file and Dependabot security alerts" (GitHub docs, +# code-security/concepts/supply-chain-security/dependabot-security-updates). version: 2 updates: - - package-ecosystem: pip - directory: "/" - schedule: - interval: weekly - day: monday - open-pull-requests-limit: 5 - # Version-update PRs are restricted to the network packages — the drift - # alarm this file exists for. Without this allow-list, dependabot also - # proposes FLOOR-RAISES for every other requirement (gunicorn >=23 → - # >=26, pandas >=1.2.3 → >=3, ...). Those floors encode - # minimum-compatibility knowledge (the gunicorn floor IS the CVE fact), - # the resolver already installs latest under them, and a raise erases - # the floor's meaning while adding none — llms-2plot-dev's fork opened - # five such PRs on its first push, all closed with reasons - # (2026-08-23). SECURITY updates are unaffected: they arrive through - # GitHub's separate security-update channel regardless of this list. - allow: - - dependency-name: "dash*" - - dependency-name: "plotly*" - - dependency-name: "markdown2dash" - groups: - dash-network: - patterns: - - "dash*" - - "plotly*" - - "markdown2dash" - + # Runner/action bumps are mechanical and CI-gated; a human merges the + # group's PR when its CI is green — never a bot actor on main. (The + # 1.6.24 auto-merge workflow was retired in 1.6.25: a GITHUB_TOKEN merge + # gets zero workflow runs on the merge sha, so its deploy is never + # certified by CD.) - package-ecosystem: github-actions directory: "/" schedule: @@ -43,6 +33,8 @@ updates: patterns: - "*" + # Docker base-image bumps stay manual: a Python major is a merits + # decision, never an auto-merge. - package-ecosystem: docker directory: "/" schedule: diff --git a/scripts/smoke_live.py b/scripts/smoke_live.py index 0ca45b0..bf955da 100644 --- a/scripts/smoke_live.py +++ b/scripts/smoke_live.py @@ -175,6 +175,38 @@ def header(headers: Dict[str, str], name: str) -> str: return "" +def post(url: str, payload: str = "{}") -> int: + """POST for the auth-wiring probe; returns the status, 0 on transport. + + No retry ladder on purpose: a 4xx here IS the answer (invalid token, + anonymous signout — both prove the route is registered and callable), + so only a transport failure reads as 0. + """ + request = urllib.request.Request( + url, + data=payload.encode("utf-8"), + headers={"User-Agent": BROWSER_UA, "Content-Type": "application/json"}, + method="POST", + ) + try: + # context= must match fetch()'s — this line shipped WITHOUT it, so on + # any Python without OS trust-store integration (macOS: the fleet's + # whole local-dev half) every auth POST died in the TLS handshake, + # returned 0, and the check accused the app of the exact + # configure_app regression it exists to detect. CI never saw it + # (Linux verifies fine); no wired test can see it (they monkeypatch + # post) — hence the SOURCE pin in tests/test_auth_wiring.py. + # Found by flexlayout during the F1 kit adoption (154688e). + with urllib.request.urlopen( + request, timeout=TIMEOUT, context=SSL_CONTEXT + ) as resp: + return resp.status + except urllib.error.HTTPError as exc: + return exc.code + except (urllib.error.URLError, TimeoutError, OSError): + return 0 + + def check(name: str, passed: bool, detail: str = "", fatal: bool = True) -> None: """Record one check. ``fatal=False`` warns instead of failing the deploy. @@ -256,6 +288,28 @@ def main(base: str) -> int: status, home, _ = fetch(f"{base}/") check("home page responds 200", status == 200, f"got {status}") + # --- Auth wiring: the two-call split, proven from outside -------------- + # dash-clerk-auth wires either side of Dash(...): register() is the UI + # half, configure_app(app) registers /api/auth/* and per-request + # identity. A fork that drops the second call still LOOKS signed in + # (components render, ClerkJS runs) while every server render reads + # signed-out and sign-out never revokes — flexlayout shipped exactly + # that, and no local suite can see it because Clerk is off in test + # environments. From outside the tell is unambiguous: registered, these + # POSTs answer 2xx/4xx; unregistered, the path falls through to Dash's + # GET-only page catch-all and answers 405 (or 404). Gated on the + # package's inline bootstrap being in the served shell, so clerk-off + # hosts skip rather than fail. + if "dashClerkAuth" in home: + for endpoint in ("session", "signout"): + status = post(f"{base}/api/auth/{endpoint}") + check( + f"POST /api/auth/{endpoint} is a registered route", + status not in (0, 404, 405), + f"got {status} — the configure_app(app) half of the auth " + "wiring is missing: components without a server", + ) + status, llms, llms_headers = fetch(f"{base}/llms.txt") check("/llms.txt responds 200", status == 200, f"got {status}") check("/llms.txt lists pages", "## Pages" in llms or "# " in llms) diff --git a/tests/test_auth_demos.py b/tests/test_auth_demos.py new file mode 100644 index 0000000..6cf4d50 --- /dev/null +++ b/tests/test_auth_demos.py @@ -0,0 +1,51 @@ +"""Every auth-gate demo entry resolves — on THIS site, loudly. + +`build_demo` swallows import failures BY DESIGN (a broken example must never +take down the sign-in funnel), and its warning only fires when that +endpoint's card actually renders — which never happens when the endpoint is +not a page here at all. That combination made a dead entry perfectly silent: +every fork inherited the template's entry, it resolved on none of them, and +their gate cards rendered demo-less from fork time without a line of log +(batch-1 finding, excalidraw, 2026-08-25). This file is the one surface +where a dead entry is loud. + +Byte-verbatim across the fleet: it sweeps THIS repo's DEMOS table against +THIS repo's page registry, so the same bytes hold everywhere. The entries +themselves are site judgment (swap in your own hero example) — an EMPTY +table passes; a dead entry never does. +""" + +from __future__ import annotations + +import importlib + +from lib.auth_demos import DEMOS + + +def test_every_demo_endpoint_is_a_registered_page(app_module): + import dash + + registered = {entry["path"] for entry in dash.page_registry.values()} + dead = sorted(set(DEMOS) - registered) + assert dead == [], ( + f"DEMOS endpoints that are not pages on this site: {dead} — " + "a card that never renders can never surface its own broken demo" + ) + + +def test_every_demo_module_imports_and_exposes_component(app_module): + # import_module is exactly what build_demo does in production; app_module + # first, because the example modules assume the app (and the docs pass + # that imports them at startup) already exists. + problems = [] + for path, spec in sorted(DEMOS.items()): + try: + module = importlib.import_module(spec["module"]) + except Exception as e: + problems.append(f"{path}: {spec['module']} failed to import ({e})") + continue + if not hasattr(module, "component"): + problems.append( + f"{path}: {spec['module']} has no module-level `component`" + ) + assert problems == [], "; ".join(problems) diff --git a/tests/test_claude_kit.py b/tests/test_claude_kit.py index 9dfe7b3..b98da15 100644 --- a/tests/test_claude_kit.py +++ b/tests/test_claude_kit.py @@ -37,13 +37,30 @@ def _ignored(path: str) -> bool: ) +def _in_repo(rel: str) -> bool: + return ".." not in rel and not rel.startswith("/") + + def _machine_fence(kind: str, text: str, where: str) -> None: """The shared pin for machine fences (```yaml sync-verbatim in specs, ```yaml byte-owned in DIVERGENCES.md): exactly one block, `- path` lines with `#` comments, every path repo-relative and real at HEAD. Empty is valid — an empty block is a statement, a missing one is an - omission. `# requires: ` lines (the fan-out's adoption gate, - 1.6.23) are validated like paths — a typo'd gate gates nothing.""" + omission. Gate lines (the fan-out's adoption gates) are validated + like paths — a typo'd gate gates nothing: + + `# requires: ` (1.6.23) — the block applies only where + exists. For paths no pre-existing file can occupy; + where one can, the gate must name a contract instead + (sync/README.md — flows' pre-existing CLAUDE.md, 1.6.28). + `# requires-contract: :: ` (1.6.28) — the block + applies only where exists AND contains . The + clause must be real in THIS repo's copy at HEAD too. + `- # requires: ` (1.6.28) — per-file gate: the + fan-out skips this one copy where is absent, instead + of gating the whole block (clerkhook: a lockdown fork has no + lib/auth_demos.py, legitimately, and must still receive the + rest).""" fences = re.findall( r"^```yaml " + kind + r"[ \t]*\n(.*?)^```[ \t]*$", text, re.M | re.S ) @@ -52,10 +69,34 @@ def _machine_fence(kind: str, text: str, where: str) -> None: f"found {len(fences)}" ) for raw in fences[0].splitlines(): - required = re.match(r"#\s*requires:\s*(.+)$", raw.strip()) + stripped = raw.strip() + if re.match(r"#\s*requires-contract:", stripped): + gate = re.match( + r"#\s*requires-contract:\s*(.+?)\s*::\s*(.+)$", stripped + ) + assert gate, ( + f"{where} {kind}: {raw!r} — `# requires-contract:` takes " + "` :: `; a malformed gate gates nothing" + ) + req, clause = gate.group(1).strip(), gate.group(2).strip() + assert _in_repo(req), ( + f"{where} {kind}: `# requires-contract:` path {req!r} " + "escapes the repo" + ) + assert (REPO / req).is_file(), ( + f"{where} {kind}: `# requires-contract:` names {req!r} " + "which does not exist at HEAD — a typo'd gate gates nothing" + ) + assert clause in (REPO / req).read_text(), ( + f"{where} {kind}: `# requires-contract:` clause {clause!r} " + f"is not in this repo's own {req} — a typo'd clause gates " + "nothing" + ) + continue + required = re.match(r"#\s*requires:\s*(.+)$", stripped) if required: req = required.group(1).strip() - assert ".." not in req and not req.startswith("/"), ( + assert _in_repo(req), ( f"{where} {kind}: `# requires:` path {req!r} escapes the repo" ) assert (REPO / req).is_file(), ( @@ -63,20 +104,36 @@ def _machine_fence(kind: str, text: str, where: str) -> None: "not exist at HEAD — a typo'd gate gates nothing" ) continue - entry = raw.split("#", 1)[0].strip() + entry, _, comment = raw.partition("#") + entry = entry.strip() if not entry: continue assert entry.startswith("- "), ( f"{where} {kind}: {raw!r} is not a `- path` line" ) path = entry[2:].strip() - assert ".." not in path and not path.startswith("/"), ( + assert _in_repo(path), ( f"{where} {kind}: {path!r} escapes the repo" ) assert (REPO / path).is_file(), ( f"{where} {kind}: {path!r} does not exist at HEAD " "— the machine would act on nothing or the wrong thing" ) + # A per-file gate is the WHOLE trailing comment, `requires: ` + # from its first character; prose comments that merely mention the + # word stay prose. + per_file = re.match(r"\s*requires:\s*(.+)$", comment) + if per_file: + gate_path = per_file.group(1).strip() + assert _in_repo(gate_path), ( + f"{where} {kind}: per-file gate on {path!r} escapes the " + f"repo: {gate_path!r}" + ) + assert (REPO / gate_path).is_file(), ( + f"{where} {kind}: per-file gate on {path!r} names " + f"{gate_path!r} which does not exist at HEAD — a typo'd " + "gate gates nothing" + ) def test_kit_files_exist_and_are_not_ignored():