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:
@@ -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):
|
||||
* 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)
|
||||
* The body is YAML with two optional maps (`declareSchema` below):
|
||||
*
|
||||
* When `tag` is empty: $key is a reactively computed variable.
|
||||
* When `tag` is present: when #tag met (source's tagmap value >= threshold),
|
||||
* $key gets expr added to its base.
|
||||
* vars:
|
||||
* $hp: $con*5 + $mod_hp
|
||||
* $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
|
||||
@@ -38,65 +45,134 @@ export interface DeclareResult {
|
||||
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 trimmed = csv.trim();
|
||||
const exprSchema = z
|
||||
.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: [] };
|
||||
|
||||
// Validate that required columns exist (order-agnostic, tag is optional)
|
||||
const firstLine = trimmed.split(/\r?\n/)[0];
|
||||
const headers = firstLine.split(",").map((h) => h.trim().toLowerCase());
|
||||
if (!headers.includes("key") || !headers.includes("expr")) {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = yaml.load(trimmed);
|
||||
} catch (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, {
|
||||
columns: true,
|
||||
trim: true,
|
||||
skipEmptyLines: true,
|
||||
}) as Array<{ tag?: string; threshold?: string; key: string; expr: string }>;
|
||||
const parsed = declareSchema.safeParse(data ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new Error(`role=declare: ${formatIssues(parsed.error)}`);
|
||||
}
|
||||
|
||||
const variables: VarDeclaration[] = [];
|
||||
for (const [key, expression] of Object.entries(parsed.data.vars ?? {})) {
|
||||
variables.push({ key, expression });
|
||||
}
|
||||
|
||||
const tagModifiers: TagModifier[] = [];
|
||||
|
||||
for (const row of records) {
|
||||
const tag = row.tag ?? "";
|
||||
const key = row.key ?? "";
|
||||
const expr = row.expr ?? "";
|
||||
|
||||
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}"`);
|
||||
for (const [name, thresholds] of Object.entries(parsed.data.tags ?? {})) {
|
||||
const tag = name.startsWith("#") ? name : `#${name}`;
|
||||
for (const [rawThreshold, targets] of Object.entries(thresholds)) {
|
||||
const threshold = Number(rawThreshold);
|
||||
for (const [target, expression] of Object.entries(targets)) {
|
||||
tagModifiers.push({ tag, target, expression, threshold });
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Tests for the variable/tag system:
|
||||
* - declare-parser (CSV parsing)
|
||||
* - declare-parser (YAML parsing)
|
||||
* - block-scanner (attribute parsing)
|
||||
* - variable-expression (expression evaluation)
|
||||
* - var-reactivity (dependency graph, cascade, tag activation)
|
||||
@@ -27,7 +27,7 @@ jest.mock("github-slugger", () => {
|
||||
// Imports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { parseDeclareCsv } from "./declare-parser";
|
||||
import { parseDeclare } from "./declare-parser";
|
||||
import { parseBlockAttrs } from "./block-scanner";
|
||||
import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression";
|
||||
import {
|
||||
@@ -63,154 +63,154 @@ function store(entries: Record<string, string> = {}): Record<string, string> {
|
||||
// declare-parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseDeclareCsv", () => {
|
||||
describe("parseDeclare", () => {
|
||||
test("parses variable declarations", () => {
|
||||
const csv = `tag,key,expr
|
||||
,$hp,$con*5+$mod_hp
|
||||
,$ac,10+$dex`;
|
||||
const result = parseDeclareCsv(csv, "test.md");
|
||||
expect(result.variables).toHaveLength(2);
|
||||
expect(result.variables[0]).toEqual({
|
||||
key: "$hp",
|
||||
expression: "$con*5+$mod_hp",
|
||||
});
|
||||
expect(result.variables[1]).toEqual({ key: "$ac", expression: "10+$dex" });
|
||||
const result = parseDeclare(`
|
||||
vars:
|
||||
$hp: $con*5 + $mod_hp
|
||||
$ac: 10 + $dex
|
||||
`);
|
||||
expect(result.variables).toEqual([
|
||||
{ key: "$hp", expression: "$con*5 + $mod_hp" },
|
||||
{ key: "$ac", expression: "10 + $dex" },
|
||||
]);
|
||||
expect(result.tagModifiers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("parses tag modifiers", () => {
|
||||
const csv = `tag,key,expr
|
||||
#warrior,$mod_hp,20
|
||||
#mage,$mod_mp,15`;
|
||||
const result = parseDeclareCsv(csv, "test.md");
|
||||
test("parses tag modifiers with thresholds", () => {
|
||||
const result = parseDeclare(`
|
||||
tags:
|
||||
warrior:
|
||||
2:
|
||||
$mod_hp: 20
|
||||
mage:
|
||||
1:
|
||||
$mod_mp: 15
|
||||
`);
|
||||
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({
|
||||
tag: "#warrior",
|
||||
target: "$mod_hp",
|
||||
expression: "20",
|
||||
threshold: 1,
|
||||
});
|
||||
expect(result.tagModifiers[1]).toEqual({
|
||||
tag: "#mage",
|
||||
target: "$mod_mp",
|
||||
expression: "15",
|
||||
threshold: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses tag modifiers with threshold", () => {
|
||||
const csv = `tag,threshold,key,expr
|
||||
#warrior,2,$mod_hp,20
|
||||
#mage,,$mod_mp,15`;
|
||||
const result = parseDeclareCsv(csv, "test.md");
|
||||
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("normalizes numeric values to expression text", () => {
|
||||
const result = parseDeclare("vars:\n $con: 10\n $dex: 12\n");
|
||||
expect(result.variables).toEqual([
|
||||
{ key: "$con", expression: "10" },
|
||||
{ key: "$dex", expression: "12" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects invalid threshold", () => {
|
||||
const csv = `tag,threshold,key,expr
|
||||
#warrior,0,$mod_hp,20`;
|
||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||
"threshold must be a positive integer",
|
||||
);
|
||||
test("YAML numeric forms are resolved before stringification", () => {
|
||||
const result = parseDeclare("vars:\n $a: 0x1F\n $b: 1e3\n");
|
||||
expect(result.variables).toEqual([
|
||||
{ key: "$a", expression: "31" },
|
||||
{ key: "$b", expression: "1000" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses mixed declarations and tag modifiers", () => {
|
||||
const csv = `tag,key,expr
|
||||
,$hp,$con*5
|
||||
#warrior,$mod_hp,20
|
||||
,$ac,10+$dex`;
|
||||
const result = parseDeclareCsv(csv, "test.md");
|
||||
const result = parseDeclare(`
|
||||
vars:
|
||||
$hp: $con*5
|
||||
$ac: 10+$dex
|
||||
tags:
|
||||
warrior:
|
||||
1:
|
||||
$mod_hp: 20
|
||||
`);
|
||||
expect(result.variables).toHaveLength(2);
|
||||
expect(result.tagModifiers).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("rejects missing key column", () => {
|
||||
const csv = `tag,expr
|
||||
,$con*5`;
|
||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||
'role=declare blocks must have "key" and "expr" columns',
|
||||
test("handles empty, whitespace, and comment-only bodies", () => {
|
||||
for (const body of ["", " \n ", "# just a comment\n"]) {
|
||||
expect(parseDeclare(body)).toEqual({ variables: [], tagModifiers: [] });
|
||||
}
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const csv = `tag,key
|
||||
,$hp`;
|
||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||
'role=declare blocks must have "key" and "expr" columns',
|
||||
test("rejects unknown top-level keys", () => {
|
||||
expect(() => parseDeclare("variable:\n $hp: 1\n")).toThrow(
|
||||
"Unrecognized key",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects tag without # prefix", () => {
|
||||
const csv = `tag,key,expr
|
||||
warrior,$mod_hp,20`;
|
||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||
"tag must start with #",
|
||||
test("rejects a variable name without $", () => {
|
||||
expect(() => parseDeclare("vars:\n hp: 1\n")).toThrow(
|
||||
"variable names must start with $",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects key without $ prefix in declaration", () => {
|
||||
const csv = `tag,key,expr
|
||||
,hp,$con*5`;
|
||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||
"key must start with $",
|
||||
test("rejects a tag modifier target without $", () => {
|
||||
expect(() =>
|
||||
parseDeclare("tags:\n warrior:\n 1:\n mod_hp: 20\n"),
|
||||
).toThrow("tag modifier targets 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", () => {
|
||||
const csv = `tag,key,expr
|
||||
#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,
|
||||
});
|
||||
test("rejects non-numeric, non-string values", () => {
|
||||
expect(() => parseDeclare("vars:\n $hp: true\n")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -229,13 +229,13 @@ describe("parseBlockAttrs", () => {
|
||||
});
|
||||
|
||||
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.role).toBe("declare");
|
||||
});
|
||||
|
||||
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" });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user