From 0eb151117ead8e25a13de84c5536871aeaf11b44 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Mon, 26 Oct 2026 17:26:00 +1100 Subject: [PATCH] Phase 1a: content schema validator (freeze the schema) Implements AGENTS.md section 7 front-matter schema as a single-file, standard-library-only Python validator; no third-party YAML dependency and no framework. Adds the `make validate` gate, a 20-case fixture set and docs/content-schema.md. DoD satisfied: - make validate runs and exits 0 on an empty/clean content/ tree (missing dir and empty dir both exit 0; fixture ok-empty covers it). - Every violation class has a fixture under test/fixtures/fail-*; each exits non-zero with one line per violation (file:line: field: rule). `make test` runs all 20 cases: 20/20 pass. - Dangling artefact reference is fatal (fail-dangling-artefact; artefacts[].url must resolve to an existing in-repo L3 stub or asset). - Unknown front-matter field is fatal (fail-unknown-field), including unknown keys nested inside artefacts[]/metrics[]. - docs/content-schema.md committed; states the frozen schema, links to AGENTS.md section 7, and records that any later schema change is a breaking change requiring Sam's approval. - Scope respected: no content authored, no rendering, no site code. Files: Makefile, tools/validate_content.py, test/fixtures/**, docs/content-schema.md, .gitignore (__pycache__). --- .gitignore | 4 + Makefile | 19 + docs/content-schema.md | 184 ++++ .../content/assets/audio-rack.svg | 1 + .../fail-area-enum/content/edge/audio.md | 21 + .../content/edge/l3/audio-architecture.md | 17 + test/fixtures/fail-area-enum/expect.txt | 1 + .../content/assets/audio-rack.svg | 1 + .../content/edge/audio.md | 21 + .../content/edge/l3/audio-architecture.md | 17 + .../fail-artefact-external-url/expect.txt | 1 + .../content/assets/audio-rack.svg | 1 + .../content/edge/audio.md | 21 + .../content/edge/l3/audio-architecture.md | 17 + .../fail-artefact-missing-url/expect.txt | 1 + .../content/assets/audio-rack.svg | 1 + .../content/edge/audio.md | 21 + .../content/edge/l3/audio-architecture.md | 17 + .../fail-artefact-type-enum/expect.txt | 1 + .../content/assets/audio-rack.svg | 1 + .../fail-bad-yaml/content/edge/audio.md | 21 + .../content/edge/l3/audio-architecture.md | 17 + test/fixtures/fail-bad-yaml/expect.txt | 1 + .../content/assets/audio-rack.svg | 1 + .../content/edge/audio.md | 21 + .../content/edge/l3/audio-architecture.md | 17 + test/fixtures/fail-confidence-enum/expect.txt | 1 + .../content/assets/audio-rack.svg | 1 + .../content/edge/audio.md | 21 + .../content/edge/l3/audio-architecture.md | 17 + .../fail-dangling-artefact/expect.txt | 1 + .../content/assets/audio-rack.svg | 1 + .../fail-duplicate-id/content/edge/audio.md | 21 + .../content/edge/l3/audio-architecture.md | 17 + .../content/platform/audio.md | 21 + test/fixtures/fail-duplicate-id/expect.txt | 1 + .../content/assets/audio-rack.svg | 1 + .../content/edge/audio.md | 21 + .../content/edge/l3/audio-architecture.md | 17 + .../fail-filename-mismatch/expect.txt | 1 + .../content/assets/audio-rack.svg | 1 + .../content/edge/audio.md | 21 + .../content/edge/l3/audio-architecture.md | 17 + .../fail-last-verified-date/expect.txt | 1 + .../content/assets/audio-rack.svg | 1 + .../content/edge/audio.md | 21 + .../content/edge/l3/audio-architecture.md | 17 + .../fail-last-verified-format/expect.txt | 1 + .../content/assets/audio-rack.svg | 1 + .../fail-level-enum/content/edge/audio.md | 21 + .../content/edge/l3/audio-architecture.md | 17 + test/fixtures/fail-level-enum/expect.txt | 1 + .../content/assets/audio-rack.svg | 1 + .../fail-metrics-empty/content/edge/audio.md | 21 + .../content/edge/l3/audio-architecture.md | 17 + test/fixtures/fail-metrics-empty/expect.txt | 1 + .../content/assets/audio-rack.svg | 1 + .../fail-missing-field/content/edge/audio.md | 20 + .../content/edge/l3/audio-architecture.md | 17 + test/fixtures/fail-missing-field/expect.txt | 1 + .../content/edge/audio.md | 3 + test/fixtures/fail-no-front-matter/expect.txt | 1 + .../content/assets/audio-rack.svg | 1 + .../fail-unknown-field/content/edge/audio.md | 22 + .../content/edge/l3/audio-architecture.md | 17 + test/fixtures/fail-unknown-field/expect.txt | 1 + .../content/assets/audio-rack.svg | 1 + .../content/edge/audio.md | 19 + .../content/edge/l3/audio-architecture.md | 15 + .../fail-unknown-nested-field/expect.txt | 1 + .../content/about/background.md | 25 + test/fixtures/ok-block-scalars/expect.txt | 0 .../ok-clean/content/assets/audio-rack.svg | 1 + test/fixtures/ok-clean/content/edge/audio.md | 21 + .../content/edge/l3/audio-architecture.md | 17 + test/fixtures/ok-clean/expect.txt | 0 test/fixtures/ok-empty/content/.gitkeep | 0 test/fixtures/ok-empty/expect.txt | 0 tools/validate_content.py | 801 ++++++++++++++++++ 79 files changed, 1733 insertions(+) create mode 100644 Makefile create mode 100644 docs/content-schema.md create mode 100644 test/fixtures/fail-area-enum/content/assets/audio-rack.svg create mode 100644 test/fixtures/fail-area-enum/content/edge/audio.md create mode 100644 test/fixtures/fail-area-enum/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/fail-area-enum/expect.txt create mode 100644 test/fixtures/fail-artefact-external-url/content/assets/audio-rack.svg create mode 100644 test/fixtures/fail-artefact-external-url/content/edge/audio.md create mode 100644 test/fixtures/fail-artefact-external-url/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/fail-artefact-external-url/expect.txt create mode 100644 test/fixtures/fail-artefact-missing-url/content/assets/audio-rack.svg create mode 100644 test/fixtures/fail-artefact-missing-url/content/edge/audio.md create mode 100644 test/fixtures/fail-artefact-missing-url/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/fail-artefact-missing-url/expect.txt create mode 100644 test/fixtures/fail-artefact-type-enum/content/assets/audio-rack.svg create mode 100644 test/fixtures/fail-artefact-type-enum/content/edge/audio.md create mode 100644 test/fixtures/fail-artefact-type-enum/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/fail-artefact-type-enum/expect.txt create mode 100644 test/fixtures/fail-bad-yaml/content/assets/audio-rack.svg create mode 100644 test/fixtures/fail-bad-yaml/content/edge/audio.md create mode 100644 test/fixtures/fail-bad-yaml/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/fail-bad-yaml/expect.txt create mode 100644 test/fixtures/fail-confidence-enum/content/assets/audio-rack.svg create mode 100644 test/fixtures/fail-confidence-enum/content/edge/audio.md create mode 100644 test/fixtures/fail-confidence-enum/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/fail-confidence-enum/expect.txt create mode 100644 test/fixtures/fail-dangling-artefact/content/assets/audio-rack.svg create mode 100644 test/fixtures/fail-dangling-artefact/content/edge/audio.md create mode 100644 test/fixtures/fail-dangling-artefact/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/fail-dangling-artefact/expect.txt create mode 100644 test/fixtures/fail-duplicate-id/content/assets/audio-rack.svg create mode 100644 test/fixtures/fail-duplicate-id/content/edge/audio.md create mode 100644 test/fixtures/fail-duplicate-id/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/fail-duplicate-id/content/platform/audio.md create mode 100644 test/fixtures/fail-duplicate-id/expect.txt create mode 100644 test/fixtures/fail-filename-mismatch/content/assets/audio-rack.svg create mode 100644 test/fixtures/fail-filename-mismatch/content/edge/audio.md create mode 100644 test/fixtures/fail-filename-mismatch/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/fail-filename-mismatch/expect.txt create mode 100644 test/fixtures/fail-last-verified-date/content/assets/audio-rack.svg create mode 100644 test/fixtures/fail-last-verified-date/content/edge/audio.md create mode 100644 test/fixtures/fail-last-verified-date/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/fail-last-verified-date/expect.txt create mode 100644 test/fixtures/fail-last-verified-format/content/assets/audio-rack.svg create mode 100644 test/fixtures/fail-last-verified-format/content/edge/audio.md create mode 100644 test/fixtures/fail-last-verified-format/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/fail-last-verified-format/expect.txt create mode 100644 test/fixtures/fail-level-enum/content/assets/audio-rack.svg create mode 100644 test/fixtures/fail-level-enum/content/edge/audio.md create mode 100644 test/fixtures/fail-level-enum/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/fail-level-enum/expect.txt create mode 100644 test/fixtures/fail-metrics-empty/content/assets/audio-rack.svg create mode 100644 test/fixtures/fail-metrics-empty/content/edge/audio.md create mode 100644 test/fixtures/fail-metrics-empty/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/fail-metrics-empty/expect.txt create mode 100644 test/fixtures/fail-missing-field/content/assets/audio-rack.svg create mode 100644 test/fixtures/fail-missing-field/content/edge/audio.md create mode 100644 test/fixtures/fail-missing-field/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/fail-missing-field/expect.txt create mode 100644 test/fixtures/fail-no-front-matter/content/edge/audio.md create mode 100644 test/fixtures/fail-no-front-matter/expect.txt create mode 100644 test/fixtures/fail-unknown-field/content/assets/audio-rack.svg create mode 100644 test/fixtures/fail-unknown-field/content/edge/audio.md create mode 100644 test/fixtures/fail-unknown-field/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/fail-unknown-field/expect.txt create mode 100644 test/fixtures/fail-unknown-nested-field/content/assets/audio-rack.svg create mode 100644 test/fixtures/fail-unknown-nested-field/content/edge/audio.md create mode 100644 test/fixtures/fail-unknown-nested-field/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/fail-unknown-nested-field/expect.txt create mode 100644 test/fixtures/ok-block-scalars/content/about/background.md create mode 100644 test/fixtures/ok-block-scalars/expect.txt create mode 100644 test/fixtures/ok-clean/content/assets/audio-rack.svg create mode 100644 test/fixtures/ok-clean/content/edge/audio.md create mode 100644 test/fixtures/ok-clean/content/edge/l3/audio-architecture.md create mode 100644 test/fixtures/ok-clean/expect.txt create mode 100644 test/fixtures/ok-empty/content/.gitkeep create mode 100644 test/fixtures/ok-empty/expect.txt create mode 100644 tools/validate_content.py diff --git a/.gitignore b/.gitignore index 2ad2a82..bc3cbf6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# python +__pycache__/ +*.pyc + # build output /dist/ /current/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..db3d4de --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +# Ai Resume - build entry points. +# +# `make validate` is the single gate: it is what CI and deploy.sh call. +# It exits 0 only on a clean content tree. + +PYTHON ?= python3 +VALIDATOR := $(PYTHON) tools/validate_content.py + +.PHONY: validate test help + +help: + @echo "make validate validate content/** front-matter against the frozen schema" + @echo "make test run the validator self-test (fixtures under test/fixtures/)" + +validate: + @$(VALIDATOR) + +test: + @$(VALIDATOR) --self-test diff --git a/docs/content-schema.md b/docs/content-schema.md new file mode 100644 index 0000000..11e3d8f --- /dev/null +++ b/docs/content-schema.md @@ -0,0 +1,184 @@ +# Content schema (frozen) + +This document states the front-matter schema that every file in `content/` must carry, and the rules the validator enforces. The schema is the one in [`AGENTS.md` §7](../AGENTS.md#7-content-conventions). Read that section first; this page is the exact, testable restatement of it. + +**The schema is frozen.** Any change to the fields, their types, their enum values or the artefact rules is a **breaking change**: it invalidates every existing file in `content/` and both renderers. A change requires **Sam's written approval** before it is made. Do not widen or narrow the schema to make a document validate — fix the document. + +--- + +## 1. How to validate + +``` +make validate # the gate: exits 0 only on a clean tree +make test # validator self-test, 20 fixture cases +``` + +`make validate` is the command CI and `deploy.sh` call in later phases. Run it before every content commit. + +Both targets invoke: + +``` +python3 tools/validate_content.py [--root DIR] [--content DIR] [--quiet] +``` + +Exit codes: `0` clean (also when `content/` does not exist yet), `1` at least one violation, `2` usage or internal error. + +--- + +## 2. Document shape + +* Every `.md` file under `content/` is a **document** and is validated. Other file types under `content/` (images, SVG, PDF) are **assets** and are not validated — they exist to be referenced. +* A document starts with a front-matter block on line 1: + +``` +--- + +--- +``` + +* The closing delimiter is `---` (or `...`). Everything after it is the Markdown body and is not validated. +* One document per file. One front-matter block per file. +* `content/` is organised by area: + +``` +content/ + ai/ platform/ edge/ products/ history/ about/ # documents, plus l3/ below each + assets/ # diagrams, screenshots, logos +``` + +--- + +## 3. Fields + +All thirteen fields are **required on every document**, including L3 stubs (which use empty lists). Order does not matter. + +| Field | Type | Rule | +|---|---|---| +| `id` | string | Lowercase kebab-case: `a-z`, `0-9`, single hyphens between parts. **Must equal the file name** without `.md`. **Unique** across all of `content/`. | +| `area` | enum | One of `ai`, `platform`, `edge`, `products`, `history`, `about`. | +| `level` | integer | `1`, `2` or `3` (depth level: L1 landing, L2 capability, L3 artefact). | +| `title` | string | Non-empty. Corporate capability framing (see `AGENTS.md` §7); product names are evidence, not headings. | +| `claim` | string | Non-empty. One sentence a hiring manager can verify. | +| `confidence` | enum | One of `delivered`, `running`, `in-progress`, `abandoned`, `historic`. **Displayed on the page.** Never promote a value to make a page read better. | +| `period` | string | Non-empty. Free text, e.g. `"2023–"`, `"2019–2024"`, `"2000–2014"`. | +| `tech` | list of strings | Non-empty strings. May be `[]`. Product and technology names belong here, not in `title`. | +| `metrics` | list of maps | May be `[]`. Each item is exactly `{label, value}`. Both non-empty. `value` may be a number (`8`) or a string (`"50+"`). Every value must trace to the evidence notes — the validator checks shape, not truth. | +| `artefacts` | list of maps | May be `[]`. Each item is exactly `{type, label, url}`. See §4. | +| `public` | boolean | `true` or `false`. `false` keeps the document out of the published output. | +| `last_verified` | date | `YYYY-MM-DD` and a real calendar date. | +| `tags` | list of strings | Non-empty strings. May be `[]`. | + +**Unknown fields are rejected — at the top level and inside `metrics[]` and `artefacts[]`.** There is no extension point. A field that an author thinks is useful is a schema change (§6). + +The one exception to "all required": nothing. `tech: []`, `metrics: []`, `artefacts: []` and `tags: []` are the correct way to say "this document has none". An omitted field is a violation; an empty list is not. + +--- + +## 4. Artefact links — dangling references are fatal + +Every `artefacts[].url` must resolve to a file that exists in this repository. This is the rule that keeps every claim one step away from its proof. + +| `url` form | Resolves against | Example | +|---|---|---| +| `content/...` or any path without a leading `./` or `../` | the repository root | `content/edge/l3/audio-architecture.md` | +| `./...` or `../...` | the directory of the document | `./l3/audio-architecture.md` | + +Rules: + +* The target must be an existing **file** — an L3 stub document (`.md`) or an asset (`.svg`, `.png`, `.pdf`, …). A directory is a violation. +* **External URLs (`https://…`) are not allowed.** If the artefact lives outside the repository (a Gitea repo, a video), create an in-repo L3 stub document that describes and links it, and point `url` at that stub. This keeps every drill-down verifiable offline and keeps Mode A free of third-party dependencies. +* Root-absolute paths (`/content/...`) are not allowed — they do not survive a release directory move. +* A path that escapes the repository is a violation. + +`type` is one of `repo`, `image`, `video`, `doc` and describes the artefact behind the link, not the file extension of the stub. + +--- + +## 5. Cross-document rules + +* **`id` is unique** across `content/`. Two files with the same `id` are a violation on the second file. +* **`id` matches the file name.** `content/edge/snapcast.md` must have `id: snapcast`; an L3 stub `content/edge/l3/snapcast-code.md` must have `id: snapcast-code`. + +--- + +## 6. Changing the schema + +Breaking (requires Sam's written approval, then a migration of every file): + +* adding, removing or renaming a field +* changing a field's type +* adding or removing an enum value (`area`, `level`, `confidence`, artefact `type`) +* relaxing or tightening a rule (e.g. allowing external artefact URLs) + +Not a schema change: adding documents, filling empty lists, adding tags or metrics within the existing shape. + +A change that supersedes this document must update, in one commit: `AGENTS.md` §7, this file, `tools/validate_content.py`, and the fixtures under `test/fixtures/`. + +--- + +## 7. Error output + +One line per violation, on stdout: + +``` +:: : +``` + +Examples: + +``` +content/edge/audio.md:14: artefacts[0].url: dangling reference: 'content/edge/l3/not-written-yet.md' does not exist (create the L3 stub or asset) +content/edge/audio.md:16: author: unknown field (the schema is frozen; see docs/content-schema.md) +content/edge/audio.md:3: area: must be one of ai, platform, edge, products, history, about +content/edge/audio.md:1: claim: missing required field +``` + +A missing field is reported on line 1, because there is no line that carries it. A malformed front-matter block is reported as `: invalid YAML: …` and stops validation of that document only. + +The final line is either `FAIL: N violation(s) in M document(s)` or `OK: M document(s) validated, 0 violations`. + +--- + +## 8. Front-matter dialect the parser accepts + +The validator contains its own small YAML-subset parser so the repository needs no third-party dependency (`AGENTS.md` §4: the stack is frozen, and the `.27` build host has no Go toolchain yet). It accepts: + +* block mappings, block sequences, and flow collections: `tech: [a, b]`, `metrics: [{label: "endpoints", value: 8}]` +* single- and double-quoted scalars, plain scalars +* literal (`|`) and folded (`>`) block scalars, including `|-` / `>-` +* `#` comments outside quoted strings +* blank lines + +It does **not** accept: anchors/aliases (`&`, `*`), explicit tags (`!!str`), multi-document files, complex keys, tab indentation. Use two or four spaces, consistently within a block. + +Two consequences worth knowing: + +* A quoted string must be closed. `claim: "text # not a comment` is an error, not a comment. +* `#` after a space ends a plain scalar, as in YAML. Quote values that contain ` #`. + +--- + +## 9. What the validator does not check + +Enforced elsewhere in Phase 1, and reviewed by Sam: + +* that a `metric` value appears in the evidence notes (no invented numbers) +* that `confidence` matches reality — the validator cannot know; it only forces an honest label to be present +* the redaction pass (no family names, address, phone, credentials) +* that every L1 claim has a metric and at least one L3 artefact +* corporate framing of headings + +--- + +## 10. Implementation and tests + +| Path | Purpose | +|---|---| +| `tools/validate_content.py` | The validator. One file, Python 3 standard library only, no framework. | +| `Makefile` | `validate` and `test` targets. | +| `test/fixtures//content/` | Fixture trees. `ok-*` cases must pass, `fail-*` cases must fail. | +| `test/fixtures//expect.txt` | Substrings that `fail-*` output must contain, one per line. | + +Fixture cases cover: missing field, `area` enum, `level` enum, `confidence` enum, `last_verified` format and calendar validity, empty `metrics[].label`, artefact `type` enum, artefact missing `url`, dangling artefact, external artefact URL, duplicate `id`, `id`/filename mismatch, unknown top-level field, unknown nested artefact field, missing front-matter, malformed YAML. `ok-empty` proves an empty `content/` exits 0; `ok-block-scalars` proves the block-scalar and block-sequence dialects. + +Fixtures live under `test/`, never under `content/`. diff --git a/test/fixtures/fail-area-enum/content/assets/audio-rack.svg b/test/fixtures/fail-area-enum/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/fail-area-enum/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/fail-area-enum/content/edge/audio.md b/test/fixtures/fail-area-enum/content/edge/audio.md new file mode 100644 index 0000000..9cd4676 --- /dev/null +++ b/test/fixtures/fail-area-enum/content/edge/audio.md @@ -0,0 +1,21 @@ +--- +id: audio +area: security +level: 2 +title: "Distributed Audio" +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg"} +public: true +last_verified: 2026-10-20 +tags: [audio, snapcast] +--- + +# Distributed Audio diff --git a/test/fixtures/fail-area-enum/content/edge/l3/audio-architecture.md b/test/fixtures/fail-area-enum/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..e03783e --- /dev/null +++ b/test/fixtures/fail-area-enum/content/edge/l3/audio-architecture.md @@ -0,0 +1,17 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- + +# Audio architecture note diff --git a/test/fixtures/fail-area-enum/expect.txt b/test/fixtures/fail-area-enum/expect.txt new file mode 100644 index 0000000..3d1cad1 --- /dev/null +++ b/test/fixtures/fail-area-enum/expect.txt @@ -0,0 +1 @@ +area: must be one of ai, platform, edge, products, history, about diff --git a/test/fixtures/fail-artefact-external-url/content/assets/audio-rack.svg b/test/fixtures/fail-artefact-external-url/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/fail-artefact-external-url/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/fail-artefact-external-url/content/edge/audio.md b/test/fixtures/fail-artefact-external-url/content/edge/audio.md new file mode 100644 index 0000000..0cd3046 --- /dev/null +++ b/test/fixtures/fail-artefact-external-url/content/edge/audio.md @@ -0,0 +1,21 @@ +--- +id: audio +area: edge +level: 2 +title: "Distributed Audio" +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram", url: "https://example.invalid/rack.svg"} +public: true +last_verified: 2026-10-20 +tags: [audio, snapcast] +--- + +# Distributed Audio diff --git a/test/fixtures/fail-artefact-external-url/content/edge/l3/audio-architecture.md b/test/fixtures/fail-artefact-external-url/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..e03783e --- /dev/null +++ b/test/fixtures/fail-artefact-external-url/content/edge/l3/audio-architecture.md @@ -0,0 +1,17 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- + +# Audio architecture note diff --git a/test/fixtures/fail-artefact-external-url/expect.txt b/test/fixtures/fail-artefact-external-url/expect.txt new file mode 100644 index 0000000..27e88e6 --- /dev/null +++ b/test/fixtures/fail-artefact-external-url/expect.txt @@ -0,0 +1 @@ +artefacts[1].url: must be an in-repo path to an L3 stub or asset, not an external URL diff --git a/test/fixtures/fail-artefact-missing-url/content/assets/audio-rack.svg b/test/fixtures/fail-artefact-missing-url/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/fail-artefact-missing-url/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/fail-artefact-missing-url/content/edge/audio.md b/test/fixtures/fail-artefact-missing-url/content/edge/audio.md new file mode 100644 index 0000000..495eee0 --- /dev/null +++ b/test/fixtures/fail-artefact-missing-url/content/edge/audio.md @@ -0,0 +1,21 @@ +--- +id: audio +area: edge +level: 2 +title: "Distributed Audio" +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram"} +public: true +last_verified: 2026-10-20 +tags: [audio, snapcast] +--- + +# Distributed Audio diff --git a/test/fixtures/fail-artefact-missing-url/content/edge/l3/audio-architecture.md b/test/fixtures/fail-artefact-missing-url/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..e03783e --- /dev/null +++ b/test/fixtures/fail-artefact-missing-url/content/edge/l3/audio-architecture.md @@ -0,0 +1,17 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- + +# Audio architecture note diff --git a/test/fixtures/fail-artefact-missing-url/expect.txt b/test/fixtures/fail-artefact-missing-url/expect.txt new file mode 100644 index 0000000..9277acf --- /dev/null +++ b/test/fixtures/fail-artefact-missing-url/expect.txt @@ -0,0 +1 @@ +artefacts[1].url: missing required field diff --git a/test/fixtures/fail-artefact-type-enum/content/assets/audio-rack.svg b/test/fixtures/fail-artefact-type-enum/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/fail-artefact-type-enum/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/fail-artefact-type-enum/content/edge/audio.md b/test/fixtures/fail-artefact-type-enum/content/edge/audio.md new file mode 100644 index 0000000..81a5677 --- /dev/null +++ b/test/fixtures/fail-artefact-type-enum/content/edge/audio.md @@ -0,0 +1,21 @@ +--- +id: audio +area: edge +level: 2 +title: "Distributed Audio" +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: screenshot + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg"} +public: true +last_verified: 2026-10-20 +tags: [audio, snapcast] +--- + +# Distributed Audio diff --git a/test/fixtures/fail-artefact-type-enum/content/edge/l3/audio-architecture.md b/test/fixtures/fail-artefact-type-enum/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..e03783e --- /dev/null +++ b/test/fixtures/fail-artefact-type-enum/content/edge/l3/audio-architecture.md @@ -0,0 +1,17 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- + +# Audio architecture note diff --git a/test/fixtures/fail-artefact-type-enum/expect.txt b/test/fixtures/fail-artefact-type-enum/expect.txt new file mode 100644 index 0000000..5c1229a --- /dev/null +++ b/test/fixtures/fail-artefact-type-enum/expect.txt @@ -0,0 +1 @@ +artefacts[0].type: must be one of repo, image, video, doc diff --git a/test/fixtures/fail-bad-yaml/content/assets/audio-rack.svg b/test/fixtures/fail-bad-yaml/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/fail-bad-yaml/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/fail-bad-yaml/content/edge/audio.md b/test/fixtures/fail-bad-yaml/content/edge/audio.md new file mode 100644 index 0000000..604b8aa --- /dev/null +++ b/test/fixtures/fail-bad-yaml/content/edge/audio.md @@ -0,0 +1,21 @@ +--- +id: audio +area: edge +level: 2 +title: "Distributed Audio +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg"} +public: true +last_verified: 2026-10-20 +tags: [audio, snapcast] +--- + +# Distributed Audio diff --git a/test/fixtures/fail-bad-yaml/content/edge/l3/audio-architecture.md b/test/fixtures/fail-bad-yaml/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..e03783e --- /dev/null +++ b/test/fixtures/fail-bad-yaml/content/edge/l3/audio-architecture.md @@ -0,0 +1,17 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- + +# Audio architecture note diff --git a/test/fixtures/fail-bad-yaml/expect.txt b/test/fixtures/fail-bad-yaml/expect.txt new file mode 100644 index 0000000..470fc6d --- /dev/null +++ b/test/fixtures/fail-bad-yaml/expect.txt @@ -0,0 +1 @@ +invalid YAML: unterminated quoted string diff --git a/test/fixtures/fail-confidence-enum/content/assets/audio-rack.svg b/test/fixtures/fail-confidence-enum/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/fail-confidence-enum/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/fail-confidence-enum/content/edge/audio.md b/test/fixtures/fail-confidence-enum/content/edge/audio.md new file mode 100644 index 0000000..aa91729 --- /dev/null +++ b/test/fixtures/fail-confidence-enum/content/edge/audio.md @@ -0,0 +1,21 @@ +--- +id: audio +area: edge +level: 2 +title: "Distributed Audio" +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: planned +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg"} +public: true +last_verified: 2026-10-20 +tags: [audio, snapcast] +--- + +# Distributed Audio diff --git a/test/fixtures/fail-confidence-enum/content/edge/l3/audio-architecture.md b/test/fixtures/fail-confidence-enum/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..e03783e --- /dev/null +++ b/test/fixtures/fail-confidence-enum/content/edge/l3/audio-architecture.md @@ -0,0 +1,17 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- + +# Audio architecture note diff --git a/test/fixtures/fail-confidence-enum/expect.txt b/test/fixtures/fail-confidence-enum/expect.txt new file mode 100644 index 0000000..c47146c --- /dev/null +++ b/test/fixtures/fail-confidence-enum/expect.txt @@ -0,0 +1 @@ +confidence: must be one of delivered, running, in-progress, abandoned, historic diff --git a/test/fixtures/fail-dangling-artefact/content/assets/audio-rack.svg b/test/fixtures/fail-dangling-artefact/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/fail-dangling-artefact/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/fail-dangling-artefact/content/edge/audio.md b/test/fixtures/fail-dangling-artefact/content/edge/audio.md new file mode 100644 index 0000000..f78ebaa --- /dev/null +++ b/test/fixtures/fail-dangling-artefact/content/edge/audio.md @@ -0,0 +1,21 @@ +--- +id: audio +area: edge +level: 2 +title: "Distributed Audio" +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/not-written-yet.md" + - {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg"} +public: true +last_verified: 2026-10-20 +tags: [audio, snapcast] +--- + +# Distributed Audio diff --git a/test/fixtures/fail-dangling-artefact/content/edge/l3/audio-architecture.md b/test/fixtures/fail-dangling-artefact/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..e03783e --- /dev/null +++ b/test/fixtures/fail-dangling-artefact/content/edge/l3/audio-architecture.md @@ -0,0 +1,17 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- + +# Audio architecture note diff --git a/test/fixtures/fail-dangling-artefact/expect.txt b/test/fixtures/fail-dangling-artefact/expect.txt new file mode 100644 index 0000000..d41c164 --- /dev/null +++ b/test/fixtures/fail-dangling-artefact/expect.txt @@ -0,0 +1 @@ +artefacts[0].url: dangling reference: 'content/edge/l3/not-written-yet.md' does not exist diff --git a/test/fixtures/fail-duplicate-id/content/assets/audio-rack.svg b/test/fixtures/fail-duplicate-id/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/fail-duplicate-id/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/fail-duplicate-id/content/edge/audio.md b/test/fixtures/fail-duplicate-id/content/edge/audio.md new file mode 100644 index 0000000..da69873 --- /dev/null +++ b/test/fixtures/fail-duplicate-id/content/edge/audio.md @@ -0,0 +1,21 @@ +--- +id: audio +area: edge +level: 2 +title: "Distributed Audio" +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg"} +public: true +last_verified: 2026-10-20 +tags: [audio, snapcast] +--- + +# Distributed Audio diff --git a/test/fixtures/fail-duplicate-id/content/edge/l3/audio-architecture.md b/test/fixtures/fail-duplicate-id/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..e03783e --- /dev/null +++ b/test/fixtures/fail-duplicate-id/content/edge/l3/audio-architecture.md @@ -0,0 +1,17 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- + +# Audio architecture note diff --git a/test/fixtures/fail-duplicate-id/content/platform/audio.md b/test/fixtures/fail-duplicate-id/content/platform/audio.md new file mode 100644 index 0000000..0932e66 --- /dev/null +++ b/test/fixtures/fail-duplicate-id/content/platform/audio.md @@ -0,0 +1,21 @@ +--- +id: audio +area: platform +level: 2 +title: "Distributed Audio" +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg"} +public: true +last_verified: 2026-10-20 +tags: [audio, snapcast] +--- + +# Distributed Audio diff --git a/test/fixtures/fail-duplicate-id/expect.txt b/test/fixtures/fail-duplicate-id/expect.txt new file mode 100644 index 0000000..8e96d04 --- /dev/null +++ b/test/fixtures/fail-duplicate-id/expect.txt @@ -0,0 +1 @@ +duplicate id 'audio' diff --git a/test/fixtures/fail-filename-mismatch/content/assets/audio-rack.svg b/test/fixtures/fail-filename-mismatch/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/fail-filename-mismatch/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/fail-filename-mismatch/content/edge/audio.md b/test/fixtures/fail-filename-mismatch/content/edge/audio.md new file mode 100644 index 0000000..bcd82f6 --- /dev/null +++ b/test/fixtures/fail-filename-mismatch/content/edge/audio.md @@ -0,0 +1,21 @@ +--- +id: edge-audio +area: edge +level: 2 +title: "Distributed Audio" +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg"} +public: true +last_verified: 2026-10-20 +tags: [audio, snapcast] +--- + +# Distributed Audio diff --git a/test/fixtures/fail-filename-mismatch/content/edge/l3/audio-architecture.md b/test/fixtures/fail-filename-mismatch/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..e03783e --- /dev/null +++ b/test/fixtures/fail-filename-mismatch/content/edge/l3/audio-architecture.md @@ -0,0 +1,17 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- + +# Audio architecture note diff --git a/test/fixtures/fail-filename-mismatch/expect.txt b/test/fixtures/fail-filename-mismatch/expect.txt new file mode 100644 index 0000000..e3a62af --- /dev/null +++ b/test/fixtures/fail-filename-mismatch/expect.txt @@ -0,0 +1 @@ +must match the file name (expected 'audio', got 'edge-audio') diff --git a/test/fixtures/fail-last-verified-date/content/assets/audio-rack.svg b/test/fixtures/fail-last-verified-date/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/fail-last-verified-date/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/fail-last-verified-date/content/edge/audio.md b/test/fixtures/fail-last-verified-date/content/edge/audio.md new file mode 100644 index 0000000..8fe930c --- /dev/null +++ b/test/fixtures/fail-last-verified-date/content/edge/audio.md @@ -0,0 +1,21 @@ +--- +id: audio +area: edge +level: 2 +title: "Distributed Audio" +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg"} +public: true +last_verified: 2026-13-45 +tags: [audio, snapcast] +--- + +# Distributed Audio diff --git a/test/fixtures/fail-last-verified-date/content/edge/l3/audio-architecture.md b/test/fixtures/fail-last-verified-date/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..e03783e --- /dev/null +++ b/test/fixtures/fail-last-verified-date/content/edge/l3/audio-architecture.md @@ -0,0 +1,17 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- + +# Audio architecture note diff --git a/test/fixtures/fail-last-verified-date/expect.txt b/test/fixtures/fail-last-verified-date/expect.txt new file mode 100644 index 0000000..640fe3e --- /dev/null +++ b/test/fixtures/fail-last-verified-date/expect.txt @@ -0,0 +1 @@ +last_verified: must be a real calendar date (YYYY-MM-DD) diff --git a/test/fixtures/fail-last-verified-format/content/assets/audio-rack.svg b/test/fixtures/fail-last-verified-format/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/fail-last-verified-format/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/fail-last-verified-format/content/edge/audio.md b/test/fixtures/fail-last-verified-format/content/edge/audio.md new file mode 100644 index 0000000..c0a6793 --- /dev/null +++ b/test/fixtures/fail-last-verified-format/content/edge/audio.md @@ -0,0 +1,21 @@ +--- +id: audio +area: edge +level: 2 +title: "Distributed Audio" +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg"} +public: true +last_verified: "20 October 2026" +tags: [audio, snapcast] +--- + +# Distributed Audio diff --git a/test/fixtures/fail-last-verified-format/content/edge/l3/audio-architecture.md b/test/fixtures/fail-last-verified-format/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..e03783e --- /dev/null +++ b/test/fixtures/fail-last-verified-format/content/edge/l3/audio-architecture.md @@ -0,0 +1,17 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- + +# Audio architecture note diff --git a/test/fixtures/fail-last-verified-format/expect.txt b/test/fixtures/fail-last-verified-format/expect.txt new file mode 100644 index 0000000..4dcf5ee --- /dev/null +++ b/test/fixtures/fail-last-verified-format/expect.txt @@ -0,0 +1 @@ +last_verified: must be a date written as YYYY-MM-DD diff --git a/test/fixtures/fail-level-enum/content/assets/audio-rack.svg b/test/fixtures/fail-level-enum/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/fail-level-enum/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/fail-level-enum/content/edge/audio.md b/test/fixtures/fail-level-enum/content/edge/audio.md new file mode 100644 index 0000000..feafdc0 --- /dev/null +++ b/test/fixtures/fail-level-enum/content/edge/audio.md @@ -0,0 +1,21 @@ +--- +id: audio +area: edge +level: 4 +title: "Distributed Audio" +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg"} +public: true +last_verified: 2026-10-20 +tags: [audio, snapcast] +--- + +# Distributed Audio diff --git a/test/fixtures/fail-level-enum/content/edge/l3/audio-architecture.md b/test/fixtures/fail-level-enum/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..e03783e --- /dev/null +++ b/test/fixtures/fail-level-enum/content/edge/l3/audio-architecture.md @@ -0,0 +1,17 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- + +# Audio architecture note diff --git a/test/fixtures/fail-level-enum/expect.txt b/test/fixtures/fail-level-enum/expect.txt new file mode 100644 index 0000000..a3b2982 --- /dev/null +++ b/test/fixtures/fail-level-enum/expect.txt @@ -0,0 +1 @@ +level: must be the integer 1, 2 or 3 diff --git a/test/fixtures/fail-metrics-empty/content/assets/audio-rack.svg b/test/fixtures/fail-metrics-empty/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/fail-metrics-empty/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/fail-metrics-empty/content/edge/audio.md b/test/fixtures/fail-metrics-empty/content/edge/audio.md new file mode 100644 index 0000000..6e5c2d8 --- /dev/null +++ b/test/fixtures/fail-metrics-empty/content/edge/audio.md @@ -0,0 +1,21 @@ +--- +id: audio +area: edge +level: 2 +title: "Distributed Audio" +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg"} +public: true +last_verified: 2026-10-20 +tags: [audio, snapcast] +--- + +# Distributed Audio diff --git a/test/fixtures/fail-metrics-empty/content/edge/l3/audio-architecture.md b/test/fixtures/fail-metrics-empty/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..e03783e --- /dev/null +++ b/test/fixtures/fail-metrics-empty/content/edge/l3/audio-architecture.md @@ -0,0 +1,17 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- + +# Audio architecture note diff --git a/test/fixtures/fail-metrics-empty/expect.txt b/test/fixtures/fail-metrics-empty/expect.txt new file mode 100644 index 0000000..afdad74 --- /dev/null +++ b/test/fixtures/fail-metrics-empty/expect.txt @@ -0,0 +1 @@ +metrics[0].label: must be non-empty diff --git a/test/fixtures/fail-missing-field/content/assets/audio-rack.svg b/test/fixtures/fail-missing-field/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/fail-missing-field/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/fail-missing-field/content/edge/audio.md b/test/fixtures/fail-missing-field/content/edge/audio.md new file mode 100644 index 0000000..8a63df2 --- /dev/null +++ b/test/fixtures/fail-missing-field/content/edge/audio.md @@ -0,0 +1,20 @@ +--- +id: audio +area: edge +level: 2 +title: "Distributed Audio" +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg"} +public: true +last_verified: 2026-10-20 +tags: [audio, snapcast] +--- + +# Distributed Audio diff --git a/test/fixtures/fail-missing-field/content/edge/l3/audio-architecture.md b/test/fixtures/fail-missing-field/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..e03783e --- /dev/null +++ b/test/fixtures/fail-missing-field/content/edge/l3/audio-architecture.md @@ -0,0 +1,17 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- + +# Audio architecture note diff --git a/test/fixtures/fail-missing-field/expect.txt b/test/fixtures/fail-missing-field/expect.txt new file mode 100644 index 0000000..47b67ee --- /dev/null +++ b/test/fixtures/fail-missing-field/expect.txt @@ -0,0 +1 @@ +claim: missing required field diff --git a/test/fixtures/fail-no-front-matter/content/edge/audio.md b/test/fixtures/fail-no-front-matter/content/edge/audio.md new file mode 100644 index 0000000..d55d4bf --- /dev/null +++ b/test/fixtures/fail-no-front-matter/content/edge/audio.md @@ -0,0 +1,3 @@ +# Distributed Audio + +No front-matter block at all. diff --git a/test/fixtures/fail-no-front-matter/expect.txt b/test/fixtures/fail-no-front-matter/expect.txt new file mode 100644 index 0000000..a03c55a --- /dev/null +++ b/test/fixtures/fail-no-front-matter/expect.txt @@ -0,0 +1 @@ +missing front-matter block diff --git a/test/fixtures/fail-unknown-field/content/assets/audio-rack.svg b/test/fixtures/fail-unknown-field/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/fail-unknown-field/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/fail-unknown-field/content/edge/audio.md b/test/fixtures/fail-unknown-field/content/edge/audio.md new file mode 100644 index 0000000..ec4b721 --- /dev/null +++ b/test/fixtures/fail-unknown-field/content/edge/audio.md @@ -0,0 +1,22 @@ +--- +id: audio +area: edge +level: 2 +title: "Distributed Audio" +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg"} +public: true +last_verified: 2026-10-20 +tags: [audio, snapcast] +author: "Sam" +--- + +# Distributed Audio diff --git a/test/fixtures/fail-unknown-field/content/edge/l3/audio-architecture.md b/test/fixtures/fail-unknown-field/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..e03783e --- /dev/null +++ b/test/fixtures/fail-unknown-field/content/edge/l3/audio-architecture.md @@ -0,0 +1,17 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- + +# Audio architecture note diff --git a/test/fixtures/fail-unknown-field/expect.txt b/test/fixtures/fail-unknown-field/expect.txt new file mode 100644 index 0000000..54a9f7e --- /dev/null +++ b/test/fixtures/fail-unknown-field/expect.txt @@ -0,0 +1 @@ +author: unknown field (the schema is frozen; see docs/content-schema.md) diff --git a/test/fixtures/fail-unknown-nested-field/content/assets/audio-rack.svg b/test/fixtures/fail-unknown-nested-field/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/fail-unknown-nested-field/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/fail-unknown-nested-field/content/edge/audio.md b/test/fixtures/fail-unknown-nested-field/content/edge/audio.md new file mode 100644 index 0000000..43faf0e --- /dev/null +++ b/test/fixtures/fail-unknown-nested-field/content/edge/audio.md @@ -0,0 +1,19 @@ +--- +id: audio +area: edge +level: 2 +title: "Distributed Audio" +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg", caption: "the rack"} +public: true +last_verified: 2026-10-20 +tags: [audio, snapcast] +--- diff --git a/test/fixtures/fail-unknown-nested-field/content/edge/l3/audio-architecture.md b/test/fixtures/fail-unknown-nested-field/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..262c91b --- /dev/null +++ b/test/fixtures/fail-unknown-nested-field/content/edge/l3/audio-architecture.md @@ -0,0 +1,15 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- diff --git a/test/fixtures/fail-unknown-nested-field/expect.txt b/test/fixtures/fail-unknown-nested-field/expect.txt new file mode 100644 index 0000000..437a5da --- /dev/null +++ b/test/fixtures/fail-unknown-nested-field/expect.txt @@ -0,0 +1 @@ +artefacts[1].caption: unknown field (artefacts allow only 'type', 'label' and 'url') diff --git a/test/fixtures/ok-block-scalars/content/about/background.md b/test/fixtures/ok-block-scalars/content/about/background.md new file mode 100644 index 0000000..dedf540 --- /dev/null +++ b/test/fixtures/ok-block-scalars/content/about/background.md @@ -0,0 +1,25 @@ +--- +# a leading comment is allowed +id: background +area: about +level: 1 +title: >- + Twenty-five years of delivery +claim: | + Shipped software since 2000. + Now agent systems and the platform under them. +confidence: delivered +period: "2000-" +tech: + - "NixOS" + - 'Golang' +metrics: + - label: years + value: 25 +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [narrative, career] +--- + +# Twenty-five years of delivery diff --git a/test/fixtures/ok-block-scalars/expect.txt b/test/fixtures/ok-block-scalars/expect.txt new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/ok-clean/content/assets/audio-rack.svg b/test/fixtures/ok-clean/content/assets/audio-rack.svg new file mode 100644 index 0000000..e3bea05 --- /dev/null +++ b/test/fixtures/ok-clean/content/assets/audio-rack.svg @@ -0,0 +1 @@ + diff --git a/test/fixtures/ok-clean/content/edge/audio.md b/test/fixtures/ok-clean/content/edge/audio.md new file mode 100644 index 0000000..da69873 --- /dev/null +++ b/test/fixtures/ok-clean/content/edge/audio.md @@ -0,0 +1,21 @@ +--- +id: audio +area: edge +level: 2 +title: "Distributed Audio" +claim: "Eight synchronised endpoints with music ducking for voice." +confidence: running +period: "2023-" +tech: [Snapcast, Mopidy, Spotify] +metrics: [{label: "endpoints", value: 8}] +artefacts: + - type: doc + label: "Architecture note" + url: "content/edge/l3/audio-architecture.md" + - {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg"} +public: true +last_verified: 2026-10-20 +tags: [audio, snapcast] +--- + +# Distributed Audio diff --git a/test/fixtures/ok-clean/content/edge/l3/audio-architecture.md b/test/fixtures/ok-clean/content/edge/l3/audio-architecture.md new file mode 100644 index 0000000..e03783e --- /dev/null +++ b/test/fixtures/ok-clean/content/edge/l3/audio-architecture.md @@ -0,0 +1,17 @@ +--- +id: audio-architecture +area: edge +level: 3 +title: "Audio architecture note" +claim: "How the eight endpoints stay in sync." +confidence: running +period: "2023-" +tech: [] +metrics: [] +artefacts: [] +public: true +last_verified: 2026-10-20 +tags: [audio] +--- + +# Audio architecture note diff --git a/test/fixtures/ok-clean/expect.txt b/test/fixtures/ok-clean/expect.txt new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/ok-empty/content/.gitkeep b/test/fixtures/ok-empty/content/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/ok-empty/expect.txt b/test/fixtures/ok-empty/expect.txt new file mode 100644 index 0000000..e69de29 diff --git a/tools/validate_content.py b/tools/validate_content.py new file mode 100644 index 0000000..829edff --- /dev/null +++ b/tools/validate_content.py @@ -0,0 +1,801 @@ +#!/usr/bin/env python3 +"""Validate content/** front-matter against the frozen schema (AGENTS.md section 7). + +Single file, Python standard library only. No PyYAML, no framework, no other +dependency: the front-matter dialect is a deliberately small YAML subset and the +parser below is part of this file. See docs/content-schema.md for the schema. + +Exit codes + 0 clean tree (or nothing to validate) + 1 at least one violation + 2 usage / internal error + +Usage + python3 tools/validate_content.py [--root DIR] [--content DIR] [--quiet] + python3 tools/validate_content.py --self-test + +`make validate` is the supported entry point; CI and deploy.sh call the same +command. +""" + +from __future__ import annotations + +import argparse +import datetime +import os +import re +import sys + +# --------------------------------------------------------------------------- +# Frozen schema (AGENTS.md section 7). Changing anything here is a breaking +# change to every file in content/ and requires Sam's written approval. +# --------------------------------------------------------------------------- + +AREAS = ("ai", "platform", "edge", "products", "history", "about") +LEVELS = (1, 2, 3) +CONFIDENCE = ("delivered", "running", "in-progress", "abandoned", "historic") +ARTEFACT_TYPES = ("repo", "image", "video", "doc") + +REQUIRED_FIELDS = ( + "id", + "area", + "level", + "title", + "claim", + "confidence", + "period", + "tech", + "metrics", + "artefacts", + "public", + "last_verified", + "tags", +) +ALLOWED_FIELDS = frozenset(REQUIRED_FIELDS) + +METRIC_FIELDS = ("label", "value") +ARTEFACT_FIELDS = ("type", "label", "url") + +ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +# --------------------------------------------------------------------------- +# Minimal YAML-subset parser for front-matter +# +# Supported: block mappings, block sequences, flow sequences/mappings, +# single and double quoted scalars, plain scalars, literal (|) and folded (>) +# block scalars, comments. Not supported (and not needed by the schema): +# anchors, aliases, tags, multi-document streams, complex keys. +# --------------------------------------------------------------------------- + + +class YamlError(Exception): + def __init__(self, line: int, msg: str) -> None: + self.line = line + self.msg = msg + super().__init__(f"line {line}: {msg}") + + +class Node: + """A parsed front-matter node. `line` is the 1-based file line.""" + + __slots__ = ("kind", "value", "line", "keylines") + + def __init__(self, kind, value, line, keylines=None): + self.kind = kind # "map" | "seq" | "scalar" | "null" + self.value = value + self.line = line + # NB: an empty dict is falsy, so it must not be replaced by `or`. + self.keylines = {} if keylines is None else keylines + + def line_of(self, key): + return self.keylines.get(key) or self.line or 1 + + +def _is_seq_item(text: str) -> bool: + stripped = text.strip() + return stripped == "-" or stripped.startswith("- ") + + +def _strip_comment(text: str) -> str: + """Remove a trailing '#' comment that is not inside a quoted scalar.""" + out = [] + quote = None + i = 0 + while i < len(text): + ch = text[i] + if quote: + if quote == '"' and ch == "\\": + out.append(ch) + i += 1 + if i < len(text): + out.append(text[i]) + i += 1 + continue + if ch == quote: + if quote == "'" and i + 1 < len(text) and text[i + 1] == "'": + out.append("''") + i += 2 + continue + quote = None + out.append(ch) + else: + if ch in "\"'" and (i == 0 or text[i - 1] in " \t:[{,-"): + quote = ch + out.append(ch) + elif ch == "#" and (i == 0 or text[i - 1] in " \t"): + break + else: + out.append(ch) + i += 1 + return "".join(out).rstrip() + + +def _skip_ws(s: str, i: int) -> int: + while i < len(s) and s[i] in " \t": + i += 1 + return i + + +_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", "0": "\0", '"': '"', "\\": "\\", "/": "/"} + + +def _read_quoted(s: str, i: int, lineno: int): + quote = s[i] + j = i + 1 + buf = [] + while j < len(s): + ch = s[j] + if quote == '"' and ch == "\\": + if j + 1 >= len(s): + raise YamlError(lineno, "unterminated escape in double-quoted string") + buf.append(_ESCAPES.get(s[j + 1], s[j + 1])) + j += 2 + continue + if ch == quote: + if quote == "'" and j + 1 < len(s) and s[j + 1] == "'": + buf.append("'") + j += 2 + continue + return Node("scalar", "".join(buf), lineno), j + 1 + buf.append(ch) + j += 1 + raise YamlError(lineno, "unterminated quoted string") + + +def _flow_scalar(s: str, i: int, lineno: int, in_key: bool = False): + i = _skip_ws(s, i) + if i >= len(s): + raise YamlError(lineno, "unexpected end of flow collection") + if s[i] in "\"'": + return _read_quoted(s, i, lineno) + j = i + while j < len(s): + ch = s[j] + if ch in ",]}": + break + if ch == ":" and in_key: + break + j += 1 + return Node("scalar", s[i:j].strip(), lineno), j + + +def _flow_value(s: str, i: int, lineno: int): + i = _skip_ws(s, i) + if i >= len(s): + raise YamlError(lineno, "unexpected end of flow collection") + ch = s[i] + if ch == "[": + i += 1 + items = [] + node = Node("seq", items, lineno) + while True: + i = _skip_ws(s, i) + if i >= len(s): + raise YamlError(lineno, "unterminated flow sequence '['") + if s[i] == "]": + i += 1 + return node, i + value, i = _flow_value(s, i, lineno) + items.append(value) + i = _skip_ws(s, i) + if i < len(s) and s[i] == ",": + i += 1 + continue + if i < len(s) and s[i] == "]": + i += 1 + return node, i + raise YamlError(lineno, "expected ',' or ']' in flow sequence") + if ch == "{": + i += 1 + mapping = {} + keylines = {} + node = Node("map", mapping, lineno, keylines) + while True: + i = _skip_ws(s, i) + if i >= len(s): + raise YamlError(lineno, "unterminated flow mapping '{'") + if s[i] == "}": + i += 1 + return node, i + key_node, i = _flow_scalar(s, i, lineno, in_key=True) + key = key_node.value + i = _skip_ws(s, i) + if i >= len(s) or s[i] != ":": + raise YamlError(lineno, f"expected ':' after key {key!r} in flow mapping") + i += 1 + value, i = _flow_value(s, i, lineno) + if key in mapping: + raise YamlError(lineno, f"duplicate key {key!r}") + mapping[key] = value + keylines[key] = lineno + i = _skip_ws(s, i) + if i < len(s) and s[i] == ",": + i += 1 + continue + if i < len(s) and s[i] == "}": + i += 1 + return node, i + raise YamlError(lineno, "expected ',' or '}' in flow mapping") + return _flow_scalar(s, i, lineno) + + +_KEY_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9_.\-]*)\s*:(?:\s+(.*))?$") +_MAP_START_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.\-]*\s*:\s*(\S.*)?$") + + +class _BlockParser: + def __init__(self, lines): + # lines: list of (lineno, text) + self.lines = list(lines) + self.i = 0 + + # -- helpers ---------------------------------------------------------- + def _skip(self): + while self.i < len(self.lines): + text = self.lines[self.i][1] + if text.strip() == "" or text.strip().startswith("#"): + self.i += 1 + else: + break + + def _indent(self, text, lineno): + head = text[: len(text) - len(text.lstrip(" \t"))] + if "\t" in head: + raise YamlError(lineno, "tab indentation is not allowed; use spaces") + return len(head) + + def _last_line(self): + return self.lines[-1][0] if self.lines else 1 + + # -- entry point ------------------------------------------------------ + def parse_document(self): + self._skip() + if self.i >= len(self.lines): + return Node("map", {}, self._last_line()) + lineno, text = self.lines[self.i] + return self.parse_block(self._indent(text, lineno)) + + def parse_block(self, indent): + self._skip() + if self.i >= len(self.lines): + return Node("map", {}, self._last_line()) + lineno, text = self.lines[self.i] + if _is_seq_item(text): + return self.parse_seq(self._indent(text, lineno)) + return self.parse_map(self._indent(text, lineno)) + + # -- mappings --------------------------------------------------------- + def parse_map(self, indent): + first_line = self.lines[self.i][0] + mapping = {} + keylines = {} + node = Node("map", mapping, first_line, keylines) + while True: + self._skip() + if self.i >= len(self.lines): + break + lineno, text = self.lines[self.i] + ind = self._indent(text, lineno) + if ind < indent: + break + if ind > indent: + raise YamlError(lineno, "unexpected indentation") + content = _strip_comment(text.strip()) + if not content: + self.i += 1 + continue + if _is_seq_item(content): + raise YamlError(lineno, "unexpected sequence item, expected 'key: value'") + match = _KEY_RE.match(content) + if not match: + raise YamlError(lineno, "expected 'key: value'") + key = match.group(1) + rest = (match.group(2) or "").strip() + if key in mapping: + raise YamlError(lineno, f"duplicate key {key!r}") + self.i += 1 + if rest == "": + child = self._value_block(indent, lineno) + elif rest[0] in "[{": + child, end = _flow_value(rest, 0, lineno) + trailing = _strip_comment(rest[end:].strip()) + if trailing: + raise YamlError(lineno, f"trailing characters after value: {trailing!r}") + elif rest[0] in "|>": + child = self._block_scalar(rest, indent, lineno) + elif rest[0] in "\"'": + child, end = _read_quoted(rest, 0, lineno) + trailing = _strip_comment(rest[end:].strip()) + if trailing: + raise YamlError(lineno, f"trailing characters after quoted value: {trailing!r}") + else: + child = Node("scalar", rest, lineno) + mapping[key] = child + keylines[key] = child.line or lineno + return node + + def _value_block(self, indent, lineno): + self._skip() + if self.i < len(self.lines): + next_lineno, next_text = self.lines[self.i] + next_ind = self._indent(next_text, next_lineno) + if next_ind > indent: + return self.parse_block(next_ind) + if next_ind == indent and _is_seq_item(next_text): + return self.parse_seq(next_ind) + return Node("null", None, lineno) + + # -- sequences -------------------------------------------------------- + def parse_seq(self, indent): + first_line = self.lines[self.i][0] + items = [] + node = Node("seq", items, first_line) + while True: + self._skip() + if self.i >= len(self.lines): + break + lineno, text = self.lines[self.i] + ind = self._indent(text, lineno) + if ind < indent: + break + if ind > indent: + raise YamlError(lineno, "unexpected indentation") + content = _strip_comment(text.strip()) + if not content: + self.i += 1 + continue + if not _is_seq_item(content): + break + rest = content[1:].strip() + if rest == "": + self.i += 1 + self._skip() + if self.i < len(self.lines): + next_lineno, next_text = self.lines[self.i] + if self._indent(next_text, next_lineno) > indent: + items.append(self.parse_block(self._indent(next_text, next_lineno))) + continue + items.append(Node("null", None, lineno)) + elif rest[0] in "[{": + value, end = _flow_value(rest, 0, lineno) + trailing = _strip_comment(rest[end:].strip()) + if trailing: + raise YamlError(lineno, f"trailing characters after value: {trailing!r}") + self.i += 1 + items.append(value) + elif rest[0] in "\"'": + value, end = _read_quoted(rest, 0, lineno) + trailing = _strip_comment(rest[end:].strip()) + if trailing: + raise YamlError(lineno, f"trailing characters after quoted value: {trailing!r}") + self.i += 1 + items.append(value) + elif rest[0] in "|>": + self.i += 1 + items.append(self._block_scalar(rest, indent, lineno)) + elif _MAP_START_RE.match(rest): + # Inline first key of a mapping item: rewrite the line at the + # item's child indent and let the mapping parser consume it. + self.lines[self.i] = (lineno, " " * (indent + 2) + rest) + items.append(self.parse_map(indent + 2)) + else: + self.i += 1 + items.append(Node("scalar", rest, lineno)) + return node + + # -- block scalars ---------------------------------------------------- + def _block_scalar(self, header, indent, lineno): + style = header[0] + collected = [] + while self.i < len(self.lines): + lno, text = self.lines[self.i] + if text.strip() == "": + collected.append("") + self.i += 1 + continue + ind = self._indent(text, lno) + if ind <= indent: + break + collected.append(text) + self.i += 1 + widths = [len(t) - len(t.lstrip(" ")) for t in collected if t.strip()] + cut = min(widths) if widths else 0 + rows = [t[cut:] if t.strip() else "" for t in collected] + while rows and rows[-1] == "": + rows.pop() + if style == "|": + text = "\n".join(rows) + else: + folded = [] + for row in rows: + if row == "": + folded.append("\n") + else: + folded.append(row.strip() + " ") + text = "".join(folded).strip() + return Node("scalar", text, lineno) + + +def parse_front_matter(fm_lines): + return _BlockParser(fm_lines).parse_document() + + +def split_front_matter(path): + """Return (fm_lines, body, problem). fm_lines are (lineno, text) tuples.""" + try: + with open(path, "r", encoding="utf-8-sig") as handle: + raw = handle.read() + except OSError as exc: + return None, None, f"cannot read file: {exc.strerror}" + lines = raw.splitlines() + if not lines or lines[0].strip() != "---": + return None, None, "missing front-matter block (the file must start with '---')" + end = None + for index in range(1, len(lines)): + if lines[index].strip() in ("---", "..."): + end = index + break + if end is None: + return None, None, "unterminated front-matter block (no closing '---')" + fm_lines = [(index + 1, lines[index]) for index in range(1, end)] + body = "\n".join(lines[end + 1:]) + return fm_lines, body, None + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +def _is_empty_value(node): + if node is None: + return True + if node.kind == "null": + return True + if node.kind == "scalar": + return (node.value or "").strip() == "" + return False + + +def validate_document(root, path, rel): + """Validate one document. Returns (violations, doc_id, doc_id_line).""" + violations = [] + + def add(line, field, rule): + violations.append(f"{rel}:{int(line or 1)}: {field}: {rule}") + + fm_lines, _body, problem = split_front_matter(path) + if problem: + add(1, "", problem) + return violations, None, 1 + try: + doc = parse_front_matter(fm_lines) + except YamlError as exc: + add(exc.line, "", f"invalid YAML: {exc.msg}") + return violations, None, exc.line + if doc.kind != "map": + add(doc.line, "", "must be a mapping of fields") + return violations, None, doc.line + + fields = doc.value + + # --- unknown fields (frozen schema) --------------------------------- + for key in sorted(fields, key=lambda k: (doc.line_of(k), k)): + if key not in ALLOWED_FIELDS: + add(doc.line_of(key), key, "unknown field (the schema is frozen; see docs/content-schema.md)") + + # --- required fields ------------------------------------------------ + for key in REQUIRED_FIELDS: + if key not in fields: + add(1, key, "missing required field") + + # --- scalar helpers ------------------------------------------------- + def scalar(key, allow_empty=False): + node = fields.get(key) + if node is None: + return None + if node.kind != "scalar": + add(node.line, key, "must be a string") + return None + value = (node.value or "").strip() + if not allow_empty and value == "": + add(node.line, key, "must be a non-empty string") + return None + return value + + def sequence(key): + node = fields.get(key) + if node is None: + return None + if node.kind != "seq": + add(node.line, key, "must be a list") + return None + return node + + # --- id ------------------------------------------------------------- + doc_id = scalar("id") + if doc_id is not None and not ID_PATTERN.match(doc_id): + add(fields["id"].line, "id", "must be lowercase kebab-case (a-z, 0-9, single hyphens)") + + # --- area / level / confidence / public ----------------------------- + area = scalar("area") + if area is not None and area not in AREAS: + add(fields["area"].line, "area", "must be one of " + ", ".join(AREAS)) + + level_node = fields.get("level") + if level_node is not None: + if level_node.kind != "scalar": + add(level_node.line, "level", "must be the integer 1, 2 or 3") + else: + raw = (level_node.value or "").strip() + if not re.fullmatch(r"[0-9]+", raw or ""): + add(level_node.line, "level", "must be the integer 1, 2 or 3") + elif int(raw) not in LEVELS: + add(level_node.line, "level", "must be the integer 1, 2 or 3") + + confidence = scalar("confidence") + if confidence is not None and confidence not in CONFIDENCE: + add(fields["confidence"].line, "confidence", "must be one of " + ", ".join(CONFIDENCE)) + + public_node = fields.get("public") + if public_node is not None: + if public_node.kind != "scalar" or (public_node.value or "").strip().lower() not in ("true", "false"): + add(public_node.line, "public", "must be true or false") + + # --- title / claim / period ----------------------------------------- + scalar("title") + scalar("claim") + scalar("period") + + # --- last_verified --------------------------------------------------- + verified = scalar("last_verified") + if verified is not None: + node = fields["last_verified"] + if not DATE_PATTERN.match(verified): + add(node.line, "last_verified", "must be a date written as YYYY-MM-DD") + else: + try: + datetime.date.fromisoformat(verified) + except ValueError: + add(node.line, "last_verified", "must be a real calendar date (YYYY-MM-DD)") + + # --- tech / tags: list of non-empty strings -------------------------- + for key in ("tech", "tags"): + seq = sequence(key) + if seq is None: + continue + for index, item in enumerate(seq.value): + if item.kind != "scalar" or (item.value or "").strip() == "": + add(item.line or seq.line, f"{key}[{index}]", "must be a non-empty string") + + # --- metrics: list of {label, value} -------------------------------- + metrics = sequence("metrics") + if metrics is not None: + for index, item in enumerate(metrics.value): + label = f"metrics[{index}]" + if item.kind != "map": + add(item.line or metrics.line, label, "must be a mapping with 'label' and 'value'") + continue + for key in item.value: + if key not in METRIC_FIELDS: + add(item.line_of(key), f"{label}.{key}", "unknown field (metrics allow only 'label' and 'value')") + for key in METRIC_FIELDS: + if key not in item.value: + add(item.line or metrics.line, f"{label}.{key}", "missing required field") + continue + if _is_empty_value(item.value[key]): + add(item.value[key].line or item.line, f"{label}.{key}", "must be non-empty") + + # --- artefacts: list of {type, label, url} -------------------------- + artefacts = sequence("artefacts") + if artefacts is not None: + for index, item in enumerate(artefacts.value): + label = f"artefacts[{index}]" + if item.kind != "map": + add(item.line or artefacts.line, label, "must be a mapping with 'type', 'label' and 'url'") + continue + for key in item.value: + if key not in ARTEFACT_FIELDS: + add(item.line_of(key), f"{label}.{key}", "unknown field (artefacts allow only 'type', 'label' and 'url')") + for key in ARTEFACT_FIELDS: + if key not in item.value: + add(item.line or artefacts.line, f"{label}.{key}", "missing required field") + type_node = item.value.get("type") + if type_node is not None: + value = (type_node.value or "").strip() if type_node.kind == "scalar" else None + if value not in ARTEFACT_TYPES: + add(type_node.line or item.line, f"{label}.type", "must be one of " + ", ".join(ARTEFACT_TYPES)) + label_node = item.value.get("label") + if label_node is not None and _is_empty_value(label_node): + add(label_node.line or item.line, f"{label}.label", "must be non-empty") + url_node = item.value.get("url") + if url_node is not None: + if _is_empty_value(url_node): + add(url_node.line or item.line, f"{label}.url", "must be non-empty") + else: + problem_text = resolve_artefact_url(root, path, (url_node.value or "").strip()) + if problem_text: + add(url_node.line or item.line, f"{label}.url", problem_text) + + # --- id matches file name ------------------------------------------- + if doc_id is not None: + stem = os.path.splitext(os.path.basename(path))[0] + if doc_id != stem: + add(fields["id"].line, "id", f"must match the file name (expected '{stem}', got '{doc_id}')") + + return violations, doc_id, doc.line_of("id") + + +def resolve_artefact_url(root, doc_path, url): + """Return None when `url` resolves to an existing in-repo file, else a rule text.""" + if "://" in url or url.startswith("//"): + return "must be an in-repo path to an L3 stub or asset, not an external URL" + if url.startswith("/"): + return "must be relative (repo-root or document-relative), not root-absolute" + if url.startswith("./") or url.startswith("../"): + base = os.path.dirname(doc_path) + else: + base = root + target = os.path.normpath(os.path.join(base, url)) + root_norm = os.path.normpath(root) + if target != root_norm and not target.startswith(root_norm + os.sep): + return f"resolves outside the repo ({url})" + if not os.path.exists(target): + return f"dangling reference: '{url}' does not exist (create the L3 stub or asset)" + if os.path.isdir(target): + return f"dangling reference: '{url}' is a directory, not a document or asset" + return None + + +def iter_documents(content_dir): + found = [] + for dirpath, dirnames, filenames in os.walk(content_dir): + dirnames.sort() + for name in sorted(filenames): + if name.endswith(".md"): + found.append(os.path.join(dirpath, name)) + return found + + +def validate_tree(root, content_dir, quiet=False): + """Validate every document under content_dir. Returns (violations, count).""" + root = os.path.abspath(root) + content_dir = os.path.abspath(content_dir) + violations = [] + documents = [] + if os.path.isdir(content_dir): + for path in iter_documents(content_dir): + rel = os.path.relpath(path, root) + doc_violations, doc_id, id_line = validate_document(root, path, rel) + violations.extend(doc_violations) + documents.append((rel, doc_id, id_line)) + elif not quiet: + print(f"note: {os.path.relpath(content_dir, root)}/ does not exist - nothing to validate") + + # --- cross-document rules: id uniqueness ---------------------------- + seen = {} + for rel, doc_id, id_line in documents: + if doc_id is None: + continue + if doc_id in seen: + violations.append( + f"{rel}:{int(id_line or 1)}: id: duplicate id '{doc_id}' (already used by {seen[doc_id]})" + ) + else: + seen[doc_id] = rel + + if not quiet: + for line in violations: + print(line) + if violations: + print(f"FAIL: {len(violations)} violation(s) in {len(documents)} document(s)") + else: + print(f"OK: {len(documents)} document(s) validated, 0 violations") + return violations, len(documents) + + +# --------------------------------------------------------------------------- +# Self-test: fixtures under test/fixtures// { content/, expect.txt } +# Cases named ok-* must exit 0; cases named fail-* must exit non-zero and the +# output must contain every line of expect.txt. +# --------------------------------------------------------------------------- + + +def run_self_test(root): + fixtures_dir = os.path.join(root, "test", "fixtures") + if not os.path.isdir(fixtures_dir): + print(f"FAIL: fixtures directory not found: {fixtures_dir}") + return 2 + cases = sorted(entry for entry in os.listdir(fixtures_dir) + if os.path.isdir(os.path.join(fixtures_dir, entry))) + if not cases: + print("FAIL: no fixture cases found") + return 2 + failed = 0 + for case in cases: + case_dir = os.path.join(fixtures_dir, case) + content_dir = os.path.join(case_dir, "content") + violations, documents = validate_tree(case_dir, content_dir, quiet=True) + problems = [] + if case.startswith("fail-"): + if not violations: + problems.append("expected a non-zero exit, got a clean result") + elif not violations: + pass + else: + problems.append(f"expected a clean result, got {len(violations)} violation(s)") + + expect_path = os.path.join(case_dir, "expect.txt") + if os.path.isfile(expect_path): + with open(expect_path, "r", encoding="utf-8") as handle: + expected = [line.strip() for line in handle if line.strip()] + else: + expected = [] + for needle in expected: + if not any(needle in violation for violation in violations): + problems.append(f"missing expected output: {needle!r}") + + if problems: + failed += 1 + print(f"FAIL {case}") + for problem in problems: + print(f" - {problem}") + for violation in violations: + print(f" got: {violation}") + else: + note = "exit 0" if not violations else f"{len(violations)} violation(s)" + print(f"PASS {case} ({documents} document(s), {note})") + + print(f"\n{len(cases) - failed}/{len(cases)} fixture case(s) passed") + return 0 if failed == 0 else 1 + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main(argv=None): + default_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + parser = argparse.ArgumentParser( + description="Validate content/** front-matter against the frozen schema (AGENTS.md section 7).", + ) + parser.add_argument("--root", default=default_root, help="repo root (default: parent of tools/)") + parser.add_argument("--content", default=None, help="content directory (default: /content)") + parser.add_argument("--quiet", action="store_true", help="print nothing except violations") + parser.add_argument("--self-test", action="store_true", help="run the fixture self-test") + args = parser.parse_args(argv) + + root = os.path.abspath(args.root) + if args.self_test: + return run_self_test(root) + + content_dir = os.path.abspath(args.content) if args.content else os.path.join(root, "content") + violations, _documents = validate_tree(root, content_dir, quiet=args.quiet) + return 1 if violations else 0 + + +if __name__ == "__main__": + sys.exit(main())