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
+24 -37
View File
@@ -2,8 +2,7 @@
* Code-block role checks.
*/
import { parse } from "csv-parse/browser/esm/sync";
import { parseDeclareCsv } from "../../completions/declare-parser.js";
import { parseDeclare, type DeclareResult } from "../../completions/declare-parser.js";
import { diagnosticAt } from "../diagnostics.js";
import { ELEMENT_NAMES, KNOWN_ROLES } from "../known.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 {
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 {
parseDeclareCsv(fence.body, file);
result = parseDeclare(fence.body);
} catch (error) {
sink.report(
diagnosticAt(
@@ -71,51 +87,22 @@ function checkDeclare(fence: RawFence, ctx: CheckContext): void {
"TTTK-R003",
"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;
}
checkDeclareRows(fence, ctx);
}
/**
* 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;
if (result.variables.length === 0 && result.tagModifiers.length === 0) {
sink.report(
diagnosticAt(
file,
source,
source.lineStart(baseLine + info.lines - 1),
fence.offset,
"TTTK-R004",
"warning",
`declare row skipped: ${!key ? "empty key" : "empty expr"}`,
"every declare row needs both a key and an expression",
"role=declare block declares nothing",
"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",
info: "csv role=declare",
info: "yaml role=declare",
summary: "Reactive variable / tag-modifier definitions.",
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: [
"```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);
});
test("R003 flags a declare block missing key/expr columns", () => {
test("R003 flags a declare block with an invalid shape", () => {
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");
});
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", () => {
const dir = fixture({
"doc.md": "```yaml role=tag\ntag: [unclosed\n```\n",
@@ -168,16 +180,16 @@ describe("runCheck", () => {
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({
"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 diagnostic = report.diagnostics.find((d) => d.code === "TTTK-R004");
expect(diagnostic).toBeDefined();
expect(diagnostic!.line).toBe(4);
expect(diagnostic!.line).toBe(1);
});
test("V001 flags a CSV that fails to parse", () => {