From 7b3994135d5702e4a7014ef0b9027fc5c06608c6 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Sun, 8 Nov 2026 19:36:55 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(WF-1):=20workflow=20extension=20?= =?UTF-8?q?=E2=80=94=20rulesets,=20use+override,=20language=20conditioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ISC-WF-1 (maps docs/isc-wf-1.md, PR #259) for the TypeScript runtime, per the decided design split: - W1/W2 resolve at parse/compile time: the parser accepts ruleset/use/ override, expandDocument() expands uses recursively in place (cycles are IscLoadError) and applies per-site overrides (child-wins on exact from-equality; kept-in-order then overrides). The IR stays flat. - W3 is runtime: maps carry languages on the document and when arrays on rules; the executor filters per the selected language (guard-applicable wins over unguarded same-from; inert otherwise). transliterate and the CLI gain an optional language parameter. IR (interscript.maps.ir.v1) gains only optional document-level languages and optional rule-level when — additive, legacy maps serialize byte-identically. Additivity gate (scripts/wf1-gate.mjs): 408/408 corpus files parse and all 11,048 golden rows are byte-identical before/after (digest 77bb59bf...); 11,043/11,048 match the rs golden (5 pre-existing alalc-ell-Grek-Latn-2010 deltas present on main). --- scripts/wf1-gate.mjs | 191 +++++++++ src/cli.ts | 10 +- src/index.ts | 36 +- src/isc/converter.ts | 72 +++- src/isc/expand.ts | 213 ++++++++++ src/isc/index.ts | 6 +- src/isc/loader.ts | 62 ++- src/isc/parser.ts | 191 ++++++++- src/isc/types.ts | 36 ++ src/loaders.ts | 2 + src/runtime/context.ts | 14 +- src/runtime/executor.ts | 70 +++- src/runtime/interpreter.ts | 12 +- src/types.ts | 13 + test/fixtures/wf1/wf1-dependent.isc | 24 ++ test/fixtures/wf1/wf1-langs.isc | 25 ++ test/fixtures/wf1/wf1-lib.isc | 22 ++ test/fixtures/wf1/wf1-same-map.isc | 29 ++ test/isc/wf1-e2e.test.ts | 77 ++++ test/isc/wf1.test.ts | 578 ++++++++++++++++++++++++++++ 20 files changed, 1628 insertions(+), 55 deletions(-) create mode 100644 scripts/wf1-gate.mjs create mode 100644 src/isc/expand.ts create mode 100644 test/fixtures/wf1/wf1-dependent.isc create mode 100644 test/fixtures/wf1/wf1-langs.isc create mode 100644 test/fixtures/wf1/wf1-lib.isc create mode 100644 test/fixtures/wf1/wf1-same-map.isc create mode 100644 test/isc/wf1-e2e.test.ts create mode 100644 test/isc/wf1.test.ts diff --git a/scripts/wf1-gate.mjs b/scripts/wf1-gate.mjs new file mode 100644 index 0000000..d0820cf --- /dev/null +++ b/scripts/wf1-gate.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node +// ISC-WF-1 additivity gate (spec §7) — golden capture + zero-delta check. +// +// Runs every row of the frozen golden capture (produced by interscript-rs +// tests/golden/isc-wf0.jsonl: {map, src, out}) through THIS runtime and +// verifies byte-identical output on maps that use none of the WF-1 +// constructs (ruleset / use / override / languages / when). +// +// Usage: +// node scripts/wf1-gate.mjs capture # write computed outputs +// node scripts/wf1-gate.mjs check # zero-delta vs a prior capture +// node scripts/wf1-gate.mjs parity # delta count vs the rs golden +// node scripts/wf1-gate.mjs parse # parse the whole corpus +// +// Environment: +// GOLDEN_JSONL path to isc-wf0.jsonl (default: ../interscript-rs/tests/golden/) +// MAPS_CORPUS maps dir (default: ../maps/maps) +// MAPS_LIBS libs dir (default: ../maps/libs) + +import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs" +import { basename, dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { createHash } from "node:crypto" + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const mapsDir = process.env.MAPS_CORPUS ?? resolve(repoRoot, "..", "maps", "maps") +const libsDir = process.env.MAPS_LIBS ?? resolve(repoRoot, "..", "maps", "libs") +const goldenPath = + process.env.GOLDEN_JSONL ?? resolve(repoRoot, "..", "interscript-rs", "tests", "golden", "isc-wf0.jsonl") + +const [, , mode, arg] = process.argv +if (!mode || (mode !== "parse" && mode !== "parity" && !arg)) { + console.error("usage: wf1-gate.mjs capture|check | parity | parse") + process.exit(2) +} + +function loadSources() { + const sources = {} + let count = 0 + for (const dir of [mapsDir, libsDir]) { + if (!existsSync(dir)) continue + for (const f of readdirSync(dir).filter((f) => f.endsWith(".isc"))) { + const code = basename(f, ".isc") + sources[code] = readFileSync(join(dir, f), "utf8") + count++ + } + } + return { sources, count } +} + +// The WF-1 construct tokens — the adopting set is any corpus file whose +// source contains them OUTSIDE metadata prose. The gate treats a map as +// adopting if the raw source mentions them at line start (prose lines in +// metadata are consumed as raw text by the parser and never dispatch). +const WF1_TOKENS = /^\s*(ruleset\s|use\s|override\s|languages\s|when\s)/m + +function goldenRows() { + if (!existsSync(goldenPath)) { + console.error(`golden not found: ${goldenPath}`) + process.exit(1) + } + return readFileSync(goldenPath, "utf8") + .split("\n") + .filter((l) => l.trim() !== "") + .map((l) => JSON.parse(l)) +} + +async function run() { + const { parseIsc } = await import(new URL("../src/isc/parser.ts", import.meta.url)) + const { iscBundledStrategy } = await import(new URL("../src/isc/loader.ts", import.meta.url)) + const { configure, transliterate } = await import(new URL("../src/index.ts", import.meta.url)) + + const { sources, count } = loadSources() + + if (mode === "parse") { + let ok = 0 + const failures = [] + for (const [code, src] of Object.entries(sources)) { + try { + parseIsc(src, `${code}.isc`) + ok++ + } catch (e) { + failures.push(`${code}: ${e.message}`) + } + } + console.log(`parsed ${ok}/${count} corpus files`) + if (failures.length > 0) { + console.log("FAILURES:\n" + failures.join("\n")) + process.exit(1) + } + return + } + + configure({ strategies: [iscBundledStrategy(sources)] }) + const rows = goldenRows() + const computed = [] + let errors = 0 + for (const row of rows) { + let out + try { + out = transliterate(row.map, row.src) + } catch (e) { + out = `__ERROR__:${e.message}` + errors++ + } + computed.push({ map: row.map, src: row.src, out }) + } + + if (mode === "parity") { + // Cross-runtime audit against the rs-produced golden (informational: + // pre-existing divergences on origin/main are not gate failures). + let delta = 0 + const mismatched = new Set() + for (let i = 0; i < rows.length; i++) { + if (rows[i].out !== computed[i].out) { + delta++ + mismatched.add(computed[i].map) + } + } + console.log( + `cross-runtime parity vs rust golden: ${rows.length - delta}/${rows.length} rows match ` + + `(${delta} delta on ${mismatched.size} maps, ${errors} errors)`, + ) + if (mismatched.size > 0) console.log(`divergent maps: ${[...mismatched].sort().join(", ")}`) + return + } + + if (mode === "capture") { + writeFileSync( + arg, + computed.map((r) => JSON.stringify(r)).join("\n") + "\n", + ) + const digest = createHash("sha256") + .update(computed.map((r) => JSON.stringify(r)).join("\n")) + .digest("hex") + console.log( + `captured ${computed.length} rows (${errors} errors) -> ${arg}\ndigest: ${digest}`, + ) + return + } + + // check mode: compare a fresh capture against a previously captured + // baseline of THIS runtime (the additivity gate — zero delta required). + // Use `capture` + `diff` against interscript-rs' golden to audit + // cross-runtime parity separately. + let baseline + try { + baseline = readFileSync(arg, "utf8") + .split("\n") + .filter((l) => l.trim() !== "") + .map((l) => JSON.parse(l)) + } catch { + console.error(`cannot read baseline capture: ${arg}`) + process.exit(1) + } + if (baseline.length !== computed.length) { + console.error(`row count mismatch: baseline ${baseline.length} vs computed ${computed.length}`) + process.exit(1) + } + let delta = 0 + const samples = [] + for (let i = 0; i < computed.length; i++) { + const b = baseline[i] + const c = computed[i] + if (b.map !== c.map || b.src !== c.src) { + console.error(`row ${i}: map/src mismatch against baseline`) + process.exit(1) + } + if (b.out !== c.out) { + delta++ + if (samples.length < 10) { + samples.push( + `${c.map} "${c.src.slice(0, 24)}": baseline "${b.out.slice(0, 40)}" vs got "${c.out.slice(0, 40)}"`, + ) + } + } + } + const digest = createHash("sha256") + .update(computed.map((r) => JSON.stringify(r)).join("\n")) + .digest("hex") + console.log( + `checked ${computed.length} rows against baseline: ${delta} delta, ${errors} errors\ndigest: ${digest}`, + ) + if (samples.length > 0) console.log("sample deltas:\n" + samples.join("\n")) + process.exit(delta === 0 ? 0 : 1) +} + +run().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/src/cli.ts b/src/cli.ts index 9e5f42c..7da9139 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -116,6 +116,8 @@ async function cmdTransliterate(args: string[], opts: GlobalOpts): Promise { options: { output: { type: "string", short: "o" }, csv: { type: "boolean", default: false }, + language: { type: "string" }, }, allowPositionals: true, strict: true, @@ -185,7 +188,7 @@ async function cmdBatch(args: string[], opts: GlobalOpts): Promise { const rows: Array<{ input: string; output: string; error?: string }> = [] for (const input of lines) { try { - const output = await transliterateAsync(systemCode, input) + const output = await transliterateAsync(systemCode, input, undefined, values.language) rows.push({ input, output }) } catch (e) { rows.push({ input, output: "", error: (e as Error).message }) @@ -303,6 +306,7 @@ Usage: Commands: transliterate [-i FILE] [-o FILE] Transliterate text (alias: t) + [--language ] Select a declared language (WF-1) batch [--csv] [-o FILE] Bulk process many lines (alias: b) list [--authority X] [--source-script X] List available systems (alias: l) detect Find best-matching system (alias: d) diff --git a/src/index.ts b/src/index.ts index 28d4462..aeb75b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,14 +66,16 @@ export type { IscDocument, IscItem, IscRule, + IscRuleset, IscStage, IscStageItem, IscTest, IscConstraint, } from "./isc/types.js" export { parseIsc } from "./isc/parser.js" -export { IscParseError } from "./isc/types.js" -export { iscToCompiledMap } from "./isc/converter.js" +export { IscParseError, IscLoadError } from "./isc/types.js" +export { iscToCompiledMap, type IscToCompiledMapOptions } from "./isc/converter.js" +export { expandDocument, requiredDependencyDocs, type DependencyDocResolver } from "./isc/expand.js" export { setRababaConfig, resetRababaConfigs, type RababaConfigEntry } from "./stdlib/ml.js" export interface InterscriptConfig { @@ -149,12 +151,17 @@ class InterscriptRuntime { /** * Transliterate `input` using `systemCode`. Loads the map on first use, * caches it. + * + * `language` (ISC-WF-1 W3): selects one of the map's declared + * languages. When given and declared, `when`-guarded rules listing it + * apply and all other guarded rules are inert; when omitted (or + * undeclared), every guarded rule is inert. */ - transliterate(systemCode: SystemCode, input: string, stage?: string): string { + transliterate(systemCode: SystemCode, input: string, stage?: string, language?: string): string { try { const map = this.loadMap(systemCode) const stageName = stage ?? this.defaultStage - return executeStage(map, stageName, input, this.loader) + return executeStage(map, stageName, input, this.loader, language) } catch (e) { if (e instanceof InterscriptError) throw e throw new SystemConversionError( @@ -170,11 +177,16 @@ class InterscriptRuntime { * Also handles ML-powered maps (rababa, secryst) — use this instead * of transliterate() for any map that might contain ML funcalls. */ - async transliterateAsync(systemCode: SystemCode, input: string, stage?: string): Promise { + async transliterateAsync( + systemCode: SystemCode, + input: string, + stage?: string, + language?: string, + ): Promise { try { const map = await this.loadMapAsync(systemCode) const stageName = stage ?? this.defaultStage - return await executeStageAsync(map, stageName, input, this.loader) + return await executeStageAsync(map, stageName, input, this.loader, language) } catch (e) { if (e instanceof InterscriptError) throw e throw new SystemConversionError( @@ -236,8 +248,13 @@ function runtime(): InterscriptRuntime { } /** Public API — mirrors Interscript.transliterate from Ruby. */ -export function transliterate(systemCode: SystemCode, input: string, stage?: string): string { - return runtime().transliterate(systemCode, input, stage) +export function transliterate( + systemCode: SystemCode, + input: string, + stage?: string, + language?: string, +): string { + return runtime().transliterate(systemCode, input, stage, language) } /** @@ -248,8 +265,9 @@ export function transliterateAsync( systemCode: SystemCode, input: string, stage?: string, + language?: string, ): Promise { - return runtime().transliterateAsync(systemCode, input, stage) + return runtime().transliterateAsync(systemCode, input, stage, language) } /** Public API — mirrors Interscript.load. */ diff --git a/src/isc/converter.ts b/src/isc/converter.ts index ec8693f..7de63af 100644 --- a/src/isc/converter.ts +++ b/src/isc/converter.ts @@ -4,9 +4,22 @@ * * This bridges the ISC parser output to the existing runtime without * modifying any runtime code (OCP). + * + * ISC-WF-1 (docs/isc-wf-1.md): + * - W1/W2 (rulesets, use+override) resolve here at compile time via + * expandDocument(). The emitted IR stays FLAT — expanded rules only, + * no ruleset or use nodes survive into interscript.maps.ir.v1. + * - The IR gains exactly two additive fields: optional document-level + * `languages: string[]` (W3 declaration) and optional rule-level + * `when: string[]` (W3 guards). Maps using none of the constructs + * serialize byte-identically to the pre-WF-1 format (additivity, §1). + * - W3 guard semantics (inertness, guard-applicable-wins precedence) + * are RUNTIME concerns — see runtime/executor.ts. */ import type { IscDocument, IscItem, IscRule, IscStageItem } from "./types.js" +import { IscLoadError } from "./types.js" +import { expandDocument, type DependencyDocResolver } from "./expand.js" import type { AliasItem, AnyCharClassItem, @@ -28,34 +41,60 @@ import type { SubRule, } from "../types.js" -export function iscToCompiledMap(doc: IscDocument): CompiledMapJson { +export interface IscToCompiledMapOptions { + /** + * Resolver for dependency documents, needed when the map (or one of + * its dependencies) expands a ruleset across a dependency boundary + * (`use .`). Declared dependencies only. + */ + readonly resolveDependency?: DependencyDocResolver +} + +export function iscToCompiledMap( + doc: IscDocument, + opts: IscToCompiledMapOptions = {}, +): CompiledMapJson { + // W1/W2: resolve ruleset uses before converting. A no-op for maps + // without `use` directives (legacy corpus — additivity preserved). + const expanded = expandDocument( + doc, + opts.resolveDependency ? { resolveDependency: opts.resolveDependency } : {}, + ) + const aliases: Record = {} - for (const a of doc.aliases) { + for (const a of expanded.aliases) { aliases[a.name] = convertItem(a.value) } // Build alias → target system code map for run-rule resolution. const depAliases = new Map() - for (const d of doc.dependencies) { + for (const d of expanded.dependencies) { if (d.aliasName) depAliases.set(d.aliasName, d.target) } const resolveDep = (name: string): string => depAliases.get(name) ?? name - const stages = doc.stages.map((s) => convertStage(s, resolveDep)) + const stages = expanded.stages.map((s) => convertStage(s, resolveDep)) + + const base = { + schemaVersion: 1 as const, + systemCode: expanded.systemCode, + dependencies: expanded.dependencies.map((d) => d.target), + // IR WF-1 extension: languages declared by the map (absent when the + // map declares none, preserving the legacy JSON shape byte-for-byte). + ...(expanded.languages && expanded.languages.length > 0 + ? { languages: [...expanded.languages] } + : {}), + } - if (Object.keys(doc.metadata).length === 0) { + if (Object.keys(expanded.metadata).length === 0) { return { - schemaVersion: 1, - systemCode: doc.systemCode, - dependencies: doc.dependencies.map((d) => d.target), + ...base, stages, aliases, functions: {}, } } return { - schemaVersion: 1, - systemCode: doc.systemCode, - dependencies: doc.dependencies.map((d) => d.target), - metadata: doc.metadata, + ...base, + metadata: expanded.metadata, stages, aliases, functions: {}, @@ -77,6 +116,12 @@ function convertStage( function convertStageItem(item: IscStageItem, resolveDep: DepResolver): Rule { switch (item.kind) { + case "use": + // Unreachable: expandDocument() resolves every `use` before + // conversion — the IR stays flat (no ruleset/use nodes). + throw new IscLoadError( + `unexpanded use "${item.dependency ? `${item.dependency}.` : ""}${item.name}" reached the converter`, + ) case "parallel": { const r: ParallelRule = { kind: "parallel", rules: item.rules.map(convertRule) } return r @@ -144,6 +189,9 @@ function convertRule(rule: IscRule): SubRule { ...(after !== undefined ? { after } : {}), ...(notBefore !== undefined ? { notBefore } : {}), ...(notAfter !== undefined ? { notAfter } : {}), + // IR WF-1 extension: language guard (absent on unguarded rules, + // preserving the legacy JSON shape). + ...(rule.when && rule.when.length > 0 ? { when: [...rule.when] } : {}), } return r } diff --git a/src/isc/expand.ts b/src/isc/expand.ts new file mode 100644 index 0000000..7b558f2 --- /dev/null +++ b/src/isc/expand.ts @@ -0,0 +1,213 @@ +/** + * ISC-WF-1 W1/W2 — ruleset expansion (§3) and use-with-override merge (§4). + * + * Expansion happens at COMPILE time: `expandDocument()` rewrites a parsed + * document so that every `use` directive is replaced by the referenced + * ruleset's items at its position (a macro expansion, not a sub-run). + * The compiled IR stays flat — expanded rules only, no ruleset nodes. + * + * Merge semantics (§4.2), at each expansion site: + * 1. A rule of the expansion is REPLACED if an override row exists with + * the same from under byte-exact equality (no normalization). If + * several rules share that from, ALL are replaced. + * 2. The effective expansion = kept rules (in order) then override rows + * (in order). Override rows are complete: they replace `to` and any + * constraints wholesale, and may carry their own `when` guard. + * + * Expansion is recursive; a chain that revisits a ruleset is a load + * error (IscLoadError, §3.2 / §6.4). + */ + +import type { IscDocument, IscItem, IscRule, IscStage, IscStageItem } from "./types.js" +import { IscLoadError } from "./types.js" + +/** Resolves a dependency target (system code) to its parsed document. */ +export type DependencyDocResolver = (target: string) => IscDocument | undefined + +export interface ExpandOptions { + /** + * Resolver for dependency documents, needed by `use .`. + * Only declared dependencies are consulted — no implicit corpus-wide + * lookups (§3.3). Absent when the map has no cross-dependency uses. + */ + readonly resolveDependency?: DependencyDocResolver +} + +/** Expand every `use` in the document's stages; rulesets themselves never execute. */ +export function expandDocument(doc: IscDocument, opts: ExpandOptions = {}): IscDocument { + const scope: ExpandScope = { + doc, + docKey: doc.systemCode, + resolveDep: opts.resolveDependency, + chain: [], + } + const stages: IscStage[] = doc.stages.map((s) => ({ ...s, body: expandItems(s.body, scope) })) + return { ...doc, stages } +} + +interface ExpandScope { + /** Document that owns the rulesets currently being expanded. */ + readonly doc: IscDocument + /** Identity of that document for cycle detection (its system code). */ + readonly docKey: string + readonly resolveDep: DependencyDocResolver | undefined + /** Active expansion chain — revisiting a chain member is a cycle. */ + readonly chain: readonly string[] +} + +function expandItems(items: readonly IscStageItem[], scope: ExpandScope): IscStageItem[] { + const out: IscStageItem[] = [] + for (const item of items) { + if (item.kind === "use") { + out.push(...expandUse(item, scope)) + } else { + // parallel/sequence blocks contain plain rules only (`use` is a + // stage-level item), so no recursion into blocks is needed. + out.push(item) + } + } + return out +} + +function expandUse( + item: Extract, + scope: ExpandScope, +): IscStageItem[] { + // Resolve the owning document: same map, or a declared dependency. + let owner = scope.doc + let ownerKey = scope.docKey + if (item.dependency) { + const dep = scope.doc.dependencies.find((d) => d.aliasName === item.dependency) + const target = dep?.target + if (!target) { + throw new IscLoadError(`use: unknown dependency alias "${item.dependency}"`) + } + const depDoc = scope.resolveDep?.(target) + if (!depDoc) { + throw new IscLoadError( + `use: cannot resolve dependency "${target}" (needed for ruleset "${item.name}")`, + ) + } + owner = depDoc + ownerKey = target + } + + const key = `${ownerKey}::${item.name}` + if (scope.chain.includes(key)) { + throw new IscLoadError(`use cycle: ${[...scope.chain, key].join(" -> ")}`) + } + const ruleset = (owner.rulesets ?? []).find((r) => r.name === item.name) + if (!ruleset) { + throw new IscLoadError( + `use: unknown ruleset "${item.name}"${item.dependency ? ` in dependency "${item.dependency}"` : ""}`, + ) + } + + // Nested `use` items resolve against the OWNER's rulesets and the + // owner's own dependency aliases; the resolver itself is global + // (keyed by dependency target system code). + const inner: ExpandScope = { + doc: owner, + docKey: ownerKey, + resolveDep: scope.resolveDep, + chain: [...scope.chain, key], + } + const expanded = expandItems(ruleset.body, inner) + + // W2 — use with override (§4.2): partition the full expansion + // (including nested expansions) by exact from-equality. + if (item.override && item.override.length > 0) { + return applyOverride(expanded, item.override) + } + return expanded +} + +/** + * Remove every rule whose from matches an override row (from anywhere in + * the expansion — bare rules and rules inside parallel/sequence blocks), + * then append the override rows at the expansion site. + */ +function applyOverride( + expanded: readonly IscStageItem[], + overrides: readonly IscRule[], +): IscStageItem[] { + const overrideKeys = new Set(overrides.map(ruleFromKey)) + + const keepRule = (r: IscRule): boolean => !overrideKeys.has(ruleFromKey(r)) + + const kept: IscStageItem[] = [] + for (const item of expanded) { + if (item.kind === "bare_rule") { + if (keepRule(item.rule)) kept.push(item) + } else if (item.kind === "parallel" || item.kind === "sequence") { + kept.push({ ...item, rules: item.rules.filter(keepRule) }) + } else { + kept.push(item) + } + } + + const added: IscStageItem[] = overrides.map((rule) => ({ kind: "bare_rule", rule })) + return [...kept, ...added] +} + +/** + * Structural key of a rule's from-item — the compiled-form analog of the + * spec's "byte-exact string equality" on from-strings (§4.2). Two rules + * written identically produce identical keys; no Unicode normalization. + */ +function ruleFromKey(rule: IscRule): string { + return itemKey(rule.from) +} + +function itemKey(item: IscItem): string { + switch (item.type) { + case "string": + return `s:${JSON.stringify(item.value)}` + case "none": + return "none" + case "primitive": + return `p:${item.name}` + case "function": + return `f:${item.name}` + case "alias_ref": + return `a:${item.name}` + case "capture": + return `c:${item.index}` + case "capture_group": + return `g(${itemKey(item.inner)})` + case "concat": + return `+(${item.parts.map(itemKey).join(",")})` + case "set": + return `any(${item.items.map(itemKey).join(",")})` + case "range": + return `r:${item.lo}..${item.hi}` + case "maybe": + return `?(${itemKey(item.inner)})` + case "some": + return `*(${itemKey(item.inner)})` + } +} + +/** + * Dependency targets a document needs for its `use .` + * directives (declared dependencies only). Used by loaders that must + * pre-fetch dependency sources before the synchronous expansion runs. + */ +export function requiredDependencyDocs(doc: IscDocument): string[] { + const aliases = new Map() + for (const d of doc.dependencies) { + if (d.aliasName) aliases.set(d.aliasName, d.target) + } + const targets = new Set() + const scan = (items: readonly IscStageItem[]): void => { + for (const item of items) { + if (item.kind === "use" && item.dependency) { + const target = aliases.get(item.dependency) + if (target) targets.add(target) + } + } + } + for (const s of doc.stages) scan(s.body) + for (const r of doc.rulesets ?? []) scan(r.body) + return [...targets] +} diff --git a/src/isc/index.ts b/src/isc/index.ts index 67bfac5..89ef983 100644 --- a/src/isc/index.ts +++ b/src/isc/index.ts @@ -10,15 +10,17 @@ */ export { parseIsc } from "./parser.js" -export { iscToCompiledMap } from "./converter.js" +export { iscToCompiledMap, type IscToCompiledMapOptions } from "./converter.js" +export { expandDocument, requiredDependencyDocs, type DependencyDocResolver } from "./expand.js" export { iscStrategy, iscBundledStrategy } from "./loader.js" export type { IscDocument, IscItem, IscRule, + IscRuleset, IscStage, IscStageItem, IscTest, IscConstraint, } from "./types.js" -export { IscParseError } from "./types.js" +export { IscParseError, IscLoadError } from "./types.js" diff --git a/src/isc/loader.ts b/src/isc/loader.ts index 207d2b7..a8514fc 100644 --- a/src/isc/loader.ts +++ b/src/isc/loader.ts @@ -3,12 +3,19 @@ * ISC parser + converter, producing CompiledMap objects for the runtime. * * configure({ strategies: [iscStrategy({ baseUrl: "/maps" })] }) + * + * ISC-WF-1: ruleset uses across a dependency boundary (`use + * .`) are resolved at compile time, so the strategies + * pre-fetch and parse the needed dependency sources and hand the + * converter a resolver (declared dependencies only, §3.3). */ import type { CompiledMap, SystemCode } from "../types.js" import type { LoadStrategy } from "../loader.js" import { parseIsc } from "./parser.js" import { iscToCompiledMap } from "./converter.js" +import { requiredDependencyDocs, type DependencyDocResolver } from "./expand.js" +import type { IscDocument } from "./types.js" import { normaliseMap } from "../loaders.js" export interface IscStrategyOptions { @@ -23,25 +30,41 @@ export interface IscStrategyOptions { export function iscStrategy(opts: IscStrategyOptions): LoadStrategy { const fetchFn = opts.fetchFn ?? fetch.bind(globalThis) - return async (code: SystemCode): Promise => { - let source: string | undefined - - if (opts.bundled && opts.bundled[code]) { - source = opts.bundled[code] + const fetchSource = async (code: SystemCode): Promise => { + if (opts.bundled && opts.bundled[code]) return opts.bundled[code] + try { + const res = await fetchFn(`${opts.baseUrl}/${code}.isc`) + if (!res.ok) return undefined + return await res.text() + } catch { + return undefined } + } - if (!source) { - try { - const res = await fetchFn(`${opts.baseUrl}/${code}.isc`) - if (!res.ok) return undefined - source = await res.text() - } catch { - return undefined + return async (code: SystemCode): Promise => { + const source = await fetchSource(code) + if (!source) return undefined + + const doc = parseIsc(source, `${code}.isc`) + // WF-1: pre-fetch dependency docs the map's `use` directives need + // (recursively — a dependency's ruleset may itself use another of + // its dependencies' rulesets). Legacy maps need nothing here. + const parsedDocs = new Map() + const queue = requiredDependencyDocs(doc) + while (queue.length > 0) { + const target = queue.shift()! + if (parsedDocs.has(target)) continue + const depSource = await fetchSource(target) + if (!depSource) continue // unresolvable deps surface as IscLoadError if referenced + const depDoc = parseIsc(depSource, `${target}.isc`) + parsedDocs.set(target, depDoc) + for (const t of requiredDependencyDocs(depDoc)) { + if (!parsedDocs.has(t)) queue.push(t) } } + const resolveDependency: DependencyDocResolver = (target) => parsedDocs.get(target) - const doc = parseIsc(source, `${code}.isc`) - const json = iscToCompiledMap(doc) + const json = iscToCompiledMap(doc, { resolveDependency }) return normaliseMap(json) } } @@ -51,11 +74,20 @@ export function iscStrategy(opts: IscStrategyOptions): LoadStrategy { * Use in Node.js (read files from disk) or with bundled sources. */ export function iscBundledStrategy(sources: Record): LoadStrategy { + const parsedCache = new Map() + const resolveDependency: DependencyDocResolver = (target) => { + if (parsedCache.has(target)) return parsedCache.get(target) + const source = sources[target] + if (!source) return undefined + const doc = parseIsc(source, `${target}.isc`) + parsedCache.set(target, doc) + return doc + } return (code: SystemCode): CompiledMap | undefined => { const source = sources[code] if (!source) return undefined const doc = parseIsc(source, `${code}.isc`) - const json = iscToCompiledMap(doc) + const json = iscToCompiledMap(doc, { resolveDependency }) return normaliseMap(json) } } diff --git a/src/isc/parser.ts b/src/isc/parser.ts index 06545f9..52238e1 100644 --- a/src/isc/parser.ts +++ b/src/isc/parser.ts @@ -14,6 +14,7 @@ import type { IscDocument, IscItem, IscRule, + IscRuleset, IscStage, IscStageItem, IscTest, @@ -34,6 +35,44 @@ const FUNCTIONS = new Set(["upcase", "downcase", "title_case", "reverse", "strip const CONSTRAINTS = new Set(["before", "after", "not_before", "not_after"]) +/** + * Words that terminate an open-ended identifier list (the `languages` + * declaration and `when` guard lists). Every construct keyword of the + * language plus the block terminators — a corpus identifier never + * collides because ISC-WF-1 added these words to the keyword table + * (spec §2; verified against the 404-map corpus at adoption). + */ +const TERMINATORS = new Set([ + "metadata", + "tests", + "aliases", + "stage", + "dependency", + "ruleset", + "languages", + "parallel", + "sequence", + "run", + "separate", + "compose", + "decompose", + "sub", + "use", + "when", + "override", + "rababa", + "note", + "from", + "to", + "before", + "after", + "not_before", + "not_after", + "}", + ...FUNCTIONS, + ...PRIMITIVES, +]) + export function parseIsc(source: string, filename?: string): IscDocument { return new Parser(source, filename).parse() } @@ -64,17 +103,31 @@ class Parser { let tests: IscTest[] = [] let aliases: Array<{ name: string; value: IscItem }> = [] const stages: IscStage[] = [] + const rulesets: IscRuleset[] = [] const dependencies: Array<{ target: string; aliasName?: string }> = [] + const languages = new Set() for (const item of body) { if (item.metadata) metadata = { ...metadata, ...item.metadata } else if (item.tests) tests = item.tests else if (item.aliases) aliases = item.aliases else if (item.stage) stages.push(item.stage) + else if (item.ruleset) rulesets.push(item.ruleset) else if (item.dependency) dependencies.push(item.dependency) + else if (item.languages) for (const l of item.languages) languages.add(l) } - return { systemCode, metadata, tests, aliases, stages, dependencies } + const doc: IscDocument = { + systemCode, + metadata, + tests, + aliases, + stages, + dependencies, + ...(rulesets.length > 0 ? { rulesets } : {}), + ...(languages.size > 0 ? { languages: [...languages] } : {}), + } + return doc } // -- Block items -- @@ -90,12 +143,61 @@ class Parser { else if (keyword === "tests") items.push(this.parseTests()) else if (keyword === "aliases") items.push(this.parseAliases()) else if (keyword === "stage") items.push(this.parseStage()) + else if (keyword === "ruleset") items.push(this.parseRuleset()) + else if (keyword === "languages") items.push(this.parseLanguages()) else if (keyword === "dependency") items.push(this.parseDependency()) else this.error(`Unexpected keyword: ${keyword}`) } return items } + // -- Languages (ISC-WF-1 W3) -- + + /** + * `languages ident (","? ident)*` — declared inside the system block, + * alongside metadata. Like metadata, a second declaration merges. + */ + private parseLanguages(): ParsedBlockItem { + this.expect("languages") + const languages = this.parseIdentList() + return { languages } + } + + /** + * Open-ended identifier list: `ident (","? ident)*`, terminated by any + * language keyword (TERMINATORS) or `}`. + */ + private parseIdentList(): string[] { + const out: string[] = [] + for (;;) { + this.skipWs() + const word = this.peekWord() + if (!word || TERMINATORS.has(word)) break + out.push(this.parseIdentifier()) + this.skipInlineWs() + if (this.peek() === ",") this.pos++ + } + return out + } + + // -- Rulesets (ISC-WF-1 W1) -- + + /** + * `ruleset { }` — a named block of stage + * items. A ruleset never executes on its own; it is expanded at `use` + * sites by expandDocument(). + */ + private parseRuleset(): ParsedBlockItem { + this.expect("ruleset") + this.skipWs() + const name = this.parseIdentifier() + this.skipWs() + this.expect("{") + const body = this.parseStageBodyItems() + this.expect("}") + return { ruleset: { name, body } } + } + // -- Metadata -- private parseMetadata(): ParsedBlockItem { @@ -251,6 +353,16 @@ class Parser { const name = this.parseIdentifier() this.skipWs() this.expect("{") + const body = this.parseStageBodyItems() + this.expect("}") + return { stage: { name, body } } + } + + /** + * Stage body items — shared by `stage` and `ruleset` blocks (a ruleset + * accepts exactly the items a stage does, ISC-WF-1 §3.1). + */ + private parseStageBodyItems(): IscStageItem[] { const body: IscStageItem[] = [] for (;;) { @@ -308,6 +420,8 @@ class Parser { ? { kind: "run", dependency: dep, stage: stageName } : { kind: "run", stage: stageName }, ) + } else if (keyword === "use") { + body.push(this.parseUse()) } else if (keyword === "separate") { this.consume("separate") this.skipWs() @@ -355,8 +469,56 @@ class Parser { this.skipLine() } } - this.expect("}") - return { stage: { name, body } } + return body + } + + /** + * `use ` / `use .` with optional + * `override { }` — ISC-WF-1 W1/W2. The override block is + * part of the directive: one syntax, one site (§4.1). + */ + private parseUse(): IscStageItem { + this.expect("use") + this.skipWs() + const first = this.parseIdentifier() + let name = first + let dependency: string | undefined + this.skipInlineWs() + if (this.peek() === ".") { + this.pos++ + dependency = first + name = this.parseIdentifier() + } + this.skipWs() + let override: IscRule[] | undefined + if (this.peekWord() === "override") { + this.consume("override") + this.skipWs() + this.expect("{") + override = [] + for (;;) { + this.skipWs() + if (this.peek() === "}") break + if (this.peek() === "#") { + this.skipLine() + continue + } + if (this.peekWord() === "sub") { + override.push(this.parseRule()) + } else { + // Same parity behavior as rule blocks: unrecognized tokens + // are silently dropped. + this.skipLine() + } + } + this.expect("}") + } + return { + kind: "use", + name, + ...(dependency !== undefined ? { dependency } : {}), + ...(override !== undefined ? { override } : {}), + } } // -- Rules -- @@ -394,10 +556,11 @@ class Parser { } } this.expect("}") - return { from, to, constraints } + const when = this.parseWhenSuffix() + return { from, to, constraints, ...(when ? { when } : {}) } } - // Compact form: sub + // Compact form: sub const from = this.parseItemAtom() this.skipWs() const to = this.parseItemAtom() @@ -412,7 +575,21 @@ class Parser { constraints.push({ kind: word, item: this.parseItemAtom() }) } - return { from, to, constraints } + const when = this.parseWhenSuffix() + return { from, to, constraints, ...(when ? { when } : {}) } + } + + /** + * `when ident (","? ident)*` — ISC-WF-1 W3 guard suffix on sub rules + * (§2: attaches to a sub rule only, not to blocks). Absent → undefined. + */ + private parseWhenSuffix(): string[] | undefined { + this.skipWs() + if (this.peekWord() !== "when") return undefined + this.consume("when") + const languages = this.parseIdentList() + if (languages.length === 0) this.error("Expected at least one language after when") + return languages } // -- Items -- @@ -761,5 +938,7 @@ interface ParsedBlockItem { tests?: IscTest[] aliases?: Array<{ name: string; value: IscItem }> stage?: IscStage + ruleset?: IscRuleset + languages?: string[] dependency?: { target: string; aliasName?: string } } diff --git a/src/isc/types.ts b/src/isc/types.ts index 62cca13..7bfe27a 100644 --- a/src/isc/types.ts +++ b/src/isc/types.ts @@ -12,6 +12,16 @@ export interface IscDocument { readonly aliases: ReadonlyArray<{ name: string; value: IscItem }> readonly stages: readonly IscStage[] readonly dependencies: ReadonlyArray<{ target: string; aliasName?: string }> + /** ISC-WF-1 W1 — named rulesets declared at map top level. */ + readonly rulesets?: readonly IscRuleset[] + /** ISC-WF-1 W3 — languages the map serves (declared in the system block). */ + readonly languages?: readonly string[] +} + +/** ISC-WF-1 W1 — a named, reusable block of stage items. */ +export interface IscRuleset { + readonly name: string + readonly body: readonly IscStageItem[] } export interface IscTest { @@ -35,11 +45,24 @@ export type IscStageItem = | { kind: "decompose" } | { kind: "string_case"; op: string } | { kind: "funcall"; name: string; kwargs: Readonly> } + /** + * ISC-WF-1 W1/W2 — `use ` (or `use .`), optionally + * with an `override { ... }` block. Resolved by expandDocument() at + * compile time; never reaches the IR. + */ + | { + kind: "use" + name: string + dependency?: string + override?: readonly IscRule[] + } export interface IscRule { readonly from: IscItem readonly to: IscItem readonly constraints: readonly IscConstraint[] + /** ISC-WF-1 W3 — languages this rule applies to (`sub a b when kur`). */ + readonly when?: readonly string[] } export interface IscConstraint { @@ -72,3 +95,16 @@ export class IscParseError extends Error { this.name = "IscParseError" } } + +/** + * ISC-WF-1 load error — raised when a map's `use` directives cannot be + * resolved: unknown ruleset, unresolvable dependency, or an expansion + * cycle (§3.2, §6.4). Distinct from IscParseError because expansion + * happens after parsing, on whole documents rather than tokens. + */ +export class IscLoadError extends Error { + constructor(message: string) { + super(`ISC load error: ${message}`) + this.name = "IscLoadError" + } +} diff --git a/src/loaders.ts b/src/loaders.ts index b0e2a01..0cd7efe 100644 --- a/src/loaders.ts +++ b/src/loaders.ts @@ -26,6 +26,8 @@ export function normaliseMap(json: CompiledMapJson): CompiledMap { functions: new Map(), } as CompiledMapBuilder if (json.metadata) out.metadata = json.metadata + // ISC-WF-1 W3 — declared languages (absent on legacy maps). + if (json.languages) out.languages = json.languages return out } diff --git a/src/runtime/context.ts b/src/runtime/context.ts index ce277c8..21e744f 100644 --- a/src/runtime/context.ts +++ b/src/runtime/context.ts @@ -16,6 +16,13 @@ export class ExecutionContext { /** Map currently being executed. */ readonly map: CompiledMap + /** + * ISC-WF-1 W3 — the language the caller selected for this run, if any. + * Guards apply only when the selected language is declared by the map + * owning the rule; otherwise every `when`-guarded rule is inert. + */ + readonly selectedLanguage: string | undefined + /** Optional loader — used to resolve run-rule dependencies. */ private readonly loader: MapLoader | undefined @@ -25,11 +32,12 @@ export class ExecutionContext { /** Function cache so repeated function calls don't re-resolve. */ readonly functions: CompiledMap["functions"] - constructor(map: CompiledMap, initial: string, loader?: MapLoader) { + constructor(map: CompiledMap, initial: string, loader?: MapLoader, selectedLanguage?: string) { this.map = map this.current = initial this.functions = map.functions this.loader = loader + this.selectedLanguage = selectedLanguage } resolveAlias(name: string): Item | undefined { @@ -57,10 +65,10 @@ export class ExecutionContext { /** * Spawn a new context for a different map (used by `run` rule with docName). - * Reuses the same loader; fresh alias cache. + * Reuses the same loader and language selection; fresh alias cache. */ withMap(map: CompiledMap): ExecutionContext { - return new ExecutionContext(map, this.current, this.loader) + return new ExecutionContext(map, this.current, this.loader, this.selectedLanguage) } /** diff --git a/src/runtime/executor.ts b/src/runtime/executor.ts index 73fc9a9..bce4d4a 100644 --- a/src/runtime/executor.ts +++ b/src/runtime/executor.ts @@ -55,6 +55,62 @@ function resolveFunction(ctx: ExecutionContext, name: string) { return undefined } +// -- ISC-WF-1 W3: language guards (spec §5) -- + +/** + * Effective selected language for a rule: the caller's selection, but + * only if the map OWNING the rule declares it. Selecting an undeclared + * language (or selecting none) makes every guarded rule inert. + */ +function selectedLanguageFor(rule: SubRule, ctx: ExecutionContext): string | undefined { + const sel = ctx.selectedLanguage + if (!sel) return undefined + const declared = ctx.map.languages + if (!declared || !declared.includes(sel)) return undefined + return sel +} + +/** + * Whether a sub rule applies on this run. Unguarded rules always apply; + * a guarded rule applies only when its `when` list contains the + * (declared, selected) language. + */ +function ruleApplies(rule: SubRule, ctx: ExecutionContext): boolean { + if (!rule.when) return true + const sel = selectedLanguageFor(rule, ctx) + return sel !== undefined && rule.when.includes(sel) +} + +/** + * Structural key of a from-item — the runtime analog of the spec's + * byte-exact from-string equality (§5.2 precedence). Identical rules + * compile to identical keys; no normalization. + */ +function fromItemKey(item: Item): string { + switch (item.kind) { + case "string": + return `s:${JSON.stringify(item.value)}` + case "capture_group": + return `g(${fromItemKey(item.data)})` + case "capture_ref": + return `c:${item.id}` + case "alias": + return `a:${item.name}${item.map ? `@${item.map}` : ""}` + case "any": + return `any(${item.of.map(fromItemKey).join(",")})` + case "any_char_class": + return item.range + ? `r:${item.range[0]}..${item.range[1]}` + : `cc:${(item.chars ?? []).join("")}` + case "group": + return `+(${item.items.map(fromItemKey).join(",")})` + case "repeat": + return `rep(${fromItemKey(item.item)},${item.min},${item.max ?? "*"})` + case "stage_ref": + return `stage:${item.name}` + } +} + const executors: { [K in RuleKind]: RuleExecutorFor } = { sub: (rule, ctx) => executeSubRule(rule, ctx), @@ -91,16 +147,23 @@ const executors: { [K in RuleKind]: RuleExecutorFor } = { // mutates the string before constrained rules can compete. We must // take the WHOLE block down one path or the other. - const subRules: SubRule[] = [] + const subRulesAll: SubRule[] = [] const trailingRules: Rule[] = [] for (const inner of rule.rules) { if (inner.kind === "sub" && inner.from) { - subRules.push(inner) + // W3: inert guards drop out of the pass before anything else. + if (ruleApplies(inner, ctx)) subRulesAll.push(inner) } else { trailingRules.push(inner) } } + // W3 precedence (§5.2): if an unguarded rule and a guard-applicable + // rule share the same from, the guard-applicable rule wins — the + // generic row drops out of the pass (no restating required). + const guardedKeys = new Set(subRulesAll.filter((r) => r.when).map((r) => fromItemKey(r.from!))) + const subRules = subRulesAll.filter((r) => r.when || !guardedKeys.has(fromItemKey(r.from!))) + // A rule is tree-compatible if it has no before/after clauses AND // its `from` can be expanded to literal strings (no re-only aliases // like boundary, no captures). If any rule fails this check, the @@ -187,6 +250,9 @@ const executors: { [K in RuleKind]: RuleExecutorFor } = { } function executeSubRule(rule: SubRule, ctx: ExecutionContext): void { + // W3: a guarded rule whose guard is inert behaves exactly as if the + // rule were absent (§5.1) — applies to bare rules and sequence blocks. + if (!ruleApplies(rule, ctx)) return if (!rule.from) throw new MapLogicError("Sub rule missing 'from'") const from = compileItem(rule.from, ctx) diff --git a/src/runtime/interpreter.ts b/src/runtime/interpreter.ts index 3503b24..e44d80e 100644 --- a/src/runtime/interpreter.ts +++ b/src/runtime/interpreter.ts @@ -47,6 +47,10 @@ function containsAsyncFuncalls(rules: readonly Rule[]): boolean { /** * Run a single stage by name (SYNC path). Returns the transformed string. * + * `language` is the ISC-WF-1 W3 selection: when given (and declared by + * the map), `when`-guarded rules whose list contains it apply; all other + * guarded rules are inert. + * * Throws if the stage contains ML funcalls — use `executeStageAsync` * for those. */ @@ -55,6 +59,7 @@ export function executeStage( stageName: string, input: string, loader?: MapLoader, + language?: string, ): string { const stage: Stage | undefined = map.stages.find((s) => s.name === stageName) if (!stage) { @@ -68,7 +73,7 @@ export function executeStage( ) } - const ctx = new ExecutionContext(map, input, loader) + const ctx = new ExecutionContext(map, input, loader, language) for (const rule of stage.rules) { executeRule(rule, ctx) } @@ -85,6 +90,7 @@ export async function executeStageAsync( stageName: string, input: string, loader?: MapLoader, + language?: string, ): Promise { const stage: Stage | undefined = map.stages.find((s) => s.name === stageName) if (!stage) { @@ -93,11 +99,11 @@ export async function executeStageAsync( // Fast path: no async functions → use the sync executor directly. if (!containsAsyncFuncalls(stage.rules)) { - return executeStage(map, stageName, input, loader) + return executeStage(map, stageName, input, loader, language) } // Async path: same executors, but funcall results are awaited. - const ctx = new ExecutionContext(map, input, loader) + const ctx = new ExecutionContext(map, input, loader, language) for (const rule of stage.rules) { await executeRuleAsync(rule, ctx) } diff --git a/src/types.ts b/src/types.ts index 833d245..fcfa0e2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,11 @@ export interface CompiledMapJson { readonly schemaVersion: 1 readonly systemCode: SystemCode readonly dependencies: readonly SystemCode[] + /** + * ISC-WF-1 W3 — languages the map serves (`languages` declaration). + * Absent when the map declares none (legacy JSON shape unchanged). + */ + readonly languages?: readonly string[] readonly metadata?: Readonly> readonly stages: readonly Stage[] readonly aliases: Readonly> @@ -62,6 +67,8 @@ export interface CompiledMap { readonly schemaVersion: 1 readonly systemCode: SystemCode readonly dependencies: readonly SystemCode[] + /** ISC-WF-1 W3 — declared languages; absent when the map declares none. */ + readonly languages?: readonly string[] readonly metadata?: Readonly> readonly stages: readonly Stage[] readonly aliases: ReadonlyMap @@ -101,6 +108,12 @@ export interface SubRule { readonly notBefore?: Item readonly notAfter?: Item readonly priority?: number + /** + * ISC-WF-1 W3 — languages this rule applies to. Unguarded rules apply + * always; guarded rules apply only when the caller selected one of + * these languages (and it is declared by the map). See executor.ts. + */ + readonly when?: readonly string[] } export interface RunRule { diff --git a/test/fixtures/wf1/wf1-dependent.isc b/test/fixtures/wf1/wf1-dependent.isc new file mode 100644 index 0000000..95116ac --- /dev/null +++ b/test/fixtures/wf1/wf1-dependent.isc @@ -0,0 +1,24 @@ +system "wf1-dep:tha-Thai:Latn:2026" { + +# ISC-WF-1 W1+W2 fixture — ruleset use across a dependency, with a +# per-site override (child-wins on exact from-equality, §4.2). + +dependency "wf1-lib:tha-Thai:Latn:2026" as lib + +stage main { + use lib.leading_vowels override { + sub "ะ" "o" + } + parallel { + sub "ท" "th" + } + use lib.consonants +} + +tests { + # kept row (ิ→i) runs, override row (ะ→o) replaces the imported ะ→a, + # the parallel pass then handles ท, and the trailing use expands ก→k. + "ะทก" -> "othk" +} + +} diff --git a/test/fixtures/wf1/wf1-langs.isc b/test/fixtures/wf1/wf1-langs.isc new file mode 100644 index 0000000..f65f57e --- /dev/null +++ b/test/fixtures/wf1/wf1-langs.isc @@ -0,0 +1,25 @@ +system "wf1-langs:rus-Cyrl:Latn:2026" { + +# ISC-WF-1 W3 fixture — language conditioning (§5.4 example shape). +# no language -> generic rows only (all guards inert) +# language kur -> guard-applicable қ→k̦ wins; ҫ/ж guards for chv inert +# language chv -> guard-applicable ҫ→ș wins; kur guards inert + +languages kur chv + +stage main { + parallel { + sub "қ" "ķ" + sub "қ" "k̦" when kur + sub "ҫ" "ş" + sub "ҫ" "ș" when chv + } + sub "ж" "ž" when kur +} + +tests { + # no language selected — every guard inert, generic rows apply + "қҫж" -> "ķşж" +} + +} diff --git a/test/fixtures/wf1/wf1-lib.isc b/test/fixtures/wf1/wf1-lib.isc new file mode 100644 index 0000000..35921eb --- /dev/null +++ b/test/fixtures/wf1/wf1-lib.isc @@ -0,0 +1,22 @@ +system "wf1-lib:tha-Thai:Latn:2026" { + +# ISC-WF-1 W1 fixture — a library map: named rulesets only, no stages. +# A map that declares only rulesets never executes on its own (§3.2). + +metadata { + name WF1 fixture library + authority_id WF1 + source_script Thai + destination_script Latn +} + +ruleset leading_vowels { + sub "ะ" "a" + sub "ิ" "i" +} + +ruleset consonants { + sub "ก" "k" +} + +} diff --git a/test/fixtures/wf1/wf1-same-map.isc b/test/fixtures/wf1/wf1-same-map.isc new file mode 100644 index 0000000..9058b78 --- /dev/null +++ b/test/fixtures/wf1/wf1-same-map.isc @@ -0,0 +1,29 @@ +system "wf1-same:rus-Cyrl:Latn:2026" { + +# ISC-WF-1 W1 fixture — same-map rulesets, recursive expansion, and a +# ruleset contributing a parallel block as one pass (§3.2). + +ruleset base { + sub "а" "a" + sub "б" "b" +} + +ruleset composite { + use base + parallel { + sub "в" "v" + sub "г" "g" + } + sub "д" "d" +} + +stage main { + use composite + sub "е" "e" +} + +tests { + "абвгде" -> "abvgde" +} + +} diff --git a/test/isc/wf1-e2e.test.ts b/test/isc/wf1-e2e.test.ts new file mode 100644 index 0000000..9e3526e --- /dev/null +++ b/test/isc/wf1-e2e.test.ts @@ -0,0 +1,77 @@ +/** + * ISC-WF-1 end-to-end specs — synthetic .isc fixtures through the full + * transliterate path (parser → converter/expansion → ISC loader strategy + * → runtime executor), with and without language selection. + * + * Fixtures: test/fixtures/wf1/*.isc — a ruleset library, a dependent map + * using it across a dependency (with override), a same-map recursive + * composition, and a language-conditioned map. + */ + +import { describe, it, expect, beforeAll } from "vitest" +import { readFileSync, readdirSync } from "fs" +import { join } from "path" +import { parseIsc } from "../../src/isc/parser.ts" +import { iscBundledStrategy } from "../../src/isc/loader.ts" +import { configure, reset, transliterate } from "../../src/index.js" + +const FIXTURES_DIR = join(__dirname, "..", "fixtures", "wf1") + +function loadFixtureSources(): Record { + const sources: Record = {} + for (const f of readdirSync(FIXTURES_DIR).filter((f) => f.endsWith(".isc"))) { + const src = readFileSync(join(FIXTURES_DIR, f), "utf8") + const code = parseIsc(src, f).systemCode + sources[code] = src + } + return sources +} + +const sources = loadFixtureSources() + +beforeAll(() => { + reset() + configure({ strategies: [iscBundledStrategy(sources)] }) +}) + +describe("WF-1 fixtures — full transliterate path", () => { + it("every fixture's embedded tests pass (no language selected)", () => { + for (const [code, src] of Object.entries(sources)) { + const doc = parseIsc(src, code) + for (const t of doc.tests) { + expect(transliterate(code, t.input)).toBe(t.expected) + } + } + }) + + it("W1+W2: ruleset use across a dependency with override", () => { + const code = "wf1-dep:tha-Thai:Latn:2026" + // kept row (ิ→i) + override row (ะ→o) + parallel ท→th + trailing use ก→k + expect(transliterate(code, "ะทก")).toBe("othk") + // the override replaced ะ→a — the imported generic row is gone + expect(transliterate(code, "ะ")).toBe("o") + expect(transliterate(code, "ิ")).toBe("i") + }) + + it("W1: recursive same-map expansion with a nested parallel pass", () => { + expect(transliterate("wf1-same:rus-Cyrl:Latn:2026", "абвгде")).toBe("abvgde") + }) + + it("W3: language-conditioned rows (§5.4)", () => { + const code = "wf1-langs:rus-Cyrl:Latn:2026" + // no selection → guards inert, generic rows + expect(transliterate(code, "қҫж")).toBe("ķşж") + // kur → guard-applicable қ wins; chv guard + generic-only ж inert + expect(transliterate(code, "қҫж", undefined, "kur")).toBe("k̦şž") + // chv → guard-applicable ҫ wins; kur guards inert + expect(transliterate(code, "қҫж", undefined, "chv")).toBe("ķșж") + // undeclared selection → guards inert, generic rows + expect(transliterate(code, "қҫж", undefined, "xyz")).toBe("ķşж") + }) + + it("language selection composes with dependency expansion (override rows carry when)", () => { + const code = "wf1-dep:tha-Thai:Latn:2026" + // sanity: language selection on a guard-free map changes nothing + expect(transliterate(code, "ะทก", undefined, "kur")).toBe("othk") + }) +}) diff --git a/test/isc/wf1.test.ts b/test/isc/wf1.test.ts new file mode 100644 index 0000000..694eb0f --- /dev/null +++ b/test/isc/wf1.test.ts @@ -0,0 +1,578 @@ +/** + * Specs for ISC-WF-1 — the workflow extension (docs/isc-wf-1.md). + * + * W1 named rulesets (§3), W2 use-with-override (§4), W3 language + * conditioning (§5). Parse-level specs live next to runtime-level + * specs: W1/W2 resolve at parse/compile time (expansion), W3 resolves + * at run time (guard filtering in the executor). + */ + +import { describe, it, expect } from "vitest" +import { parseIsc } from "../../src/isc/parser.ts" +import { iscToCompiledMap } from "../../src/isc/converter.ts" +import { expandDocument } from "../../src/isc/expand.ts" +import { IscLoadError, IscParseError } from "../../src/isc/types.ts" +import { normaliseMap } from "../../src/loaders.ts" +import { executeStage } from "../../src/runtime/interpreter.ts" +import type { IscDocument, IscStageItem } from "../../src/isc/types.ts" + +function minimalWrap(body: string): string { + return `system "TEST:test:Latn:Latn:2026" {\n${body}\n}` +} + +/** Compile + run a stage through the real executor. */ +function run(source: string, input: string, stage = "main", language?: string): string { + const json = iscToCompiledMap(parseIsc(source)) + const map = normaliseMap(json) + return executeStage(map, stage, input, undefined, language) +} + +/** Flatten a stage body to a compact trace: kind + from→to for rules. */ +function trace(body: readonly IscStageItem[]): string[] { + const out: string[] = [] + for (const item of body) { + if (item.kind === "bare_rule") { + const from = item.rule.from.type === "string" ? item.rule.from.value : "?" + const to = item.rule.to.type === "string" ? item.rule.to.value : "?" + out.push(`sub ${from}->${to}${item.rule.when ? ` when ${item.rule.when.join(",")}` : ""}`) + } else if (item.kind === "parallel" || item.kind === "sequence") { + out.push( + `${item.kind}[${item.rules + .map( + (r) => `${(r.from as { value: string }).value}->${(r.to as { value: string }).value}`, + ) + .join(", ")}]`, + ) + } else if (item.kind === "use") { + out.push(`USE ${item.dependency ? `${item.dependency}.` : ""}${item.name}`) + } else { + out.push(item.kind) + } + } + return out +} + +// --------------------------------------------------------------------------- +// W1/W2 — parsing +// --------------------------------------------------------------------------- + +describe("parseIsc — ruleset (W1)", () => { + it("parses a ruleset with bare subs", () => { + const doc = parseIsc( + minimalWrap(` +ruleset vowels { + sub "a" "x" + sub "e" "y" +} +stage main { + parallel { + sub "b" "b" + } +}`), + ) + expect(doc.rulesets).toHaveLength(1) + expect(doc.rulesets![0]!.name).toBe("vowels") + expect(doc.rulesets![0]!.body).toHaveLength(2) + expect(doc.stages).toHaveLength(1) + }) + + it("parses rulesets containing parallel blocks and nested uses", () => { + const doc = parseIsc( + minimalWrap(` +ruleset base { + sub "a" "x" +} +ruleset composite { + use base + parallel { + sub "b" "y" + } + sub "c" "z" +}`), + ) + expect(doc.rulesets).toHaveLength(2) + const composite = doc.rulesets![1]! + expect(trace(composite.body)).toEqual(["USE base", "parallel[b->y]", "sub c->z"]) + }) + + it("omits rulesets/languages fields when absent (legacy shape)", () => { + const doc = parseIsc(minimalWrap(`stage main { parallel { sub "a" "a" } }`)) + expect(doc.rulesets).toBeUndefined() + expect(doc.languages).toBeUndefined() + }) +}) + +describe("parseIsc — use directive (W1/W2)", () => { + it("parses same-map use", () => { + const doc = parseIsc(minimalWrap(`stage main { use vowels }`)) + expect(doc.stages[0]!.body).toEqual([{ kind: "use", name: "vowels" }]) + }) + + it("parses dependency-qualified use", () => { + const doc = parseIsc(minimalWrap(`dependency "some-lib" as lib\nstage main { use lib.vowels }`)) + expect(doc.stages[0]!.body[0]).toEqual({ kind: "use", name: "vowels", dependency: "lib" }) + }) + + it("parses an override block with full sub rules", () => { + const doc = parseIsc( + minimalWrap(` +stage main { + use vowels override { + sub "a" "A" + sub "q" "k" when kur + sub { from "b" to "B" before "c" } + } +}`), + ) + const item = doc.stages[0]!.body[0] as Extract + expect(item.override).toHaveLength(3) + expect(item.override![0]).toMatchObject({ from: { value: "a" }, to: { value: "A" } }) + expect(item.override![1]!.when).toEqual(["kur"]) + // block-form row keeps its constraint + expect(item.override![2]!.constraints).toHaveLength(1) + }) + + it("rejects malformed use", () => { + expect(() => parseIsc(minimalWrap(`stage main { use }`))).toThrow(IscParseError) + }) +}) + +describe("parseIsc — languages declaration (W3)", () => { + it("parses a space-separated list", () => { + const doc = parseIsc(minimalWrap(`languages rus bel ukr`)) + expect(doc.languages).toEqual(["rus", "bel", "ukr"]) + }) + + it("parses comma-separated lists and multiple declarations merge", () => { + const doc = parseIsc(minimalWrap(`languages rus, bel\nlanguages ukr`)) + expect(doc.languages).toEqual(["rus", "bel", "ukr"]) + }) + + it("terminates at the next block keyword", () => { + const doc = parseIsc(minimalWrap(`languages kur chv\nstage main {}`)) + expect(doc.languages).toEqual(["kur", "chv"]) + expect(doc.stages).toHaveLength(1) + }) +}) + +describe("parseIsc — when guard suffix (W3)", () => { + it("parses the suffix on a compact rule", () => { + const doc = parseIsc(minimalWrap(`stage main { parallel { sub "a" "x" when kur } }`)) + const rules = doc.stages[0]!.body[0] as Extract + expect(rules.rules[0]!.when).toEqual(["kur"]) + }) + + it("parses comma-separated guard lists", () => { + const doc = parseIsc(minimalWrap(`stage main { sub "a" "x" when kur, chv }`)) + const rule = doc.stages[0]!.body[0] as Extract + expect(rule.rule.when).toEqual(["kur", "chv"]) + }) + + it("parses the suffix after constraints", () => { + const doc = parseIsc(minimalWrap(`stage main { sub "a" "x" before "b" when kur }`)) + const rule = doc.stages[0]!.body[0] as Extract + expect(rule.rule.constraints).toHaveLength(1) + expect(rule.rule.when).toEqual(["kur"]) + }) + + it("parses the suffix on a block-form rule", () => { + const doc = parseIsc(minimalWrap(`stage main { sub { from "a" to "x" } when kur }`)) + const rule = doc.stages[0]!.body[0] as Extract + expect(rule.rule.when).toEqual(["kur"]) + }) + + it("leaves unguarded rules untouched", () => { + const doc = parseIsc(minimalWrap(`stage main { parallel { sub "a" "x" } }`)) + const rules = doc.stages[0]!.body[0] as Extract + expect(rules.rules[0]!.when).toBeUndefined() + }) + + it("rejects an empty guard list", () => { + expect(() => parseIsc(minimalWrap(`stage main { sub "a" "x" when }`))).toThrow(IscParseError) + }) +}) + +// --------------------------------------------------------------------------- +// W1 — expansion semantics (§3.2) +// --------------------------------------------------------------------------- + +describe("expandDocument — W1 expansion", () => { + it("expands a same-map ruleset in place, in document order", () => { + const doc = parseIsc( + minimalWrap(` +ruleset rs { + sub "a" "x" + sub "b" "y" +} +stage main { + sub "0" "0" + use rs + sub "1" "1" +}`), + ) + const expanded = expandDocument(doc) + expect(trace(expanded.stages[0]!.body)).toEqual([ + "sub 0->0", + "sub a->x", + "sub b->y", + "sub 1->1", + ]) + }) + + it("expands recursively and keeps a nested parallel block as one pass", () => { + const doc = parseIsc( + minimalWrap(` +ruleset base { + sub "a" "x" +} +ruleset composite { + use base + parallel { + sub "b" "y" + sub "c" "z" + } + sub "d" "w" +} +stage main { + use composite +}`), + ) + const expanded = expandDocument(doc) + expect(trace(expanded.stages[0]!.body)).toEqual([ + "sub a->x", + "parallel[b->y, c->z]", + "sub d->w", + ]) + }) + + it("expands a ruleset across a dependency via the resolver", () => { + const libDoc = parseIsc(`system "lib:shared:Latn:2026" {\nruleset rs {\n sub "a" "x"\n}\n}`) + const doc = parseIsc( + minimalWrap(`dependency "lib:shared:Latn:2026" as lib\nstage main { use lib.rs }`), + ) + const expanded = expandDocument(doc, { + resolveDependency: (target) => (target === "lib:shared:Latn:2026" ? libDoc : undefined), + }) + expect(trace(expanded.stages[0]!.body)).toEqual(["sub a->x"]) + }) + + it("resolves nested uses against the OWNING dependency's rulesets and aliases", () => { + const libDoc = parseIsc( + `system "lib:shared:Latn:2026" {\nruleset inner {\n sub "a" "x"\n}\nruleset outer {\n use inner\n sub "b" "y"\n}\n}`, + ) + const doc = parseIsc( + minimalWrap(`dependency "lib:shared:Latn:2026" as lib\nstage main { use lib.outer }`), + ) + const expanded = expandDocument(doc, { + resolveDependency: (target) => (target === "lib:shared:Latn:2026" ? libDoc : undefined), + }) + expect(trace(expanded.stages[0]!.body)).toEqual(["sub a->x", "sub b->y"]) + }) + + it("a ruleset never executes on its own — library maps have no stages", () => { + const doc = parseIsc(minimalWrap(`ruleset rs {\n sub "a" "x"\n}`)) + expect(doc.stages).toHaveLength(0) + }) + + it("is a no-op for documents without use directives", () => { + const doc = parseIsc(minimalWrap(`stage main { parallel { sub "a" "a" } }`)) + const expanded = expandDocument(doc) + expect(expanded.stages).toEqual(doc.stages) + }) +}) + +describe("expandDocument — cycle and resolution errors (§3.2, §6.4)", () => { + it("rejects a self-cycle", () => { + const doc = parseIsc(minimalWrap(`ruleset rs {\n use rs\n}\nstage main { use rs }`)) + expect(() => expandDocument(doc)).toThrow(IscLoadError) + expect(() => expandDocument(doc)).toThrow(/use cycle/) + }) + + it("rejects a mutual cycle", () => { + const doc = parseIsc( + minimalWrap(`ruleset a { use b }\nruleset b { use a }\nstage main { use a }`), + ) + expect(() => expandDocument(doc)).toThrow(/use cycle/) + }) + + it("allows reusing the same ruleset twice (chain, not visited-set)", () => { + const doc = parseIsc(minimalWrap(`ruleset rs { sub "a" "x" }\nstage main { use rs\n use rs }`)) + const expanded = expandDocument(doc) + expect(trace(expanded.stages[0]!.body)).toEqual(["sub a->x", "sub a->x"]) + }) + + it("rejects an unknown ruleset", () => { + const doc = parseIsc(minimalWrap(`stage main { use nope }`)) + expect(() => expandDocument(doc)).toThrow(/unknown ruleset "nope"/) + }) + + it("rejects an unknown dependency alias", () => { + const doc = parseIsc(minimalWrap(`stage main { use ghost.rs }`)) + expect(() => expandDocument(doc)).toThrow(/unknown dependency alias "ghost"/) + }) + + it("rejects an unresolvable dependency document", () => { + const doc = parseIsc( + minimalWrap(`dependency "lib:missing:Latn:2026" as lib\nstage main { use lib.rs }`), + ) + expect(() => expandDocument(doc)).toThrow(/cannot resolve dependency/) + }) +}) + +// --------------------------------------------------------------------------- +// W2 — override merge semantics (§4.2) +// --------------------------------------------------------------------------- + +describe("expandDocument — W2 override merge", () => { + const wrapWithRs = (rsBody: string, useBody: string) => + parseIsc(minimalWrap(`ruleset rs {\n${rsBody}\n}\nstage main {\n${useBody}\n}`)) + + it("replaces the matching row exactly (child-wins on from-equality)", () => { + const doc = wrapWithRs(`sub "a" "x"\nsub "b" "y"`, `use rs override {\n sub "a" "A"\n}`) + const expanded = expandDocument(doc) + // kept-in-order then overrides + expect(trace(expanded.stages[0]!.body)).toEqual(["sub b->y", "sub a->A"]) + }) + + it("replaces ALL rows sharing the from-string", () => { + const doc = wrapWithRs( + `sub "a" "x"\nsub "a" "x2"\nsub "b" "y"`, + `use rs override {\n sub "a" "A"\n}`, + ) + const expanded = expandDocument(doc) + expect(trace(expanded.stages[0]!.body)).toEqual(["sub b->y", "sub a->A"]) + }) + + it("matches from-equality inside parallel blocks too, keeping block structure", () => { + const doc = wrapWithRs( + `parallel {\n sub "a" "x"\n sub "b" "y"\n}\nsub "c" "z"`, + `use rs override {\n sub "a" "A"\n}`, + ) + const expanded = expandDocument(doc) + expect(trace(expanded.stages[0]!.body)).toEqual(["parallel[b->y]", "sub c->z", "sub a->A"]) + }) + + it("appends override rows whose from matches nothing (they are simply added)", () => { + const doc = wrapWithRs(`sub "a" "x"`, `use rs override {\n sub "q" "Q"\n}`) + const expanded = expandDocument(doc) + expect(trace(expanded.stages[0]!.body)).toEqual(["sub a->x", "sub q->Q"]) + }) + + it("an override row is complete: replaces constraints and may carry its own when", () => { + const doc = wrapWithRs(`sub "a" "x" before "b"`, `use rs override {\n sub "a" "A" when kur\n}`) + const expanded = expandDocument(doc) + const [rule] = expanded.stages[0]!.body + expect(rule!.kind).toBe("bare_rule") + const imported = rule as Extract + expect(imported.rule.constraints).toHaveLength(0) + expect(imported.rule.when).toEqual(["kur"]) + }) + + it("from-equality is byte-exact — no normalization, constraints do not participate", () => { + const doc = wrapWithRs( + `sub "a" "x" before "b"\nsub "e" "y"`, + `use rs override {\n sub "a" "A"\n}`, + ) + const expanded = expandDocument(doc) + // the constrained a-row is replaced anyway: matching is on from only + expect(trace(expanded.stages[0]!.body)).toEqual(["sub e->y", "sub a->A"]) + }) +}) + +// --------------------------------------------------------------------------- +// W3 — runtime guard semantics (§5) +// --------------------------------------------------------------------------- + +describe("runtime — language guards", () => { + const LANG_SRC = minimalWrap(` +languages kur chv +stage main { + parallel { + sub "қ" "ķ" + sub "қ" "k̦" when kur + sub "ҫ" "ş" + sub "ҫ" "ș" when chv + } + sub "ж" "ž" when kur +}`) + + it("no language selected — every guarded rule is inert (§5.4)", () => { + expect(run(LANG_SRC, "қҫж")).toBe("ķşж") + }) + + it("declared language selected — guard-applicable rows win over same-from generic rows", () => { + expect(run(LANG_SRC, "қҫж", "main", "kur")).toBe("k̦şž") + expect(run(LANG_SRC, "қҫж", "main", "chv")).toBe("ķșж") + }) + + it("undeclared language selected — all guarded rules are inert", () => { + expect(run(LANG_SRC, "қҫж", "main", "xyz")).toBe("ķşж") + }) + + it("sequence blocks skip inert guarded rules in order", () => { + const src = minimalWrap(` +languages kur +stage main { + sequence { + sub "a" "b" + sub "b" "c" when kur + sub "c" "d" + } +}`) + // kur: a→b, then b→c (guard applies), then c→d => "d" + // no lang: a→b, guarded rule inert, no c present => "b" + expect(run(src, "a", "main", "kur")).toBe("d") + expect(run(src, "a")).toBe("b") + }) + + it("guard precedence holds regardless of declaration order (guarded AFTER generic)", () => { + const srcGuardedFirst = minimalWrap(` +languages kur +stage main { + parallel { + sub "x" "X" when kur + sub "x" "G" + } +}`) + const srcGuardedLast = minimalWrap(` +languages kur +stage main { + parallel { + sub "x" "G" + sub "x" "X" when kur + } +}`) + expect(run(srcGuardedFirst, "x", "main", "kur")).toBe("X") + expect(run(srcGuardedLast, "x", "main", "kur")).toBe("X") + expect(run(srcGuardedFirst, "x")).toBe("G") + expect(run(srcGuardedLast, "x")).toBe("G") + }) + + it("guarded rules apply inside sequences for the selected language", () => { + const src = minimalWrap(` +languages kur +stage main { + sequence { + sub "s" "S" when kur + sub "t" "T" + } +}`) + // kur: s→S (guard applies), t→T => "ST" + // no lang: guarded rule inert, t→T (unguarded) => "sT" + expect(run(src, "st", "main", "kur")).toBe("ST") + expect(run(src, "st")).toBe("sT") + }) + + it("an override row's when guard applies to that row alone (§5.3)", () => { + const doc = parseIsc( + minimalWrap( + `languages kur\nruleset rs { sub "a" "x" }\nstage main { use rs override { sub "a" "A" when kur } }`, + ), + ) + const json = iscToCompiledMap(doc) + const map = normaliseMap(json) + // The override REPLACES the imported row wholesale (§4.2 rule 3), so + // with the guard inert no row for "a" applies at all. + expect(executeStage(map, "main", "a")).toBe("a") + expect(executeStage(map, "main", "a", undefined, "kur")).toBe("A") + }) + + it("guards compose across a run into a dependency map", () => { + const libSrc = `system "lib:guarded:Latn:2026" {\nlanguages kur\nstage main {\n parallel {\n sub "x" "X" when kur\n sub "x" "G"\n }\n}\n}` + const doc = parseIsc( + minimalWrap( + `dependency "lib:guarded:Latn:2026" as lib\nstage main { run map.lib.stage.main }`, + ), + ) + const libJson = iscToCompiledMap(parseIsc(libSrc)) + const json = iscToCompiledMap(doc) + const libMap = normaliseMap(libJson) + const loader = { + load: (code: string) => (code === "lib:guarded:Latn:2026" ? libMap : undefined), + } + const map = normaliseMap(json) + expect(executeStage(map, "main", "x", loader)).toBe("G") + expect(executeStage(map, "main", "x", loader, "kur")).toBe("X") + }) +}) + +// --------------------------------------------------------------------------- +// IR shape — additive only (interscript.maps.ir.v1) +// --------------------------------------------------------------------------- + +describe("iscToCompiledMap — IR extensions", () => { + it("carries languages and when into the IR", () => { + const src = minimalWrap(` +languages kur chv +stage main { + parallel { + sub "a" "x" when kur + sub "b" "y" + } +}`) + const json = iscToCompiledMap(parseIsc(src)) + expect(json.languages).toEqual(["kur", "chv"]) + const stage = json.stages[0]! + const parallel = stage.rules[0] as { + kind: "parallel" + rules: ReadonlyArray<{ kind: string; when?: readonly string[] }> + } + expect(parallel.rules[0]!.when).toEqual(["kur"]) + expect(parallel.rules[1]!.when).toBeUndefined() + }) + + it("emits NO new keys for a legacy map (byte-identical JSON shape)", () => { + const legacy = minimalWrap(` +metadata { + name Legacy +} +stage main { + parallel { + sub "a" "x" before "b" + } + run map.some-dep.stage.main +}`) + const json = iscToCompiledMap(parseIsc(legacy)) + const s = JSON.stringify(json) + expect(s).not.toContain('"languages"') + expect(s).not.toContain('"when"') + // key order preserved + expect(Object.keys(json)).toEqual([ + "schemaVersion", + "systemCode", + "dependencies", + "metadata", + "stages", + "aliases", + "functions", + ]) + }) + + it("keeps the IR flat — no ruleset or use nodes survive", () => { + const src = minimalWrap(` +ruleset rs { sub "a" "x" } +stage main { use rs }`) + const json = iscToCompiledMap(parseIsc(src)) + const s = JSON.stringify(json) + expect(s).not.toContain('"use"') + expect(s).not.toContain('"ruleset"') + expect(json.stages[0]!.rules[0]).toMatchObject({ + kind: "sub", + from: { kind: "string", value: "a" }, + }) + }) +}) + +// --------------------------------------------------------------------------- +// Cross-checks +// --------------------------------------------------------------------------- + +describe("expandDocument — toResolved helper parity", () => { + it("expandDocument output converts to the same IR the converter produces internally", () => { + const src = minimalWrap(`ruleset rs { sub "a" "x" }\nstage main { use rs }`) + const doc: IscDocument = parseIsc(src) + const direct = iscToCompiledMap(doc) + const viaExpand = iscToCompiledMap(expandDocument(doc)) + expect(JSON.stringify(viaExpand)).toBe(JSON.stringify(direct)) + }) +}) From 5e20210d77ac260a3bbd2659cd2c2027d6059174 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Sun, 8 Nov 2026 20:22:39 +0800 Subject: [PATCH 2/3] fix(WF-1): override rows join the expansion's trailing parallel pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §4.2.5 requires the whole expansion (kept + override rows) to share the enclosing single longest-match pass; appending override rows as bare stage rules gave each its own pass, so a replacement could be preempted by the generic row it was meant to supersede. When the expansion ends with a parallel block, overrides now join that block (ruby-reference semantics); otherwise they append as bare rules (unchanged fallback). --- src/isc/expand.ts | 9 ++++++++- test/isc/wf1.test.ts | 23 ++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/isc/expand.ts b/src/isc/expand.ts index 7b558f2..18c3181 100644 --- a/src/isc/expand.ts +++ b/src/isc/expand.ts @@ -125,7 +125,9 @@ function expandUse( /** * Remove every rule whose from matches an override row (from anywhere in * the expansion — bare rules and rules inside parallel/sequence blocks), - * then append the override rows at the expansion site. + * then add the override rows: joined INTO a trailing parallel block when + * the expansion ends with one (§4.2.5 — the whole expansion shares that + * single longest-match pass), otherwise appended at the expansion site. */ function applyOverride( expanded: readonly IscStageItem[], @@ -146,6 +148,11 @@ function applyOverride( } } + const tail = kept[kept.length - 1] + if (tail && tail.kind === "parallel") { + kept[kept.length - 1] = { ...tail, rules: [...tail.rules, ...overrides] } + return kept + } const added: IscStageItem[] = overrides.map((rule) => ({ kind: "bare_rule", rule })) return [...kept, ...added] } diff --git a/test/isc/wf1.test.ts b/test/isc/wf1.test.ts index 694eb0f..055130c 100644 --- a/test/isc/wf1.test.ts +++ b/test/isc/wf1.test.ts @@ -349,10 +349,31 @@ describe("expandDocument — W2 override merge", () => { `use rs override {\n sub "a" "A"\n}`, ) const expanded = expandDocument(doc) + // the expansion does not END with a parallel block here, so the + // override rows append after it (fallback path, §4.2.2) expect(trace(expanded.stages[0]!.body)).toEqual(["parallel[b->y]", "sub c->z", "sub a->A"]) }) - it("appends override rows whose from matches nothing (they are simply added)", () => { + it("override rows share the expansion's trailing parallel pass — replacement acts on the source pass (§4.3)", () => { + // ruleset: generic table ends with a parallel block; the override + // replaces one of its rows. With the join, the replacement happens + // in the SAME pass — a row whose longer form exists in the block + // cannot be preempted by the generic shorter row. + const src = minimalWrap( + `ruleset rs {\nparallel {\n sub "เก" "ke"\n sub "เกะ" "ke"\n}\n}\nstage main {\nuse rs override {\n sub "เก" "kē"\n}\n}`, + ) + const expanded = expandDocument(wrapWithRs( + `parallel {\n sub "เก" "ke"\n sub "เกะ" "ke"\n}`, + `use rs override {\n sub "เก" "kē"\n}`, + )) + // kept row keeps its order; the override joins the same block after it + expect(trace(expanded.stages[0]!.body)).toEqual(["parallel[เกะ->ke, เก->kē]"]) + // "เกะ" must take its own row in the same pass, not the generic row + expect(run(src, "เกะ")).toBe("ke") + expect(run(src, "เก")).toBe("kē") + }) + + it("override rows append as bare rules only when the expansion does not end in parallel", () => { const doc = wrapWithRs(`sub "a" "x"`, `use rs override {\n sub "q" "Q"\n}`) const expanded = expandDocument(doc) expect(trace(expanded.stages[0]!.body)).toEqual(["sub a->x", "sub q->Q"]) From a0d59708ca07f0a7600332a94665d49bcfcb1018 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Sun, 8 Nov 2026 20:36:29 +0800 Subject: [PATCH 3/3] style: prettier formatting --- test/isc/wf1.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/isc/wf1.test.ts b/test/isc/wf1.test.ts index 055130c..acb3bb3 100644 --- a/test/isc/wf1.test.ts +++ b/test/isc/wf1.test.ts @@ -362,10 +362,12 @@ describe("expandDocument — W2 override merge", () => { const src = minimalWrap( `ruleset rs {\nparallel {\n sub "เก" "ke"\n sub "เกะ" "ke"\n}\n}\nstage main {\nuse rs override {\n sub "เก" "kē"\n}\n}`, ) - const expanded = expandDocument(wrapWithRs( - `parallel {\n sub "เก" "ke"\n sub "เกะ" "ke"\n}`, - `use rs override {\n sub "เก" "kē"\n}`, - )) + const expanded = expandDocument( + wrapWithRs( + `parallel {\n sub "เก" "ke"\n sub "เกะ" "ke"\n}`, + `use rs override {\n sub "เก" "kē"\n}`, + ), + ) // kept row keeps its order; the override joins the same block after it expect(trace(expanded.stages[0]!.body)).toEqual(["parallel[เกะ->ke, เก->kē]"]) // "เกะ" must take its own row in the same pass, not the generic row