feat(syntax): resolveTokens TS twin of Python resolve_text

This commit is contained in:
DumiFlex
2026-04-28 05:58:10 +03:00
parent 966ab4b1aa
commit 9d3c6a9851
3 changed files with 422 additions and 0 deletions
@@ -0,0 +1,28 @@
/**
* Deterministic seeded RNG for tests. Mulberry32 is fast, small, and
* suitable for test reproducibility (NOT cryptographically secure).
*
* Returns an object with .random() and .randrange(n) matching the API
* the resolver uses, so the same code shape works in both Python (using
* stdlib random.Random) and TS (using this helper) tests.
*/
export interface SeededRng {
random(): number;
randrange(n: number): number;
}
export function mulberry32(seed: number): SeededRng {
let s = seed | 0;
return {
random(): number {
s = (s + 0x6d2b79f5) | 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
},
randrange(n: number): number {
return Math.floor(this.random() * n);
},
};
}
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import {
resolveTokens,
type ResolveContext,
type ResolveModule,
UnknownRefError,
RefOutOfSurfaceError,
} from "../../utils/resolveTokens";
import { mulberry32 } from "./_rng-helper";
function makeCtx(opts: Partial<ResolveContext> = {}): ResolveContext {
return {
rng: mulberry32(opts.rngSeed ?? 42),
maxRefDepth: 8,
strict: false,
surface: "wildcard",
developerMode: false,
warnings: [],
vars: {},
modules: {},
...opts,
} as ResolveContext;
}
describe("resolveTokens — text/escape/var", () => {
it("empty input returns empty string", () => {
expect(resolveTokens("", makeCtx())).toBe("");
});
it("plain text passthrough", () => {
expect(resolveTokens("hello world", makeCtx())).toBe("hello world");
});
it("escape sequences resolve to literals", () => {
expect(resolveTokens("price: $$5", makeCtx())).toBe("price: $5");
expect(resolveTokens("user@@host", makeCtx())).toBe("user@host");
});
it("var resolves from ctx.vars", () => {
const ctx = makeCtx({ vars: { color: "red" } });
expect(resolveTokens("$color thing", ctx)).toBe("red thing");
});
it("missing var emits empty string", () => {
expect(resolveTokens("$missing thing", makeCtx())).toBe(" thing");
});
});
describe("resolveTokens — ref", () => {
const wcModule = (_uuid: string, varBinding: string, options: Array<{ value: string; weight: number }> = [{ value: "default", weight: 1 }]): ResolveModule => ({
type: "wildcard",
var_binding: varBinding,
options,
});
it("ref in wildcard surface picks an option", () => {
const ctx = makeCtx({
modules: { a4f7b2e1: wcModule("a4f7b2e1", "color", [{ value: "red", weight: 1 }]) },
});
expect(resolveTokens("@{a4f7b2e1}", ctx)).toBe("red");
});
it("unknown ref strict raises", () => {
expect(() => resolveTokens("@{00000000}", makeCtx({ strict: true }))).toThrow(UnknownRefError);
});
it("unknown ref lenient emits empty + warning", () => {
const ctx = makeCtx({ strict: false });
const out = resolveTokens("a @{00000000} b", ctx);
expect(out).toBe("a b");
expect(ctx.warnings.find((w) => w.type === "unknown_ref")).toBeDefined();
});
it("ref out of surface lenient emits empty", () => {
const ctx = makeCtx({
surface: "combine",
modules: { a4f7b2e1: wcModule("a4f7b2e1", "color") },
});
expect(resolveTokens("a @{a4f7b2e1} b", ctx)).toBe("a b");
expect(ctx.warnings.find((w) => w.type === "ref_out_of_surface")).toBeDefined();
});
it("ref out of surface strict raises", () => {
const ctx = makeCtx({
surface: "combine",
strict: true,
modules: { a4f7b2e1: wcModule("a4f7b2e1", "color") },
});
expect(() => resolveTokens("@{a4f7b2e1}", ctx)).toThrow(RefOutOfSurfaceError);
});
it("cycle detected lenient emits empty + warning", () => {
const ctx = makeCtx({
modules: {
a0000001: wcModule("a0000001", "a", [{ value: "@{b0000002}", weight: 1 }]),
b0000002: wcModule("b0000002", "b", [{ value: "@{a0000001}", weight: 1 }]),
},
});
expect(resolveTokens("@{a0000001}", ctx)).toBe("");
expect(ctx.warnings.find((w) => w.type === "cycle_detected")).toBeDefined();
});
});
describe("resolveTokens — inline + multi-pick", () => {
it("inline pick returns one of branches", () => {
const out = resolveTokens("{a|b|c}", makeCtx());
expect(["a", "b", "c"]).toContain(out);
});
it("multi-pick zero count empty", () => {
expect(resolveTokens("{0$$, $$a|b|c}", makeCtx())).toBe("");
});
it("multi-pick returns joined picks", () => {
const out = resolveTokens("{2$$, $$a|b|c|d}", makeCtx());
const parts = out.split(", ");
expect(parts.length).toBe(2);
parts.forEach((p) => expect(["a", "b", "c", "d"]).toContain(p));
});
it("multi-pick clamps when N > branches", () => {
const out = resolveTokens("{5$$, $$a|b|c}", makeCtx());
expect(out.split(", ").length).toBe(3);
});
});
+269
View File
@@ -0,0 +1,269 @@
/**
* TS twin of engine/syntax/resolve.py. Walks the token list emitted by
* tokenizeRich, dispatching by kind. Used by Test Runner; same semantics
* as the Python engine.
*/
import { tokenizeRich, type RichToken } from "./richTokenize";
export type SurfaceKind = "wildcard" | "combine" | "derivation" | "assembler";
export interface ResolveModule {
type: string;
var_binding: string;
options: Array<{ value: string; weight: number }>;
}
export interface ResolveWarning {
type: string;
severity: "info" | "warn" | "error";
module_id: string;
source_field: string;
position: number;
token_index: number | null;
detail: Record<string, unknown>;
message: string;
}
export interface ResolveContext {
rng: { random(): number; randrange(n: number): number };
rngSeed?: number;
maxRefDepth: number;
strict: boolean;
surface: SurfaceKind;
developerMode: boolean;
warnings: ResolveWarning[];
vars: Record<string, string>;
modules: Record<string, ResolveModule>;
}
export class UnknownRefError extends Error {
constructor(public uuid: string) {
super(`unknown wildcard ref: @{${uuid}}`);
}
}
export class RefOutOfSurfaceError extends Error {
constructor(public uuid: string, public surface: string) {
super(`ref @{${uuid}} not allowed in ${surface!} surface`);
}
}
export class RecursionLimitExceeded extends Error {}
export class CycleDetectedError extends Error {
constructor(public chain: string[]) {
super("cycle: " + chain.map((u) => `@{${u}}`).join(" → "));
}
}
export function resolveTokens(text: string, ctx: ResolveContext): string {
const tokens = tokenizeRich(text);
return resolveTokenList(tokens, ctx, 0, []);
}
function resolveTokenList(
tokens: RichToken[],
ctx: ResolveContext,
depth: number,
visited: string[],
): string {
const parts: string[] = [];
for (const tok of tokens) {
switch (tok.kind) {
case "text":
parts.push(tok.raw);
break;
case "escape":
parts.push((tok.meta?.literal as string) ?? "");
break;
case "var":
parts.push(resolveVar(tok, ctx));
break;
case "ref":
parts.push(resolveRef(tok, ctx, depth, visited));
break;
case "dp-brace":
parts.push(resolveInlinePick(tok, ctx, depth, visited));
break;
case "dp-multi":
parts.push(resolveMultiPick(tok, ctx, depth, visited));
break;
// dp-pipe, dp-weight not emitted at top level — assertion failure if we hit them
default:
throw new Error(`unknown token kind: ${tok.kind}`);
}
}
return parts.join("");
}
function resolveVar(tok: RichToken, ctx: ResolveContext): string {
const name = (tok.meta?.name as string) ?? "";
if (name.startsWith("__")) return "";
return ctx.vars[name] ?? "";
}
function resolveRef(
tok: RichToken,
ctx: ResolveContext,
depth: number,
visited: string[],
): string {
const uuid = (tok.meta?.uuid as string) ?? "";
if (depth >= ctx.maxRefDepth) {
const chain = [...visited, uuid];
if (ctx.strict) throw new RecursionLimitExceeded("max ref depth exceeded");
pushWarning(ctx, {
type: "recursion_limit", severity: "error", position: tok.start,
detail: { chain, limit: ctx.maxRefDepth },
message: `Recursion limit reached at depth ${ctx.maxRefDepth}`,
});
return "";
}
if (visited.includes(uuid)) {
const chain = [...visited, uuid];
if (ctx.strict) throw new CycleDetectedError(chain);
pushWarning(ctx, {
type: "cycle_detected", severity: "error", position: tok.start,
detail: { chain },
message: "Cycle: " + chain.map((u) => `@{${u}}`).join(" → "),
});
return "";
}
if (ctx.surface !== "wildcard") {
if (ctx.strict) throw new RefOutOfSurfaceError(uuid, ctx.surface);
const mod = ctx.modules[uuid];
pushWarning(ctx, {
type: "ref_out_of_surface", severity: "info", position: tok.start,
detail: { uuid, name: mod?.var_binding ?? null, surface: ctx.surface },
message: `@{${uuid}} ignored in ${ctx.surface!} surface`,
});
return "";
}
const module = ctx.modules[uuid];
if (!module) {
if (ctx.strict) throw new UnknownRefError(uuid);
pushWarning(ctx, {
type: "unknown_ref", severity: "warn", position: tok.start,
detail: { uuid, name: null },
message: `Unknown wildcard ref @{${uuid}}`,
});
return "";
}
const chosen = pickWeighted(module.options ?? [], ctx.rng);
if (!chosen) return "";
const chosenValue = String(chosen.value ?? "");
if (!chosenValue) return "";
const nested = tokenizeRich(chosenValue);
return resolveTokenList(nested, ctx, depth + 1, [...visited, uuid]);
}
function resolveInlinePick(
tok: RichToken,
ctx: ResolveContext,
depth: number,
visited: string[],
): string {
const branches = (tok.meta?.branches as string[]) ?? [];
if (!branches.length) return "";
const idx = ctx.rng.randrange(branches.length);
const chosen = branches[idx];
if (!chosen) return "";
return resolveTokenList(tokenizeRich(chosen), ctx, depth, visited);
}
function resolveMultiPick(
tok: RichToken,
ctx: ResolveContext,
depth: number,
visited: string[],
): string {
const count = (tok.meta?.count as number) ?? 0;
const sep = (tok.meta?.sep as string) ?? "";
const branches = [...((tok.meta?.branches as string[]) ?? [])];
if (count <= 0) return "";
const nPicks = Math.min(count, branches.length);
if (nPicks === 0) return "";
const weights = branches.map(parseBranchWeight);
const values = branches.map(stripBranchWeight);
const chosenIndices: number[] = [];
const availableIdx = branches.map((_, i) => i);
const availableW = [...weights];
for (let i = 0; i < nPicks; i++) {
if (!availableIdx.length) break;
const pickIdx = weightedPickIndex(availableW, ctx.rng);
chosenIndices.push(availableIdx.splice(pickIdx, 1)[0]);
availableW.splice(pickIdx, 1);
}
return chosenIndices
.map((idx) => resolveTokenList(tokenizeRich(values[idx]), ctx, depth, visited))
.join(sep);
}
function pickWeighted(
options: Array<{ value: string; weight: number }>,
rng: ResolveContext["rng"],
): { value: string; weight: number } | null {
if (!options.length) return null;
const total = options.reduce((s, o) => s + Math.max(0, Number(o.weight) || 0), 0);
if (total <= 0) return options[0];
const r = rng.random() * total;
let acc = 0;
for (const o of options) {
acc += Math.max(0, Number(o.weight) || 0);
if (r <= acc) return o;
}
return options[options.length - 1];
}
function parseBranchWeight(branch: string): number {
if (!branch.includes("::")) return 1;
const [prefix] = branch.split("::");
const n = Number(prefix);
if (!Number.isFinite(n) || n <= 0) return 1;
return n;
}
function stripBranchWeight(branch: string): string {
if (!branch.includes("::")) return branch;
const idx = branch.indexOf("::");
const prefix = branch.slice(0, idx);
if (!Number.isFinite(Number(prefix))) return branch;
return branch.slice(idx + 2);
}
function weightedPickIndex(weights: number[], rng: ResolveContext["rng"]): number {
const total = weights.reduce((s, w) => s + Math.max(0, w), 0);
if (total <= 0) return 0;
const r = rng.random() * total;
let acc = 0;
for (let i = 0; i < weights.length; i++) {
acc += Math.max(0, weights[i]);
if (r <= acc) return i;
}
return weights.length - 1;
}
function pushWarning(ctx: ResolveContext, partial: Partial<ResolveWarning>): void {
ctx.warnings.push({
type: "",
severity: "warn",
module_id: "",
source_field: "",
position: 0,
token_index: null,
detail: {},
message: "",
...partial,
} as ResolveWarning);
}