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,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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user