diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..72a8c74 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# A committed source's bytes are what SHA256SUMS records - line-ending +# normalization on checkout would make the hash disagree with itself on a +# clone whose autocrlf setting differs from the one that last touched the +# working tree. Force LF unconditionally rather than depend on that setting. +library/**/*.source.html text eol=lf diff --git a/.github/workflows/library.yml b/.github/workflows/library.yml new file mode 100644 index 0000000..690a59e --- /dev/null +++ b/.github/workflows/library.yml @@ -0,0 +1,38 @@ +name: Library + +# The library's shape and no-gate contract - see library/README.md - is +# machine-enforced so an item that violates it fails the build rather than +# reaching a reader. Passes vacuously with zero items present. + +on: + push: + branches: [main] + paths: + - 'library/**' + - 'scripts/check_library.py' + - '.github/workflows/library.yml' + pull_request: + branches: [main] + paths: + - 'library/**' + - 'scripts/check_library.py' + - '.github/workflows/library.yml' + +permissions: + contents: read + +jobs: + check-library: + runs-on: ubuntu-latest + name: library/ shape and no-gate contract hold + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Check library/ contract + run: python3 scripts/check_library.py diff --git a/assets/fonts/ibm-plex/IBMPlexMono-Medium.woff2 b/assets/fonts/ibm-plex/IBMPlexMono-Medium.woff2 new file mode 100644 index 0000000..090f82f Binary files /dev/null and b/assets/fonts/ibm-plex/IBMPlexMono-Medium.woff2 differ diff --git a/assets/fonts/ibm-plex/IBMPlexMono-Regular.woff2 b/assets/fonts/ibm-plex/IBMPlexMono-Regular.woff2 new file mode 100644 index 0000000..0804aaf Binary files /dev/null and b/assets/fonts/ibm-plex/IBMPlexMono-Regular.woff2 differ diff --git a/assets/fonts/ibm-plex/IBMPlexSans-Bold.woff2 b/assets/fonts/ibm-plex/IBMPlexSans-Bold.woff2 new file mode 100644 index 0000000..953f94f Binary files /dev/null and b/assets/fonts/ibm-plex/IBMPlexSans-Bold.woff2 differ diff --git a/assets/fonts/ibm-plex/IBMPlexSans-Medium.woff2 b/assets/fonts/ibm-plex/IBMPlexSans-Medium.woff2 new file mode 100644 index 0000000..6d5527e Binary files /dev/null and b/assets/fonts/ibm-plex/IBMPlexSans-Medium.woff2 differ diff --git a/assets/fonts/ibm-plex/IBMPlexSans-Regular.woff2 b/assets/fonts/ibm-plex/IBMPlexSans-Regular.woff2 new file mode 100644 index 0000000..f0ee65d Binary files /dev/null and b/assets/fonts/ibm-plex/IBMPlexSans-Regular.woff2 differ diff --git a/assets/fonts/ibm-plex/IBMPlexSans-SemiBold.woff2 b/assets/fonts/ibm-plex/IBMPlexSans-SemiBold.woff2 new file mode 100644 index 0000000..08c0d5a Binary files /dev/null and b/assets/fonts/ibm-plex/IBMPlexSans-SemiBold.woff2 differ diff --git a/assets/fonts/ibm-plex/OFL.txt b/assets/fonts/ibm-plex/OFL.txt new file mode 100644 index 0000000..01497cc --- /dev/null +++ b/assets/fonts/ibm-plex/OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/library/README.md b/library/README.md new file mode 100644 index 0000000..bfb65c3 --- /dev/null +++ b/library/README.md @@ -0,0 +1,50 @@ +# Library — contract + +This directory holds the transitrix.com library of free reference material. The shape +below is enforced by `scripts/check_library.py` in CI (`.github/workflows/library.yml`) +— an item that violates it fails the check rather than reaching a reader. + +- **One directory per item:** `library//`, served at + `https://transitrix.com/library//`. +- **The source lives at `library//.source.html`.** This is the committed, + human-authored content — the words are its author's. It is never edited by a + rendering step; only read. +- **The PDF lives at `library//.pdf`, rendered from the source, never + hand-authored.** `scripts/render_library_item.py ` renders it via headless + Chrome on an agent's own host, offline, and asserts the result's page count and + geometry against the source's own `@page` rule before it is committed. CI never + renders — see "Checked, not rendered" below. The filename is the slug, so a revision + replaces the file in place and never mints a second URL. +- **The source's `` leads with the item's own printed name** — read from the + source's own `<h1>` — with no suffix beyond the site's existing ` | Transitrix`. +- **Kinds of material are not path segments.** A cheat sheet, a template and a worked + example all sit directly under `library/<slug>/`; grouping belongs on the index page, + where it can be revised, never in an address that has been printed and handed out. +- **No gate of any kind.** No form, no email field, no sign-up, no interstitial, no + tracking wall, on the source or on any item page. +- **The typefaces are vendored, not fetched.** A source pulls its fonts from + `/assets/fonts/`, declared with local `@font-face`; nothing under `library/` may + depend on a third-party font host or any other network fetch at render time. +- **`SHA256SUMS` carries a pair of entries per item** — `<slug>/<slug>.source.html` + and `<slug>/<slug>.pdf` — one `<sha256> <path>` line each (`sha256sum` output + format), updated in the same commit as the files they describe. + +## Checked, not rendered + +`scripts/check_library.py` verifies the *committed* pair — it never launches a +browser and never re-renders to check its work. Concretely it checks: the checksum +pair against the committed files, the PDF's page count and its `MediaBox` against +the source's own `@page` rule, the no-gate patterns against the source, and the +source's `<title>` against its own `<h1>`. + +**A source edited without re-rendering is caught as an ordinary checksum +mismatch**, the same way a tampered PDF already was: `SHA256SUMS` is only ever +updated by the render step, so an edit that skips it leaves the source's committed +bytes disagreeing with the hash recorded for it. + +**Do not add a check that re-renders and diffs the two PDFs to verify a render is +current.** It cannot work: a browser embeds a creation date in the PDF it writes, +and rendered output shifts across Chrome milestones, so two renders of the same +source are not byte-identical even seconds apart — the diff would fail on nothing, +every time, and teach everyone to ignore it. This has already been tried once; it +is written here so it is not tried again. diff --git a/library/SHA256SUMS b/library/SHA256SUMS new file mode 100644 index 0000000..2ae8bec --- /dev/null +++ b/library/SHA256SUMS @@ -0,0 +1,2 @@ +5cc0ad239a5c959a0a62ed060d16db3ba31314f6da3ced4c3fe3ac0183159c15 archimate-cheat-sheet/archimate-cheat-sheet.source.html +5c2a78a82d03173d05e9095f784418a83faad4c992d0fb7437b3989d12e9956d archimate-cheat-sheet/archimate-cheat-sheet.pdf diff --git a/library/archimate-cheat-sheet/archimate-cheat-sheet.pdf b/library/archimate-cheat-sheet/archimate-cheat-sheet.pdf new file mode 100644 index 0000000..876afdd Binary files /dev/null and b/library/archimate-cheat-sheet/archimate-cheat-sheet.pdf differ diff --git a/library/archimate-cheat-sheet/archimate-cheat-sheet.source.html b/library/archimate-cheat-sheet/archimate-cheat-sheet.source.html new file mode 100644 index 0000000..192e5d8 --- /dev/null +++ b/library/archimate-cheat-sheet/archimate-cheat-sheet.source.html @@ -0,0 +1,288 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<title>ArchiMate cheat sheet + + + +
+ +
+
+

ArchiMate cheat sheet — every element, by layer and aspect

+

All 60 elements of ArchiMate® 3.2 in one grid, placed by the two questions that decide where an element belongs.

+
+
+ transitrix.com
+ Free to share and print — no sign-up +
+
+ +
+ + +
+

The framework layer answers whom it serves · aspect answers what kind of thing it is

+
+
+
Active structurewho or what does it
+
Behaviourwhat is done
+
Passive structurewhat it is done to
+ + +
Motivationwhy — its own aspect
+
StakeholderDriverAssessmentGoalOutcomePrincipleRequirementConstraintMeaningValue
+ + +
Strategywhat we must be able to do
+
Resource
+
CapabilityValue StreamCourse of Action
+
—
+ + +
Businesswhat the organisation does for its customers
+
Business ActorBusiness RoleBusiness CollaborationBusiness Interface
+
Business ProcessBusiness FunctionBusiness InteractionBusiness EventBusiness Service
+
Business ObjectContractRepresentationProduct †
+ + +
Applicationwhat software does for the business
+
Application ComponentApplication CollaborationApplication Interface
+
Application FunctionApplication InteractionApplication ProcessApplication EventApplication Service
+
Data Object
+ + +
Technologywhat infrastructure does for software
+
NodeDeviceSystem SoftwareTechnology CollaborationTechnology InterfacePathCommunication Network
+
Technology FunctionTechnology ProcessTechnology InteractionTechnology EventTechnology Service
+
Artifact
+ + +
Physicalpart of Technology, not a layer of its own
+
EquipmentFacilityDistribution Network
+
— uses Technology behaviour
+
Material
+ + +
Implementation & Migrationhow we get there
+
—
+
Work PackageImplementation Event
+
DeliverablePlateau ‡Gap ‡
+ + +
Compositeno layer of their own
+
Grouping — anything that belongs togetherLocation — where elements sit or behaviour happens
+
+

† Product is composite, not passive structure: it aggregates services and passive elements across layers, under a contract. Most cheat sheets file it wrongly.  ‡ Plateau aggregates core elements as they stand at one point in time; Gap states the difference between two plateaus.

+
+ + +
+

Relationships 11 + junction

+
+ +
Structural — static coherence
+
+
Compositionthe part cannot exist without the whole
+ +
+
Aggregationgroups parts that can exist alone
+ +
+
Assignmentwho performs it, where it runs
+ +
+
Realizationthe concrete one delivers the abstract one
+ +
Dependency — one needs another
+
+
Servingprovides its functionality to another
+ +
+
Accessreads, writes or deletes a passive element
+ +
+/-
+
Influenceaffects a motivation element, for or against
+ +
+
Associationa link the language has no word for
+ +
Dynamic — something moves
+
+
Triggeringthis then that — order in time
+ +
+
Flowsomething is transferred — data, money, goods
+ +
Other & connector
+
+
Specializationis a particular kind of
+ +
+
Junctionand / or — joins relationships of one type
+
+ +
+ The derivation rule — the one worth memorising + A chain of relationships pointing the same way can be replaced by the weakest link in the chain. That is how a diagram stays readable when you zoom out.
+ composition › aggregation › assignment › realization › serving › access › influence › association +
+ +
+ Three pairs that get confused +

Assignment vs realization. Assignment allocates a performer — an actor does a process. Realization says something concrete delivers something abstract — a process provides a service.

+

Serving vs flow. Serving is a standing offer: it is available to you. Flow is one actual transfer during one run.

+

Composition vs aggregation. A composed part dies with its whole and has exactly one whole. An aggregated part survives on its own and may belong to several.

+
+
+ + + + +
+ +
+ Made with Transitrix — an open method for describing a company as text · transitrix.com + Element set per the ArchiMate® 3.2 Specification. ArchiMate is a registered trademark of The Open Group. + More sheets: transitrix.com/library +
+ +
+ + diff --git a/scripts/check_library.py b/scripts/check_library.py new file mode 100644 index 0000000..9287a02 --- /dev/null +++ b/scripts/check_library.py @@ -0,0 +1,582 @@ +#!/usr/bin/env python3 +"""Guard the library's shape and no-gate contract. + +Checked against library/ (see library/README.md for the contract in prose): every +item directory carries .source.html and .pdf and no other file named +.pdf, the source's own `@page { size: mm mm }` rule matches the committed +PDF's MediaBox, the PDF is exactly one page, the source's `` leads with the +item's own printed name (its `<h1>`), no item page contains a form/input/submit- +button/signup-script gate, and library/SHA256SUMS carries a matching pair of +hashes - source and PDF - for every item, byte-for-byte. + +Usage: python3 scripts/check_library.py +Runs a self-test against throwaway fixtures in a temp directory before checking the +real tree - see .github/workflows/library.yml for the same shape. + +Rendering happens only in scripts/render_library_item.py, on an agent's own host; +this check never launches a browser and never re-renders to verify - see +library/README.md for why a byte-diff re-render check is not attempted. +""" +import hashlib +import os +import re +import shutil +import sys +import tempfile + +LIBRARY_DIR = "library" +CHECKSUMS_FILE = "SHA256SUMS" +NON_ITEM_ENTRIES = {"README.md", CHECKSUMS_FILE} +GEOMETRY_TOLERANCE_PT = 0.5 + +GATE_PATTERNS = [ + (re.compile(r"<form\b", re.IGNORECASE), "a <form> element"), + (re.compile(r"<input\b", re.IGNORECASE), "an <input> element"), + (re.compile(r'<button[^>]*type\s*=\s*["\']?submit', re.IGNORECASE), 'a <button type="submit">'), + ( + re.compile( + r"(mailchimp|convertkit|hubspot|getresponse|klaviyo|substack|beehiiv|typeform)", + re.IGNORECASE, + ), + "a mail-capture/signup script", + ), + ( + re.compile(r"(interstitial|signup-modal|email-gate|paywall)", re.IGNORECASE), + "an interstitial/gate marker", + ), +] + + +def find_slug_dirs(library_root): + if not os.path.isdir(library_root): + return [] + slugs = [] + for name in sorted(os.listdir(library_root)): + if name in NON_ITEM_ENTRIES or name.startswith("."): + continue + if os.path.isdir(os.path.join(library_root, name)): + slugs.append(name) + return slugs + + +def source_path_for(library_root, slug): + return os.path.join(library_root, slug, f"{slug}.source.html") + + +def pdf_path_for(library_root, slug): + return os.path.join(library_root, slug, f"{slug}.pdf") + + +def check_shape(library_root, slug): + errors = [] + slug_dir = os.path.join(library_root, slug) + + if not os.path.isfile(source_path_for(library_root, slug)): + errors.append(f"{slug_dir}: missing {slug}.source.html") + + expected_pdf = f"{slug}.pdf" + pdfs = [f for f in os.listdir(slug_dir) if f.lower().endswith(".pdf")] + if expected_pdf not in pdfs: + errors.append(f"{slug_dir}: missing {expected_pdf}") + for f in pdfs: + if f != expected_pdf: + errors.append(f"{slug_dir}: PDF named {f!r}, expected only {expected_pdf!r}") + return errors + + +def _read(path): + with open(path, encoding="utf-8") as f: + return f.read() + + +def check_gate(library_root, slug): + errors = [] + source_path = source_path_for(library_root, slug) + if not os.path.isfile(source_path): + return errors + html = _read(source_path) + for pattern, label in GATE_PATTERNS: + if pattern.search(html): + errors.append(f"{source_path}: contains {label} - no gate is allowed on a library page") + return errors + + +def check_title(library_root, slug): + """The source's <title> must lead with the item's own printed name - the same + convention library/README.md states for the served page, applied here to the + source it will eventually be built from. The printed name is read from the + source's own <h1>, so there is nothing outside the file to keep in sync.""" + source_path = source_path_for(library_root, slug) + if not os.path.isfile(source_path): + return [] + html = _read(source_path) + + title_m = re.search(r"<title>(.*?)", html, re.IGNORECASE | re.DOTALL) + h1_m = re.search(r"]*>(.*?)", html, re.IGNORECASE | re.DOTALL) + if not title_m: + return [f"{source_path}: no element"] + if not h1_m: + return [f"{source_path}: no <h1> to read the item's printed name from"] + + title_text = re.sub(r"\s+", " ", title_m.group(1)).strip() + h1_text = re.sub(r"<[^>]+>", "", h1_m.group(1)) + h1_text = re.sub(r"\s+", " ", h1_text).strip() + + if not h1_text.startswith(title_text): + return [ + f"{source_path}: <title> {title_text!r} does not lead the printed name " + f"{h1_text!r} (<h1>)" + ] + return [] + + +def page_size_mm(source_path): + html = _read(source_path) + m = re.search(r"@page\s*\{[^}]*size:\s*([\d.]+)mm\s+([\d.]+)mm", html) + if not m: + return None + return float(m.group(1)), float(m.group(2)) + + +def mm_to_pt(mm): + return mm * 72.0 / 25.4 + + +def read_mediabox_and_page_count(pdf_path): + with open(pdf_path, "rb") as f: + data = f.read() + boxes = re.findall(rb"/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\]", data) + if not boxes: + return None + page_count = len(re.findall(rb"/Type\s*/Page[^s]", data)) + w = float(boxes[0][2]) - float(boxes[0][0]) + h = float(boxes[0][3]) - float(boxes[0][1]) + return w, h, page_count + + +def check_geometry(library_root, slug): + """Statically compares the source's own @page rule against the committed PDF's + MediaBox and page count - never by re-rendering (see module docstring).""" + source_path = source_path_for(library_root, slug) + pdf_path = pdf_path_for(library_root, slug) + if not os.path.isfile(source_path) or not os.path.isfile(pdf_path): + return [] + + want_mm = page_size_mm(source_path) + if want_mm is None: + return [f"{source_path}: no `@page {{ size: <w>mm <h>mm }}` rule"] + want_w_pt, want_h_pt = mm_to_pt(want_mm[0]), mm_to_pt(want_mm[1]) + + got = read_mediabox_and_page_count(pdf_path) + if got is None: + return [f"{pdf_path}: no /MediaBox found"] + got_w_pt, got_h_pt, page_count = got + + errors = [] + if page_count != 1: + errors.append(f"{pdf_path}: expected exactly 1 page, found {page_count}") + if abs(got_w_pt - want_w_pt) > GEOMETRY_TOLERANCE_PT or abs(got_h_pt - want_h_pt) > GEOMETRY_TOLERANCE_PT: + errors.append( + f"{pdf_path}: MediaBox {got_w_pt:.2f}x{got_h_pt:.2f}pt does not match " + f"{source_path}'s @page size {want_w_pt:.2f}x{want_h_pt:.2f}pt " + f"(tolerance {GEOMETRY_TOLERANCE_PT}pt)" + ) + return errors + + +def parse_checksums(checksums_path): + """Returns {relative_path: lowercase_hex_sha256}, or None if the file is missing.""" + if not os.path.isfile(checksums_path): + return None + entries = {} + with open(checksums_path, encoding="utf-8") as f: + for lineno, line in enumerate(f, start=1): + line = line.rstrip("\n") + if not line.strip(): + continue + m = re.match(r"^([0-9a-fA-F]{64})\s+(.+)$", line) + if not m: + raise ValueError(f"{checksums_path}:{lineno}: unparseable line {line!r}") + entries[m.group(2).strip()] = m.group(1).lower() + return entries + + +def sha256_of(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 16), b""): + h.update(chunk) + return h.hexdigest() + + +def check_checksums(library_root, slugs): + """Every item carries a *pair* of entries - source and PDF - each verified + against the committed file it names. A source edited without re-rendering is + caught here: its committed bytes no longer match the hash recorded for it, + the same way a tampered PDF already was. See library/README.md for the failure + mode this exists to catch and the one it deliberately does not attempt.""" + checksums_path = os.path.join(library_root, CHECKSUMS_FILE) + try: + entries = parse_checksums(checksums_path) + except ValueError as e: + return [str(e)] + if entries is None: + return [f"{checksums_path}: missing"] + + errors = [] + committed = {} + for slug in slugs: + for rel, abs_path in ( + (f"{slug}/{slug}.source.html", source_path_for(library_root, slug)), + (f"{slug}/{slug}.pdf", pdf_path_for(library_root, slug)), + ): + if os.path.isfile(abs_path): + committed[rel] = abs_path + + for rel, abs_path in committed.items(): + if rel not in entries: + errors.append(f"{checksums_path}: no entry for {rel}") + continue + actual = sha256_of(abs_path) + if actual != entries[rel]: + errors.append( + f"{checksums_path}: checksum for {rel} does not match the committed file " + f"(expected {entries[rel]}, got {actual})" + ) + + for rel in entries: + if rel not in committed: + errors.append(f"{checksums_path}: entry for {rel} but no such file exists") + + return errors + + +def run_checks(root): + library_root = os.path.join(root, LIBRARY_DIR) + slugs = find_slug_dirs(library_root) + errors = [] + for slug in slugs: + errors.extend(check_shape(library_root, slug)) + errors.extend(check_gate(library_root, slug)) + errors.extend(check_title(library_root, slug)) + errors.extend(check_geometry(library_root, slug)) + errors.extend(check_checksums(library_root, slugs)) + return errors, slugs + + +def _write(path, content=""): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +PAGE_W_MM, PAGE_H_MM = 100.0, 50.0 +PAGE_W_PT, PAGE_H_PT = mm_to_pt(PAGE_W_MM), mm_to_pt(PAGE_H_MM) + + +def _fixture_source(title="Widgets", h1="Widgets — the complete field guide", extra_head="", extra_body=""): + return ( + "<!DOCTYPE html><html><head>" + f"<title>{title}" + f"" + f"{extra_head}" + f"

{h1}

{extra_body}" + ) + + +def _fixture_pdf(w_pt=PAGE_W_PT, h_pt=PAGE_H_PT, page_count=1): + pages = "".join(f"<< /Type /Page /MediaBox [0 0 {w_pt:.2f} {h_pt:.2f}] >>\n" for _ in range(page_count)) + return f"%PDF-1.4\n{pages}%%EOF".encode("ascii") + + +def _sha256sums_lines(root_for_hash, slug): + source_path = source_path_for(os.path.join(root_for_hash, LIBRARY_DIR), slug) + pdf_path = pdf_path_for(os.path.join(root_for_hash, LIBRARY_DIR), slug) + return ( + f"{sha256_of(source_path)} {slug}/{slug}.source.html\n" + f"{sha256_of(pdf_path)} {slug}/{slug}.pdf\n" + ) + + +def _fresh_item(tmp, slug="widgets", source_html=None, pdf_bytes=None): + """A minimal, contract-clean item: .source.html, .pdf, and a + matching pair of SHA256SUMS entries. Callers mutate one thing at a time to + demonstrate a failure.""" + _write(source_path_for(os.path.join(tmp, LIBRARY_DIR), slug), source_html or _fixture_source()) + with open(pdf_path_for(os.path.join(tmp, LIBRARY_DIR), slug), "wb") as f: + f.write(pdf_bytes if pdf_bytes is not None else _fixture_pdf()) + _write(os.path.join(tmp, LIBRARY_DIR, CHECKSUMS_FILE), _sha256sums_lines(tmp, slug)) + return slug + + +def _expect(condition, ok_msg, fail_msg, failures): + if condition: + print(f"ok: {ok_msg}") + else: + print(f"::error::self-test regression - {fail_msg}") + failures.append(fail_msg) + + +def _selftest(): + print("== Self-test: the checker itself must catch what it's meant to catch ==") + failures = [] + + # A clean item passes outright. + tmp = tempfile.mkdtemp() + try: + slug = _fresh_item(tmp) + errors, slugs = run_checks(tmp) + _expect(errors == [], "a contract-clean item passes with zero errors", "a clean item was flagged", failures) + finally: + shutil.rmtree(tmp) + + # Zero items present passes vacuously, provided SHA256SUMS exists (empty). + tmp = tempfile.mkdtemp() + try: + _write(os.path.join(tmp, LIBRARY_DIR, CHECKSUMS_FILE), "") + errors, slugs = run_checks(tmp) + _expect(errors == [] and slugs == [], "zero items present passes vacuously", "an empty library was flagged", failures) + finally: + shutil.rmtree(tmp) + + # Missing source. + tmp = tempfile.mkdtemp() + try: + slug = _fresh_item(tmp) + os.remove(source_path_for(os.path.join(tmp, LIBRARY_DIR), slug)) + errors, _ = run_checks(tmp) + _expect( + any("missing widgets.source.html" in e for e in errors), + "an item directory missing .source.html is caught", + "a missing source was not caught", + failures, + ) + finally: + shutil.rmtree(tmp) + + # Missing PDF. + tmp = tempfile.mkdtemp() + try: + slug = _fresh_item(tmp) + os.remove(pdf_path_for(os.path.join(tmp, LIBRARY_DIR), slug)) + errors, _ = run_checks(tmp) + _expect( + any(f"missing {slug}.pdf" in e for e in errors), + "an item directory missing .pdf is caught", + "a missing PDF was not caught", + failures, + ) + finally: + shutil.rmtree(tmp) + + # PDF named wrong (not .pdf). + tmp = tempfile.mkdtemp() + try: + slug = _fresh_item(tmp) + os.rename( + pdf_path_for(os.path.join(tmp, LIBRARY_DIR), slug), + os.path.join(tmp, LIBRARY_DIR, slug, "final-v2.pdf"), + ) + errors, _ = run_checks(tmp) + _expect( + any("PDF named 'final-v2.pdf'" in e for e in errors), + "a PDF whose name is not .pdf is caught", + "a misnamed PDF was not caught", + failures, + ) + finally: + shutil.rmtree(tmp) + + # Gate:
. + tmp = tempfile.mkdtemp() + try: + slug = _fresh_item(tmp, source_html=_fixture_source(extra_body="
")) + errors, _ = run_checks(tmp) + _expect( + any("
" in e for e in errors), + "a source with a gate is caught", + "a gate was not caught", + failures, + ) + finally: + shutil.rmtree(tmp) + + # Gate: . + tmp = tempfile.mkdtemp() + try: + slug = _fresh_item(tmp, source_html=_fixture_source(extra_body='')) + errors, _ = run_checks(tmp) + _expect( + any("" in e for e in errors), + "a source with a bare gate is caught", + "an gate was not caught", + failures, + ) + finally: + shutil.rmtree(tmp) + + # Gate: ')) + errors, _ = run_checks(tmp) + _expect( + any('type="submit"' in e for e in errors), + 'a source with a