feat(cli): Add tttk check validator
Statically validate a directory of tttk documents and report structured diagnostics with file/line positions, exit codes, and --json output. Checks cover code-block roles, unresolved content references, tag blocks, element names, links, anchors, and assets. Runs over the same content registry as the app.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Code-block role checks.
|
||||
*/
|
||||
|
||||
import { parseDeclareCsv } from "../../completions/declare-parser.js";
|
||||
import { diagnosticAt } from "../diagnostics.js";
|
||||
import { ELEMENT_NAMES, KNOWN_ROLES } from "../known.js";
|
||||
import type { RawFence } from "../scan.js";
|
||||
import type { Check, CheckContext } from "./context.js";
|
||||
import { readTagBlock } from "./tag-block.js";
|
||||
|
||||
export const rolesCheck: Check = (ctx) => {
|
||||
const { scan, source, file, sink } = ctx;
|
||||
|
||||
for (const fence of scan.fences) {
|
||||
const role = fence.attrs.role;
|
||||
if (!role) continue;
|
||||
|
||||
if (!KNOWN_ROLES.has(role)) {
|
||||
sink.report(
|
||||
diagnosticAt(
|
||||
file,
|
||||
source,
|
||||
fence.offset,
|
||||
"TTTK-R001",
|
||||
"error",
|
||||
`unknown code-block role "${role}"`,
|
||||
`known roles: ${[...KNOWN_ROLES].join(", ")}. The block is stripped, silently dropping its content`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === "spark-table") checkSparkTable(fence, ctx);
|
||||
else if (role === "declare") checkDeclare(fence, ctx);
|
||||
else if (role === "tag") checkTag(fence, ctx);
|
||||
}
|
||||
};
|
||||
|
||||
function checkSparkTable(fence: RawFence, ctx: CheckContext): void {
|
||||
const { source, file, sink } = ctx;
|
||||
const header = firstHeaderCell(fence.body, fence.lang);
|
||||
if (!header) return;
|
||||
|
||||
if (!/^\s*\d*d\d+/i.test(header)) {
|
||||
sink.report(
|
||||
diagnosticAt(
|
||||
file,
|
||||
source,
|
||||
fence.offset,
|
||||
"TTTK-R002",
|
||||
"warning",
|
||||
`spark-table first header "${header}" is not a dice formula`,
|
||||
"use a notation like d6, 2d6+1, or 3d6kh1",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function checkDeclare(fence: RawFence, ctx: CheckContext): void {
|
||||
const { source, file, sink } = ctx;
|
||||
try {
|
||||
parseDeclareCsv(fence.body, file);
|
||||
} catch (error) {
|
||||
sink.report(
|
||||
diagnosticAt(
|
||||
file,
|
||||
source,
|
||||
fence.offset,
|
||||
"TTTK-R003",
|
||||
"error",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
"role=declare needs `key` and `expr` columns; `tag` and `threshold` are optional",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function checkTag(fence: RawFence, ctx: CheckContext): void {
|
||||
const { source, file, sink } = ctx;
|
||||
const { tag, error } = readTagBlock(fence);
|
||||
|
||||
if (error) {
|
||||
sink.report(
|
||||
diagnosticAt(
|
||||
file,
|
||||
source,
|
||||
fence.offset,
|
||||
"TTTK-R005",
|
||||
"error",
|
||||
`role=tag body is not valid YAML: ${error.message}`,
|
||||
"check the indentation and quoting of the YAML mapping",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tag) {
|
||||
sink.report(
|
||||
diagnosticAt(
|
||||
file,
|
||||
source,
|
||||
fence.offset,
|
||||
"TTTK-R006",
|
||||
"error",
|
||||
"role=tag block has no `tag:`",
|
||||
"set `tag:` to a registered element, e.g. `tag: md-deck`",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ELEMENT_NAMES.has(tag)) {
|
||||
sink.report(
|
||||
diagnosticAt(
|
||||
file,
|
||||
source,
|
||||
fence.offset,
|
||||
"TTTK-R006",
|
||||
"error",
|
||||
`role=tag names unknown element "${tag}"`,
|
||||
`known elements: ${[...ELEMENT_NAMES].join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** First header cell of a spark-table body (CSV or markdown pipe table). */
|
||||
function firstHeaderCell(body: string, lang: string): string | null {
|
||||
if (/^markdown$/i.test(lang)) {
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("|")) continue;
|
||||
const cells = trimmed
|
||||
.split("|")
|
||||
.map((cell) => cell.trim())
|
||||
.filter(Boolean);
|
||||
return cells[0] ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
return trimmed.split(",")[0].trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user