diff --git a/package-lock.json b/package-lock.json index c69cd9c..5a2e335 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,20 @@ "name": "@rlippmann/context-compiler", "version": "0.9.0-dev.1", "license": "Apache-2.0", + "dependencies": { + "@ar-nelson/foldcase": "^1.0.1" + }, "devDependencies": { "typescript": "^5.9.3", "vitest": "^3.2.4" } }, + "node_modules/@ar-nelson/foldcase": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@ar-nelson/foldcase/-/foldcase-1.0.1.tgz", + "integrity": "sha512-CHlMkXpG3l3PA/EV5rxRcBm7J3V9oBcpvbo5OgX3TP4ErEGi90DKcVHHKDBJYSiNzP3AkI0IqiaC+DY+EoKdFQ==", + "license": "ISC" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", diff --git a/package.json b/package.json index bd38f4a..ad3795a 100644 --- a/package.json +++ b/package.json @@ -58,5 +58,8 @@ "devDependencies": { "typescript": "^5.9.3", "vitest": "^3.2.4" + }, + "dependencies": { + "@ar-nelson/foldcase": "^1.0.1" } } diff --git a/src/decision.ts b/src/decision.ts index 8616612..c1869c5 100644 --- a/src/decision.ts +++ b/src/decision.ts @@ -1,4 +1,5 @@ import { CanonicalDirective } from './grammar.js'; +import { unicodeCaseFold } from './unicode.js'; export const DECISION_ERROR = 'error' as const; export const DECISION_NO_DIRECTIVE = 'no_directive' as const; @@ -89,16 +90,11 @@ function formatFailure(failure: string, directive: CanonicalDirective): string { } function normalizeItemForMessage(value: string): string { - return value + const normalized = value .normalize('NFKC') .replaceAll('’', "'") - .replaceAll('`', "'") - .toLowerCase() - .replaceAll('ß', 'ss') - .replaceAll('ς', 'σ') - .replaceAll('ſ', 's') - .replace(/\s+/g, ' ') - .trim(); + .replaceAll('`', "'"); + return unicodeCaseFold(normalized).replace(/\s+/g, ' ').trim(); } function freezeDirective(directive: CanonicalDirective): CanonicalDirective { diff --git a/src/engine.ts b/src/engine.ts index f850b57..7770439 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -7,6 +7,7 @@ import { SemanticFailure, UpdateDecision } from './decision.js'; +import { unicodeCaseFold } from './unicode.js'; export const POLICY_USE = 'use' as const; export const POLICY_PROHIBIT = 'prohibit' as const; @@ -277,14 +278,6 @@ function normalizeItem(value: string): string { return normalized.trim(); } -function unicodeCaseFold(value: string): string { - return value - .toLowerCase() - .replaceAll('ß', 'ss') - .replaceAll('ς', 'σ') - .replaceAll('ſ', 's'); -} - function sortKeysDeep(value: unknown): unknown { if (Array.isArray(value)) { return value.map((v) => sortKeysDeep(v)); diff --git a/src/grammar.ts b/src/grammar.ts index febeb12..081f0e2 100644 --- a/src/grammar.ts +++ b/src/grammar.ts @@ -81,7 +81,7 @@ export class CanonicalDirective { const kind = normalizeDirectiveKind(kindInput); const operands = normalizeCanonicalOperands(kind, operandsInput); const rendered = serializeCanonicalDirective(kind, operands); - if (kind !== DirectiveKind.SET_PREMISE && containsMultipleCanonicalDirectives(rendered)) { + if (containsMultipleCanonicalDirectives(rendered)) { throw new Error(`Operands do not produce a canonical ${kind} directive.`); } this.kind = kind; @@ -243,9 +243,7 @@ function parseReplacement(text: string): CanonicalDirective | null { export function decompose_directive(text: string): CanonicalDirective | InvalidDirectiveSyntax | null { const trimmed = trimAsciiWhitespace(text); if (trimmed === '' || !startsWithDirectiveFamily(trimmed)) return null; - // A set-premise operand is opaque: directive-shaped text inside it is premise - // content, not a second directive. - if (!normalizedForMatching(trimmed).startsWith('set premise ') && containsMultipleCanonicalDirectives(trimmed)) { + if (containsMultipleCanonicalDirectives(trimmed)) { return invalid(DirectiveSyntaxFailure.COMPOUND_DIRECTIVE); } diff --git a/src/unicode.ts b/src/unicode.ts new file mode 100644 index 0000000..28c4789 --- /dev/null +++ b/src/unicode.ts @@ -0,0 +1,6 @@ +import foldcase from '@ar-nelson/foldcase'; + +/** Apply Unicode Default Case Folding (the full, potentially expanding form). */ +export function unicodeCaseFold(value: string): string { + return foldcase.full(value); +} diff --git a/src/vendor.d.ts b/src/vendor.d.ts new file mode 100644 index 0000000..bc62303 --- /dev/null +++ b/src/vendor.d.ts @@ -0,0 +1,12 @@ +declare module '@ar-nelson/foldcase' { + interface Foldcase { + (value: string): string; + full(value: string): string; + simple(value: string): string; + charFull(value: string): string; + charSimple(value: string): string; + } + + const foldcase: Foldcase; + export default foldcase; +} diff --git a/tests/api_parity.test.ts b/tests/api_parity.test.ts index fa3b4b6..59178fb 100644 --- a/tests/api_parity.test.ts +++ b/tests/api_parity.test.ts @@ -53,6 +53,7 @@ type EngineMemberSpec = { probes?: Array<{ args: unknown[]; raises?: { type: string }; + rejects?: boolean; }>; }; @@ -401,7 +402,7 @@ describe('public API parity contract (conformance fixture)', () => { return values.map(materializeProbeValue); })(); const construct = () => Reflect.construct(cc.SemanticErrorDecision, args); - if (probe.raises != null) { + if (probe.raises != null || probe.rejects === true) { expect(construct, `SemanticErrorDecision construction probe ${index} should raise`).toThrowError(TypeError); continue; } @@ -434,8 +435,8 @@ describe('public API parity contract (conformance fixture)', () => { return Reflect.construct(cc.Engine, constructorArgs); }; - if (probe.raises != null) { - if (probe.raises.type === 'TypeError') { + if (probe.raises != null || probe.rejects === true) { + if (probe.raises?.type === 'TypeError') { expect(construct, `Engine construction probe ${index} should raise TypeError`).toThrowError(TypeError); } else { expect(construct, `Engine construction probe ${index} should raise`).toThrow(); @@ -529,7 +530,7 @@ describe('public API parity contract (conformance fixture)', () => { for (const [index, probe] of (memberSpec.probes ?? []).entries()) { const invoke = () => (engine as unknown as Record unknown>)[memberName](...probe.args); - if (probe.raises != null) { + if (probe.raises != null || probe.rejects === true) { expect(invoke, `${memberName} probe ${index} should raise`).toThrow(); } else { expect(invoke, `${memberName} probe ${index} should not raise`).not.toThrow(); diff --git a/tests/apply-directive-fixtures.test.ts b/tests/apply-directive-fixtures.test.ts index cc7ec27..72d7385 100644 --- a/tests/apply-directive-fixtures.test.ts +++ b/tests/apply-directive-fixtures.test.ts @@ -15,7 +15,9 @@ describe('apply-directive fixtures (conformance)', () => { 'function' ); - const directive = decompose_directive(fixture.payload.action.text); + const directive = fixture.payload.action.directive === undefined + ? decompose_directive(fixture.payload.action.text as string) + : new CanonicalDirective(fixture.payload.action.directive.kind, fixture.payload.action.directive.operands as Record); expect(directive).toBeInstanceOf(CanonicalDirective); const decision = (applyDirective as (value: CanonicalDirective) => unknown).call(engine, directive); expect(decision).toEqual(fixture.payload.expected.decision); diff --git a/tests/fixtures/.source-commit b/tests/fixtures/.source-commit index 7569023..0e305c8 100644 --- a/tests/fixtures/.source-commit +++ b/tests/fixtures/.source-commit @@ -1 +1 @@ -ee49df7fdcd8965392da31d4ead37c7514d53d29 +7ffc19c53ef5c41e4e421f4fd2958655e52737c9 diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index e8cdb16..8b08bf3 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -45,18 +45,6 @@ Portable serialization contract coverage for `engine.export_json()` and `engine.import_json(...)`, including canonical export payload shape and deterministic validation/error boundaries. -## Controller fixtures - -For [`conformance/controller/`](conformance/controller/): - -Portable controller contract coverage for: - -* `step(engine, user_input)` result envelope and state snapshot -* `preview(engine, user_input)` result envelope, `would_mutate`, and non-mutation of live engine state -* `state_diff(state_before, state_after)` deterministic structural diff output - -These fixtures keep a minimal, language-neutral contract matrix for controller APIs. - ## Source of truth Fixtures reflect current Python behavior and tests. @@ -89,5 +77,4 @@ See the TypeScript fixture runners in this repository for execution details: * [`step-fixtures.test.ts`](../step-fixtures.test.ts) * [`state-json-fixtures.test.ts`](../state-json-fixtures.test.ts) -* [`controller-fixtures.test.ts`](../controller-fixtures.test.ts) * [`structured-regression-fixtures.test.ts`](../structured-regression-fixtures.test.ts) diff --git a/tests/fixtures/conformance/api/public-api-v2.json b/tests/fixtures/conformance/api/public-api-v2.json index a3a4dd2..d396aed 100644 --- a/tests/fixtures/conformance/api/public-api-v2.json +++ b/tests/fixtures/conformance/api/public-api-v2.json @@ -37,6 +37,7 @@ "_NO_DIRECTIVE" ], "forbidden_engine_members": [ + "apply_transcript", "export_checkpoint", "export_checkpoint_json", "has_pending_clarification", @@ -99,17 +100,13 @@ "args": [ "unexpected" ], - "raises": { - "type": "TypeError" - } + "rejects": true }, { "kwargs": { "value": "unexpected" }, - "raises": { - "type": "TypeError" - } + "rejects": true } ] }, @@ -179,9 +176,7 @@ }, { "args": [], - "raises": { - "type": "TypeError" - } + "rejects": true }, { "kwargs": { @@ -193,9 +188,7 @@ }, "unexpected": true }, - "raises": { - "type": "TypeError" - } + "rejects": true } ] }, @@ -252,17 +245,13 @@ }, { "args": [], - "raises": { - "type": "TypeError" - } + "rejects": true }, { "kwargs": { "value": true }, - "raises": { - "type": "TypeError" - } + "rejects": true } ] }, @@ -293,17 +282,13 @@ "args": [ "unexpected" ], - "raises": { - "type": "TypeError" - } + "rejects": true }, { "kwargs": { "state": {} }, - "raises": { - "type": "TypeError" - } + "rejects": true } ] }, @@ -351,33 +336,25 @@ "args": [ "use docker" ], - "raises": { - "type": "AttributeError" - } + "rejects": true }, { "args": [ null ], - "raises": { - "type": "AttributeError" - } + "rejects": true }, { "args": [ 1 ], - "raises": { - "type": "AttributeError" - } + "rejects": true }, { "args": [ {} ], - "raises": { - "type": "AttributeError" - } + "rejects": true } ] }, @@ -394,10 +371,14 @@ } }, "policies": { - "kind": "property" + "kind": "property", + "readable": true, + "writable": false }, "premise": { - "kind": "property" + "kind": "property", + "readable": true, + "writable": false }, "step": { "kind": "method", diff --git a/tests/fixtures/conformance/api/public-grammar-v1.json b/tests/fixtures/conformance/api/public-grammar-v1.json index 0385f77..a3f9cab 100644 --- a/tests/fixtures/conformance/api/public-grammar-v1.json +++ b/tests/fixtures/conformance/api/public-grammar-v1.json @@ -80,7 +80,7 @@ }, { "args": [], - "raises": {"type": "TypeError"} + "rejects": true }, { "kwargs": { @@ -89,7 +89,7 @@ "operand_names": ["item"], "unexpected": true }, - "raises": {"type": "TypeError"} + "rejects": true } ] }, @@ -172,18 +172,14 @@ "item": "docker" } }, - "raises": { - "type": "ValueError" - } + "rejects": true }, { "kwargs": { "kind": "use_item", "operands": {} }, - "raises": { - "type": "ValueError" - } + "rejects": true }, { "kwargs": { @@ -192,9 +188,7 @@ "item": "docker" } }, - "raises": { - "type": "ValueError" - } + "rejects": true }, { "kwargs": { @@ -203,9 +197,7 @@ "value": 123 } }, - "raises": { - "type": "ValueError" - } + "rejects": true }, { "kwargs": { @@ -214,9 +206,7 @@ "value": "to concise replies" } }, - "raises": { - "type": "ValueError" - } + "rejects": true }, { "kwargs": { @@ -226,9 +216,7 @@ "old_item": "docker" } }, - "raises": { - "type": "ValueError" - } + "rejects": true }, { "kwargs": { @@ -237,9 +225,7 @@ "item": "instead of" } }, - "raises": { - "type": "ValueError" - } + "rejects": true }, { "kwargs": { @@ -248,13 +234,11 @@ "item": "docker prohibit peanuts" } }, - "raises": { - "type": "ValueError" - } + "rejects": true }, { "args": [], - "raises": {"type": "TypeError"} + "rejects": true }, { "kwargs": { @@ -262,7 +246,7 @@ "operands": {"item": "docker"}, "unexpected": true }, - "raises": {"type": "TypeError"} + "rejects": true } ] }, @@ -300,7 +284,7 @@ }, { "kwargs": {"unexpected": true}, - "raises": {"type": "TypeError"} + "rejects": true } ] }, diff --git a/tests/fixtures/conformance/apply-directive/README.md b/tests/fixtures/conformance/apply-directive/README.md index 72889c6..d386fbd 100644 --- a/tests/fixtures/conformance/apply-directive/README.md +++ b/tests/fixtures/conformance/apply-directive/README.md @@ -27,12 +27,14 @@ Each fixture is a JSON object with: ### `action` -The current portable action form is: +The portable action forms are: * `{"fn":"apply_directive","text":"...canonical directive text..."}` + decomposes the text before applying the resulting directive. +* `{"fn":"apply_directive","directive":{"kind":"...","operands":{...}}}` + constructs a public `CanonicalDirective` directly and passes it to + `engine.apply_directive(...)` without parsing text first. -The Python source-of-truth runner validates that `text` decomposes to a -canonical directive before calling `engine.apply_directive(...)`. Ports may construct the equivalent canonical directive object using their own public grammar surface. diff --git a/tests/fixtures/conformance/apply-directive/apply_directive_structural_use_item_conflict_error.json b/tests/fixtures/conformance/apply-directive/apply_directive_structural_use_item_conflict_error.json new file mode 100644 index 0000000..ce36575 --- /dev/null +++ b/tests/fixtures/conformance/apply-directive/apply_directive_structural_use_item_conflict_error.json @@ -0,0 +1,57 @@ +{ + "id": "apply_directive_structural_use_item_conflict_error", + "kind": "apply_directive", + "initial_state": { + "premise": null, + "policies": { + "docker": "prohibit" + }, + "version": 2 + }, + "action": { + "fn": "apply_directive", + "directive": { + "kind": "use_item", + "operands": { + "item": "docker" + } + } + }, + "expected": { + "decision": { + "kind": "error", + "failure": "item_prohibited", + "directive": { + "kind": "use_item", + "text": "use docker", + "operands": { + "item": "docker" + } + }, + "repairs": [ + { + "kind": "remove_policy", + "text": "remove policy docker", + "operands": { + "item": "docker" + } + }, + { + "kind": "use_item", + "text": "use docker", + "operands": { + "item": "docker" + } + } + ], + "message": "\"docker\" is currently prohibited.\nRemove or replace it before using it." + }, + "state": { + "premise": null, + "policies": { + "docker": "prohibit" + }, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/apply-directive/apply_directive_structural_use_item_update.json b/tests/fixtures/conformance/apply-directive/apply_directive_structural_use_item_update.json new file mode 100644 index 0000000..5793fca --- /dev/null +++ b/tests/fixtures/conformance/apply-directive/apply_directive_structural_use_item_update.json @@ -0,0 +1,31 @@ +{ + "id": "apply_directive_structural_use_item_update", + "kind": "apply_directive", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "action": { + "fn": "apply_directive", + "directive": { + "kind": "use_item", + "operands": { + "item": "docker" + } + } + }, + "expected": { + "decision": { + "kind": "update", + "changed": true + }, + "state": { + "premise": null, + "policies": { + "docker": "use" + }, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_render_invalid_use_item_replacement_prefix_rejected.json b/tests/fixtures/conformance/grammar/grammar_construct_canonical_directive_invalid_use_item_replacement_prefix_rejected.json similarity index 62% rename from tests/fixtures/conformance/grammar/grammar_render_invalid_use_item_replacement_prefix_rejected.json rename to tests/fixtures/conformance/grammar/grammar_construct_canonical_directive_invalid_use_item_replacement_prefix_rejected.json index 3cb11d7..a173bab 100644 --- a/tests/fixtures/conformance/grammar/grammar_render_invalid_use_item_replacement_prefix_rejected.json +++ b/tests/fixtures/conformance/grammar/grammar_construct_canonical_directive_invalid_use_item_replacement_prefix_rejected.json @@ -1,8 +1,8 @@ { - "id": "grammar_render_invalid_use_item_replacement_prefix_rejected", + "id": "grammar_construct_canonical_directive_invalid_use_item_replacement_prefix_rejected", "kind": "grammar", "action": { - "fn": "render_directive", + "fn": "construct_canonical_directive", "kind": "use_item", "operands": { "item": "instead of docker" @@ -10,7 +10,6 @@ }, "expected": { "error": { - "type": "ValueError", "message_contains": "canonical use_item directive" } } diff --git a/tests/fixtures/conformance/grammar/grammar_render_replace_use.json b/tests/fixtures/conformance/grammar/grammar_construct_canonical_directive_replace_use.json similarity index 70% rename from tests/fixtures/conformance/grammar/grammar_render_replace_use.json rename to tests/fixtures/conformance/grammar/grammar_construct_canonical_directive_replace_use.json index c0059ca..c1c0607 100644 --- a/tests/fixtures/conformance/grammar/grammar_render_replace_use.json +++ b/tests/fixtures/conformance/grammar/grammar_construct_canonical_directive_replace_use.json @@ -1,8 +1,8 @@ { - "id": "grammar_render_replace_use", + "id": "grammar_construct_canonical_directive_replace_use", "kind": "grammar", "action": { - "fn": "render_directive", + "fn": "construct_canonical_directive", "kind": "replace_use", "operands": { "new_item": "podman", diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_compound_rejected.json new file mode 100644 index 0000000..093baee --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_change_premise_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "change premise to vegetarian and use docker" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_newline_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_newline_compound_rejected.json new file mode 100644 index 0000000..2a62a69 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_newline_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_change_premise_newline_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "change premise to first\nchange premise to second" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_use_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_use_compound_rejected.json new file mode 100644 index 0000000..1d07bbc --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_use_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_change_premise_use_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "change premise to deployment target is staging, then use cautious rollout" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_users_prohibit_password_authentication_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_users_prohibit_password_authentication_compound_rejected.json new file mode 100644 index 0000000..d20f2d7 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_change_premise_users_prohibit_password_authentication_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_change_premise_users_prohibit_password_authentication_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "change premise to users prohibit password authentication" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_clear_premise_suffix_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_clear_premise_suffix_rejected.json new file mode 100644 index 0000000..b65809b --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_clear_premise_suffix_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_invalid_clear_premise_suffix_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "clear premise now" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "malformed_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_clear_state_suffix_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_clear_state_suffix_rejected.json new file mode 100644 index 0000000..7407879 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_clear_state_suffix_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_invalid_clear_state_suffix_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "clear state now" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "malformed_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_reset_policies_suffix_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_reset_policies_suffix_rejected.json new file mode 100644 index 0000000..13e4350 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_invalid_reset_policies_suffix_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_invalid_reset_policies_suffix_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "reset policies now" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "malformed_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_conjunction.json b/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_conjunction.json deleted file mode 100644 index c6da6f1..0000000 --- a/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_conjunction.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "grammar_decompose_opaque_premise_conjunction", - "kind": "grammar", - "action": { - "fn": "decompose_directive", - "text": "set premise vegetarian and use docker" - }, - "expected": { - "directive": { - "text": "set premise vegetarian and use docker", - "kind": "set_premise", - "operands": { - "value": "vegetarian and use docker" - } - } - } -} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_prohibit.json b/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_prohibit.json deleted file mode 100644 index 4a9337a..0000000 --- a/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_prohibit.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "grammar_decompose_opaque_premise_prohibit", - "kind": "grammar", - "action": { - "fn": "decompose_directive", - "text": "set premise users may prohibit unsafe operations" - }, - "expected": { - "directive": { - "text": "set premise users may prohibit unsafe operations", - "kind": "set_premise", - "operands": { - "value": "users may prohibit unsafe operations" - } - } - } -} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_use.json b/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_use.json deleted file mode 100644 index 27748c3..0000000 --- a/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_use.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "grammar_decompose_opaque_premise_use", - "kind": "grammar", - "action": { - "fn": "decompose_directive", - "text": "set premise we must use gaap" - }, - "expected": { - "directive": { - "text": "set premise we must use gaap", - "kind": "set_premise", - "operands": { - "value": "we must use gaap" - } - } - } -} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_clear_premise_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_clear_premise_compound_rejected.json new file mode 100644 index 0000000..938af49 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_clear_premise_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_set_premise_clear_premise_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "set premise clear premise before release" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_clear_state_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_clear_state_compound_rejected.json new file mode 100644 index 0000000..ca8eeae --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_clear_state_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_set_premise_clear_state_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "set premise clear state before starting" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_compound_rejected.json new file mode 100644 index 0000000..4d8f291 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_set_premise_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "set premise concise replies and change premise to formal tone" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_prohibit_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_prohibit_compound_rejected.json new file mode 100644 index 0000000..70afeb7 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_prohibit_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_set_premise_prohibit_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "set premise users may prohibit unsafe operations" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_remove_policy_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_remove_policy_compound_rejected.json new file mode 100644 index 0000000..8966751 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_remove_policy_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_set_premise_remove_policy_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "set premise cleanup requires remove policy docker" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_reset_policies_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_reset_policies_compound_rejected.json new file mode 100644 index 0000000..557d233 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_reset_policies_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_set_premise_reset_policies_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "set premise reset policies after migration" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_compound_use_and_prohibit_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_use_compound_rejected.json similarity index 69% rename from tests/fixtures/conformance/grammar/grammar_decompose_invalid_compound_use_and_prohibit_rejected.json rename to tests/fixtures/conformance/grammar/grammar_decompose_set_premise_use_compound_rejected.json index f71adf1..7a7b4d6 100644 --- a/tests/fixtures/conformance/grammar/grammar_decompose_invalid_compound_use_and_prohibit_rejected.json +++ b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_use_compound_rejected.json @@ -1,9 +1,9 @@ { - "id": "grammar_decompose_invalid_compound_use_and_prohibit_rejected", + "id": "grammar_decompose_set_premise_use_compound_rejected", "kind": "grammar", "action": { "fn": "decompose_directive", - "text": "use docker and prohibit peanuts" + "text": "set premise we must use gaap" }, "expected": { "directive": { diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_users_use_sso_compound_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_users_use_sso_compound_rejected.json new file mode 100644 index 0000000..a4ad59a --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_set_premise_users_use_sso_compound_rejected.json @@ -0,0 +1,16 @@ +{ + "id": "grammar_decompose_set_premise_users_use_sso_compound_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "set premise users use SSO" + }, + "expected": { + "directive": { + "kind": "invalid_directive_syntax", + "failure": "compound_directive", + "directive_kind": null, + "missing_operand": null + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_unicode_casefold_structural_delimiter_not_replacement.json b/tests/fixtures/conformance/grammar/grammar_decompose_unicode_casefold_structural_delimiter_not_replacement.json new file mode 100644 index 0000000..2d5655d --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_unicode_casefold_structural_delimiter_not_replacement.json @@ -0,0 +1,17 @@ +{ + "id": "grammar_decompose_unicode_casefold_structural_delimiter_not_replacement", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "use podman inſtead of docker" + }, + "expected": { + "directive": { + "kind": "use_item", + "text": "use podman inſtead of docker", + "operands": { + "item": "podman inſtead of docker" + } + } + } +} diff --git a/tests/fixtures/conformance/mutation-isolation/014_canonical_directive_constructor_operands_isolation.json b/tests/fixtures/conformance/mutation-isolation/014_canonical_directive_constructor_operands_isolation.json new file mode 100644 index 0000000..04540af --- /dev/null +++ b/tests/fixtures/conformance/mutation-isolation/014_canonical_directive_constructor_operands_isolation.json @@ -0,0 +1,60 @@ +{ + "id": "014_canonical_directive_constructor_operands_isolation", + "kind": "mutation_isolation", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "operation": { + "fn": "canonical_directive.constructor_operands", + "kind": "use_item", + "operands": { + "item": "docker" + }, + "source_handle": "source_operands", + "result_handle": "directive" + }, + "handles": { + "source_operands": { + "kind": "caller_owned_constructor_input" + }, + "directive": { + "kind": "constructed_directive" + } + }, + "mutations": [ + { + "target_handle": "source_operands", + "path": [ + "item" + ], + "op": "set", + "value": "podman" + } + ], + "expected": { + "authoritative_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "caller_owned_observations": { + "directive_operands_after_source_mutation": { + "target_handle": "directive", + "path": [ + "operands", + "item" + ], + "value": "docker" + }, + "directive_text_after_source_mutation": { + "target_handle": "directive", + "path": [ + "text" + ], + "value": "use docker" + } + } + } +} diff --git a/tests/fixtures/conformance/mutation-isolation/README.md b/tests/fixtures/conformance/mutation-isolation/README.md index 4a32a22..82ffde1 100644 --- a/tests/fixtures/conformance/mutation-isolation/README.md +++ b/tests/fixtures/conformance/mutation-isolation/README.md @@ -22,7 +22,8 @@ are immutable. The contract requires: For the grammar-object fixtures, exposed `CanonicalDirective.operands` and the covered `DirectiveMetadata` fields must preserve mutation isolation: a caller mutation either is rejected or does not change the exposed value observed after -the mutation. +the mutation. Constructed directives also capture their operand values +independently from the caller-owned mapping used to construct them. ## Fixture shape @@ -49,6 +50,7 @@ Examples: * `engine.policies` * `engine.premise` * `canonical_directive.operands` +* `canonical_directive.constructor_operands` * `directive_metadata` For the current corpus, `operation` uses a closed per-function field set. Unknown operation fields are invalid. diff --git a/tests/fixtures/conformance/mutation-isolation/008_engine_policies_property_isolation.json b/tests/fixtures/conformance/mutation-isolation/mutation_isolation_engine_policies_property.json similarity index 100% rename from tests/fixtures/conformance/mutation-isolation/008_engine_policies_property_isolation.json rename to tests/fixtures/conformance/mutation-isolation/mutation_isolation_engine_policies_property.json diff --git a/tests/fixtures/conformance/mutation-isolation/009_engine_premise_property_isolation.json b/tests/fixtures/conformance/mutation-isolation/mutation_isolation_engine_premise_property.json similarity index 100% rename from tests/fixtures/conformance/mutation-isolation/009_engine_premise_property_isolation.json rename to tests/fixtures/conformance/mutation-isolation/mutation_isolation_engine_premise_property.json diff --git a/tests/fixtures/conformance/mutation-isolation/003_update_decision_state_isolation.json b/tests/fixtures/conformance/mutation-isolation/mutation_isolation_update_decision.json similarity index 100% rename from tests/fixtures/conformance/mutation-isolation/003_update_decision_state_isolation.json rename to tests/fixtures/conformance/mutation-isolation/mutation_isolation_update_decision.json diff --git a/tests/fixtures/conformance/state-json/state_json_import_json_empty_normalized_policy_key_rejected_atomically.json b/tests/fixtures/conformance/state-json/state_json_import_json_empty_normalized_policy_key_rejected_atomically.json index cf0f1ad..60e60a0 100644 --- a/tests/fixtures/conformance/state-json/state_json_import_json_empty_normalized_policy_key_rejected_atomically.json +++ b/tests/fixtures/conformance/state-json/state_json_import_json_empty_normalized_policy_key_rejected_atomically.json @@ -15,7 +15,6 @@ }, "expected": { "error": { - "type": "ValueError", "message_contains": "Invalid state payload" }, "state": { diff --git a/tests/fixtures/conformance/state-json/state_json_import_json_empty_sanitized_premise_rejected_atomically.json b/tests/fixtures/conformance/state-json/state_json_import_json_empty_sanitized_premise_rejected_atomically.json index 80ca26a..ad61acf 100644 --- a/tests/fixtures/conformance/state-json/state_json_import_json_empty_sanitized_premise_rejected_atomically.json +++ b/tests/fixtures/conformance/state-json/state_json_import_json_empty_sanitized_premise_rejected_atomically.json @@ -16,7 +16,6 @@ }, "expected": { "error": { - "type": "ValueError", "message_contains": "Invalid state payload" }, "state": { diff --git a/tests/fixtures/conformance/state-json/state_json_import_json_invalid_json_rejected.json b/tests/fixtures/conformance/state-json/state_json_import_json_invalid_json_rejected.json index 8fe097f..77ea34a 100644 --- a/tests/fixtures/conformance/state-json/state_json_import_json_invalid_json_rejected.json +++ b/tests/fixtures/conformance/state-json/state_json_import_json_invalid_json_rejected.json @@ -15,7 +15,6 @@ }, "expected": { "error": { - "type": "ValueError", "message_contains": "Invalid JSON payload" }, "state": { diff --git a/tests/fixtures/conformance/state-json/state_json_import_json_invalid_policies_type_rejected.json b/tests/fixtures/conformance/state-json/state_json_import_json_invalid_policies_type_rejected.json index b102324..94dcb7a 100644 --- a/tests/fixtures/conformance/state-json/state_json_import_json_invalid_policies_type_rejected.json +++ b/tests/fixtures/conformance/state-json/state_json_import_json_invalid_policies_type_rejected.json @@ -12,7 +12,6 @@ }, "expected": { "error": { - "type": "ValueError", "message_contains": "Invalid state payload" }, "state": { diff --git a/tests/fixtures/conformance/state-json/state_json_import_json_invalid_policy_value_rejected.json b/tests/fixtures/conformance/state-json/state_json_import_json_invalid_policy_value_rejected.json index 6cb9434..90fd584 100644 --- a/tests/fixtures/conformance/state-json/state_json_import_json_invalid_policy_value_rejected.json +++ b/tests/fixtures/conformance/state-json/state_json_import_json_invalid_policy_value_rejected.json @@ -12,7 +12,6 @@ }, "expected": { "error": { - "type": "ValueError", "message_contains": "Invalid state payload" }, "state": { diff --git a/tests/fixtures/conformance/state-json/state_json_import_json_invalid_premise_type_rejected.json b/tests/fixtures/conformance/state-json/state_json_import_json_invalid_premise_type_rejected.json index 409d061..2848e16 100644 --- a/tests/fixtures/conformance/state-json/state_json_import_json_invalid_premise_type_rejected.json +++ b/tests/fixtures/conformance/state-json/state_json_import_json_invalid_premise_type_rejected.json @@ -16,7 +16,6 @@ }, "expected": { "error": { - "type": "ValueError", "message_contains": "Invalid state payload" }, "state": { diff --git a/tests/fixtures/conformance/state-json/state_json_import_json_missing_policies_field_rejected.json b/tests/fixtures/conformance/state-json/state_json_import_json_missing_policies_field_rejected.json index 7655c25..240f573 100644 --- a/tests/fixtures/conformance/state-json/state_json_import_json_missing_policies_field_rejected.json +++ b/tests/fixtures/conformance/state-json/state_json_import_json_missing_policies_field_rejected.json @@ -12,7 +12,6 @@ }, "expected": { "error": { - "type": "ValueError", "message_contains": "Invalid state payload" }, "state": { diff --git a/tests/fixtures/conformance/state-json/state_json_import_json_non_object_rejected.json b/tests/fixtures/conformance/state-json/state_json_import_json_non_object_rejected.json index 924ebd5..dfe3767 100644 --- a/tests/fixtures/conformance/state-json/state_json_import_json_non_object_rejected.json +++ b/tests/fixtures/conformance/state-json/state_json_import_json_non_object_rejected.json @@ -16,7 +16,6 @@ }, "expected": { "error": { - "type": "ValueError", "message_contains": "Invalid state payload" }, "state": { diff --git a/tests/fixtures/conformance/state-json/state_json_import_json_normalized_policy_collision_rejected_atomically.json b/tests/fixtures/conformance/state-json/state_json_import_json_normalized_policy_collision_rejected_atomically.json index 49927e9..ead35e3 100644 --- a/tests/fixtures/conformance/state-json/state_json_import_json_normalized_policy_collision_rejected_atomically.json +++ b/tests/fixtures/conformance/state-json/state_json_import_json_normalized_policy_collision_rejected_atomically.json @@ -15,7 +15,6 @@ }, "expected": { "error": { - "type": "ValueError", "message_contains": "Invalid state payload" }, "state": { diff --git a/tests/fixtures/conformance/state-json/state_json_import_json_unicode_casefold_policy_collision_rejected_atomically.json b/tests/fixtures/conformance/state-json/state_json_import_json_unicode_casefold_policy_collision_rejected_atomically.json index 8821e85..cdd3246 100644 --- a/tests/fixtures/conformance/state-json/state_json_import_json_unicode_casefold_policy_collision_rejected_atomically.json +++ b/tests/fixtures/conformance/state-json/state_json_import_json_unicode_casefold_policy_collision_rejected_atomically.json @@ -15,7 +15,6 @@ }, "expected": { "error": { - "type": "ValueError", "message_contains": "Invalid state payload" }, "state": { diff --git a/tests/fixtures/conformance/state-json/state_json_import_json_unsupported_version_rejected.json b/tests/fixtures/conformance/state-json/state_json_import_json_unsupported_version_rejected.json index 411dead..c2a3f37 100644 --- a/tests/fixtures/conformance/state-json/state_json_import_json_unsupported_version_rejected.json +++ b/tests/fixtures/conformance/state-json/state_json_import_json_unsupported_version_rejected.json @@ -15,7 +15,6 @@ }, "expected": { "error": { - "type": "ValueError", "message_contains": "Unsupported state version" }, "state": { diff --git a/tests/fixtures/conformance/step/step_compound_set_premise_and_use_invalid_boundary.json b/tests/fixtures/conformance/step/step_compound_set_premise_and_use_invalid_boundary.json index 73e61ae..89acb74 100644 --- a/tests/fixtures/conformance/step/step_compound_set_premise_and_use_invalid_boundary.json +++ b/tests/fixtures/conformance/step/step_compound_set_premise_and_use_invalid_boundary.json @@ -9,11 +9,10 @@ "input": "set premise vegetarian and use docker", "expected": { "decision": { - "kind": "update", - "changed": true + "kind": "no_directive" }, "state": { - "premise": "vegetarian and use docker", + "premise": null, "policies": {}, "version": 2 } diff --git a/tests/fixtures/conformance/step/step_policy_identity_capital_sharp_s_casefold_message.json b/tests/fixtures/conformance/step/step_policy_identity_capital_sharp_s_casefold_message.json new file mode 100644 index 0000000..00d9e03 --- /dev/null +++ b/tests/fixtures/conformance/step/step_policy_identity_capital_sharp_s_casefold_message.json @@ -0,0 +1,50 @@ +{ + "id": "step_policy_identity_capital_sharp_s_casefold_message", + "kind": "step", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "prelude": [ + "use ẞ" + ], + "input": "prohibit SS", + "expected": { + "decision": { + "kind": "error", + "failure": "item_already_in_use", + "directive": { + "kind": "prohibit_item", + "text": "prohibit SS", + "operands": { + "item": "SS" + } + }, + "repairs": [ + { + "kind": "remove_policy", + "text": "remove policy SS", + "operands": { + "item": "SS" + } + }, + { + "kind": "prohibit_item", + "text": "prohibit SS", + "operands": { + "item": "SS" + } + } + ], + "message": "\"ss\" is currently in use.\nRemove or replace it before prohibiting it." + }, + "state": { + "premise": null, + "policies": { + "ss": "use" + }, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/workflow/workflow_apply_returned_repair.json b/tests/fixtures/conformance/workflow/workflow_apply_returned_repair.json new file mode 100644 index 0000000..5a7711e --- /dev/null +++ b/tests/fixtures/conformance/workflow/workflow_apply_returned_repair.json @@ -0,0 +1,85 @@ +{ + "id": "workflow_apply_returned_repair", + "kind": "workflow", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "operations": [ + { + "fn": "step", + "input": "prohibit docker", + "label": "prohibit_step" + }, + { + "fn": "step", + "input": "use docker", + "label": "conflict" + }, + { + "fn": "apply_repair", + "decision_ref": "conflict", + "repair_index": 0, + "label": "remove_repair" + }, + { + "fn": "apply_repair", + "decision_ref": "conflict", + "repair_index": 1, + "label": "use_repair" + } + ], + "expected": { + "observations": { + "prohibit_step": { + "kind": "update", + "changed": true + }, + "conflict": { + "kind": "error", + "failure": "item_prohibited", + "directive": { + "kind": "use_item", + "text": "use docker", + "operands": { + "item": "docker" + } + }, + "repairs": [ + { + "kind": "remove_policy", + "text": "remove policy docker", + "operands": { + "item": "docker" + } + }, + { + "kind": "use_item", + "text": "use docker", + "operands": { + "item": "docker" + } + } + ], + "message": "\"docker\" is currently prohibited.\nRemove or replace it before using it." + }, + "remove_repair": { + "kind": "update", + "changed": true + }, + "use_repair": { + "kind": "update", + "changed": true + } + }, + "equal": [], + "state": { + "premise": null, + "policies": { + "docker": "use" + }, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/controller/controller_export_import_fixed_point_three_cycles.json b/tests/fixtures/conformance/workflow/workflow_export_import_fixed_point_three_cycles.json similarity index 92% rename from tests/fixtures/conformance/controller/controller_export_import_fixed_point_three_cycles.json rename to tests/fixtures/conformance/workflow/workflow_export_import_fixed_point_three_cycles.json index b1f4e9e..e8abcec 100644 --- a/tests/fixtures/conformance/controller/controller_export_import_fixed_point_three_cycles.json +++ b/tests/fixtures/conformance/workflow/workflow_export_import_fixed_point_three_cycles.json @@ -1,6 +1,6 @@ { - "id": "controller_export_import_fixed_point_three_cycles", - "kind": "controller", + "id": "workflow_export_import_fixed_point_three_cycles", + "kind": "workflow", "initial_state": { "premise": "Use concise output", "policies": { diff --git a/tests/fixtures/conformance/workflow/workflow_mixed_conflict_remove_retry.json b/tests/fixtures/conformance/workflow/workflow_mixed_conflict_remove_retry.json new file mode 100644 index 0000000..9487974 --- /dev/null +++ b/tests/fixtures/conformance/workflow/workflow_mixed_conflict_remove_retry.json @@ -0,0 +1,83 @@ +{ + "id": "workflow_mixed_conflict_remove_retry", + "kind": "workflow", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "operations": [ + { + "fn": "step", + "input": "use docker", + "label": "use_step" + }, + { + "fn": "apply_directive", + "text": "prohibit docker", + "label": "conflict_apply" + }, + { + "fn": "step", + "input": "remove policy docker", + "label": "remove_step" + }, + { + "fn": "apply_directive", + "text": "use docker", + "label": "retry_apply" + } + ], + "expected": { + "observations": { + "use_step": { + "kind": "update", + "changed": true + }, + "conflict_apply": { + "kind": "error", + "failure": "item_already_in_use", + "directive": { + "kind": "prohibit_item", + "text": "prohibit docker", + "operands": { + "item": "docker" + } + }, + "repairs": [ + { + "kind": "remove_policy", + "text": "remove policy docker", + "operands": { + "item": "docker" + } + }, + { + "kind": "prohibit_item", + "text": "prohibit docker", + "operands": { + "item": "docker" + } + } + ], + "message": "\"docker\" is currently in use.\nRemove or replace it before prohibiting it." + }, + "remove_step": { + "kind": "update", + "changed": true + }, + "retry_apply": { + "kind": "update", + "changed": true + } + }, + "equal": [], + "state": { + "premise": null, + "policies": { + "docker": "use" + }, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/controller/controller_replace_error_import_then_yes_no_no_reserved_state.json b/tests/fixtures/conformance/workflow/workflow_replace_error_import_then_yes_no_no_continuation_state.json similarity index 93% rename from tests/fixtures/conformance/controller/controller_replace_error_import_then_yes_no_no_reserved_state.json rename to tests/fixtures/conformance/workflow/workflow_replace_error_import_then_yes_no_no_continuation_state.json index b1f5006..7e0fa6c 100644 --- a/tests/fixtures/conformance/controller/controller_replace_error_import_then_yes_no_no_reserved_state.json +++ b/tests/fixtures/conformance/workflow/workflow_replace_error_import_then_yes_no_no_continuation_state.json @@ -1,6 +1,6 @@ { - "id": "controller_replace_error_import_then_yes_no_no_reserved_state", - "kind": "controller", + "id": "workflow_replace_error_import_then_yes_no_no_continuation_state", + "kind": "workflow", "initial_state": { "premise": null, "policies": {}, diff --git a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_change_premise.json b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_change_premise.json similarity index 93% rename from tests/fixtures/conformance/controller/controller_step_apply_equivalence_change_premise.json rename to tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_change_premise.json index 882f55d..b68282d 100644 --- a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_change_premise.json +++ b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_change_premise.json @@ -1,6 +1,6 @@ { - "id": "controller_step_apply_equivalence_change_premise", - "kind": "controller", + "id": "workflow_step_apply_equivalence_change_premise", + "kind": "workflow", "initial_state": { "premise": "verbose replies", "policies": {}, diff --git a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_clear_premise.json b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_clear_premise.json similarity index 93% rename from tests/fixtures/conformance/controller/controller_step_apply_equivalence_clear_premise.json rename to tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_clear_premise.json index 5194aee..90962e6 100644 --- a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_clear_premise.json +++ b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_clear_premise.json @@ -1,6 +1,6 @@ { - "id": "controller_step_apply_equivalence_clear_premise", - "kind": "controller", + "id": "workflow_step_apply_equivalence_clear_premise", + "kind": "workflow", "initial_state": { "premise": "baseline", "policies": { diff --git a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_clear_state.json b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_clear_state.json similarity index 93% rename from tests/fixtures/conformance/controller/controller_step_apply_equivalence_clear_state.json rename to tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_clear_state.json index b8dc562..efbb680 100644 --- a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_clear_state.json +++ b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_clear_state.json @@ -1,6 +1,6 @@ { - "id": "controller_step_apply_equivalence_clear_state", - "kind": "controller", + "id": "workflow_step_apply_equivalence_clear_state", + "kind": "workflow", "initial_state": { "premise": "baseline", "policies": { diff --git a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_prohibit_item.json b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_prohibit_item.json similarity index 93% rename from tests/fixtures/conformance/controller/controller_step_apply_equivalence_prohibit_item.json rename to tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_prohibit_item.json index cee2ca0..cef00f9 100644 --- a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_prohibit_item.json +++ b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_prohibit_item.json @@ -1,6 +1,6 @@ { - "id": "controller_step_apply_equivalence_prohibit_item", - "kind": "controller", + "id": "workflow_step_apply_equivalence_prohibit_item", + "kind": "workflow", "initial_state": { "premise": null, "policies": {}, diff --git a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_remove_policy.json b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_remove_policy.json similarity index 92% rename from tests/fixtures/conformance/controller/controller_step_apply_equivalence_remove_policy.json rename to tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_remove_policy.json index d54df2d..7dbaf4d 100644 --- a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_remove_policy.json +++ b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_remove_policy.json @@ -1,6 +1,6 @@ { - "id": "controller_step_apply_equivalence_remove_policy", - "kind": "controller", + "id": "workflow_step_apply_equivalence_remove_policy", + "kind": "workflow", "initial_state": { "premise": null, "policies": { diff --git a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_replace_use.json b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_replace_use.json similarity index 93% rename from tests/fixtures/conformance/controller/controller_step_apply_equivalence_replace_use.json rename to tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_replace_use.json index 655e8ef..a08fb34 100644 --- a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_replace_use.json +++ b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_replace_use.json @@ -1,6 +1,6 @@ { - "id": "controller_step_apply_equivalence_replace_use", - "kind": "controller", + "id": "workflow_step_apply_equivalence_replace_use", + "kind": "workflow", "initial_state": { "premise": null, "policies": { diff --git a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_reset_policies.json b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_reset_policies.json similarity index 92% rename from tests/fixtures/conformance/controller/controller_step_apply_equivalence_reset_policies.json rename to tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_reset_policies.json index ba98831..c6f3339 100644 --- a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_reset_policies.json +++ b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_reset_policies.json @@ -1,6 +1,6 @@ { - "id": "controller_step_apply_equivalence_reset_policies", - "kind": "controller", + "id": "workflow_step_apply_equivalence_reset_policies", + "kind": "workflow", "initial_state": { "premise": "baseline", "policies": { diff --git a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_set_premise.json b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_set_premise.json similarity index 93% rename from tests/fixtures/conformance/controller/controller_step_apply_equivalence_set_premise.json rename to tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_set_premise.json index 0678ea8..3348e60 100644 --- a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_set_premise.json +++ b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_set_premise.json @@ -1,6 +1,6 @@ { - "id": "controller_step_apply_equivalence_set_premise", - "kind": "controller", + "id": "workflow_step_apply_equivalence_set_premise", + "kind": "workflow", "initial_state": { "premise": null, "policies": {}, diff --git a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_use_item.json b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_use_item.json similarity index 93% rename from tests/fixtures/conformance/controller/controller_step_apply_equivalence_use_item.json rename to tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_use_item.json index eab3782..17bd189 100644 --- a/tests/fixtures/conformance/controller/controller_step_apply_equivalence_use_item.json +++ b/tests/fixtures/conformance/workflow/workflow_step_apply_equivalence_use_item.json @@ -1,6 +1,6 @@ { - "id": "controller_step_apply_equivalence_use_item", - "kind": "controller", + "id": "workflow_step_apply_equivalence_use_item", + "kind": "workflow", "initial_state": { "premise": null, "policies": {}, diff --git a/tests/grammar-api-contract.test.ts b/tests/grammar-api-contract.test.ts index 18581b1..d3f0325 100644 --- a/tests/grammar-api-contract.test.ts +++ b/tests/grammar-api-contract.test.ts @@ -81,7 +81,7 @@ describe('public grammar API parity contract (conformance fixture)', () => { .construction_probes as Array>; for (const probe of probes) { const construct = () => constructGrammar('CanonicalDirective', probe); - if (probe.raises != null) { + if (probe.raises != null || probe.rejects === true) { expect(construct).toThrowError(); continue; } @@ -104,7 +104,7 @@ describe('public grammar API parity contract (conformance fixture)', () => { .construction_probes as Array>; for (const probe of probes) { const construct = () => constructGrammar('DirectiveMetadata', probe); - if (probe.raises != null) { + if (probe.raises != null || probe.rejects === true) { expect(construct).toThrowError(TypeError); continue; } @@ -122,7 +122,7 @@ describe('public grammar API parity contract (conformance fixture)', () => { it('matches declared public grammar object fields', () => { for (const [name, member] of Object.entries(contract.exports.members)) { if (member.public_fields === undefined) continue; - const probe = (member.construction_probes ?? []).find((candidate) => candidate.raises == null); + const probe = (member.construction_probes ?? []).find((candidate) => candidate.raises == null && candidate.rejects !== true); expect(probe, `${name} requires a successful construction probe`).toBeDefined(); if (probe === undefined) continue; const actual = constructGrammar(name, probe) as object; @@ -182,7 +182,7 @@ describe('public grammar API parity contract (conformance fixture)', () => { ? [] : [kwargs.failure, kwargs.directive_kind, kwargs.missing_operand, ...(Object.keys(kwargs).includes('unexpected') ? [true] : [])]; const construct = () => new grammar.InvalidDirectiveSyntax(...args as [string?, grammar.DirectiveKindValue?, string?]); - if (probe.raises != null) { + if (probe.raises != null || probe.rejects === true) { expect(construct).toThrowError(TypeError); continue; } diff --git a/tests/grammar-fixtures.test.ts b/tests/grammar-fixtures.test.ts index 4b4ed4b..d4c367d 100644 --- a/tests/grammar-fixtures.test.ts +++ b/tests/grammar-fixtures.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; import * as grammar from '../src/grammar.js'; -import { render_directive } from '../src/grammar-render.js'; import { loadGrammarFixtures } from './harness/fixtures.js'; const fixtures = await loadGrammarFixtures(); @@ -9,7 +8,26 @@ describe('grammar fixtures (conformance)', () => { for (const fixture of fixtures) { it(fixture.name, () => { const publicGrammar = grammar as unknown as Record; - const fn = fixture.payload.action.fn === 'render_directive' ? render_directive : publicGrammar[fixture.payload.action.fn]; + const fn = publicGrammar[fixture.payload.action.fn]; + if (fixture.payload.action.fn === 'construct_canonical_directive') { + let result: unknown; + try { + result = new grammar.CanonicalDirective( + fixture.payload.action.kind as string, + fixture.payload.action.operands as Record + ); + } catch (error) { + if (fixture.payload.expected.error == null) throw error; + expect(String(error)).toContain(fixture.payload.expected.error.message_contains); + return; + } + expect(fixture.payload.expected.error, `${fixture.name}: expected an error`).toBeUndefined(); + expect(result).toMatchObject({ + kind: fixture.payload.expected.directive_kind, + text: fixture.payload.expected.text + }); + return; + } expect(typeof fn, `${fixture.name}: missing grammar export '${fixture.payload.action.fn}'`).toBe('function'); if (fixture.payload.action.fn === 'decompose_directive') { diff --git a/tests/harness/fixtures.ts b/tests/harness/fixtures.ts index e4b8899..abf6bac 100644 --- a/tests/harness/fixtures.ts +++ b/tests/harness/fixtures.ts @@ -28,7 +28,7 @@ export interface StateJsonFixtureCase { payload?: string; state: Record; error?: { - type: string; + type?: string; message_contains: string; }; }; @@ -53,18 +53,38 @@ export interface ControllerFixtureCase { }; } +export interface WorkflowFixtureCase { + id: string; + kind: 'workflow'; + initial_state: Record; + operations: Array<{ + fn: 'step' | 'apply_directive' | 'apply_repair' | 'export_json' | 'import_json'; + input?: string; + text?: string; + payload?: string; + payload_ref?: string; + decision_ref?: string; + repair_index?: number; + label?: string; + }>; + expected: { observations: Record; equal: string[][]; state: Record }; +} + export interface GrammarFixtureCase { id: string; kind: 'grammar'; action: { - fn: 'decompose_directive' | 'render_directive'; + fn: 'decompose_directive' | 'construct_canonical_directive'; text?: string; kind?: string; operands?: Record; }; expected: { directive?: Record | null; - error?: { type: string; message_contains: string }; + directive_kind?: string; + text?: string; + operands?: Record; + error?: { type?: string; message_contains: string }; }; } @@ -72,7 +92,7 @@ export interface ApplyDirectiveFixtureCase { id: string; kind: 'apply_directive'; initial_state: Record; - action: { fn: 'apply_directive'; text: string }; + action: { fn: 'apply_directive'; text?: string; directive?: { kind: string; operands: Record } }; expected: { decision: Record; state: Record; @@ -85,7 +105,7 @@ export interface MutationIsolationFixtureCase { initial_state: Record; prelude?: string[]; operation: { - fn: 'engine.step' | 'engine.policies' | 'engine.premise' | 'canonical_directive.operands' | 'directive_metadata'; + fn: 'engine.step' | 'engine.policies' | 'engine.premise' | 'canonical_directive.operands' | 'canonical_directive.constructor_operands' | 'directive_metadata'; input?: string; kind?: string; operands?: Record; @@ -185,4 +205,8 @@ export async function loadMutationIsolationFixtures(): Promise('mutation-isolation'); } +export async function loadWorkflowFixtures(): Promise[]> { + return loadFixtureFiles('workflow'); +} + export { FIXTURE_ROOT }; diff --git a/tests/mutation-isolation-fixtures.test.ts b/tests/mutation-isolation-fixtures.test.ts index 073e4de..9f8edbf 100644 --- a/tests/mutation-isolation-fixtures.test.ts +++ b/tests/mutation-isolation-fixtures.test.ts @@ -30,6 +30,7 @@ describe('mutation-isolation fixtures (conformance)', () => { engine.import_json(JSON.stringify(fixture.payload.initial_state)); const operation = fixture.payload.operation; let result: unknown; + const handleValues: Record = {}; for (const priorInput of fixture.payload.prelude ?? []) { engine.step(priorInput); @@ -48,6 +49,10 @@ describe('mutation-isolation fixtures (conformance)', () => { } else if (operation.fn === 'canonical_directive.operands') { const directive = new CanonicalDirective(operation.kind as string, operation.operands as Record); result = directive.operands; + } else if (operation.fn === 'canonical_directive.constructor_operands') { + const sourceOperands = { ...(operation.operands as Record) }; + handleValues.source_operands = sourceOperands; + result = new CanonicalDirective(operation.kind as string, sourceOperands); } else if (operation.fn === 'directive_metadata') { result = new DirectiveMetadata( operation.kind as 'use_item', @@ -65,9 +70,12 @@ describe('mutation-isolation fixtures (conformance)', () => { operation.fn === 'canonical_directive.operands' || operation.fn === 'directive_metadata'; for (const mutation of fixture.payload.mutations) { - expect(mutation.target_handle).toBe(operation.result_handle); expect(mutation.op).toBe('set'); - const mutate = () => setPath(result, mutation.path, mutation.value); + const target = mutation.target_handle === operation.result_handle + ? result + : handleValues[mutation.target_handle]; + expect(target, `${fixture.name}: missing mutation target '${mutation.target_handle}'`).toBeDefined(); + const mutate = () => setPath(target, mutation.path, mutation.value); if (immutableResult) { expect(mutate, `${fixture.name}: mutation should be rejected`).toThrow(); } else { diff --git a/tests/normalization_parity.test.ts b/tests/normalization_parity.test.ts index 634f2c5..8de7659 100644 --- a/tests/normalization_parity.test.ts +++ b/tests/normalization_parity.test.ts @@ -10,6 +10,15 @@ describe('normalization parity', () => { expect(JSON.parse(engine.export_json()).policies).toEqual({ 'the docker cli': 'use' }); }); + it('uses full Unicode case folding for policy identity', () => { + const engine = new Engine(); + engine.step('use ǰ'); + const decision = engine.step('use J̌'); + + expect(decision).toMatchObject({ kind: 'update', changed: false }); + expect(JSON.parse(engine.export_json()).policies).toEqual({ 'ǰ': 'use' }); + }); + it('normalizes apostrophes without rewriting distinct operands', () => { const engine = new Engine(); engine.step('use Don’t panic'); diff --git a/tests/controller-fixtures.test.ts b/tests/workflow-fixtures.test.ts similarity index 68% rename from tests/controller-fixtures.test.ts rename to tests/workflow-fixtures.test.ts index e07c17c..f778b80 100644 --- a/tests/controller-fixtures.test.ts +++ b/tests/workflow-fixtures.test.ts @@ -1,15 +1,13 @@ import { describe, expect, it } from 'vitest'; import { Engine } from '../src/engine.js'; import { CanonicalDirective, decompose_directive } from '../src/grammar.js'; -import { loadControllerFixtures } from './harness/fixtures.js'; +import { loadWorkflowFixtures } from './harness/fixtures.js'; -const fixtures = await loadControllerFixtures(); +const fixtures = await loadWorkflowFixtures(); -describe('controller fixtures (conformance)', () => { +describe('workflow fixtures (conformance)', () => { for (const fixture of fixtures) { it(fixture.name, () => { - expect(fixture.payload.kind).toBe('controller'); - const engine = new Engine(); engine.import_json(JSON.stringify(fixture.payload.initial_state)); const observations: Record = {}; @@ -22,13 +20,16 @@ describe('controller fixtures (conformance)', () => { result = engine.step(operation.input as string); break; case 'apply_directive': { - const applyDirective = (engine as unknown as Record).apply_directive; - expect(typeof applyDirective, `${fixture.name}: Engine.apply_directive is required by this fixture`).toBe( - 'function' - ); const directive = decompose_directive(operation.text as string); expect(directive, `${fixture.name}: apply_directive input must be canonical`).toBeInstanceOf(CanonicalDirective); - result = (applyDirective as (value: CanonicalDirective) => unknown).call(engine, directive); + result = engine.apply_directive(directive as CanonicalDirective); + break; + } + case 'apply_repair': { + const decision = observations[operation.decision_ref as string] as { repairs: CanonicalDirective[] }; + const repair = decision?.repairs?.[operation.repair_index as number]; + expect(repair, `${fixture.name}: repair reference is missing`).toBeInstanceOf(CanonicalDirective); + result = engine.apply_directive(repair); break; } case 'export_json': @@ -41,13 +42,13 @@ describe('controller fixtures (conformance)', () => { result = undefined; break; } + default: + throw new Error(`${fixture.name}: unsupported workflow operation '${operation.fn}'`); } if (operation.label != null) { observations[operation.label] = result; - if (operation.fn === 'export_json') { - payloads[operation.label] = result as string; - } + if (operation.fn === 'export_json') payloads[operation.label] = result as string; } }