bespoke kits: an invented register gets a kit, not hand-built style code #328
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| # A push that changes THIS file did not produce a run — no queued job, no failed job, nothing to | |
| # look at, and no way to ask for one. A workflow whose only triggers are events means an absent | |
| # run is indistinguishable from a passing one, which is the same silent class the steps below | |
| # keep legislating against. This makes "run it now" reachable from the Actions tab. | |
| workflow_dispatch: | |
| jobs: | |
| smoke: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| defaults: | |
| run: | |
| working-directory: skills/slide-maker | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 # the lossless guard diffs SKILL.md against the merge base | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| # A SKILL.md slimming PR must LAYER content into references/, not delete it. Both look | |
| # identical in review — thousands of removed lines, every one of which *might* have landed | |
| # somewhere. This checks that it actually did. Deliberate deletions go in the allowlist | |
| # with a written reason; see the script's docstring. | |
| - name: SKILL.md refactors are lossless (content moved, not deleted) | |
| working-directory: ${{ github.workspace }} | |
| run: | | |
| git fetch -q origin main | |
| BASE=$(git merge-base origin/main HEAD) | |
| echo "comparing SKILL.md against merge base $BASE" | |
| ALLOW=skills/slide-maker/.skill-lossless-allow.json | |
| python skills/slide-maker/scripts/check_skill_lossless.py \ | |
| --baseline "${BASE}:skills/slide-maker/SKILL.md" \ | |
| $( [ -f "$ALLOW" ] && echo --allow "$ALLOW" ) \ | |
| --report /tmp/skill-lost.md | |
| # Credential scan that discriminates by SHAPE (length + Shannon entropy), not by prefix. | |
| # A prefix matcher flags this repo on every run, because the direction-gate preview names its | |
| # CSS classes after slide skeletons — sk-body, sk-split, sk-rail. A report that is always | |
| # wrong is a report everyone learns to ignore, which is worse than no report. This one | |
| # self-tests both directions before it scans, so a rule that stops catching real keys fails | |
| # the build rather than passing quietly. | |
| # A file can exist on the author's disk, run clean locally, be documented, be invoked by | |
| # CI — and still not be in the repo. That is exactly what happened: `.gitignore`'s | |
| # `*_secret*` swallowed `scripts/scan_secrets.py`, the credential scanner itself, and every | |
| # clone was missing it. No single layer looked wrong; only the intersection was. Runs first | |
| # and needs nothing but git, so it reports before anything else can fail confusingly. | |
| - name: Repo integrity (no ignored source files, every CI path tracked) | |
| working-directory: ${{ github.workspace }} | |
| run: python scripts/check_repo_integrity.py | |
| - name: No credentials by shape (working tree + full history) | |
| working-directory: ${{ github.workspace }} | |
| run: | | |
| python scripts/scan_secrets.py --selftest | |
| python scripts/scan_secrets.py | |
| python scripts/scan_secrets.py --history | |
| - name: Version markers match (plugin.json · marketplace.json · CHANGELOG · skills VERSION) | |
| working-directory: ${{ github.workspace }} | |
| run: python scripts/check_versions.py | |
| # A documented command must be runnable AS WRITTEN, and CI is the one place that could | |
| # never notice this one: `actions/setup-python` puts a `python` on PATH, so 35 bare | |
| # `python <script>.py` lines across SKILL.md, its references and the scripts' own printed | |
| # usage strings all "worked" here — while /usr/bin/python does not exist on macOS at all. | |
| # The reader that breaks on it is an agent copying the line verbatim, which is exactly the | |
| # cross-runtime story this skill sells. Runs before the heavy jobs; needs nothing but git. | |
| - name: Documented commands are runnable as written (python3, not python) | |
| working-directory: ${{ github.workspace }} | |
| run: | | |
| python3 scripts/check_doc_commands.py --selftest | |
| python3 scripts/check_doc_commands.py | |
| - name: Install system deps (LibreOffice, rsvg, CJK fonts) | |
| run: | | |
| sudo apt-get update -q | |
| sudo apt-get install -y -q libreoffice-impress librsvg2-bin fonts-noto-cjk | |
| - name: Install python deps | |
| run: python -m pip install -r requirements.txt | |
| - name: Environment check | |
| run: python scripts/check_env.py | |
| - name: deckkit smoke (every public helper) | |
| run: python scripts/smoke_deckkit.py | |
| # Two defect classes a per-slide check structurally cannot see, both found by a human eye on | |
| # a delivered deck AFTER both lints reported clean: the same string rendered twice on one | |
| # page (an orphaned copy of an earlier layout; a component's own label beside a hand-written | |
| # one), and a repeated chrome line that drifts off the slot the rest of the deck uses. Both | |
| # directions are asserted, because a check that fires on a legitimate repeat is worse than | |
| # no check — a deck may repeat 是/N/A and may re-anchor chrome on a divider. | |
| # A motif had no machine-readable existence, so its two stated contracts were unmeasurable: | |
| # a hand-rolled register signature drew three INTERLOCKING circles (offset in x, not y) and | |
| # shipped to 12 pages, and a subtitle laid straight across the cover motif produced zero | |
| # findings because TEXT_OVERLAP measures text against TEXT and a motif is geometry. Tagging | |
| # the device is what makes both countable. The silent cases are the load-bearing half: an | |
| # untagged deck, a declared overlap, a full-bleed ground and the quiet register signature | |
| # must every one of them stay quiet. | |
| # The rule was WRONG rather than missing, which is the worse kind: a run tuple's font slot | |
| # writes <a:latin> while CJK glyphs render from <a:ea>, so an author following the documented | |
| # call shape set a display face that reached zero Chinese characters — measured on a real | |
| # build across 6 of 14 slides, silently, for the whole build. text() gained a seventh slot; | |
| # this suite holds both the new capability and the backward compatibility that makes it safe, | |
| # since every component in the library is built from 5- and 6-element tuples. | |
| # Occupancy is a bounding-box union, so a drawn container counts as a full page: measured, | |
| # one empty outlined rect with four characters inside scored 49% ink — "full" by every | |
| # density check, and therefore exempt from UNDERFILLED too. The suite also pins the BOUNDARY: | |
| # a white-filled node box is NOT hollow, because that is the ordinary card form and widening | |
| # the rule would fire across a large class of legitimate decks. | |
| - name: Hollow ink (a container is not content) | |
| run: | | |
| set -o pipefail | |
| python tests/test_hollow_ink.py | tee /tmp/hollow.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/hollow.log || { | |
| echo "::error::hollow-ink suite did not run to completion"; exit 1; } | |
| - name: CJK face routing (the 7th slot · unreached-face warn · back-compat) | |
| run: | | |
| set -o pipefail | |
| python tests/test_cjk_face_routing.py | tee /tmp/cjkface.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/cjkface.log || { | |
| echo "::error::CJK face routing suite did not run to completion"; exit 1; } | |
| # CJK width measurement read <a:latin>, which python-pptx exposes as run.font.name — but a | |
| # Chinese glyph renders from <a:ea>. Every geometry check on a CJK deck was therefore scored | |
| # in a Latin face and came out ~46% short. | |
| - name: CJK measurement (the face a width is measured in) | |
| run: | | |
| set -o pipefail | |
| python tests/test_cjk_measurement.py | tee /tmp/cjkmeas.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/cjkmeas.log || { | |
| echo "::error::CJK measurement suite did not run to completion"; exit 1; } | |
| # content_band() — the helper SKILL.md offers INSTEAD of hand-picked y-coordinates — returned | |
| # a constant tuned for a title with no kicker, so with a kicker the first block landed 0.09in | |
| # inside the title rule. It shipped in this skill's own worked example, unseen by every gate. | |
| - name: Content band (the safe rect must clear the deck's own chrome) | |
| run: | | |
| set -o pipefail | |
| python tests/test_content_band.py | tee /tmp/band.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/band.log || { | |
| echo "::error::content-band suite did not run to completion"; exit 1; } | |
| # The scaffold is the curriculum: whatever references/examples/build_example_generic.py | |
| # demonstrates is what gets written, and whatever it omits effectively does not exist. It | |
| # taught hand-picked y-coordinates and zero motif tags, and both showed up in a real build. | |
| # "Never hand-pick a y" was unfollowable for the components that grow: 173 public callables, | |
| # 4 measure_* helpers. takeaway_rail reserved a fixed 2.0in and put a long body's ink inside | |
| # the footer band with the lint clean; a node below 0.5in² is not a "card" to ESCAPES_CARD, | |
| # so its label escaped by 0.55in top and bottom with every gate green. Each helper here is | |
| # asserted against REAL rendered geometry, never against its own arithmetic. | |
| - name: Measure contracts (a block whose height cannot be known cannot be packed) | |
| run: | | |
| set -o pipefail | |
| python tests/test_measure_contracts.py | tee /tmp/measure.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/measure.log || { | |
| echo "::error::measure-contract suite did not run to completion"; exit 1; } | |
| - name: Scaffold teaches (the example is checked like a contract) | |
| run: | | |
| set -o pipefail | |
| python tests/test_scaffold_teaches.py | tee /tmp/scaffold.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/scaffold.log || { | |
| echo "::error::scaffold suite did not run to completion"; exit 1; } | |
| # Which deck this IS was a fact only the operator's memory carried: --gate-check read a | |
| # recorded `delivery` key, lint_deck.py read only flags. Measured on a delivered self-read | |
| # deck: 20 advisory lines with no flag, 10 with the right one. | |
| - name: Delivery record (recorded mode beats a remembered flag) | |
| run: | | |
| set -o pipefail | |
| python tests/test_delivery_record.py | tee /tmp/deliv.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/deliv.log || { | |
| echo "::error::delivery-record suite did not run to completion"; exit 1; } | |
| # TEXT_OVERLAP measures text against TEXT, so a label grazing a bar/chip/node is invisible | |
| # to it — the same blindness TEXT_OVER_MOTIF was written for, except that one only sees | |
| # TAGGED shapes and nobody tags a bar. Measured on a delivered deck: a row label ran | |
| # 0.015in² into the negative bar beside it, both lints clean, found by eye — twice. | |
| # The one defect class where the file does not open at all. Every other check here is | |
| # geometric, pixel-based or semantic; none asks whether the part is well-formed against | |
| # ECMA-376. Measured: two Build(s) on one slide left TWO <p:timing> elements — save() silent, | |
| # LibreOffice happy, lint clean, and preflight read the duplicate as MORE compliant. | |
| # Every other suite asks whether the CODE works. This one guards the instrument that asks | |
| # whether the SKILL works — run_eval.py scores a produced deck against machine-decidable | |
| # assertions. Only the SCORER runs here (~1s, no model, no network); producing a deck is | |
| # the other half and is developer/nightly-only. An eval harness that scores wrong is worse | |
| # than none: it launders a bad build into a number. | |
| # A fan-out used to be atomic in the worst way: results returned into the coordinator's | |
| # memory, so one dead member cost the whole round. Measured — a critic panel lost its DESIGN | |
| # lens to a session limit while the CONTENT lens had already produced a full review that was | |
| # never written down, so validate_review --record had nothing to register. | |
| # The template/taste registry root was prose naming exactly two hosts, repeated in six | |
| # files and one docstring. On any third runtime (Kimi, Gemini, Cursor, Coze, an API | |
| # caller) NEITHER root exists, so Q1(a) silently lost the saved-templates option and | |
| # taste.md was never read or written -- with no lint, no gate and no message anywhere. | |
| # The suite is mostly about the two silent halves: an unknown runtime must still get a | |
| # write target, and an existing Claude/Codex install must NOT move (quietly relocating a | |
| # user's saved templates would be a worse bug than the one being fixed). | |
| # A register has to reach the PIXELS, not just the palette. Measured before this suite: | |
| # RULE_W_SCALE was read by hrule() and nothing else (1 of 181 public functions), node() | |
| # read RADIUS_SCALE not at all, presets.apply() returned a `bg` it applied nowhere (8 of 18 | |
| # registers are dark), node()'s own two defaults contradicted each other on every dark | |
| # register (paints WHITE, labels in DEEP = that register's LIGHT ink -> 1.09:1 with NO | |
| # caller override), and the four registers added last stored colours as lists so the | |
| # documented p["accents"][0] raised ValueError on 4 of 18. Nothing reported any of it: the | |
| # sameness lint measures monotony WITHIN a deck, never fidelity to the register it chose, | |
| # so a brutalist deck rendered with glassmorphism's geometry passed every gate. Asserted | |
| # against the built PPTX, which is the level the defect lived at. | |
| # The direction-gate preview is an HTML file the USER opens in their browser, built from a | |
| # directions.json the agent wrote while reading the user's SOURCE MATERIAL — untrusted input | |
| # to a language model. Text was escaped; colours were not, and they land in style="..." | |
| # attributes in ~86 places, so a crafted accent closed the attribute and attached an event | |
| # handler. Most of this suite is the BOUNDARY: cover_motif/ambient_motif stay raw HTML on | |
| # purpose (a bespoke register draws its own signature and the structure gate requires it), | |
| # so the sanitiser has to remove behaviour while keeping shape — one that eats the <svg> is | |
| # one nobody keeps. | |
| - name: Preview injection (untrusted source material must not reach a live page) | |
| run: | | |
| set -o pipefail | |
| python tests/test_preview_injection.py | tee /tmp/inject.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/inject.log || { | |
| echo "::error::preview-injection suite did not run to completion"; exit 1; } | |
| - name: Register expression (a preset must reach the pixels, not just the palette) | |
| run: | | |
| set -o pipefail | |
| python scripts/check_style_applied.py --selftest | |
| python tests/test_register_expression.py | tee /tmp/register.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/register.log || { | |
| echo "::error::register-expression suite did not run to completion"; exit 1; } | |
| - name: Register guard (a declared register must be obeyed, not just paletted) | |
| run: | | |
| set -o pipefail | |
| python scripts/check_register_guard.py --selftest | |
| - name: Register surface (the preset must build its LOOK, not just its colourway) | |
| run: | | |
| set -o pipefail | |
| python scripts/register_surface.py --selftest | |
| python tests/test_register_surface.py | tee /tmp/regsurf.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/regsurf.log || { | |
| echo "::error::register-surface suite did not run to completion"; exit 1; } | |
| - name: Bespoke kits (an INVENTED register gets a kit, not just prose) | |
| run: | | |
| set -o pipefail | |
| python scripts/bespoke_kits.py --selftest | |
| - name: Register write-back (an invented look outlives the folder it shipped in) | |
| run: | | |
| set -o pipefail | |
| python scripts/save_register.py --selftest | |
| - name: Decodability (can a viewer read the marks, measured not claimed) | |
| run: | | |
| set -o pipefail | |
| python tests/test_decodability.py | tee /tmp/decode.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/decode.log || { | |
| echo "::error::decodability suite did not run to completion"; exit 1; } | |
| - name: Design capability (architecture, rhythm, and what a page looks like) | |
| run: | | |
| set -o pipefail | |
| python scripts/plan_rhythm.py --selftest | |
| python scripts/composition_cues.py --selftest | |
| python tests/test_design_capability.py | tee /tmp/designcap.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/designcap.log || { | |
| echo "::error::design-capability suite did not run to completion"; exit 1; } | |
| - name: A11y floors, print carve, poster proportion, CVD, bento | |
| run: | | |
| set -o pipefail | |
| python tests/test_a11y_and_surface_extras.py | tee /tmp/a11y.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/a11y.log || { | |
| echo "::error::a11y/surface-extras suite did not run to completion"; exit 1; } | |
| - name: Register pixels (the look must arrive, and must not be the last deck's) | |
| run: | | |
| set -o pipefail | |
| python scripts/check_register_pixels.py --selftest | |
| python tests/test_register_pixels.py | tee /tmp/regpix.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/regpix.log || { | |
| echo "::error::register-pixels suite did not run to completion"; exit 1; } | |
| - name: Surface contract (a canvas format the registry describes and nothing enforced) | |
| run: | | |
| set -o pipefail | |
| python scripts/check_surface.py --selftest | |
| python tests/test_surface_contract.py | tee /tmp/surface.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/surface.log || { | |
| echo "::error::surface-contract suite did not run to completion"; exit 1; } | |
| - name: Registry roots (a runtime nobody enumerated still has a registry) | |
| run: | | |
| set -o pipefail | |
| python tests/test_registry_roots.py | tee /tmp/registry.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/registry.log || { | |
| echo "::error::registry-roots suite did not run to completion"; exit 1; } | |
| - name: Fan-out record (one dead agent costs one agent) | |
| run: | | |
| set -o pipefail | |
| python tests/test_fanout_record.py | tee /tmp/fanout.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/fanout.log || { | |
| echo "::error::fanout-record suite did not run to completion"; exit 1; } | |
| - name: Eval harness (skip is not pass) | |
| run: | | |
| set -o pipefail | |
| python tests/test_run_eval.py | tee /tmp/runeval.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/runeval.log || { | |
| echo "::error::run_eval suite did not run to completion"; exit 1; } | |
| - name: OOXML shape (a file that opens nowhere passes every other gate) | |
| run: | | |
| set -o pipefail | |
| python tests/test_ooxml_shape.py | tee /tmp/ooxml.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/ooxml.log || { | |
| echo "::error::ooxml-shape suite did not run to completion"; exit 1; } | |
| # A real <p:bg> takes the backdrop OUT of the shape tree, and _backing_fill's contract is | |
| # "slide bg unknown -> None" with every caller treating None as skip. So the failure mode | |
| # of this feature is not a wrong colour, it is 62 contrast checks going quiet while the | |
| # deck reports clean. The suite's load-bearing assertion is the equivalence property: | |
| # a p:bg deck and the rect deck it replaces must produce the SAME findings, failures | |
| # included -- plus a live negative control, so it cannot pass by two silences agreeing. | |
| - name: Slide background (p:bg must not blind the contrast checks) | |
| run: | | |
| set -o pipefail | |
| python tests/test_slide_background.py | tee /tmp/slidebg.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/slidebg.log || { | |
| echo "::error::slide-background suite did not run to completion"; exit 1; } | |
| # The non-text contrast check skips `pic`, and this skill's icons ARE pictures (recolored | |
| # SVG -> transparent PNG), so icon contrast was never checked on any deck while icons are | |
| # default-on for category-rich content. Measured: a gold icon at 2.69:1 passed every | |
| # DETERMINISTIC gate; only the design critic caught it, and a model that costs a dispatch is | |
| # the wrong home for arithmetic. Most of this suite is NEGATIVE -- photograph, | |
| # two-colour art, opaque PNG, unresolvable backdrop must all be SILENT, because inventing | |
| # an ink colour an image does not have is worse than the miss it replaces. | |
| - name: Icon contrast (icons are pictures, so nothing was checking them) | |
| run: | | |
| set -o pipefail | |
| python tests/test_icon_contrast.py | tee /tmp/iconc.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/iconc.log || { | |
| echo "::error::icon-contrast suite did not run to completion"; exit 1; } | |
| # Nearly not shipped. The first framing flagged 10 of 12 slides on a delivered deck and | |
| # MISSED the ragged page a human had complained about; every guard exists because a class | |
| # of false positive was measured and removed (nested labels, value labels trailing their | |
| # bars, unrelated elements, sub-perceptual 2px slips, deck-wide designed indents). Hence a | |
| # suite that is mostly NEGATIVE: a check that cries wolf on a correct deck is worse than no | |
| # check, because it teaches the reader to skip the output the real findings live in. | |
| - name: Ragged left edge (the guards matter more than the check) | |
| run: | | |
| set -o pipefail | |
| python tests/test_ragged_edge.py | tee /tmp/ragged.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/ragged.log || { | |
| echo "::error::ragged-edge suite did not run to completion"; exit 1; } | |
| # Bar length is a proportion CLAIM. Every other geometric check asks whether a page is | |
| # readable; this asks whether it is true, the only failure where a fully passing deck still | |
| # misleads the room -- a truncated baseline turns 1.5 vs 2.1 into 1 : 7 while legibility, | |
| # overflow and contrast stay green. Preventive rather than corrective: no instance was found | |
| # in this skill's own output (two delivered hand-drawn charts held to 1.0003 and 1.0000, and | |
| # both are live negative controls in the suite). What it displaces is real -- the design | |
| # critic hand-computed a bar ratio on a delivered deck, arithmetic paid for at model prices. | |
| # The first version wrapped its parser in a bare `except: continue` that swallowed a | |
| # NameError, so the check silently did nothing while looking like it passed; the suite now | |
| # asserts a known-bad deck still fires, so that cannot recur quietly. | |
| - name: Datum scale (a bar that misstates its own number) | |
| run: | | |
| set -o pipefail | |
| python tests/test_datum_scale.py | tee /tmp/datum.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/datum.log || { | |
| echo "::error::datum-scale suite did not run to completion"; exit 1; } | |
| # A file existing is not proof that acquiring it succeeded. A failed generation writes a | |
| # truncated download or a flat placeholder plate; a bad crop writes a transparent frame. All | |
| # three embed without complaint and pass every geometric and density check, because the | |
| # picture does occupy its box. The load-bearing negatives are the icons: mostly transparent | |
| # by construction, so a carelessly written emptiness rule fires on every one in the library. | |
| - name: Asset usable (existence is not success) | |
| run: | | |
| set -o pipefail | |
| python tests/test_asset_usable.py | tee /tmp/asset.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/asset.log || { | |
| echo "::error::asset-usable suite did not run to completion"; exit 1; } | |
| # check_skill_lossless.py proves a refactor kept the BYTES. It cannot see two gates merged | |
| # into one, a warning promoted to an auto-fix, or a plan field trusted instead of re-tested | |
| # -- each keeps every line of text and removes the property the text described, so the | |
| # lossless check reports a perfect score while the skill gets worse. This asserts the | |
| # negative contracts that a program can decide. | |
| - name: Maintenance boundaries (the properties a lossless refactor can still delete) | |
| run: | | |
| set -o pipefail | |
| python tests/test_maintenance_boundaries.py | tee /tmp/bounds.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/bounds.log || { | |
| echo "::error::maintenance-boundaries suite did not run to completion"; exit 1; } | |
| # A deck's wall clock is round-trips, not computation -- 9.1s of deterministic pipeline | |
| # inside an 88-minute build. deck_cycle.py and the contact sheet both shorten that, and both | |
| # are therefore shortcuts through a checking pipeline, which is how a pipeline stops | |
| # checking. The guards ARE the feature, so they are what this suite asserts: findings | |
| # verbatim (no --quiet exists), rendering opt-in, a CRITICAL build fault stopping before any | |
| # pixels, the sheet written BESIDE every per-slide PNG with the hint still demanding all of | |
| # them, and no gate anywhere reading the round-trip metric (a number whose cheapest | |
| # improvement is looking at less must never become a target). | |
| - name: Deck cycle + contact sheet (the guards are the feature) | |
| run: | | |
| set -o pipefail | |
| python tests/test_deck_cycle.py | tee /tmp/cycle.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/cycle.log || { | |
| echo "::error::deck-cycle suite did not run to completion"; exit 1; } | |
| # Every "record a written reason" floor in this skill was a codepoint count, which made the | |
| # bar depend on the writing system: a Chinese sentence saying MORE was refused for using | |
| # fewer characters, on the decks this skill most often builds. One shared measure now counts | |
| # East-Asian wide characters as 2. Both directions are asserted — substantive CJK reasons | |
| # pass, short ones in any language still do not, and every ASCII floor is unchanged. | |
| - name: Written reasons are language-fair (CJK floors) | |
| run: | | |
| set -o pipefail | |
| python tests/test_written_reason.py | tee /tmp/reason.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/reason.log || { | |
| echo "::error::written-reason suite did not run to completion"; exit 1; } | |
| # The design step gated a motif's CONCEPT thoroughly and its MATERIAL not at all. Measured: | |
| # a deck whose concept was genuinely right — a config row derived from the product's own | |
| # cordis.yml, correctly rejecting the plug-socket stereotype — shipped as six grey | |
| # rectangles, passed every concept gate, and the repair changed only the material. Step 2 | |
| # now opens by MAKING one real slide. The second half closes the hole that made the old | |
| # evidence mechanism hollow: a 960x540 rectangle of one grey satisfied the ANCHOR PROOF. | |
| - name: Material probe (make it before you declare it; a blank PNG is not evidence) | |
| run: | | |
| set -o pipefail | |
| python tests/test_material_probe.py | tee /tmp/probe.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/probe.log || { | |
| echo "::error::material-probe suite did not run to completion"; exit 1; } | |
| # THE COUNTERWEIGHT. Every other blocking signal punishes a deck for being too much or too | |
| # same; nothing could hold one for being too SAFE, and a real 12-page build proved the cost — | |
| # ten advisory-driven iterations, each one flatter than the last, and no gate said a word. | |
| # Both directions matter more here than anywhere: a miscalibrated gate on THIS side would | |
| # punish exactly the restrained registers the skill protects, so the suite asserts it blocks | |
| # a text-only greyscale deck AND stands down on declared restraint, real protagonists, and | |
| # small decks. | |
| - name: Timidity gate (a deck can be held for being safe) | |
| run: | | |
| set -o pipefail | |
| python tests/test_timidity_gate.py | tee /tmp/timid.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/timid.log || { | |
| echo "::error::timidity suite did not run to completion"; exit 1; } | |
| # The hand-off gate reports every failure in one run instead of stopping at the first — the | |
| # same ping-pong `codex_delivery_gate.py` already avoided and `validate_review.py` names by | |
| # name. Batching is only safe if it did not weaken anything, so the suite asserts both | |
| # directions: all N problems named in ONE run, and every failure still fatal, unmasked, with | |
| # a clean record still passing silently. | |
| - name: Hand-off gate batching (one run, every failure) | |
| run: | | |
| set -o pipefail | |
| python tests/test_gate_batching.py | tee /tmp/gatebatch.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/gatebatch.log || { | |
| echo "::error::gate-batching suite did not run to completion"; exit 1; } | |
| # Guard 4, separately: the loop breaker keys faults by (stage, slide, code) so a nudge that | |
| # only changes the numbers in the message still counts as the SAME fault, and the third | |
| # consecutive failure escalates to "re-derive by measurement" instead of another nudge. | |
| # Measured origin: 10+ nudge iterations on one slide; the computed-fit rewrite landed first | |
| # try. Both directions are asserted — it fires at 3, and it stays silent on a cleared fault, | |
| # below the limit, and across a build-only run that never judged the render-stage keys. | |
| - name: Deck cycle loop breaker (the third attempt is never another nudge) | |
| run: | | |
| set -o pipefail | |
| python tests/test_deck_cycle_loop_breaker.py | tee /tmp/cycle_breaker.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/cycle_breaker.log || { | |
| echo "::error::loop-breaker suite did not run to completion"; exit 1; } | |
| # Both checks here came out of VERIFYING a review's claims rather than trusting them: of six | |
| # re-measured by hand, three were factually wrong, and two of those nearly became lint checks | |
| # built to enforce a defect that did not exist. What survived verification ships; what did | |
| # not is recorded as an absence in the suite's own docstring. | |
| - name: Review grounding (split page · carried_by device consistency) | |
| run: | | |
| set -o pipefail | |
| python tests/test_review_grounding.py | tee /tmp/ground.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/ground.log || { | |
| echo "::error::review-grounding suite did not run to completion"; exit 1; } | |
| - name: Graze contract (a label running into the bar beside it) | |
| run: | | |
| set -o pipefail | |
| python tests/test_graze_contract.py | tee /tmp/graze.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/graze.log || { | |
| echo "::error::graze-contract suite did not run to completion"; exit 1; } | |
| - name: Motif contract (register_mark · keep-out · loud budget) | |
| run: | | |
| set -o pipefail | |
| python tests/test_motif_contract.py | tee /tmp/motif.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/motif.log || { | |
| echo "::error::motif contract suite did not run to completion"; exit 1; } | |
| # Every image row's evidence token held against the ledger and the built deck, plus the | |
| # non-Latin paths that were quietly ASCII-only (a CJK subject collapsed to the bare filename | |
| # fallback; a two-character Chinese author could not satisfy a len>=4 credit check). | |
| - name: Image sourcing (provenance tokens · QC · the non-Latin paths) | |
| run: | | |
| set -o pipefail | |
| python tests/test_image_sourcing.py | tee /tmp/imgsrc.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/imgsrc.log || { | |
| echo "::error::image-sourcing suite did not run to completion"; exit 1; } | |
| # Six defects found by RUNNING the skill end to end, not by reading it — the six-round-trip | |
| # gate sequence, the direction competition nothing required anyone to score, a preset setting | |
| # an uninstalled face, a strike-through that could not be built, a builds choice carried by | |
| # memory. Each one is a measurement from one real build. | |
| - name: Gate ergonomics (deck_gates shapes · direction re-scoring · the three declarations) | |
| run: | | |
| set -o pipefail | |
| python tests/test_gate_ergonomics.py | tee /tmp/gateerg.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/gateerg.log || { | |
| echo "::error::gate-ergonomics suite did not run to completion"; exit 1; } | |
| - name: Deck-level faults (duplicate text · chrome slot drift) | |
| run: | | |
| set -o pipefail | |
| python tests/test_deck_level_faults.py | tee /tmp/decklevel.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/decklevel.log || { | |
| echo "::error::deck-level fault suite did not run to completion"; exit 1; } | |
| # `CJK_NO_EA` is a build BLOCKER whose remedy used to be unreachable at the moment it fired: | |
| # it said "set EAFONT before building", but the lint runs at the END of the build, and the | |
| # two paths where the fault actually lives -- an opened template's own text, a surgical | |
| # fix-pass on a foreign deck -- never route through set_font(), the only reader of EAFONT. | |
| # retrofit_ea() is the lever that is connected; this holds it to what the CRITICAL promises. | |
| # Both directions: a retrofit that silently fixed nothing, or that overwrote a deliberate | |
| # per-run face, would each be worse than the fault it clears. | |
| - name: CJK EA retrofit (the CJK_NO_EA remedy actually works) | |
| run: | | |
| set -o pipefail | |
| python tests/test_retrofit_ea.py | tee /tmp/retrofit_ea.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/retrofit_ea.log || { | |
| echo "::error::retrofit_ea suite did not run to completion"; exit 1; } | |
| # The direction gate's own smoke suite. It was not wired in, and a change to the gate's | |
| # exit semantics broke two of its divergence assertions with nothing reporting it -- | |
| # exactly the silent class this repo keeps legislating against. | |
| - name: Direction gate smoke (divergence + bespoke requirement) | |
| run: python scripts/smoke_directions.py | |
| - name: Review-contract validator selftest | |
| run: python scripts/validate_review.py --selftest | |
| # `validate_review.py` is the ONLY gate on the critic -- the one judgement a deck cannot | |
| # self-certify -- and its entire test suite was --selftest's three inline fixtures, whose | |
| # canonical "good critic" set `plan_audit: null`. So the populated-audit path, where the | |
| # contract card actually gets audited, was never exercised: a review with `plan_audit: {}`, | |
| # `contract_card_seen: true` and no `probes` validated clean and consented, while | |
| # agents/critic.md told the critic that exact shape is rejected. Both directions are asserted, | |
| # because over-rejecting here stops a per-section critic or a direction preview from filing a | |
| # legitimate review, and the first honest failure teaches everyone to bypass the gate. | |
| - name: Review contract (plan_audit subfields, lens-scoped probes, both directions) | |
| run: | | |
| set -o pipefail | |
| python tests/test_validate_review.py | tee /tmp/vreview.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/vreview.log || { | |
| echo "::error::review-contract suite did not run to completion"; exit 1; } | |
| - name: Minimal build, lint, render | |
| run: | | |
| python - <<'EOF' | |
| import sys | |
| sys.path.insert(0, 'scripts') | |
| import deckkit as dk | |
| prs = dk.blank_deck() | |
| s = dk.add_slide(prs) | |
| dk.title_bar(s, "CI smoke deck", kicker="TEST") | |
| dk.bullet(s, 0.8, 1.6, 8.4, [("Builds", "deckkit constructs a slide"), | |
| ("Lints", "geometry gate passes"), | |
| ("Renders", "LibreOffice produces a PNG")]) | |
| dk.footer(s, tag="ci", page=1) | |
| dk.lint_layout(prs) | |
| prs.save('/tmp/ci.pptx') | |
| EOF | |
| python scripts/render_deck.py /tmp/ci.pptx /tmp/ci_render | |
| test -f /tmp/ci_render/slide01.png | |
| python scripts/lint_deck.py /tmp/ci.pptx | |
| # Two-sided lint regression. Both directions are asserted because both have failed in | |
| # production: a real deck shipped a hairline painted over its provenance line with the gate | |
| # reporting clean, and a deliberate rhymed triptych was flagged as "layout sameness" while | |
| # the documented waiver for it was dead code. A rule set that only guards one direction | |
| # drifts toward catching craft instead of defects. | |
| # Placement matters: it imports deckkit and RENDERS, so it must come after both dep installs | |
| # and after the minimal render smoke — it was originally slotted above `pip install` and | |
| # every CI run since died on ModuleNotFoundError before reaching any assertion. | |
| - name: Lint catches real defects and leaves declared craft alone | |
| run: | | |
| set -o pipefail | |
| python tests/test_lint_regressions.py | tee /tmp/reg.log | |
| # The suite exits 0 and prints SKIPPED when a dependency is absent — right on a | |
| # developer's laptop, wrong here. In CI everything it needs is installed above, so a | |
| # skip means the step order broke again. Assert it actually RAN: a gate that quietly | |
| # disables itself is worse than one that fails. | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/reg.log || { | |
| echo "::error::regression suite exited 0 without running to completion"; exit 1; } | |
| # Connector edge-docking (CONNECTOR_IN_BOX now sees axis-aligned + elbow connectors, and | |
| # loop_between docks by construction), the import-time dependency backstop (deckkit self-heals | |
| # its required pip deps for ANY agent), and TEMPLATE-BOUND — the composition-boldness signal, | |
| # RUNTIME-AGNOSTIC by reading boldness from BOTH .deck-gates.json (shared) and | |
| # .codex-deck-evidence.json (Codex). Script-style like the rest; same skip-means-broken guard. | |
| # test_design_checkpoint_gate — Step 2 (design plan + design checkpoint) is BRANCH-INVARIANT and | |
| # ENFORCED: render_deck.py refuses a full render when a content plan is recorded but no design | |
| # plan + checkpoint is (reading both .deck-gates.json and .codex-deck-evidence.json); a --slides | |
| # probe / no-content-plan / tiny-ask deck stays exempt. Closes the generated-branch regression | |
| # where the hero checkpoint stood in for Step 2 and the plan was reconstructed post-hoc. | |
| # test_web_research_floors — a WEB-RESEARCHED deck (source_mode: web) must carry the three | |
| # research floors (content-planner §2e): coverage (全面) + lifecycle sweep (proactive | |
| # live-vs-discontinued) + provenance digest & per-claim confidence (准确). codex_delivery_gate | |
| # requires them; provided/none decks are exempt. Closes the thin+outdated no-source failure. | |
| - name: Connectors, dependency backstop, composition boldness, design-checkpoint gate, web-research floors | |
| run: | | |
| set -o pipefail | |
| for t in test_connector_in_box test_env_bootstrap test_composition_boldness test_design_checkpoint_gate test_web_research_floors; do | |
| python tests/$t.py | tee /tmp/$t.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/$t.log || { | |
| echo "::error::$t did not run to completion"; exit 1; } | |
| done | |
| # The two render optimizations (chunked parallel rasterization, pooled LibreOffice | |
| # profile) are only legitimate if they are INVISIBLE in the output. This renders the | |
| # same deck down every path and compares sha256, and asserts the invariant the old | |
| # throwaway profile existed for: concurrent renders must still all produce a PDF. | |
| # Same skip-means-broken assertion as above — it renders for real, so a silent SKIPPED | |
| # here means LibreOffice went missing, not that the contract holds. | |
| - name: Render optimizations are byte-identical (parallel + profile pool) | |
| run: | | |
| set -o pipefail | |
| python tests/test_render_parallel.py | tee /tmp/renderpar.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/renderpar.log || { | |
| echo "::error::render-equivalence suite did not run to completion"; exit 1; } | |
| # The Codex delivery gate is the strictest thing in the skill -- it is what stops a Codex | |
| # run from calling a deck delivered without two schema-valid critic reviews. It shipped | |
| # with 437 lines of tests wired to nothing. Script-style on purpose (matching | |
| # test_lint_regressions.py), so pytest would collect zero from them: invoke directly. | |
| - name: Codex delivery gate + visual contract | |
| run: | | |
| set -o pipefail | |
| python tests/test_codex_delivery_gate.py | tee /tmp/codex_gate.log | |
| grep -q "^ok - " /tmp/codex_gate.log || { | |
| echo "::error::codex delivery gate suite exited 0 without asserting"; exit 1; } | |
| python tests/test_codex_visual_contract.py | tee /tmp/codex_vc.log | |
| grep -q "^ok - " /tmp/codex_vc.log || { | |
| echo "::error::codex visual contract suite exited 0 without asserting"; exit 1; } | |
| # Prose that teaches an API is still an API contract, and nothing used to check it. | |
| # Measured: the shared references simultaneously taught `dk.card()` (no such helper), | |
| # `icon_tile(tile_color=)` (the param is `fill=`), a dead `deckkit-component-guide.md` | |
| # pointer, and `fore_color.alpha = ...` -- which raises nothing, writes nothing, and | |
| # renders a 100% opaque shape that erased a cover plate to pure black while every | |
| # existing lint reported clean. All four were invisible to CI until this step existed. | |
| # A gate without a test regresses the same silent way the bugs it catches do. | |
| # Eleven of the twelve PRE-FLIGHT ticks were self-attested: the model wrote twelve | |
| # checkmarks and nothing anywhere read them. This suite guards the tool that now decides | |
| # the mechanical half — and, just as importantly, that it still refuses to imply it | |
| # covered the five genuine judgment items. | |
| - name: PRE-FLIGHT mechanical subset | |
| run: | | |
| set -o pipefail | |
| python tests/test_preflight_check.py | tee /tmp/preflight.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/preflight.log || { | |
| echo "::error::preflight suite did not run to completion"; exit 1; } | |
| - name: Critic waiver must be classified (shared path) | |
| run: | | |
| set -o pipefail | |
| python tests/test_critic_waiver_gate.py | tee /tmp/waiver.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/waiver.log || { | |
| echo "::error::critic waiver gate suite did not run to completion"; exit 1; } | |
| - name: Documented deckkit API actually exists | |
| run: | | |
| set -o pipefail | |
| python scripts/check_reference_code.py | tee /tmp/refcode.log | |
| grep -qE "^checked [0-9]+ markdown" /tmp/refcode.log || { | |
| echo "::error::reference-code checker did not run"; exit 1; } | |
| # The design pipeline's RULES were sound and its INDEXES had drifted. A whole-skill audit found | |
| # the self-verify header claiming "(a-q)" while the list ran to (r) -- and (r) is the density | |
| # line, the one self-verify item with a hard deterministic backstop, so an agent sweeping the | |
| # declared range stopped one item short of the gated one. Same class: PRE-FLIGHT 12(a) said | |
| # ">~40%" where eight other files said "~40-50%", and `interior register:` was required by | |
| # self-verify (q), PRE-FLIGHT 6b AND the critic contract card while appearing nowhere in the | |
| # file SKILL.md names as the OWNER of the checkpoint's required lines. | |
| # None of that is catchable by reading -- they are agreements BETWEEN files, and every one is | |
| # decidable by a program. Verified: this check flags all five on the pre-fix tree and is clean | |
| # on the fixed one. --selftest runs first so a check that has stopped being able to fail is | |
| # itself a build failure. | |
| - name: Design stack indexes agree (self-verify letters, shared thresholds, checkpoint owner) | |
| run: | | |
| set -o pipefail | |
| python scripts/check_design_contracts.py --selftest | |
| python scripts/check_design_contracts.py | tee /tmp/designcontracts.log | |
| grep -qE "^checked [0-9]+ self-verify items" /tmp/designcontracts.log || { | |
| echo "::error::design-contract checker did not run"; exit 1; } | |
| # The five files where the USER'S artifacts enter and leave. The four that produce and police | |
| # the skill's OWN output (deckkit, lint_deck, render_deck, codex_delivery_gate) are ~12k lines | |
| # with five suites, three meta-checkers and a CI job that asserts its own assertions ran. These | |
| # five -- extract_deck (their deck), inspect_template (their template), export_notes (their | |
| # rehearsal script), assemble (their sectioned deck), anim (what they actually click through) -- | |
| # were ~455 lines with NO tests and were never executed by CI at all. Every one of them failed | |
| # SILENTLY when run: charts and speaker notes vanished under a success message, a chart-only | |
| # slide was reported as "(empty / decorative only)", a real .potx died on a raw library | |
| # ValueError with the string "potx" appearing nowhere in the skill, a rehearsal script came out | |
| # with no titles, a second deck inherited the first's palette, a requested transition became a | |
| # fade. Every gate this skill owns measures the deck against ITSELF, and internal consistency is | |
| # exactly the property that cannot detect a wrong input. These assertions face outward. | |
| # Mutation-verified: the suite fails on the pre-fix tree. | |
| - name: Boundary I/O (their deck, their template, their notes, their sections) | |
| run: | | |
| set -o pipefail | |
| python tests/test_boundary_io.py | tee /tmp/boundary.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/boundary.log || { | |
| echo "::error::boundary I/O suite did not run to completion"; exit 1; } | |
| # The design side has measured direction-candidate divergence for a while; the content side | |
| # derived its arc from the primary goal and recorded "which arc I chose and why" -- a reason | |
| # written after the fact, against no alternative. The arc is the only decision whose error | |
| # invalidates everything downstream, so it now runs a competition, and this measures whether | |
| # the candidates are genuinely different (and whether the losers were ever developed). | |
| # The CJK cases are load-bearing: a whitespace tokeniser scores every pair of Chinese asks at | |
| # 0.0 and switches two of the four axes off on every Chinese deck, silently and forever. | |
| - name: Narrative-arc candidate divergence | |
| run: | | |
| set -o pipefail | |
| python tests/test_arc_divergence.py | tee /tmp/arcdiv.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/arcdiv.log || { | |
| echo "::error::arc divergence suite did not run to completion"; exit 1; } | |
| # Step 4's proof was ONE rendered page. It proved the aesthetic risk and nothing else, so a | |
| # design approved on the deck's most spacious page could still fail to hold its densest one, | |
| # and a palette chosen entirely against text could meet its first native chart at Step 5 -- | |
| # where the fix is a rebuild. Three anchors now. The second half of this suite is the reason | |
| # it exists: both gate paths must IMPORT the shared contract rather than restate it, because | |
| # they have already diverged once on this exact field. | |
| - name: Anchor proof (three-page contract, and the two gates that must not diverge) | |
| run: | | |
| set -o pipefail | |
| python tests/test_anchor_proof.py | tee /tmp/anchor.log | |
| grep -qE "^[0-9]+ passed, 0 failed" /tmp/anchor.log || { | |
| echo "::error::anchor proof suite did not run to completion"; exit 1; } | |
| # A parameter accepted and never read is the quietest defect this library can ship: the caller | |
| # writes the call the docs describe, nothing raises, no lint fires, and the setting is simply | |
| # discarded. Three were found by hand, in three different files, over three separate audits -- | |
| # gantt(accents=) drawing every bar in the default blue (a silent breach of SKILL.md's RED | |
| # "never ship deckkit's default blue"), anim.slide_transition(kind=) turning every requested | |
| # transition into a fade, native_bubble(xlabel=/ylabel=) dropping documented axis titles. One | |
| # AST pass over the module bodies found seven MORE, including node(fill=) and | |
| # backdrop_motif(kind=) whose own docstring advertised the modes it never implemented. | |
| # Whether a parameter is read is decidable by a program, so it belongs here rather than in a | |
| # docstring nobody re-reads. Deliberate no-ops go in EXPECTED_UNREAD with a written reason. | |
| - name: Every accepted parameter is read by its own body | |
| run: | | |
| set -o pipefail | |
| python scripts/check_param_reach.py | tee /tmp/paramreach.log | |
| grep -qE "^checked [0-9]+ module" /tmp/paramreach.log || { | |
| echo "::error::param-reach checker did not run"; exit 1; } | |
| - name: Icon pipeline (fetch, recolor, ink guard) | |
| run: | | |
| python - <<'EOF' | |
| import sys | |
| sys.path.insert(0, 'scripts') | |
| from icons import icon_png | |
| from PIL import Image | |
| # bare hex on purpose: must be normalized, not silently transparent | |
| icon_png('lucide:rocket', '/tmp/icon.png', color='C2410C', px=128) | |
| im = Image.open('/tmp/icon.png').convert('RGBA') | |
| assert im.getchannel('A').getbbox(), 'icon rasterized fully transparent' | |
| print('icon ok') | |
| EOF | |
| - name: Contrast lint regression (invisible dark-on-dark text must fail) | |
| run: | | |
| python - <<'EOF' | |
| import sys, subprocess | |
| sys.path.insert(0, 'scripts') | |
| import deckkit as dk | |
| from pptx.util import Inches, Pt | |
| prs = dk.blank_deck() | |
| s = dk.add_slide(prs) | |
| dk.box(s, 1, 1, 5, 2, fill=dk.RGBColor(0x14, 0x14, 0x1A)) | |
| tb = s.shapes.add_textbox(Inches(1.3), Inches(1.5), Inches(4), Inches(0.6)) | |
| r = tb.text_frame.paragraphs[0].add_run() | |
| r.text = "invisible body text" | |
| r.font.size = Pt(14) | |
| prs.save('/tmp/bad_contrast.pptx') | |
| p = subprocess.run(['python', 'scripts/lint_deck.py', '/tmp/bad_contrast.pptx'], | |
| capture_output=True, text=True) | |
| assert p.returncode != 0, 'lint must fail on invisible text' | |
| assert 'INVISIBLE TEXT' in p.stdout + p.stderr, p.stdout + p.stderr | |
| print('contrast guard ok') | |
| EOF | |
| - name: PDF figure extraction (pymupdf API drift canary) | |
| run: | | |
| python - <<'EOF' | |
| import sys | |
| sys.path.insert(0, 'scripts') | |
| import fitz | |
| doc = fitz.open() | |
| page = doc.new_page(width=595, height=842) | |
| page.insert_text((72, 100), "Figure 1: canary", fontsize=11) | |
| page.draw_rect(fitz.Rect(72, 120, 300, 300), color=(0, 0, 1), width=2) | |
| doc.save('/tmp/canary.pdf') | |
| from extract_pdf import find_figures # crashes here if pymupdf drifts | |
| find_figures('/tmp/canary.pdf', 1) | |
| print('extract_pdf ok') | |
| EOF |