feat(declare)!: parse role=declare blocks as YAML

Replace the tag/threshold/key/expr CSV body with YAML `vars:` and
`tags:` maps. `parseDeclare` validates the body with an exported Zod
schema and returns the same declaration and modifier shapes the
reactivity engine already consumes.

The breaking rename from `parseDeclareCsv` updates the registry, the
journal re-exports, and the `tttk check` role checks, where R003 now
reports schema errors with their path and R004 warns when a block
declares nothing.
This commit is contained in:
hyper
2026-09-12 22:50:48 +08:00
parent 16f2038fd8
commit fadfd4cba6
8 changed files with 300 additions and 224 deletions
+133 -57
View File
@@ -1,21 +1,28 @@
/** /**
* Declare parser — parses ```csv role=declare code blocks from markdown. * Declare parser — parses ```yaml role=declare code blocks from markdown.
* *
* Shared between CLI completions scanner and browser-side completions. * Shared between the CLI scanner/validator and the browser-side journal.
* *
* Format (columns can be in any order; `tag` and `threshold` are optional): * The body is YAML with two optional maps (`declareSchema` below):
* tag,threshold,key,expr
* ,,$hp,$con*5+$mod_hp ← variable declaration
* ,,$ac,10+$dex ← variable declaration
* #warrior,2,$mod_hp,20 ← tag modifier (threshold 2)
* #warrior,,$mod_str,1 ← tag modifier (threshold defaults to 1)
* *
* When `tag` is empty: $key is a reactively computed variable. * vars:
* When `tag` is present: when #tag met (source's tagmap value >= threshold), * $hp: $con*5 + $mod_hp
* $key gets expr added to its base. * $ac: 10 + $dex
* tags:
* warrior:
* 1:
* $mod_hp: 20
* 2:
* $mod_str: 1
*
* `vars` entries are reactively computed variables. `tags` entries are
* modifiers: when a source variable's tag map holds `#warrior` at a count
* >= the threshold key, each `$target` gains the modifier value. Tag names
* may be written with or without the leading `#`.
*/ */
import { parse } from "csv-parse/browser/esm/sync"; import yaml from "js-yaml";
import { z } from "zod";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -38,65 +45,134 @@ export interface DeclareResult {
tagModifiers: TagModifier[]; tagModifiers: TagModifier[];
} }
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
/** A `$name` variable key. */
const VAR_NAME = /^\$[A-Za-z_][A-Za-z0-9_]*$/;
/** A tag name, optionally carrying its leading `#`. */
const TAG_NAME = /^#?[A-Za-z_][A-Za-z0-9_-]*$/;
/** Threshold map keys are positive integers (1, 2, 3, …). */
const THRESHOLD = /^[1-9][0-9]*$/;
/** /**
* Parse a single ```csv role=declare block body. * Expression text. YAML resolves bare numbers to `number`, so both strings and
* numbers are accepted and normalized to a trimmed string. A missing value
* loads as `null` and becomes empty text, which the refine rejects. Anything
* else (boolean, date) is a mistake in the source. Unknown references inside
* the expression are resolved by the reactivity engine, not here.
*/ */
export function parseDeclareCsv(csv: string, source: string): DeclareResult { const exprSchema = z
const trimmed = csv.trim(); .union([z.string(), z.number(), z.null()])
.transform((value) => (value === null ? "" : String(value).trim()))
.refine((value) => value.length > 0, "expression must not be empty");
/** A map whose keys must look like `$name`. */
function varMap(message: string) {
return z.record(z.string(), exprSchema).superRefine((obj, ctx) => {
for (const key of Object.keys(obj)) {
if (!VAR_NAME.test(key)) {
ctx.addIssue({ code: "custom", message, path: [key] });
}
}
});
}
/** `threshold -> { $target: expression }`. */
const thresholdMap = z
.record(
z.string(),
varMap("tag modifier targets must start with $, e.g. `$mod_hp`"),
)
.superRefine((obj, ctx) => {
for (const key of Object.keys(obj)) {
if (!THRESHOLD.test(key)) {
ctx.addIssue({
code: "custom",
message: "threshold must be a positive integer",
path: [key],
});
}
}
});
/** The accepted `role=declare` shape. Exported for tests and reuse. */
export const declareSchema = z.strictObject({
vars: varMap("variable names must start with $, e.g. `$hp`").optional(),
tags: z
.record(z.string(), thresholdMap)
.superRefine((obj, ctx) => {
for (const key of Object.keys(obj)) {
if (!TAG_NAME.test(key)) {
ctx.addIssue({
code: "custom",
message: "tag names may not contain spaces or punctuation",
path: [key],
});
}
}
})
.optional(),
});
// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------
/**
* Parse a single ```yaml role=declare block body.
*
* Throws with a `path: message` summary when the body is not valid YAML or
* does not match the schema. Callers attach the file/line context.
*/
export function parseDeclare(body: string): DeclareResult {
const trimmed = body.trim();
if (!trimmed) return { variables: [], tagModifiers: [] }; if (!trimmed) return { variables: [], tagModifiers: [] };
// Validate that required columns exist (order-agnostic, tag is optional) let data: unknown;
const firstLine = trimmed.split(/\r?\n/)[0]; try {
const headers = firstLine.split(",").map((h) => h.trim().toLowerCase()); data = yaml.load(trimmed);
if (!headers.includes("key") || !headers.includes("expr")) { } catch (error) {
throw new Error( throw new Error(
`${source}: role=declare blocks must have "key" and "expr" columns. Got: ${headers.join(",")}`, `role=declare body is not valid YAML: ${errorText(error)}`,
); );
} }
const records = parse(trimmed, { const parsed = declareSchema.safeParse(data ?? {});
columns: true, if (!parsed.success) {
trim: true, throw new Error(`role=declare: ${formatIssues(parsed.error)}`);
skipEmptyLines: true, }
}) as Array<{ tag?: string; threshold?: string; key: string; expr: string }>;
const variables: VarDeclaration[] = []; const variables: VarDeclaration[] = [];
for (const [key, expression] of Object.entries(parsed.data.vars ?? {})) {
variables.push({ key, expression });
}
const tagModifiers: TagModifier[] = []; const tagModifiers: TagModifier[] = [];
for (const [name, thresholds] of Object.entries(parsed.data.tags ?? {})) {
for (const row of records) { const tag = name.startsWith("#") ? name : `#${name}`;
const tag = row.tag ?? ""; for (const [rawThreshold, targets] of Object.entries(thresholds)) {
const key = row.key ?? ""; const threshold = Number(rawThreshold);
const expr = row.expr ?? ""; for (const [target, expression] of Object.entries(targets)) {
tagModifiers.push({ tag, target, expression, threshold });
if (!key || !expr) {
console.warn(`${source}: skipping row with empty key or expr`);
continue;
} }
if (tag) {
// Tag modifier
if (!tag.startsWith("#")) {
throw new Error(`${source}: tag must start with #, got "${tag}"`);
}
if (!key.startsWith("$")) {
throw new Error(`${source}: key must start with $, got "${key}"`);
}
const thresholdRaw = row.threshold?.trim() ?? "";
const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1;
if (isNaN(threshold) || threshold < 1) {
throw new Error(
`${source}: threshold must be a positive integer, got "${row.threshold}"`,
);
}
tagModifiers.push({ tag, target: key, expression: expr, threshold });
} else {
// Variable declaration
if (!key.startsWith("$")) {
throw new Error(`${source}: key must start with $, got "${key}"`);
}
variables.push({ key, expression: expr });
} }
} }
return { variables, tagModifiers }; return { variables, tagModifiers };
} }
function formatIssues(error: z.ZodError): string {
return error.issues
.map((issue) =>
issue.path.length
? `${issue.path.join(".")}: ${issue.message}`
: issue.message,
)
.join("; ");
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+118 -118
View File
@@ -1,6 +1,6 @@
/** /**
* Tests for the variable/tag system: * Tests for the variable/tag system:
* - declare-parser (CSV parsing) * - declare-parser (YAML parsing)
* - block-scanner (attribute parsing) * - block-scanner (attribute parsing)
* - variable-expression (expression evaluation) * - variable-expression (expression evaluation)
* - var-reactivity (dependency graph, cascade, tag activation) * - var-reactivity (dependency graph, cascade, tag activation)
@@ -27,7 +27,7 @@ jest.mock("github-slugger", () => {
// Imports // Imports
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import { parseDeclareCsv } from "./declare-parser"; import { parseDeclare } from "./declare-parser";
import { parseBlockAttrs } from "./block-scanner"; import { parseBlockAttrs } from "./block-scanner";
import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression"; import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression";
import { import {
@@ -63,154 +63,154 @@ function store(entries: Record<string, string> = {}): Record<string, string> {
// declare-parser // declare-parser
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("parseDeclareCsv", () => { describe("parseDeclare", () => {
test("parses variable declarations", () => { test("parses variable declarations", () => {
const csv = `tag,key,expr const result = parseDeclare(`
,$hp,$con*5+$mod_hp vars:
,$ac,10+$dex`; $hp: $con*5 + $mod_hp
const result = parseDeclareCsv(csv, "test.md"); $ac: 10 + $dex
expect(result.variables).toHaveLength(2); `);
expect(result.variables[0]).toEqual({ expect(result.variables).toEqual([
key: "$hp", { key: "$hp", expression: "$con*5 + $mod_hp" },
expression: "$con*5+$mod_hp", { key: "$ac", expression: "10 + $dex" },
}); ]);
expect(result.variables[1]).toEqual({ key: "$ac", expression: "10+$dex" });
expect(result.tagModifiers).toHaveLength(0); expect(result.tagModifiers).toHaveLength(0);
}); });
test("parses tag modifiers", () => { test("parses tag modifiers with thresholds", () => {
const csv = `tag,key,expr const result = parseDeclare(`
#warrior,$mod_hp,20 tags:
#mage,$mod_mp,15`; warrior:
const result = parseDeclareCsv(csv, "test.md"); 2:
$mod_hp: 20
mage:
1:
$mod_mp: 15
`);
expect(result.variables).toHaveLength(0); expect(result.variables).toHaveLength(0);
expect(result.tagModifiers).toHaveLength(2); expect(result.tagModifiers).toEqual([
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 },
{ tag: "#mage", target: "$mod_mp", expression: "15", threshold: 1 },
]);
});
test("multiple thresholds for one tag stack in ascending order", () => {
const result = parseDeclare(`
tags:
warrior:
3:
$mod_hp: 20
1:
$mod_hp: 10
`);
expect(result.tagModifiers).toEqual([
{ tag: "#warrior", target: "$mod_hp", expression: "10", threshold: 1 },
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 3 },
]);
});
test("one threshold can carry multiple targets", () => {
const result = parseDeclare(`
tags:
warrior:
1:
$mod_hp: 20
$mod_str: 1
`);
expect(result.tagModifiers).toEqual([
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{ tag: "#warrior", target: "$mod_str", expression: "1", threshold: 1 },
]);
});
test("accepts a tag name written with #", () => {
const result = parseDeclare('tags:\n "#warrior":\n 1:\n $mod_hp: 20\n');
expect(result.tagModifiers[0]).toEqual({ expect(result.tagModifiers[0]).toEqual({
tag: "#warrior", tag: "#warrior",
target: "$mod_hp", target: "$mod_hp",
expression: "20", expression: "20",
threshold: 1, threshold: 1,
}); });
expect(result.tagModifiers[1]).toEqual({
tag: "#mage",
target: "$mod_mp",
expression: "15",
threshold: 1,
});
}); });
test("parses tag modifiers with threshold", () => { test("normalizes numeric values to expression text", () => {
const csv = `tag,threshold,key,expr const result = parseDeclare("vars:\n $con: 10\n $dex: 12\n");
#warrior,2,$mod_hp,20 expect(result.variables).toEqual([
#mage,,$mod_mp,15`; { key: "$con", expression: "10" },
const result = parseDeclareCsv(csv, "test.md"); { key: "$dex", expression: "12" },
expect(result.tagModifiers).toHaveLength(2); ]);
expect(result.tagModifiers[0]).toEqual({
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 2,
});
expect(result.tagModifiers[1]).toEqual({
tag: "#mage",
target: "$mod_mp",
expression: "15",
threshold: 1,
});
}); });
test("rejects invalid threshold", () => { test("YAML numeric forms are resolved before stringification", () => {
const csv = `tag,threshold,key,expr const result = parseDeclare("vars:\n $a: 0x1F\n $b: 1e3\n");
#warrior,0,$mod_hp,20`; expect(result.variables).toEqual([
expect(() => parseDeclareCsv(csv, "test.md")).toThrow( { key: "$a", expression: "31" },
"threshold must be a positive integer", { key: "$b", expression: "1000" },
); ]);
}); });
test("parses mixed declarations and tag modifiers", () => { test("parses mixed declarations and tag modifiers", () => {
const csv = `tag,key,expr const result = parseDeclare(`
,$hp,$con*5 vars:
#warrior,$mod_hp,20 $hp: $con*5
,$ac,10+$dex`; $ac: 10+$dex
const result = parseDeclareCsv(csv, "test.md"); tags:
warrior:
1:
$mod_hp: 20
`);
expect(result.variables).toHaveLength(2); expect(result.variables).toHaveLength(2);
expect(result.tagModifiers).toHaveLength(1); expect(result.tagModifiers).toHaveLength(1);
}); });
test("rejects missing key column", () => { test("handles empty, whitespace, and comment-only bodies", () => {
const csv = `tag,expr for (const body of ["", " \n ", "# just a comment\n"]) {
,$con*5`; expect(parseDeclare(body)).toEqual({ variables: [], tagModifiers: [] });
expect(() => parseDeclareCsv(csv, "test.md")).toThrow( }
'role=declare blocks must have "key" and "expr" columns', });
test("rejects invalid YAML", () => {
expect(() => parseDeclare("vars: [unclosed\n")).toThrow("not valid YAML");
});
test("rejects duplicate keys", () => {
expect(() => parseDeclare("vars:\n $hp: 1\n $hp: 2\n")).toThrow(
"duplicated mapping key",
); );
}); });
test("rejects missing expr column", () => { test("rejects unknown top-level keys", () => {
const csv = `tag,key expect(() => parseDeclare("variable:\n $hp: 1\n")).toThrow(
,$hp`; "Unrecognized key",
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
'role=declare blocks must have "key" and "expr" columns',
); );
}); });
test("rejects tag without # prefix", () => { test("rejects a variable name without $", () => {
const csv = `tag,key,expr expect(() => parseDeclare("vars:\n hp: 1\n")).toThrow(
warrior,$mod_hp,20`; "variable names must start with $",
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
"tag must start with #",
); );
}); });
test("rejects key without $ prefix in declaration", () => { test("rejects a tag modifier target without $", () => {
const csv = `tag,key,expr expect(() =>
,hp,$con*5`; parseDeclare("tags:\n warrior:\n 1:\n mod_hp: 20\n"),
expect(() => parseDeclareCsv(csv, "test.md")).toThrow( ).toThrow("tag modifier targets must start with $");
"key must start with $", });
test("rejects a threshold that is not a positive integer", () => {
expect(() =>
parseDeclare("tags:\n warrior:\n 0:\n $mod_hp: 20\n"),
).toThrow("threshold must be a positive integer");
});
test("rejects an empty expression", () => {
expect(() => parseDeclare("vars:\n $hp:\n")).toThrow(
"expression must not be empty",
); );
}); });
test("rejects key without $ prefix in tag modifier", () => { test("rejects non-numeric, non-string values", () => {
const csv = `tag,key,expr expect(() => parseDeclare("vars:\n $hp: true\n")).toThrow();
#warrior,mod_hp,20`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
"key must start with $",
);
});
test("skips rows with empty key or expr (warns, doesn't throw)", () => {
const csv = `tag,key,expr
,$hp,$con*5
,,`;
const result = parseDeclareCsv(csv, "test.md");
expect(result.variables).toHaveLength(1);
});
test("handles empty CSV", () => {
const result = parseDeclareCsv("", "test.md");
expect(result.variables).toHaveLength(0);
expect(result.tagModifiers).toHaveLength(0);
});
test("handles whitespace-only CSV", () => {
const result = parseDeclareCsv(" \n ", "test.md");
expect(result.variables).toHaveLength(0);
expect(result.tagModifiers).toHaveLength(0);
});
test("columns can be in any order", () => {
const csv = `expr,key,tag
$con*5,$hp,
20,$mod_hp,#warrior`;
const result = parseDeclareCsv(csv, "test.md");
expect(result.variables).toHaveLength(1);
expect(result.variables[0]).toEqual({ key: "$hp", expression: "$con*5" });
expect(result.tagModifiers).toHaveLength(1);
expect(result.tagModifiers[0]).toEqual({
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
});
}); });
}); });
@@ -229,13 +229,13 @@ describe("parseBlockAttrs", () => {
}); });
test("parses quoted values", () => { test("parses quoted values", () => {
const attrs = parseBlockAttrs('csv id="my stats" role=declare'); const attrs = parseBlockAttrs('yaml id="my stats" role=declare');
expect(attrs.id).toBe("my stats"); expect(attrs.id).toBe("my stats");
expect(attrs.role).toBe("declare"); expect(attrs.role).toBe("declare");
}); });
test("collects unknown attributes in extra", () => { test("collects unknown attributes in extra", () => {
const attrs = parseBlockAttrs("csv role=declare foo=bar baz=42"); const attrs = parseBlockAttrs("yaml role=declare foo=bar baz=42");
expect(attrs.extra).toEqual({ foo: "bar", baz: "42" }); expect(attrs.extra).toEqual({ foo: "bar", baz: "42" });
}); });
+2 -2
View File
@@ -17,7 +17,7 @@
import Slugger from "github-slugger"; import Slugger from "github-slugger";
import { import {
parseDeclareCsv, parseDeclare,
type VarDeclaration, type VarDeclaration,
type TagModifier, type TagModifier,
} from "./completions/declare-parser.js"; } from "./completions/declare-parser.js";
@@ -471,7 +471,7 @@ export function deriveBlocks(registry: ContentRegistry): {
for (const entry of Object.values(store)) { for (const entry of Object.values(store)) {
if (entry.kind !== "declare") continue; if (entry.kind !== "declare") continue;
try { try {
const result = parseDeclareCsv(entry.body, docPath); const result = parseDeclare(entry.body);
declarations.push(...result.variables); declarations.push(...result.variables);
tagModifiers.push(...result.tagModifiers); tagModifiers.push(...result.tagModifiers);
} catch (e) { } catch (e) {
+24 -37
View File
@@ -2,8 +2,7 @@
* Code-block role checks. * Code-block role checks.
*/ */
import { parse } from "csv-parse/browser/esm/sync"; import { parseDeclare, type DeclareResult } from "../../completions/declare-parser.js";
import { parseDeclareCsv } from "../../completions/declare-parser.js";
import { diagnosticAt } from "../diagnostics.js"; import { diagnosticAt } from "../diagnostics.js";
import { ELEMENT_NAMES, KNOWN_ROLES } from "../known.js"; import { ELEMENT_NAMES, KNOWN_ROLES } from "../known.js";
import type { RawFence } from "../scan.js"; import type { RawFence } from "../scan.js";
@@ -60,8 +59,25 @@ function checkSparkTable(fence: RawFence, ctx: CheckContext): void {
function checkDeclare(fence: RawFence, ctx: CheckContext): void { function checkDeclare(fence: RawFence, ctx: CheckContext): void {
const { source, file, sink } = ctx; const { source, file, sink } = ctx;
if (/^csv$/i.test(fence.lang)) {
sink.report(
diagnosticAt(
file,
source,
fence.offset,
"TTTK-R003",
"error",
"role=declare blocks are YAML, not CSV",
"use ```yaml role=declare with `vars:` and `tags:` maps",
),
);
return;
}
let result: DeclareResult;
try { try {
parseDeclareCsv(fence.body, file); result = parseDeclare(fence.body);
} catch (error) { } catch (error) {
sink.report( sink.report(
diagnosticAt( diagnosticAt(
@@ -71,51 +87,22 @@ function checkDeclare(fence: RawFence, ctx: CheckContext): void {
"TTTK-R003", "TTTK-R003",
"error", "error",
error instanceof Error ? error.message : String(error), error instanceof Error ? error.message : String(error),
"role=declare needs `key` and `expr` columns; `tag` and `threshold` are optional", "role=declare is YAML: `vars:` maps `$name` to an expression; `tags:` maps a tag name to threshold maps",
), ),
); );
return; return;
} }
checkDeclareRows(fence, ctx); if (result.variables.length === 0 && result.tagModifiers.length === 0) {
}
/**
* R004 — `parseDeclareCsv` silently skips rows with an empty `key` or `expr`.
* Re-parse with line info so the skipped row can be localized.
*/
function checkDeclareRows(fence: RawFence, ctx: CheckContext): void {
const { source, file, sink } = ctx;
let records: Array<{ record: Record<string, string>; info: { lines: number } }>;
try {
records = parse(fence.body, {
columns: true,
trim: true,
skipEmptyLines: true,
info: true,
}) as unknown as typeof records;
} catch {
return; // R003 already reported the parse failure
}
if (records.length === 0) return;
const headers = Object.keys(records[0].record).map((h) => h.toLowerCase());
if (!headers.includes("key") || !headers.includes("expr")) return;
const baseLine = source.position(fence.bodyOffset).line;
for (const { record, info } of records) {
const key = record.key ?? "";
const expr = record.expr ?? "";
if (key && expr) continue;
sink.report( sink.report(
diagnosticAt( diagnosticAt(
file, file,
source, source,
source.lineStart(baseLine + info.lines - 1), fence.offset,
"TTTK-R004", "TTTK-R004",
"warning", "warning",
`declare row skipped: ${!key ? "empty key" : "empty expr"}`, "role=declare block declares nothing",
"every declare row needs both a key and an expression", "add a `vars:` or `tags:` mapping; the block has no effect",
), ),
); );
} }
+3 -3
View File
@@ -232,12 +232,12 @@ export const ROLE_SPECS: readonly RoleSpec[] = [
}, },
{ {
role: "declare", role: "declare",
info: "csv role=declare", info: "yaml role=declare",
summary: "Reactive variable / tag-modifier definitions.", summary: "Reactive variable / tag-modifier definitions.",
details: details:
"Columns (any order): `tag`, `threshold`, `key`, `expr`. A row with an empty `tag` declares a variable; a row with a `#tag` applies a modifier when that tag is active.", "YAML with two optional maps. `vars:` maps a `$name` to its expression. `tags:` maps a tag name to a map of thresholds to `$target` modifiers: at that tag count the target gains the value, so sibling thresholds stack. Tag names may be written with or without the leading `#`. YAML resolves bare numbers to numbers and everything else to strings; both become expression text.",
examples: [ examples: [
"```csv role=declare\ntag,threshold,key,expr\n,,$hp,$con*5+$mod_hp\n#warrior,2,$mod_hp,20\n```", "```yaml role=declare\nvars:\n $hp: $con*5 + $mod_hp\n $ac: 10 + $dex\ntags:\n warrior:\n 1:\n $mod_hp: 20\n 2:\n $mod_str: 1\n```",
], ],
}, },
{ {
+17 -5
View File
@@ -65,14 +65,26 @@ describe("runCheck", () => {
expect(report.diagnostics[0].line).toBe(1); expect(report.diagnostics[0].line).toBe(1);
}); });
test("R003 flags a declare block missing key/expr columns", () => { test("R003 flags a declare block with an invalid shape", () => {
const dir = fixture({ const dir = fixture({
"doc.md": "```csv role=declare\nfoo,bar\n1,2\n```\n", "doc.md": "```yaml role=declare\nvars:\n hp: 1\n```\n",
}); });
expect(codes(runCheck(dir))).toContain("TTTK-R003"); expect(codes(runCheck(dir))).toContain("TTTK-R003");
}); });
test("R003 flags a CSV role=declare block with a migration hint", () => {
const dir = fixture({
"doc.md": "```csv role=declare\ntag,key,expr\n,,$hp,1\n```\n",
});
const report = runCheck(dir);
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-R003");
expect(diagnostic).toBeDefined();
expect(diagnostic!.message).toContain("YAML, not CSV");
});
test("R005 flags invalid YAML in a role=tag block", () => { test("R005 flags invalid YAML in a role=tag block", () => {
const dir = fixture({ const dir = fixture({
"doc.md": "```yaml role=tag\ntag: [unclosed\n```\n", "doc.md": "```yaml role=tag\ntag: [unclosed\n```\n",
@@ -168,16 +180,16 @@ describe("runCheck", () => {
expect(codes(runCheck(dir))).not.toContain("TTTK-D002"); expect(codes(runCheck(dir))).not.toContain("TTTK-D002");
}); });
test("R004 flags a declare row with an empty key or expr", () => { test("R004 flags a declare block that declares nothing", () => {
const dir = fixture({ const dir = fixture({
"doc.md": "```csv role=declare\nkey,expr\n$hp,1\n,2\n```\n", "doc.md": "```yaml role=declare\n# nothing here\n```\n",
}); });
const report = runCheck(dir); const report = runCheck(dir);
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-R004"); const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-R004");
expect(diagnostic).toBeDefined(); expect(diagnostic).toBeDefined();
expect(diagnostic!.line).toBe(4); expect(diagnostic!.line).toBe(1);
}); });
test("V001 flags a CSV that fails to parse", () => { test("V001 flags a CSV that fails to parse", () => {
+2 -1
View File
@@ -3,7 +3,8 @@
*/ */
export { export {
parseDeclareCsv, parseDeclare,
declareSchema,
type VarDeclaration, type VarDeclaration,
type TagModifier, type TagModifier,
type DeclareResult, type DeclareResult,
+1 -1
View File
@@ -56,7 +56,7 @@ export type { ParsedInput, CompletionItem } from "./command-parser";
export { buildCompletions } from "./command-completions"; export { buildCompletions } from "./command-completions";
export type { CompletionsContext } from "./command-completions"; export type { CompletionsContext } from "./command-completions";
export { VariableView } from "./VariableView"; export { VariableView } from "./VariableView";
export { parseDeclareCsv } from "./declare-parser"; export { parseDeclare, declareSchema } from "./declare-parser";
export type { VarDeclaration, TagModifier } from "./declare-parser"; export type { VarDeclaration, TagModifier } from "./declare-parser";
export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression"; export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
export type { EvalContext, EvalResult, ExprValue } from "./variable-expression"; export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";