Move the skill to .agents/skills/tttk-authoring and point gen:skill-reference at it. Activate it by symlinking ~/.agents/skills/tttk-authoring to the in-repo copy. Extend the reference drift guard to cover the skill copy now that it is versioned, and document activation in docs/development.md.
18 KiB
tttk check — implementation plan (validator-first)
Status: implemented. Supersedes docs/compile-plan.md.
Landed: S001/S002, D001/D002, R001–R006, C001–C004,
L001/L002, A001/A002, V001–V005, with tests. The check command,
--json/--strict/--quiet, and exit codes are live; the markdown module is
the browser-safe one described in §4, and readContentIndex backs the walk.
The reference generator (§7.11) and both drift guards (§7.12) are in place:
docs/reference.md is generated from known.ts via npm run gen:reference,
and known.test.ts asserts the catalog matches the customElement call sites
and the committed reference.
Not implemented: D003 (P2 — the legacy :md-deck directive is not flagged).
The rule catalog in §5, the CLI surface in §6, and the resolved decisions in §10
remain the specification.
1. Goal
tttk check [dir] statically validates a directory of tttk markdown and
reports structured diagnostics with file, line, severity, and a stable
code. It exits non-zero when the content is broken.
The user is an authoring agent. The tool's job is to answer, in one command: "Does this document set render the way I intend, and if not, exactly where and why?" HTML rendering is not the deliverable — a fix list is.
Success criteria:
- Every authoring mistake that would silently drop, mangle, or fail to
resolve content is reported with
file:line:col. - Output is machine-readable (
--json) so an agent can parse and act on it without scraping human prose. - Exit code distinguishes clean / warnings-only / errors, so the command is usable directly in a loop: write → check → fix → repeat.
- Checks run over the same registry the app uses, so a clean check implies the app resolves the content too.
Non-goals (v1): rendering HTML, a site build, hydrating custom elements,
journal/multiplayer, MCP, live reload, and automatic fixes. check reports
problems; it never edits content.
2. Why not reuse compile
The previous plan framed this as tttk compile producing HTML. That optimizes
for a human browsing output. An agent cannot easily tell "correct" from
"plausible but inert" by reading <md-table>./npcs.csv</md-table> — an
unresolved reference looks identical to a resolved one.
The failure modes that actually hurt authors are invisible in inert HTML and
are currently logged to console.warn or thrown:
| Failure | Current behavior | Location |
|---|---|---|
Unknown role= |
console.warn, block stripped → content vanishes |
src/cli/content-registry.ts:174-179 |
Missing md/csv/yarn/svg asset ref |
resolveContent returns null; fails at runtime only |
src/cli/content-registry.ts:340-372 |
role=declare missing key/expr |
throws (uncaught) | src/cli/completions/declare-parser.ts:44-54 |
role=declare bad row |
console.warn, silently skipped |
src/cli/content-registry.ts:473-479 |
| spark-table first column not dice | console.warn, still stored |
src/cli/content-registry.ts:285-288 |
Unknown element name (:md-card, :dice) |
renders an inert unknown tag | none |
A validator turns all of these into diagnostics. That is the product.
Rendering, if ever built, is a separate tttk render command (see §11).
3. Design decisions
3.1 Whole-directory registry pass first
References only resolve against a full ContentRegistry: inline ids are
doc-scoped (docContent[docPath]), and file refs resolve relative to the doc
directory via posixJoin(posixDir(docPath), id)
(src/cli/content-registry.ts:340-372). So the pipeline is:
buildRegistry(dir) // reuse src/cli/commands/serve.ts:88
→ { pathIndex, docContent }
for each raw .md on disk:
→ run checks(source, registry) // §3.2
→ collect Diagnostics
sort by (file, line, col) → report → exit code
buildRegistry already walks the tree and collects .md, .csv, .yarn,
.svg — exactly what reference resolution needs. Reuse it; do not re-walk.
Only .md files are validated; non-md files are inputs to resolution.
3.2 Validate the raw source, resolve with the registry
Do not run checks over registry.pathIndex. That string has fences already
replaced/stripped (scanDoc), so offsets no longer map to the author's file
and line numbers would be wrong.
Instead: run checks over the raw file contents, tracking byte offsets, and use the registry purely to resolve refs. This works because:
- Directives (
:md-table[./npcs.csv],:md-embed[./x.md#sec]) appear literally in raw source — that is where we locate and validate them. role=spark-tableblocks are validated from their raw fence; the generated:md-table[./csv_<hash>]never appears in the author's source, so it needs no validation (the generator is trusted).- Inline ids referenced by hand (
:md-embed[./text_<hash>]) resolve throughdocContent.
Offsets are cheap: the String.replace callback in scanDoc already receives
the match offset as a trailing argument, and the directive/fence scanners we
add can capture RegExp.exec index directly. Convert offset → line/col with a
line-start table built once per source (src/cli/validate/source.ts).
3.3 Diagnostics on raw source must skip fenced examples
Docs legitimately contain example directives inside fenced blocks
(reference.md does). The scanner must track fence state (like
stripFencedBlocks, content-registry.ts:494-510) and mark matches as
inFence; refs/roles inside fences are examples, not live content. Roles are
only live on the fence info line; directive refs are only live outside
fences.
4. Diagnostic model
export type Severity = "error" | "warning" | "info";
export interface Diagnostic {
code: string; // stable, e.g. "TTTK-R001"
severity: Severity;
message: string; // one line, actionable
file: string; // project-relative, posix
line: number; // 1-based
column: number; // 1-based
hint?: string; // concrete fix
snippet?: string; // offending source line
}
Rules:
- Codes are stable and documented; agents may key off them.
- Messages name the offending value (
unknown role "spark-tabel"), not just the category. hintis imperative and concrete (did you mean \spark-table`?`).
5. Rule catalog
Priority: P0 ships in v1, P1 follows, P2 experimental.
Structural
| Code | Sev | Rule | Prio |
|---|---|---|---|
TTTK-S001 |
error | Markdown pipeline throws while parsing the doc | P1 |
TTTK-S002 |
error | Unterminated container directive (::: with no close) |
P1 |
Directives
| Code | Sev | Rule | Prio |
|---|---|---|---|
TTTK-D001 |
error | Unknown :md-* element name |
P0 |
TTTK-D002 |
error | Malformed attribute block ({pins=A:30,40} unquoted with spaces) |
P1 |
TTTK-D003 |
info | Legacy :md-deck[./x.csv] directive — prefer yaml role=tag |
P2 |
D001 uses an explicit allowlist of the registered custom elements. From
customElement(...) call sites today (verified):
md-bg md-border md-commander md-deck md-dice md-embed
md-font md-link md-pins md-table md-yarn-spinner
Free-form names are allowed for container directives (::: name) — those
are CSS classes, not elements. Inline :[name] is the icon form and is not
governed by this list.
Already-real finding:
:md-cardis matched by the completion scanner (content-registry.ts:442) but is registered nowhere, so it renders as an inert tag.D001would flag it. This is the class of bug the tool exists for.
Code-block roles
| Code | Sev | Rule | Prio |
|---|---|---|---|
TTTK-R001 |
error | Unknown role= value (block is silently stripped) |
P0 |
TTTK-R002 |
warning | role=spark-table first column header is not a dice formula |
P1 |
TTTK-R003 |
error | role=declare missing required key/expr columns |
P0 |
TTTK-R004 |
warning | role=declare row skipped by the parser |
P1 |
TTTK-R005 |
error | role=tag body is not valid YAML |
P0 |
TTTK-R006 |
error | role=tag missing tag: or names an unknown element |
P0 |
R003/R004 come from parseDeclareCsv (declare-parser.ts:44-54) — it
throws today; the check must catch and localize it rather than crash.
R005/R006 parse with js-yaml (already a dependency) and reuse the D001
allowlist.
Content references
| Code | Sev | Rule | Prio |
|---|---|---|---|
TTTK-C001 |
error | Reference does not resolve (missing file or undefined inline id) | P0 |
TTTK-C002 |
warning | Reference resolves to an unexpected file type | P1 |
TTTK-C003 |
info | Same CSV referenced by two :md-table in one doc |
P1 |
TTTK-C004 |
error | :md-embed cycle |
P1 |
C001 covers every ref-bearing directive — :md-table, :md-commander,
:md-pins, :md-bg, :md-border(image), :md-yarn-spinner,
:md-link/:md-embed path part, and role=tag body: — by calling
resolveContentEntry (content-registry.ts:340) and flagging null. This is
the single highest-value check.
Links and anchors
| Code | Sev | Rule | Prio |
|---|---|---|---|
TTTK-L001 |
error | :md-link/:md-embed/markdown link target file missing |
P0 |
TTTK-L002 |
warning | #section anchor matches no heading slug |
P1 |
Slugs come from the same Slugger used by deriveLinks
(content-registry.ts:523-536) so the validator and the app agree.
Assets
| Code | Sev | Rule | Prio |
|---|---|---|---|
TTTK-A001 |
warning | :[icon] not found under any assets/ dir within the check root |
P1 |
TTTK-A002 |
warning | :md-bg/:md-pins/:md-border image path missing |
P1 |
A001 mirrors buildIconSrc (src/markdown/icon.ts:23-40): search
<root>/<docdir>/assets/, then each ancestor down to <root>/assets/. We
assume no assets/ directory exists above the check root, which makes the
lookup definitive rather than a guess — hence a warning, not info.
CSV data files
| Code | Sev | Rule | Prio |
|---|---|---|---|
TTTK-V001 |
error | CSV fails to parse (bad quoting) | P1 |
TTTK-V002 |
warning | Row column count ≠ header column count | P1 |
TTTK-V003 |
warning | Duplicate label values |
P1 |
TTTK-V004 |
warning | {{prop}} key is not a CSV column or front-matter key |
P1 |
TTTK-V005 |
warning | spark-table row roll value is not a number/range | P2 |
V004 mirrors processVariables (src/components/utils/csv-loader.ts:86-99):
a {{key}} resolves when key is a CSV header or a front-matter key (front
matter is merged into every row, csv-loader.ts:56-61). Unresolved keys are
left verbatim in the output, so the check is definitive. Honor the parser's
options: BOM stripped, # lines are comments, headers/cells trimmed, empty
lines skipped (csv-loader.ts:45-51).
6. CLI surface
tttk check [dir] [options]
Arguments:
[dir] directory to validate (default ".")
Options:
--json emit a JSON report (see §6.1)
--strict treat warnings as errors
--quiet report errors only
--no-color disable ANSI color
-h, --help
Wire in src/cli/index.ts alongside serve (commander pattern at
src/cli/index.ts:17-30).
6.1 Output
Human (default), grouped by file:
content/adventures/hollow-mill/adventure.md
12:1 error TTTK-C001 :md-table reference "./npcs.csv" does not resolve
hint: expected content/adventures/hollow-mill/npcs.csv
40:8 warning TTTK-R002 spark-table first header "Name" is not a dice formula
hint: use d6, 2d6+1, …
2 errors, 1 warning
--json:
{
"root": "content",
"filesChecked": 12,
"diagnostics": [ { "code": "TTTK-C001", "severity": "error", "message": "…",
"file": "adventures/hollow-mill/adventure.md",
"line": 12, "column": 1, "hint": "…" } ],
"summary": { "errors": 2, "warnings": 1, "infos": 0 }
}
6.2 Exit codes
| Code | Meaning |
|---|---|
0 |
No errors (warnings allowed unless --strict) |
1 |
At least one error, or any warning under --strict |
2 |
Usage / IO / internal failure |
Exit code is the contract that makes check usable in an agent loop.
7. Implementation steps
src/cli/validate/source.ts— line-start table;offsetToPosition.src/cli/validate/diagnostics.ts—Diagnostic,Sink(collector), sort, human + JSON formatters.src/cli/validate/known.ts— the catalog is the single source of truth:KNOWN_ELEMENTS(the 11 verified names, with body/attrs/description/ examples),KNOWN_ROLES = { spark-table, declare, file, tag }, expected file-type map forC002.docs/reference.mdis generated from it (§7.11).src/cli/validate/scan.ts— fence-aware scanner yielding{ name, body, attrs, offset, inFence }for directives, plus role info lines. ReuseparseBlockAttrs(src/markdown/block-attrs.ts); do not reimplement attribute parsing.- Checks —
checks/roles.ts,checks/directives.ts,checks/refs.ts,checks/links.ts,checks/csv.ts,checks/parse.ts. Pure functions(source, registry, docPath, sink) => void. src/cli/validate/index.ts—runCheck(dir):buildRegistry, iterate raw.mdsorted, run checks, sort diagnostics, compute summary.src/cli/commands/check.ts— commander handler: parse options, run, print,process.exitCode = …. AddCheckCommandHandlertosrc/cli/types.ts.src/cli/index.ts— registercheck(aliaslint).- Refactor
scanDocto report into a sink (optional but removes drift): givescanDoc(content, docPath, sink?)an optional sink, replaceconsole.warnatcontent-registry.ts:175-179and:285-288withsink.report(...), and pass the match offset through. Default no-op keeps existing callers and tests unchanged. This lets the CLI share one code path for role diagnostics instead of duplicating the switch. - Prereq for
S001— makesrc/markdown/mermaid.tsbrowser-safe soparseMarkdownis importable from the CLI: deleteimport mermaidand the top-levelmermaid.initialize(the extension only emits<pre class="mermaid">), then callmermaid.initialize({ theme: "dark" })insrc/main.tsxbefore the existingmermaid.run()calls (Article.tsx:89,md-embed.tsx:40). Required for v1. - Reference generator —
scripts/generate-reference.tsrenders theknown.tscatalog to markdown. Canonical outputdocs/reference.md(versioned, testable);--outcan target the skill copy at.agents/skills/tttk-authoring/reference.md. Addnpm run gen:reference. - Drift guard — a test that greps
customElement("md-*")call sites undersrc/componentsand asserts each appears inknown.ts(and vice versa). This is what would have caught:md-card.
8. Tests
src/cli/validate/*.test.ts, fixture-driven (temp dirs), covering:
- Positive: a valid multi-file fixture produces zero diagnostics; exit 0.
- Negative, one per P0 code: unknown role; missing declare columns;
invalid tag YAML; unknown tag element; unresolved
:md-table; missing:md-linktarget; unknown:md-*element. Assertcode,file,line. - Fence awareness: an example directive inside a fenced block is not reported; the same directive outside a fence is.
- No false positives: a doc that round-trips cleanly, including
:md-card-free legacy content. - Exit codes: 0 / 1 / 1-under-
--strict/ 2 on a nonexistent dir. - JSON shape: stable, sorted, one entry per diagnostic.
9. Docs and skill
- Update
docs/cli.md: replace thecompilesection withcheck; document codes,--json, exit codes. - Update
README.md. - Update
.agents/skills/tttk-authoring/SKILL.md: the verification step becomesnpx tttk check ./content, and the workflow becomes write → check → fix. Remove the "compile is a no-op stub" note. Cross-link the code catalog (reference.md) with the diagnostic codes. reference.mdis generated fromknown.ts(§7.11) — do not hand-edit. The skill's copy is refreshed bynpm run gen:skill-reference; deeper authoring topics live in the skill'syaml-tag.md,csv.md,layout.md, andmarkdown.md(seedocs/skill-reference-plan.md).
10. Resolved decisions
known.tsis the source of truth;reference.mdis generated from it (§7.11). A drift test (§7.12) keeps both honest.S001is in scope. Do the mermaid refactor (§7.10).- Icon existence is checked (
A001), assuming noassets/directory above the check root, which makes the lookup definitive. V004({{prop}}keys) is in scope for v1.- Report only — no
--fix.checknever edits content. compilestays a stub, and its--helptext now says so explicitly (src/cli/index.ts:27).
11. Risks
- Icon check assumes no parent assets.
A001treats<root>as the top of the icon search. If a project keeps sharedassets/above the check root,A001will false-positive; the assumption is documented in §5. - False positives are fatal to adoption. An agent that gets spurious
errors stops trusting the tool. Prefer P0 precision over coverage; gate
noisy checks behind
--strictorinfo. - Divergence from the app. Do not reimplement attribute parsing, dice
detection, slugging, or role dispatch — import
parseBlockAttrs,resolveContentEntry,parseDeclareCsv,inspectSparkTableCsv,Slugger. ReusebuildRegistryfor the walk. - Offsets vs. stripped content. Validate raw source only (§3.2); mixing in
pathIndexwill produce wrong line numbers. - Windows paths. Use
node:path; normalize to posix keys vianormalizePathKeyfor all registry lookups. Repo targets Windows. - Encoding. Read UTF-8 explicitly.
- Declare parser throws. Wrap
parseDeclareCsv; a throw must become a localized diagnostic, never a stack trace.