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__).
185 lines
9.1 KiB
Markdown
185 lines
9.1 KiB
Markdown
# 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/`.
|