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__).
4
.gitignore
vendored
@@ -1,3 +1,7 @@
|
||||
# python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# build output
|
||||
/dist/
|
||||
/current/
|
||||
|
||||
19
Makefile
Normal file
@@ -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
|
||||
184
docs/content-schema.md
Normal file
@@ -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:
|
||||
|
||||
```
|
||||
---
|
||||
<fields>
|
||||
---
|
||||
```
|
||||
|
||||
* 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:
|
||||
|
||||
```
|
||||
<path>:<line>: <field>: <rule broken>
|
||||
```
|
||||
|
||||
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 `<front-matter>: 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/<case>/content/` | Fixture trees. `ok-*` cases must pass, `fail-*` cases must fail. |
|
||||
| `test/fixtures/<case>/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/`.
|
||||
1
test/fixtures/fail-area-enum/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
21
test/fixtures/fail-area-enum/content/edge/audio.md
vendored
Normal file
@@ -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
|
||||
17
test/fixtures/fail-area-enum/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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
|
||||
1
test/fixtures/fail-area-enum/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
area: must be one of ai, platform, edge, products, history, about
|
||||
1
test/fixtures/fail-artefact-external-url/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
21
test/fixtures/fail-artefact-external-url/content/edge/audio.md
vendored
Normal file
@@ -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
|
||||
17
test/fixtures/fail-artefact-external-url/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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
|
||||
1
test/fixtures/fail-artefact-external-url/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
artefacts[1].url: must be an in-repo path to an L3 stub or asset, not an external URL
|
||||
1
test/fixtures/fail-artefact-missing-url/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
21
test/fixtures/fail-artefact-missing-url/content/edge/audio.md
vendored
Normal file
@@ -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
|
||||
17
test/fixtures/fail-artefact-missing-url/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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
|
||||
1
test/fixtures/fail-artefact-missing-url/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
artefacts[1].url: missing required field
|
||||
1
test/fixtures/fail-artefact-type-enum/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
21
test/fixtures/fail-artefact-type-enum/content/edge/audio.md
vendored
Normal file
@@ -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
|
||||
17
test/fixtures/fail-artefact-type-enum/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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
|
||||
1
test/fixtures/fail-artefact-type-enum/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
artefacts[0].type: must be one of repo, image, video, doc
|
||||
1
test/fixtures/fail-bad-yaml/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
21
test/fixtures/fail-bad-yaml/content/edge/audio.md
vendored
Normal file
@@ -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
|
||||
17
test/fixtures/fail-bad-yaml/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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
|
||||
1
test/fixtures/fail-bad-yaml/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
invalid YAML: unterminated quoted string
|
||||
1
test/fixtures/fail-confidence-enum/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
21
test/fixtures/fail-confidence-enum/content/edge/audio.md
vendored
Normal file
@@ -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
|
||||
17
test/fixtures/fail-confidence-enum/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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
|
||||
1
test/fixtures/fail-confidence-enum/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
confidence: must be one of delivered, running, in-progress, abandoned, historic
|
||||
1
test/fixtures/fail-dangling-artefact/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
21
test/fixtures/fail-dangling-artefact/content/edge/audio.md
vendored
Normal file
@@ -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
|
||||
17
test/fixtures/fail-dangling-artefact/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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
|
||||
1
test/fixtures/fail-dangling-artefact/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
artefacts[0].url: dangling reference: 'content/edge/l3/not-written-yet.md' does not exist
|
||||
1
test/fixtures/fail-duplicate-id/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
21
test/fixtures/fail-duplicate-id/content/edge/audio.md
vendored
Normal file
@@ -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
|
||||
17
test/fixtures/fail-duplicate-id/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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
|
||||
21
test/fixtures/fail-duplicate-id/content/platform/audio.md
vendored
Normal file
@@ -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
|
||||
1
test/fixtures/fail-duplicate-id/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
duplicate id 'audio'
|
||||
1
test/fixtures/fail-filename-mismatch/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
21
test/fixtures/fail-filename-mismatch/content/edge/audio.md
vendored
Normal file
@@ -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
|
||||
17
test/fixtures/fail-filename-mismatch/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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
|
||||
1
test/fixtures/fail-filename-mismatch/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
must match the file name (expected 'audio', got 'edge-audio')
|
||||
1
test/fixtures/fail-last-verified-date/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
21
test/fixtures/fail-last-verified-date/content/edge/audio.md
vendored
Normal file
@@ -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
|
||||
17
test/fixtures/fail-last-verified-date/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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
|
||||
1
test/fixtures/fail-last-verified-date/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
last_verified: must be a real calendar date (YYYY-MM-DD)
|
||||
1
test/fixtures/fail-last-verified-format/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
21
test/fixtures/fail-last-verified-format/content/edge/audio.md
vendored
Normal file
@@ -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
|
||||
17
test/fixtures/fail-last-verified-format/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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
|
||||
1
test/fixtures/fail-last-verified-format/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
last_verified: must be a date written as YYYY-MM-DD
|
||||
1
test/fixtures/fail-level-enum/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
21
test/fixtures/fail-level-enum/content/edge/audio.md
vendored
Normal file
@@ -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
|
||||
17
test/fixtures/fail-level-enum/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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
|
||||
1
test/fixtures/fail-level-enum/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
level: must be the integer 1, 2 or 3
|
||||
1
test/fixtures/fail-metrics-empty/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
21
test/fixtures/fail-metrics-empty/content/edge/audio.md
vendored
Normal file
@@ -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
|
||||
17
test/fixtures/fail-metrics-empty/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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
|
||||
1
test/fixtures/fail-metrics-empty/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
metrics[0].label: must be non-empty
|
||||
1
test/fixtures/fail-missing-field/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
20
test/fixtures/fail-missing-field/content/edge/audio.md
vendored
Normal file
@@ -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
|
||||
17
test/fixtures/fail-missing-field/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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
|
||||
1
test/fixtures/fail-missing-field/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
claim: missing required field
|
||||
3
test/fixtures/fail-no-front-matter/content/edge/audio.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Distributed Audio
|
||||
|
||||
No front-matter block at all.
|
||||
1
test/fixtures/fail-no-front-matter/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
missing front-matter block
|
||||
1
test/fixtures/fail-unknown-field/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
22
test/fixtures/fail-unknown-field/content/edge/audio.md
vendored
Normal file
@@ -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
|
||||
17
test/fixtures/fail-unknown-field/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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
|
||||
1
test/fixtures/fail-unknown-field/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
author: unknown field (the schema is frozen; see docs/content-schema.md)
|
||||
1
test/fixtures/fail-unknown-nested-field/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
19
test/fixtures/fail-unknown-nested-field/content/edge/audio.md
vendored
Normal file
@@ -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]
|
||||
---
|
||||
15
test/fixtures/fail-unknown-nested-field/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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]
|
||||
---
|
||||
1
test/fixtures/fail-unknown-nested-field/expect.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
artefacts[1].caption: unknown field (artefacts allow only 'type', 'label' and 'url')
|
||||
25
test/fixtures/ok-block-scalars/content/about/background.md
vendored
Normal file
@@ -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
|
||||
0
test/fixtures/ok-block-scalars/expect.txt
vendored
Normal file
1
test/fixtures/ok-clean/content/assets/audio-rack.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>
|
||||
|
After Width: | Height: | Size: 65 B |
21
test/fixtures/ok-clean/content/edge/audio.md
vendored
Normal file
@@ -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
|
||||
17
test/fixtures/ok-clean/content/edge/l3/audio-architecture.md
vendored
Normal file
@@ -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
|
||||
0
test/fixtures/ok-clean/expect.txt
vendored
Normal file
0
test/fixtures/ok-empty/content/.gitkeep
vendored
Normal file
0
test/fixtures/ok-empty/expect.txt
vendored
Normal file
801
tools/validate_content.py
Normal file
@@ -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, "<front-matter>", problem)
|
||||
return violations, None, 1
|
||||
try:
|
||||
doc = parse_front_matter(fm_lines)
|
||||
except YamlError as exc:
|
||||
add(exc.line, "<front-matter>", f"invalid YAML: {exc.msg}")
|
||||
return violations, None, exc.line
|
||||
if doc.kind != "map":
|
||||
add(doc.line, "<front-matter>", "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/<case>/ { 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: <root>/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())
|
||||