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:
2026-09-12 16:38:21 +08:00
parent d1384c2ccc
commit 9def4d17aa
18 changed files with 1638 additions and 1 deletions
+187
View File
@@ -0,0 +1,187 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { dirname, join } from "path";
import { parseMarkdown } from "../../markdown/index.js";
import { runCheck } from "./index.js";
import { formatHuman, type CheckReport } from "./diagnostics.js";
// `marked` ships ESM only, which jest's CJS runtime cannot load. The markdown
// pipeline is mocked here; its real behavior is covered outside jest.
jest.mock("../../markdown/index.js", () => ({
parseMarkdown: jest.fn((content: string) => content),
}));
const dirs: string[] = [];
function fixture(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), "tttk-check-"));
dirs.push(dir);
for (const [rel, content] of Object.entries(files)) {
const full = join(dir, rel);
mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, content, "utf-8");
}
return dir;
}
function codes(report: CheckReport): string[] {
return report.diagnostics.map((d) => d.code);
}
afterEach(() => {
jest.restoreAllMocks();
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true });
});
beforeEach(() => {
// The registry legitimately warns about unknown roles; keep test output clean.
jest.spyOn(console, "warn").mockImplementation(() => {});
jest.spyOn(console, "log").mockImplementation(() => {});
(parseMarkdown as jest.Mock).mockImplementation((content: string) => content);
});
describe("runCheck", () => {
test("a clean multi-file document produces no diagnostics", () => {
const dir = fixture({
"adventure.md":
"# Adventure\n\n:md-dice[2d6]{key=\"dmg\"}\n\n:md-table[./npcs.csv]\n",
"npcs.csv": "label,body\n1,Alice\n",
});
const report = runCheck(dir);
expect(report.diagnostics).toEqual([]);
expect(report.filesChecked).toBe(1);
});
test("R001 flags an unknown code-block role", () => {
const dir = fixture({
"doc.md": "```csv role=spark-tabel\nd6,Name\n1,Alice\n```\n",
});
const report = runCheck(dir);
expect(codes(report)).toContain("TTTK-R001");
expect(report.diagnostics[0].line).toBe(1);
});
test("R003 flags a declare block missing key/expr columns", () => {
const dir = fixture({
"doc.md": "```csv role=declare\nfoo,bar\n1,2\n```\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-R003");
});
test("R005 flags invalid YAML in a role=tag block", () => {
const dir = fixture({
"doc.md": "```yaml role=tag\ntag: [unclosed\n```\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-R005");
});
test("R006 flags a role=tag naming an unknown element", () => {
const dir = fixture({
"doc.md": "```yaml role=tag\ntag: md-nope\n```\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-R006");
});
test("S001 reports when the markdown pipeline throws", () => {
(parseMarkdown as jest.Mock).mockImplementation(() => {
throw new Error("boom");
});
expect(codes(runCheck(fixture({ "doc.md": "# ok\n" })))).toContain(
"TTTK-S001",
);
});
test("C001 flags an unresolved content reference", () => {
const dir = fixture({
"doc.md": "See :md-table[./missing.csv].\n",
});
const report = runCheck(dir);
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-C001");
expect(diagnostic).toBeDefined();
expect(diagnostic!.line).toBe(1);
});
test("D001 flags an unregistered :md-* element", () => {
const dir = fixture({
"doc.md": "A card: :md-card[./cards.csv]\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-D001");
});
test("L001 flags a missing link target", () => {
const dir = fixture({
"doc.md": "See [rules](./rules.md).\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-L001");
});
test("S002 flags an unterminated container directive", () => {
const dir = fixture({
"doc.md": "::: box\nsome content\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-S002");
});
test("C004 flags an embed cycle", () => {
const dir = fixture({
"a.md": ":md-embed[./b.md]\n",
"b.md": ":md-embed[./a.md]\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-C004");
});
test("directives inside fenced blocks are not live content", () => {
const dir = fixture({
"doc.md": "```\n:md-table[./missing.csv]\n:md-card[x]\n```\n",
});
expect(runCheck(dir).diagnostics).toEqual([]);
});
test("directives inside inline code are not live content", () => {
const dir = fixture({
"doc.md": "Use `:md-table[./missing.csv]` to embed a table.\n",
});
expect(runCheck(dir).diagnostics).toEqual([]);
});
test("diagnostics are sorted by file, then line", () => {
const dir = fixture({
"b.md": "See [x](./missing.md).\n",
"a.md": "\n\nSee [y](./missing.md).\n",
});
const files = runCheck(dir).diagnostics.map((d) => d.file);
expect(files).toEqual([...files].sort());
expect(files[0]).toBe("a.md");
});
});
describe("formatHuman", () => {
test("groups by file and prints a summary", () => {
const dir = fixture({ "doc.md": "See :md-table[./missing.csv].\n" });
const text = formatHuman(runCheck(dir));
expect(text).toContain("doc.md");
expect(text).toContain("TTTK-C001");
expect(text).toContain("1 error");
expect(text).toContain("1 file checked");
});
});