diff --git a/src/cli/commands/check.ts b/src/cli/commands/check.ts new file mode 100644 index 0000000..ec262f1 --- /dev/null +++ b/src/cli/commands/check.ts @@ -0,0 +1,23 @@ +import type { CheckCommandHandler } from "../types.js"; +import { formatHuman, formatJson } from "../validate/diagnostics.js"; +import { runCheck } from "../validate/index.js"; + +export const checkCommand: CheckCommandHandler = async (dir, options) => { + let report; + try { + report = runCheck(dir); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 2; + return; + } + + console.log( + options.json ? formatJson(report) : formatHuman(report, options.quiet), + ); + + const failed = + report.summary.errors > 0 || + (options.strict && report.summary.warnings > 0); + if (failed) process.exitCode = 1; +}; diff --git a/src/cli/content-registry.ts b/src/cli/content-registry.ts index 948b4ab..1447f4b 100644 --- a/src/cli/content-registry.ts +++ b/src/cli/content-registry.ts @@ -494,7 +494,7 @@ export function deriveBlocks(registry: ContentRegistry): { * Remove fenced code blocks (backtick or tilde) from content, so text * scanners (headings, dice directives) don't match example code. */ -function stripFencedBlocks(content: string): string { +export function stripFencedBlocks(content: string): string { const out: string[] = []; let fence: string | null = null; for (const line of content.split(/\r?\n/)) { diff --git a/src/cli/index.ts b/src/cli/index.ts index a2ee998..15ec7e8 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import { serveCommand } from "./commands/serve.js"; import { compileCommand } from "./commands/compile.js"; +import { checkCommand } from "./commands/check.js"; import { mcpCommand } from "./commands/mcp.js"; import type { ServeOptions, CompileOptions } from "./types.js"; @@ -22,6 +23,16 @@ program .option("--host ", "主机地址", "0.0.0.0") .action(serveCommand); +program + .command("check") + .alias("lint") + .description("校验目录内的 tttk 文档,输出结构化诊断") + .argument("[dir]", "要校验的目录", ".") + .option("--json", "以 JSON 输出诊断", false) + .option("--strict", "将警告视为错误", false) + .option("--quiet", "只报告错误", false) + .action(checkCommand); + program .command("compile") .description("[未实现] 存根命令:不会生成任何输出。预览内容请使用 tttk serve") diff --git a/src/cli/types.ts b/src/cli/types.ts index 46504a1..5a52132 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -16,6 +16,17 @@ export type CompileCommandHandler = ( options: CompileOptions, ) => Promise; +export interface CheckOptions { + json: boolean; + strict: boolean; + quiet: boolean; +} + +export type CheckCommandHandler = ( + dir: string, + options: CheckOptions, +) => Promise; + export interface MarkdownFile { path: string; route: string; diff --git a/src/cli/validate/checks/assets.ts b/src/cli/validate/checks/assets.ts new file mode 100644 index 0000000..c357777 --- /dev/null +++ b/src/cli/validate/checks/assets.ts @@ -0,0 +1,108 @@ +/** + * Asset checks: icons and image references. + * + * Uses the filesystem, since `png`/`jpg` files are not in the content index. + * The icon search assumes no `assets/` directory exists above the check root + * (see docs/check-plan.md §5), which makes the lookup definitive. + */ + +import { existsSync } from "fs"; +import { join, posix } from "path"; +import { diagnosticAt } from "../diagnostics.js"; +import { elementFor, looksLikeColor, COLOR_BODY_ELEMENTS } from "../known.js"; +import type { Check, CheckContext } from "./context.js"; +import { resolveKey } from "./context.js"; +import { readTagBlock } from "./tag-block.js"; + +const ICON_EXTENSIONS = ["svg", "png", "gif", "jpg", "jpeg", "webp"]; + +export const assetsCheck: Check = (ctx) => { + const { scan, source, file, pathKey, rootDir, sink } = ctx; + + for (const directive of scan.directives) { + if (directive.icon) { + const value = directive.body.trim(); + if (!value) continue; + const { name, ext } = splitIcon(value); + if (!iconExists(pathKey, name, ext, rootDir)) { + sink.report( + diagnosticAt( + file, + source, + directive.offset, + "TTTK-A001", + "warning", + `icon "${value}" not found`, + `looked for assets/${name}.${ext} from this file up to the project root`, + ), + ); + } + continue; + } + + const spec = elementFor(directive.name); + if (!spec || spec.refKind !== "image") continue; + const ref = directive.body.trim(); + if (!ref) continue; + if (COLOR_BODY_ELEMENTS.has(spec.name) && looksLikeColor(ref)) continue; + reportMissingImage(ref, directive.offset, ctx); + } + + for (const fence of scan.fences) { + if (fence.attrs.role !== "tag") continue; + const { tag, body } = readTagBlock(fence); + if (!tag || !body) continue; + const spec = elementFor(tag); + if (!spec || spec.refKind !== "image") continue; + if (COLOR_BODY_ELEMENTS.has(tag) && looksLikeColor(body)) continue; + reportMissingImage(body.trim(), fence.offset, ctx); + } +}; + +function reportMissingImage( + ref: string, + offset: number, + ctx: CheckContext, +): void { + const { source, file, pathKey, rootDir, sink } = ctx; + const key = resolveKey(pathKey, ref); + if (existsSync(join(rootDir, key.replace(/^\//, "")))) return; + sink.report( + diagnosticAt( + file, + source, + offset, + "TTTK-A002", + "warning", + `image "${ref}" does not exist`, + ), + ); +} + +function splitIcon(value: string): { name: string; ext: string } { + const dot = value.lastIndexOf("."); + if (dot > 0) { + const ext = value.slice(dot + 1).toLowerCase(); + if (ICON_EXTENSIONS.includes(ext)) { + return { name: value.slice(0, dot), ext }; + } + } + return { name: value, ext: "png" }; +} + +/** Mirror `buildIconSrc`: search `assets/` from the doc dir up to the root. */ +function iconExists( + pathKey: string, + name: string, + ext: string, + rootDir: string, +): boolean { + const parts = posix.dirname(pathKey).split("/").filter(Boolean); + for (let i = parts.length; i >= 0; i--) { + const prefix = `/${parts.slice(0, i).join("/")}`; + const base = prefix === "/" ? "/assets" : `${prefix}/assets`; + const rel = `${base}/${name}.${ext}`.replace(/^\//, ""); + if (existsSync(join(rootDir, rel))) return true; + } + return false; +} diff --git a/src/cli/validate/checks/context.ts b/src/cli/validate/checks/context.ts new file mode 100644 index 0000000..c29c7c2 --- /dev/null +++ b/src/cli/validate/checks/context.ts @@ -0,0 +1,39 @@ +/** + * Shared context passed to every per-document check. + */ + +import { posix } from "path"; +import type { ContentRegistry } from "../../content-registry.js"; +import type { DiagnosticSink } from "../diagnostics.js"; +import type { ScanResult } from "../scan.js"; +import type { SourceFile } from "../source.js"; + +export interface CheckContext { + source: SourceFile; + scan: ScanResult; + /** Display path: relative to the root, posix, no leading slash. */ + file: string; + /** Registry key: posix with a leading slash. */ + pathKey: string; + registry: ContentRegistry; + /** Raw contents of every indexable file, keyed like the registry. */ + index: Record; + /** Absolute root directory of the check. */ + rootDir: string; + sink: DiagnosticSink; +} + +export type Check = (ctx: CheckContext) => void; + +/** Strip the registry key's leading slash for display. */ +export function displayPath(key: string): string { + return key.replace(/^\//, ""); +} + +/** Resolve a reference written in `pathKey` to a registry key. */ +export function resolveKey(pathKey: string, ref: string): string { + if (ref.startsWith("/")) return posix.normalize(ref); + const dir = posix.dirname(pathKey); + const joined = posix.normalize(posix.join(dir, ref)); + return joined.startsWith("/") ? joined : `/${joined}`; +} diff --git a/src/cli/validate/checks/directives.ts b/src/cli/validate/checks/directives.ts new file mode 100644 index 0000000..bf6a018 --- /dev/null +++ b/src/cli/validate/checks/directives.ts @@ -0,0 +1,61 @@ +/** + * Directive checks: `:md-*` element names must be registered. + */ + +import { diagnosticAt } from "../diagnostics.js"; +import { ELEMENT_NAMES, KNOWN_ELEMENTS } from "../known.js"; +import type { Check } from "./context.js"; + +export const directivesCheck: Check = (ctx) => { + const { scan, source, file, sink } = ctx; + + for (const directive of scan.directives) { + if (directive.icon) continue; + if (!directive.name.startsWith("md-")) continue; + if (ELEMENT_NAMES.has(directive.name)) continue; + + const suggestion = closestName(directive.name, [...ELEMENT_NAMES]); + sink.report( + diagnosticAt( + file, + source, + directive.offset, + "TTTK-D001", + "error", + `unknown element ":${directive.name}"`, + suggestion + ? `did you mean ":${suggestion}"?` + : `custom elements are: ${KNOWN_ELEMENTS.map((e) => e.name).join(", ")}`, + ), + ); + } +}; + +/** Return the closest candidate within edit distance 2, if any. */ +function closestName(name: string, candidates: string[]): string | null { + let best: string | null = null; + let bestDistance = 3; + for (const candidate of candidates) { + const distance = editDistance(name, candidate); + if (distance < bestDistance) { + bestDistance = distance; + best = candidate; + } + } + return best; +} + +function editDistance(a: string, b: string): number { + const prev = new Array(b.length + 1); + const curr = new Array(b.length + 1); + for (let j = 0; j <= b.length; j++) prev[j] = j; + for (let i = 1; i <= a.length; i++) { + curr[0] = i; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); + } + for (let j = 0; j <= b.length; j++) prev[j] = curr[j]; + } + return prev[b.length]; +} diff --git a/src/cli/validate/checks/links.ts b/src/cli/validate/checks/links.ts new file mode 100644 index 0000000..d33f6ce --- /dev/null +++ b/src/cli/validate/checks/links.ts @@ -0,0 +1,102 @@ +/** + * Markdown link and image checks. + * + * File existence uses the filesystem (not the registry) because images are not + * indexed. Section anchors are validated against heading slugs. + */ + +import { existsSync } from "fs"; +import { join } from "path"; +import Slugger from "github-slugger"; +import { stripFencedBlocks } from "../../content-registry.js"; +import { diagnosticAt } from "../diagnostics.js"; +import type { Check } from "./context.js"; +import { resolveKey } from "./context.js"; + +const EXTERNAL_RE = /^[a-z][a-z0-9+.-]*:/i; + +export const linksCheck: Check = (ctx) => { + const { scan, source, file, pathKey, registry, index, rootDir, sink } = ctx; + const slugCache = new Map>(); + + const slugsFor = (key: string): Set => { + const cached = slugCache.get(key); + if (cached) return cached; + const markdown = index[key] ?? registry.pathIndex[key] ?? ""; + const slugs = headingSlugs(markdown); + slugCache.set(key, slugs); + return slugs; + }; + + const exists = (key: string): boolean => { + if (key in registry.pathIndex || key in index) return true; + return existsSync(join(rootDir, key.replace(/^\//, ""))); + }; + + for (const link of scan.links) { + const target = link.target.trim(); + if (!target || EXTERNAL_RE.test(target) || target.startsWith("//")) continue; + + const hashIndex = target.indexOf("#"); + const pathPart = hashIndex >= 0 ? target.slice(0, hashIndex) : target; + const section = hashIndex >= 0 ? target.slice(hashIndex + 1) : ""; + const baseKey = pathPart ? resolveKey(pathKey, pathPart) : pathKey; + + if (pathPart && !exists(baseKey)) { + sink.report( + diagnosticAt( + file, + source, + link.targetOffset, + link.image ? "TTTK-A002" : "TTTK-L001", + link.image ? "warning" : "error", + `${link.image ? "image" : "link target"} "${target}" does not exist`, + ), + ); + continue; + } + + if (section && baseKey.endsWith(".md")) { + const decoded = decodeURIComponent(section).toLowerCase(); + if (!sectionMatches(slugsFor(baseKey), decoded)) { + sink.report( + diagnosticAt( + file, + source, + link.targetOffset, + "TTTK-L002", + "warning", + `section "#${section}" not found in "${pathPart || file}"`, + "anchors come from headings, lowercased and hyphenated", + ), + ); + } + } + } +}; + +/** + * A section matches an exact heading slug, or a heading slug with a suffix + * (e.g. `#珊瑚粉` for the heading `珊瑚粉 - 成瘾`) — a common authoring habit. + */ +function sectionMatches(slugs: Set, section: string): boolean { + if (slugs.has(section)) return true; + for (const slug of slugs) { + if (!slug.startsWith(section)) continue; + const next = slug[section.length]; + if (next === undefined || !/[A-Za-z0-9]/.test(next)) return true; + } + return false; +} + +function headingSlugs(markdown: string): Set { + const slugger = new Slugger(); + const slugs = new Set(); + const content = stripFencedBlocks(markdown); + const re = /^(#{1,6})\s+(.+?)\s*$/gm; + let match: RegExpExecArray | null; + while ((match = re.exec(content)) !== null) { + slugs.add(slugger.slug(match[2].trim().toLowerCase())); + } + return slugs; +} diff --git a/src/cli/validate/checks/refs.ts b/src/cli/validate/checks/refs.ts new file mode 100644 index 0000000..395391f --- /dev/null +++ b/src/cli/validate/checks/refs.ts @@ -0,0 +1,104 @@ +/** + * Content reference checks for directives and `yaml role=tag` blocks. + * + * Image references are handled by the assets check, because image files are + * not part of the content registry (only `.md`, `.csv`, `.yarn`, `.svg` are + * indexed). + */ + +import { extname } from "path"; +import { resolveContentEntry } from "../../content-registry.js"; +import { diagnosticAt } from "../diagnostics.js"; +import { REF_EXTENSIONS, elementFor, type RefKind } from "../known.js"; +import type { Check, CheckContext } from "./context.js"; +import { readTagBlock } from "./tag-block.js"; + +export const refsCheck: Check = (ctx) => { + const { scan, source, file, pathKey, registry, sink } = ctx; + const tableSeen = new Map(); + + const verify = ( + ref: string, + offset: number, + kind: RefKind, + element: string, + ): boolean => { + const resolved = resolveContentEntry(registry, pathKey, ref); + if (!resolved) { + sink.report( + diagnosticAt( + file, + source, + offset, + "TTTK-C001", + "error", + `:${element} reference "${ref}" does not resolve`, + "expected a file relative to this document, or an inline id defined in it", + ), + ); + return false; + } + + if (!resolved.inline && kind !== "none") { + const ext = extname(resolved.path).toLowerCase(); + const allowed = REF_EXTENSIONS[kind as Exclude]; + if (allowed && !allowed.includes(ext)) { + sink.report( + diagnosticAt( + file, + source, + offset, + "TTTK-C002", + "warning", + `:${element} reference "${ref}" resolves to "${ext || "no extension"}", expected ${allowed.join(" / ")}`, + ), + ); + } + } + return true; + }; + + for (const directive of scan.directives) { + if (directive.icon) continue; + const spec = elementFor(directive.name); + if (!spec || spec.refKind === "none" || spec.refKind === "image") continue; + + let ref = directive.body.trim(); + if (spec.name === "md-link" || spec.name === "md-embed") { + ref = ref.split("#")[0]; + } + if (!ref) continue; + + const ok = verify(ref, directive.offset, spec.refKind, spec.name); + if (spec.name === "md-table" && ok) { + const seen = tableSeen.get(ref); + if (seen) seen.count++; + else tableSeen.set(ref, { count: 1, offset: directive.offset }); + } + } + + for (const fence of scan.fences) { + if (fence.attrs.role !== "tag") continue; + const { tag, body } = readTagBlock(fence); + if (!tag || !body) continue; + const spec = elementFor(tag); + if (!spec || spec.refKind === "none" || spec.refKind === "image") continue; + verify(body.trim(), fence.offset, spec.refKind, tag); + } + + for (const [ref, seen] of tableSeen) { + if (seen.count > 1) { + sink.report( + diagnosticAt( + file, + source, + seen.offset, + "TTTK-C003", + "info", + `:md-table reference "${ref}" is used ${seen.count} times in this document`, + "reference each CSV once; use group tabs instead of repeating the table", + ), + ); + } + } +}; diff --git a/src/cli/validate/checks/roles.ts b/src/cli/validate/checks/roles.ts new file mode 100644 index 0000000..be2c061 --- /dev/null +++ b/src/cli/validate/checks/roles.ts @@ -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; +} diff --git a/src/cli/validate/checks/structural.ts b/src/cli/validate/checks/structural.ts new file mode 100644 index 0000000..4dca562 --- /dev/null +++ b/src/cli/validate/checks/structural.ts @@ -0,0 +1,55 @@ +/** + * Structural checks: the markdown pipeline parses, and container directives + * are balanced. + */ + +import { parseMarkdown } from "../../../markdown/index.js"; +import { diagnosticAt } from "../diagnostics.js"; +import type { RawContainerOpen } from "../scan.js"; +import type { Check } from "./context.js"; + +export const structuralCheck: Check = (ctx) => { + const { source, scan, file, pathKey, sink } = ctx; + + try { + parseMarkdown(source.text, pathKey); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + sink.report( + diagnosticAt( + file, + source, + 0, + "TTTK-S001", + "error", + `markdown failed to parse: ${detail}`, + "check for an unterminated directive or malformed attribute block", + ), + ); + } + + const events = [ + ...scan.containerOpens.map((open) => ({ offset: open.offset, open })), + ...scan.containerCloses.map((close) => ({ offset: close.offset, open: null })), + ].sort((a, b) => a.offset - b.offset); + + const stack: RawContainerOpen[] = []; + for (const event of events) { + if (event.open) stack.push(event.open); + else stack.pop(); + } + + for (const open of stack) { + sink.report( + diagnosticAt( + file, + source, + open.offset, + "TTTK-S002", + "error", + `container directive "::: ${open.name}" is never closed`, + `add a closing "${":".repeat(open.colons)}" line`, + ), + ); + } +}; diff --git a/src/cli/validate/checks/tag-block.ts b/src/cli/validate/checks/tag-block.ts new file mode 100644 index 0000000..d56b58c --- /dev/null +++ b/src/cli/validate/checks/tag-block.ts @@ -0,0 +1,38 @@ +/** + * Read a `yaml role=tag` block: the element name and body, honoring fence-line + * attribute overrides (which win over the YAML mapping). + */ + +import yaml from "js-yaml"; +import type { RawFence } from "../scan.js"; + +export interface TagBlock { + tag?: string; + body?: string; + /** Set when the YAML itself failed to parse. */ + error?: Error; +} + +export function readTagBlock(fence: RawFence): TagBlock { + let data: unknown; + try { + data = yaml.load(fence.body); + } catch (error) { + return { error: error instanceof Error ? error : new Error(String(error)) }; + } + + const mapping = + data && typeof data === "object" && !Array.isArray(data) + ? (data as Record) + : {}; + + const fenceTag = fence.attrs.extra.tag; + const fenceBody = fence.attrs.extra.body; + const yamlTag = mapping.tag; + const yamlBody = mapping.body; + + return { + tag: fenceTag ?? (typeof yamlTag === "string" ? yamlTag : undefined), + body: fenceBody ?? (typeof yamlBody === "string" ? yamlBody : undefined), + }; +} diff --git a/src/cli/validate/diagnostics.ts b/src/cli/validate/diagnostics.ts new file mode 100644 index 0000000..008cc02 --- /dev/null +++ b/src/cli/validate/diagnostics.ts @@ -0,0 +1,146 @@ +/** + * Diagnostic model, collection, and reporting. + * + * `check` reports problems; it never edits content. Diagnostics carry stable + * codes so an authoring agent can key off them. + */ + +import type { SourceFile } from "./source.js"; + +export type Severity = "error" | "warning" | "info"; + +export interface Diagnostic { + /** Stable code, e.g. "TTTK-C001". */ + code: string; + severity: Severity; + message: string; + /** Path relative to the check root, posix, no leading slash. */ + file: string; + line: number; + column: number; + /** Concrete, actionable fix. */ + hint?: string; + /** The offending source line, for context. */ + snippet?: string; +} + +export interface DiagnosticSink { + report(diagnostic: Diagnostic): void; +} + +/** Collects diagnostics in memory. */ +export class Collector implements DiagnosticSink { + readonly items: Diagnostic[] = []; + + report(diagnostic: Diagnostic): void { + this.items.push(diagnostic); + } +} + +export function diagnosticAt( + file: string, + source: SourceFile, + offset: number, + code: string, + severity: Severity, + message: string, + hint?: string, +): Diagnostic { + const { line, column } = source.position(offset); + return { + code, + severity, + message, + file, + line, + column, + hint, + snippet: source.lineText(line).trimEnd() || undefined, + }; +} + +const SEVERITY_ORDER: Record = { + error: 0, + warning: 1, + info: 2, +}; + +export function compareDiagnostics(a: Diagnostic, b: Diagnostic): number { + if (a.file !== b.file) return a.file < b.file ? -1 : 1; + if (a.line !== b.line) return a.line - b.line; + if (a.column !== b.column) return a.column - b.column; + if (a.severity !== b.severity) { + return SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]; + } + if (a.code !== b.code) return a.code < b.code ? -1 : 1; + return 0; +} + +export interface DiagnosticSummary { + errors: number; + warnings: number; + infos: number; +} + +export interface CheckReport { + root: string; + filesChecked: number; + diagnostics: Diagnostic[]; + summary: DiagnosticSummary; +} + +export function summarize(diagnostics: Diagnostic[]): DiagnosticSummary { + const summary: DiagnosticSummary = { errors: 0, warnings: 0, infos: 0 }; + for (const d of diagnostics) { + if (d.severity === "error") summary.errors++; + else if (d.severity === "warning") summary.warnings++; + else summary.infos++; + } + return summary; +} + +export function buildReport( + root: string, + filesChecked: number, + diagnostics: Diagnostic[], +): CheckReport { + const sorted = [...diagnostics].sort(compareDiagnostics); + return { + root, + filesChecked, + diagnostics: sorted, + summary: summarize(sorted), + }; +} + +/** Human-readable, grouped by file. */ +export function formatHuman(report: CheckReport, quiet = false): string { + const lines: string[] = []; + let currentFile: string | null = null; + + for (const d of report.diagnostics) { + if (quiet && d.severity !== "error") continue; + if (d.file !== currentFile) { + if (lines.length) lines.push(""); + lines.push(d.file); + currentFile = d.file; + } + const loc = `${d.line}:${d.column}`.padEnd(8); + lines.push(` ${loc} ${d.severity.padEnd(7)} ${d.code} ${d.message}`); + if (d.hint) lines.push(` ${" ".repeat(8)} hint: ${d.hint}`); + } + + if (lines.length) lines.push(""); + const { errors, warnings, infos } = report.summary; + lines.push( + `${errors} error${errors === 1 ? "" : "s"}, ` + + `${warnings} warning${warnings === 1 ? "" : "s"}, ` + + `${infos} info`, + `${report.filesChecked} file${report.filesChecked === 1 ? "" : "s"} checked`, + ); + return lines.join("\n"); +} + +export function formatJson(report: CheckReport): string { + return JSON.stringify(report, null, 2); +} diff --git a/src/cli/validate/index.ts b/src/cli/validate/index.ts new file mode 100644 index 0000000..be8e37b --- /dev/null +++ b/src/cli/validate/index.ts @@ -0,0 +1,142 @@ +/** + * `tttk check` — run all validation checks over a directory and return a + * sorted report. + * + * Design notes live in docs/check-plan.md: + * - §3.1 build the whole-tree registry first, so cross-file refs resolve + * - §3.2 diagnose the *raw* source so line numbers are accurate + */ + +import { existsSync, statSync } from "fs"; +import { resolve } from "path"; +import { + buildRegistryFromIndex, + type ContentRegistry, +} from "../content-registry.js"; +import { readContentIndex } from "../content-index.js"; +import { assetsCheck } from "./checks/assets.js"; +import type { Check, CheckContext } from "./checks/context.js"; +import { displayPath, resolveKey } from "./checks/context.js"; +import { directivesCheck } from "./checks/directives.js"; +import { linksCheck } from "./checks/links.js"; +import { refsCheck } from "./checks/refs.js"; +import { rolesCheck } from "./checks/roles.js"; +import { structuralCheck } from "./checks/structural.js"; +import { + buildReport, + Collector, + diagnosticAt, + type CheckReport, +} from "./diagnostics.js"; +import { scanSource, type ScanResult } from "./scan.js"; +import { SourceFile } from "./source.js"; + +const CHECKS: readonly Check[] = [ + structuralCheck, + directivesCheck, + rolesCheck, + refsCheck, + linksCheck, + assetsCheck, +]; + +interface ScannedDoc { + source: SourceFile; + scan: ScanResult; +} + +export function runCheck(dir: string): CheckReport { + const rootDir = resolve(dir); + if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) { + throw new Error(`not a directory: ${rootDir}`); + } + + const index = readContentIndex(rootDir); + const registry = buildRegistryFromIndex(index); + const sink = new Collector(); + + const mdKeys = Object.keys(index) + .filter((key) => key.endsWith(".md")) + .sort(); + + const docs = new Map(); + for (const key of mdKeys) { + const text = index[key]; + docs.set(key, { source: new SourceFile(text), scan: scanSource(text) }); + } + + for (const key of mdKeys) { + const doc = docs.get(key)!; + const ctx: CheckContext = { + source: doc.source, + scan: doc.scan, + file: displayPath(key), + pathKey: key, + registry, + index, + rootDir, + sink, + }; + for (const check of CHECKS) check(ctx); + } + + checkEmbedCycles(mdKeys, docs, registry, sink); + + return buildReport(rootDir, mdKeys.length, sink.items); +} + +/** Detect `:md-embed` cycles, which would expand infinitely. */ +function checkEmbedCycles( + mdKeys: string[], + docs: Map, + registry: ContentRegistry, + sink: Collector, +): void { + const graph = new Map(); + for (const key of mdKeys) { + const edges: { target: string; offset: number }[] = []; + for (const directive of docs.get(key)!.scan.directives) { + if (directive.icon || directive.name !== "md-embed") continue; + const ref = directive.body.trim().split("#")[0]; + if (!ref) continue; + const target = resolveKey(key, ref); + if (!target.endsWith(".md")) continue; + if (!(target in registry.pathIndex)) continue; // C001 already reports + edges.push({ target, offset: directive.offset }); + } + graph.set(key, edges); + } + + const state = new Map(); + const reported = new Set(); + + const visit = (key: string): void => { + state.set(key, 1); + for (const edge of graph.get(key) ?? []) { + const targetState = state.get(edge.target); + if (targetState === 1) { + const id = `${key}->${edge.target}`; + if (reported.has(id)) continue; + reported.add(id); + sink.report( + diagnosticAt( + displayPath(key), + docs.get(key)!.source, + edge.offset, + "TTTK-C004", + "error", + `:md-embed cycle: "${displayPath(key)}" embeds "${displayPath(edge.target)}", which embeds it back`, + "break the cycle by linking instead of embedding", + ), + ); + } else if (targetState === undefined) { + visit(edge.target); + } + } + state.set(key, 2); + }; + + for (const key of mdKeys) { + if (!state.has(key)) visit(key); + } +} diff --git a/src/cli/validate/known.ts b/src/cli/validate/known.ts new file mode 100644 index 0000000..1f586c8 --- /dev/null +++ b/src/cli/validate/known.ts @@ -0,0 +1,171 @@ +/** + * Catalog of tttk syntax: custom elements, inline directives, and code-block + * roles. + * + * This is the single source of truth for validation. `docs/reference.md` is + * intended to be generated from it (see docs/check-plan.md §7.11); a drift + * test asserts this list matches the `customElement(...)` call sites. + */ + +/** What a directive's body refers to, used for reference checks. */ +export type RefKind = "csv" | "md" | "yarn" | "image" | "none"; + +export interface ElementSpec { + /** Registered custom element name, e.g. "md-dice". */ + name: string; + /** Human description of the body. */ + body: string; + /** Key attribute names. */ + attrs: string[]; + summary: string; + refKind: RefKind; +} + +/** The registered `md-*` custom elements (mirrors `customElement(...)` calls). */ +export const KNOWN_ELEMENTS: readonly ElementSpec[] = [ + { + name: "md-dice", + body: "dice formula", + attrs: ["key"], + summary: "Clickable dice roller; `key` stores the result in the URL.", + refKind: "none", + }, + { + name: "md-table", + body: "CSV path or inline id", + attrs: ["roll", "remix"], + summary: "Tab-separated view of a CSV with optional group tabs.", + refKind: "csv", + }, + { + name: "md-deck", + body: "CSV path", + attrs: ["size", "grid", "layers"], + summary: "Card layout from a CSV (legacy; prefer `yaml role=tag`).", + refKind: "csv", + }, + { + name: "md-link", + body: "path, optional #section", + attrs: [], + summary: "Clickable link that expands the target article inline.", + refKind: "md", + }, + { + name: "md-embed", + body: "path, optional #section", + attrs: ["headingBase"], + summary: "Renders the target article or section inline.", + refKind: "md", + }, + { + name: "md-bg", + body: "image path or CSS color", + attrs: ["fit"], + summary: "Sets the surrounding article card background.", + refKind: "image", + }, + { + name: "md-border", + body: "color or border image", + attrs: ["width", "slice"], + summary: "Applies a border to the article card.", + refKind: "image", + }, + { + name: "md-font", + body: "font family", + attrs: ["source", "weight", "color"], + summary: "Sets the article font.", + refKind: "none", + }, + { + name: "md-pins", + body: "image path", + attrs: ["pins", "fixed", "labelStart"], + summary: "Numbered markers on an image.", + refKind: "image", + }, + { + name: "md-commander", + body: "command template CSV", + attrs: ["placeholder", "class", "height"], + summary: "Interactive command history / tracker panel.", + refKind: "csv", + }, + { + name: "md-yarn-spinner", + body: "Yarn Spinner file", + attrs: ["start"], + summary: "Runs a Yarn Spinner dialogue file.", + refKind: "yarn", + }, +]; + +export const ELEMENT_NAMES: ReadonlySet = new Set( + KNOWN_ELEMENTS.map((e) => e.name), +); + +const ELEMENT_BY_NAME: ReadonlyMap = new Map( + KNOWN_ELEMENTS.map((e) => [e.name, e]), +); + +export function elementFor(name: string): ElementSpec | undefined { + return ELEMENT_BY_NAME.get(name); +} + +/** Elements whose body may also be a CSS color rather than a file path. */ +export const COLOR_BODY_ELEMENTS: ReadonlySet = new Set([ + "md-bg", + "md-border", +]); + +export interface RoleSpec { + role: string; + summary: string; +} + +/** Code-block `role=` values the scanner understands. */ +export const ROLE_SPECS: readonly RoleSpec[] = [ + { role: "spark-table", summary: "CSV (or markdown pipe table) rendered as `:md-table`." }, + { role: "declare", summary: "Reactive variable / tag-modifier definitions." }, + { role: "file", summary: "Stored as inline text content and stripped." }, + { role: "tag", summary: "YAML rendered as a custom element." }, +]; + +export const KNOWN_ROLES: ReadonlySet = new Set( + ROLE_SPECS.map((r) => r.role), +); + +/** Inline directives that are not custom elements. */ +export const KNOWN_INLINE: ReadonlySet = new Set(["cmd"]); + +/** Expected file extensions per reference kind. */ +export const REF_EXTENSIONS: Record, readonly string[]> = + { + csv: [".csv"], + md: [".md"], + yarn: [".yarn"], + image: [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"], + }; + +/** Whether a directive body is a CSS color rather than a path. */ +export function isColorValue(value: string): boolean { + const v = value.trim(); + return ( + /^#([0-9a-f]{3,8})$/i.test(v) || + /^(rgb|rgba|hsl|hsla|var)\(/i.test(v) || + v === "transparent" || + v === "currentColor" + ); +} + +/** + * Whether a color-capable element body looks like a color rather than a path. + * A single alphabetic word is treated as a CSS named color (e.g. "crimson"); + * image paths contain a separator or an extension. + */ +export function looksLikeColor(value: string): boolean { + const v = value.trim(); + return isColorValue(v) || /^[A-Za-z]+$/.test(v); +} diff --git a/src/cli/validate/scan.ts b/src/cli/validate/scan.ts new file mode 100644 index 0000000..bd67673 --- /dev/null +++ b/src/cli/validate/scan.ts @@ -0,0 +1,246 @@ +/** + * Raw-source scanner for the validator. + * + * Unlike `scanDoc`, which rewrites fenced blocks and loses offsets, this walks + * the *raw* markdown and records character offsets so diagnostics can report + * accurate line/column positions. Directives inside fenced code blocks are + * skipped (documentation examples are not live content). + */ + +import { parseBlockAttrs, type BlockAttrs } from "../completions/block-scanner.js"; + +export interface RawDirective { + /** Directive name, e.g. "md-dice"; "" for the bare icon form `:[name]`. */ + name: string; + body: string; + /** Parsed `{key=value}` attributes (shorthands such as `.class` ignored). */ + attrs: Record; + /** Offset of the leading ":". */ + offset: number; + /** True for the icon form `:[name]`. */ + icon: boolean; +} + +export interface RawFence { + lang: string; + info: string; + attrs: BlockAttrs; + /** Offset of the opening fence line. */ + offset: number; + body: string; + /** Offset of the first body character. */ + bodyOffset: number; +} + +export interface RawLink { + image: boolean; + /** Link target as written, e.g. "./rules.md#combat". */ + target: string; + /** Offset of the target text. */ + targetOffset: number; +} + +export interface RawContainerOpen { + name: string; + colons: number; + offset: number; +} + +export interface RawContainerClose { + colons: number; + offset: number; +} + +export interface ScanResult { + directives: RawDirective[]; + fences: RawFence[]; + links: RawLink[]; + containerOpens: RawContainerOpen[]; + containerCloses: RawContainerClose[]; +} + +interface Line { + text: string; + offset: number; + end: number; +} + +const FENCE_OPEN_RE = /^\s*(`{3,}|~{3,})(.*)$/; +const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})\s*$/; +const CONTAINER_OPEN_RE = /^\s*(:{3,})\s*([A-Za-z][\w-]*)\s*$/; +const CONTAINER_CLOSE_RE = /^\s*(:{3,})\s*$/; +const NAMED_DIRECTIVE_RE = /:([a-zA-Z][a-zA-Z0-9-]*)\[([^\[\]]*)\](?:\{([^}]*)\})?/g; +const ICON_DIRECTIVE_RE = /:\[([^\[\]]*)\](?:\{([^}]*)\})?/g; +const LINK_RE = /(!?)\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g; + +export function scanSource(text: string): ScanResult { + const result: ScanResult = { + directives: [], + fences: [], + links: [], + containerOpens: [], + containerCloses: [], + }; + + let fence: + | { + marker: string; + lang: string; + info: string; + attrs: BlockAttrs; + offset: number; + lineEnd: number; + } + | null = null; + + for (const line of splitLines(text)) { + if (!fence) { + const open = FENCE_OPEN_RE.exec(line.text); + if (open) { + const rest = open[2]; + const langMatch = /^\s*(\w*)\s*/.exec(rest)!; + const lang = langMatch[1] || ""; + const info = rest.slice(langMatch[0].length).trim(); + fence = { + marker: open[1], + lang, + info, + attrs: parseBlockAttrs(info), + offset: line.offset, + lineEnd: line.end, + }; + continue; + } + scanLine(line, result); + continue; + } + + const close = FENCE_CLOSE_RE.exec(line.text); + if (close && close[1][0] === fence.marker[0] && close[1].length >= fence.marker.length) { + result.fences.push({ + lang: fence.lang, + info: fence.info, + attrs: fence.attrs, + offset: fence.offset, + body: text.slice(fence.lineEnd, line.offset), + bodyOffset: fence.lineEnd, + }); + fence = null; + } + } + + if (fence) { + result.fences.push({ + lang: fence.lang, + info: fence.info, + attrs: fence.attrs, + offset: fence.offset, + body: text.slice(fence.lineEnd), + bodyOffset: fence.lineEnd, + }); + } + + return result; +} + +function scanLine(line: Line, result: ScanResult): void { + const open = CONTAINER_OPEN_RE.exec(line.text); + if (open) { + result.containerOpens.push({ + name: open[2], + colons: open[1].length, + offset: line.offset + line.text.indexOf(":"), + }); + return; + } + + const close = CONTAINER_CLOSE_RE.exec(line.text); + if (close) { + result.containerCloses.push({ + colons: close[1].length, + offset: line.offset + line.text.indexOf(":"), + }); + return; + } + + // Directives and links inside inline code or HTML comments are examples. + const inert = maskedRanges(line.text); + + for (const m of line.text.matchAll(NAMED_DIRECTIVE_RE)) { + if (inRanges(m.index, inert)) continue; + result.directives.push({ + name: m[1], + body: m[2], + attrs: parseDirectiveAttrs(m[3] || ""), + offset: line.offset + m.index, + icon: false, + }); + } + + for (const m of line.text.matchAll(ICON_DIRECTIVE_RE)) { + if (inRanges(m.index, inert)) continue; + result.directives.push({ + name: "", + body: m[1], + attrs: parseDirectiveAttrs(m[2] || ""), + offset: line.offset + m.index, + icon: true, + }); + } + + for (const m of line.text.matchAll(LINK_RE)) { + if (inRanges(m.index, inert)) continue; + const target = m[3]; + result.links.push({ + image: m[1] === "!", + target, + targetOffset: line.offset + m.index + m[0].lastIndexOf(target), + }); + } +} + +/** Ranges of a line whose contents are inert (inline code, HTML comments). */ +function maskedRanges(line: string): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + const patterns = [/`+[^`]*`+/g, //g]; + for (const re of patterns) { + let m: RegExpExecArray | null; + while ((m = re.exec(line)) !== null) { + ranges.push([m.index, m.index + m[0].length]); + } + } + return ranges; +} + +function inRanges(index: number, ranges: Array<[number, number]>): boolean { + return ranges.some(([start, end]) => index >= start && index < end); +} + +/** Parse `{key="value" key=value}` ignoring `.class`/`#id` shorthands. */ +function parseDirectiveAttrs(raw: string): Record { + const attrs: Record = {}; + const re = /([A-Za-z_][\w-]*)\s*=\s*("[^"]*"|'[^']*'|\S+)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(raw)) !== null) { + attrs[m[1]] = m[2].replace(/^["']|["']$/g, ""); + } + return attrs; +} + +function splitLines(text: string): Line[] { + const lines: Line[] = []; + let i = 0; + while (i <= text.length) { + let j = i; + while (j < text.length && text[j] !== "\n" && text[j] !== "\r") j++; + let next = j; + if (next < text.length) { + if (text[next] === "\r" && text[next + 1] === "\n") next += 2; + else next += 1; + } + lines.push({ text: text.slice(i, j), offset: i, end: next }); + if (next >= text.length) break; + i = next; + } + return lines; +} diff --git a/src/cli/validate/source.ts b/src/cli/validate/source.ts new file mode 100644 index 0000000..3217433 --- /dev/null +++ b/src/cli/validate/source.ts @@ -0,0 +1,44 @@ +/** + * Source position mapping for diagnostics. + * + * Validation runs over raw file text and reports offsets; this converts an + * offset into a 1-based line/column and can read back the offending line for + * snippet display. + */ + +export interface Position { + line: number; + column: number; +} + +export class SourceFile { + private readonly lineStarts: number[]; + + constructor(readonly text: string) { + const starts = [0]; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) starts.push(i + 1); + } + this.lineStarts = starts; + } + + /** Map a character offset to a 1-based line/column. */ + position(offset: number): Position { + const clamped = Math.max(0, Math.min(offset, this.text.length)); + let lo = 0; + let hi = this.lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (this.lineStarts[mid] <= clamped) lo = mid; + else hi = mid - 1; + } + return { line: lo + 1, column: clamped - this.lineStarts[lo] + 1 }; + } + + /** The text of a 1-based line, without its terminator. */ + lineText(line: number): string { + const start = this.lineStarts[line - 1] ?? 0; + const end = this.lineStarts[line] ?? this.text.length; + return this.text.slice(start, end).replace(/\r?\n$/, ""); + } +} diff --git a/src/cli/validate/validate.test.ts b/src/cli/validate/validate.test.ts new file mode 100644 index 0000000..2463f9e --- /dev/null +++ b/src/cli/validate/validate.test.ts @@ -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 { + 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"); + }); +});