From c33d35bcca67982456ef0b61e78d7970f443afdb Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 21 Aug 2026 00:33:19 -0400 Subject: [PATCH 1/4] fix: align Unicode normalization with Python 0.9 --- src/decision.ts | 2 ++ src/engine.ts | 11 +++++++++-- src/grammar.ts | 11 +++++++++-- tests/fixtures/.source-commit | 2 +- ...ecompose_compatibility_keyword_rejected.json | 11 +++++++++++ ...ar_decompose_composed_operand_preserved.json | 17 +++++++++++++++++ ...ose_decomposed_keyword_variant_rejected.json | 11 +++++++++++ ..._decompose_decomposed_operand_preserved.json | 17 +++++++++++++++++ ...ar_decompose_fullwidth_keyword_rejected.json | 11 +++++++++++ 9 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/conformance/grammar/grammar_decompose_compatibility_keyword_rejected.json create mode 100644 tests/fixtures/conformance/grammar/grammar_decompose_composed_operand_preserved.json create mode 100644 tests/fixtures/conformance/grammar/grammar_decompose_decomposed_keyword_variant_rejected.json create mode 100644 tests/fixtures/conformance/grammar/grammar_decompose_decomposed_operand_preserved.json create mode 100644 tests/fixtures/conformance/grammar/grammar_decompose_fullwidth_keyword_rejected.json diff --git a/src/decision.ts b/src/decision.ts index 5c12535..38590cd 100644 --- a/src/decision.ts +++ b/src/decision.ts @@ -92,6 +92,8 @@ function normalizeItemForMessage(value: string): string { .replaceAll('`', "'") .toLowerCase() .replaceAll('ß', 'ss') + .replaceAll('ς', 'σ') + .replaceAll('ſ', 's') .replace(/\s+/g, ' ') .trim(); } diff --git a/src/engine.ts b/src/engine.ts index 73b5a28..f850b57 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -272,12 +272,19 @@ function sanitizePremiseValue(value: string): string { function normalizeItem(value: string): string { let normalized = value.normalize('NFKC'); normalized = normalized.replaceAll('’', "'").replaceAll('`', "'"); - normalized = normalized.toLowerCase(); - normalized = normalized.replaceAll('ß', 'ss'); + normalized = unicodeCaseFold(normalized); normalized = normalized.replace(/\s+/g, ' ').trim(); 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 6b14abb..8e1828d 100644 --- a/src/grammar.ts +++ b/src/grammar.ts @@ -144,7 +144,14 @@ function collapseHorizontalWhitespace(text: string): string { } function normalizedForMatching(text: string): string { - return collapseHorizontalWhitespace(trimAsciiWhitespace(text)).toLowerCase(); + return asciiLowercase(collapseHorizontalWhitespace(trimAsciiWhitespace(text))); +} + +function asciiLowercase(text: string): string { + return [...text].map((character) => { + const code = character.charCodeAt(0); + return code >= 0x41 && code <= 0x5a ? String.fromCharCode(code + 0x20) : character; + }).join(''); } function operandHasContent(value: string): boolean { @@ -166,7 +173,7 @@ function matchDirectiveToken(text: string, start: number, token: string, require if (!HORIZONTAL_WHITESPACE.includes(text[index])) return null; while (index < text.length && HORIZONTAL_WHITESPACE.includes(text[index])) index += 1; } else { - if (text[index].toLowerCase() !== tokenChar) return null; + if (asciiLowercase(text[index]) !== tokenChar) return null; index += 1; } tokenIndex += 1; diff --git a/tests/fixtures/.source-commit b/tests/fixtures/.source-commit index 0b0bb4e..18708bb 100644 --- a/tests/fixtures/.source-commit +++ b/tests/fixtures/.source-commit @@ -1 +1 @@ -9fde330f701e3bdecf03f32b6e3874a6fe0c05f3 +9fb85535d31cf442385be6e77fef8e2c32e57c6c diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_compatibility_keyword_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_compatibility_keyword_rejected.json new file mode 100644 index 0000000..bf11ed9 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_compatibility_keyword_rejected.json @@ -0,0 +1,11 @@ +{ + "id": "grammar_decompose_compatibility_keyword_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "ⓤⓢⓔ docker" + }, + "expected": { + "directive": null + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_composed_operand_preserved.json b/tests/fixtures/conformance/grammar/grammar_decompose_composed_operand_preserved.json new file mode 100644 index 0000000..cdb517c --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_composed_operand_preserved.json @@ -0,0 +1,17 @@ +{ + "id": "grammar_decompose_composed_operand_preserved", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "use café" + }, + "expected": { + "directive": { + "text": "use café", + "kind": "use_item", + "operands": { + "item": "café" + } + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_decomposed_keyword_variant_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_decomposed_keyword_variant_rejected.json new file mode 100644 index 0000000..cc92d54 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_decomposed_keyword_variant_rejected.json @@ -0,0 +1,11 @@ +{ + "id": "grammar_decompose_decomposed_keyword_variant_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "üse docker" + }, + "expected": { + "directive": null + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_decomposed_operand_preserved.json b/tests/fixtures/conformance/grammar/grammar_decompose_decomposed_operand_preserved.json new file mode 100644 index 0000000..15b7439 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_decomposed_operand_preserved.json @@ -0,0 +1,17 @@ +{ + "id": "grammar_decompose_decomposed_operand_preserved", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "use café" + }, + "expected": { + "directive": { + "text": "use café", + "kind": "use_item", + "operands": { + "item": "café" + } + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_fullwidth_keyword_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_fullwidth_keyword_rejected.json new file mode 100644 index 0000000..79e6363 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_fullwidth_keyword_rejected.json @@ -0,0 +1,11 @@ +{ + "id": "grammar_decompose_fullwidth_keyword_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "use docker" + }, + "expected": { + "directive": null + } +} From 1b62f9716b3007e3d9bd42609dec54f2703f5168 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Fri, 21 Aug 2026 01:35:01 -0400 Subject: [PATCH 2/4] test: sync Python Unicode conformance fixtures --- tests/fixtures/.source-commit | 2 +- ..._decompose_casefold_operand_preserved.json | 17 ++++++++++++ ...unicode_casefold_set_keyword_rejected.json | 11 ++++++++ ...olicy_identity_accent_distinct_update.json | 27 +++++++++++++++++++ ...sed_decomposed_equivalent_noop_update.json | 26 ++++++++++++++++++ ...tity_final_sigma_casefold_noop_update.json | 26 ++++++++++++++++++ ..._identity_long_s_casefold_noop_update.json | 26 ++++++++++++++++++ ...identity_sharp_s_casefold_noop_update.json | 26 ++++++++++++++++++ 8 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/conformance/grammar/grammar_decompose_casefold_operand_preserved.json create mode 100644 tests/fixtures/conformance/grammar/grammar_decompose_unicode_casefold_set_keyword_rejected.json create mode 100644 tests/fixtures/conformance/step/step_policy_identity_accent_distinct_update.json create mode 100644 tests/fixtures/conformance/step/step_policy_identity_composed_decomposed_equivalent_noop_update.json create mode 100644 tests/fixtures/conformance/step/step_policy_identity_final_sigma_casefold_noop_update.json create mode 100644 tests/fixtures/conformance/step/step_policy_identity_long_s_casefold_noop_update.json create mode 100644 tests/fixtures/conformance/step/step_policy_identity_sharp_s_casefold_noop_update.json diff --git a/tests/fixtures/.source-commit b/tests/fixtures/.source-commit index 18708bb..fea6bfd 100644 --- a/tests/fixtures/.source-commit +++ b/tests/fixtures/.source-commit @@ -1 +1 @@ -9fb85535d31cf442385be6e77fef8e2c32e57c6c +3048bba0a2b9d869263351232dddc3f73f15ee78 diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_casefold_operand_preserved.json b/tests/fixtures/conformance/grammar/grammar_decompose_casefold_operand_preserved.json new file mode 100644 index 0000000..a6e2161 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_casefold_operand_preserved.json @@ -0,0 +1,17 @@ +{ + "id": "grammar_decompose_casefold_operand_preserved", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "use ſtraße" + }, + "expected": { + "directive": { + "text": "use ſtraße", + "kind": "use_item", + "operands": { + "item": "ſtraße" + } + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_unicode_casefold_set_keyword_rejected.json b/tests/fixtures/conformance/grammar/grammar_decompose_unicode_casefold_set_keyword_rejected.json new file mode 100644 index 0000000..70083cf --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_unicode_casefold_set_keyword_rejected.json @@ -0,0 +1,11 @@ +{ + "id": "grammar_decompose_unicode_casefold_set_keyword_rejected", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "ſet premise concise" + }, + "expected": { + "directive": null + } +} diff --git a/tests/fixtures/conformance/step/step_policy_identity_accent_distinct_update.json b/tests/fixtures/conformance/step/step_policy_identity_accent_distinct_update.json new file mode 100644 index 0000000..e3f480d --- /dev/null +++ b/tests/fixtures/conformance/step/step_policy_identity_accent_distinct_update.json @@ -0,0 +1,27 @@ +{ + "id": "step_policy_identity_accent_distinct_update", + "kind": "step", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "prelude": [ + "use cafe" + ], + "input": "use café", + "expected": { + "decision": { + "kind": "update", + "changed": true + }, + "state": { + "premise": null, + "policies": { + "cafe": "use", + "café": "use" + }, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/step/step_policy_identity_composed_decomposed_equivalent_noop_update.json b/tests/fixtures/conformance/step/step_policy_identity_composed_decomposed_equivalent_noop_update.json new file mode 100644 index 0000000..622ebd9 --- /dev/null +++ b/tests/fixtures/conformance/step/step_policy_identity_composed_decomposed_equivalent_noop_update.json @@ -0,0 +1,26 @@ +{ + "id": "step_policy_identity_composed_decomposed_equivalent_noop_update", + "kind": "step", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "prelude": [ + "use café" + ], + "input": "use café", + "expected": { + "decision": { + "kind": "update", + "changed": false + }, + "state": { + "premise": null, + "policies": { + "café": "use" + }, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/step/step_policy_identity_final_sigma_casefold_noop_update.json b/tests/fixtures/conformance/step/step_policy_identity_final_sigma_casefold_noop_update.json new file mode 100644 index 0000000..757a65b --- /dev/null +++ b/tests/fixtures/conformance/step/step_policy_identity_final_sigma_casefold_noop_update.json @@ -0,0 +1,26 @@ +{ + "id": "step_policy_identity_final_sigma_casefold_noop_update", + "kind": "step", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "prelude": [ + "use ς" + ], + "input": "use σ", + "expected": { + "decision": { + "kind": "update", + "changed": false + }, + "state": { + "premise": null, + "policies": { + "σ": "use" + }, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/step/step_policy_identity_long_s_casefold_noop_update.json b/tests/fixtures/conformance/step/step_policy_identity_long_s_casefold_noop_update.json new file mode 100644 index 0000000..b8cb3a9 --- /dev/null +++ b/tests/fixtures/conformance/step/step_policy_identity_long_s_casefold_noop_update.json @@ -0,0 +1,26 @@ +{ + "id": "step_policy_identity_long_s_casefold_noop_update", + "kind": "step", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "prelude": [ + "use ſ" + ], + "input": "use s", + "expected": { + "decision": { + "kind": "update", + "changed": false + }, + "state": { + "premise": null, + "policies": { + "s": "use" + }, + "version": 2 + } + } +} diff --git a/tests/fixtures/conformance/step/step_policy_identity_sharp_s_casefold_noop_update.json b/tests/fixtures/conformance/step/step_policy_identity_sharp_s_casefold_noop_update.json new file mode 100644 index 0000000..d1c1954 --- /dev/null +++ b/tests/fixtures/conformance/step/step_policy_identity_sharp_s_casefold_noop_update.json @@ -0,0 +1,26 @@ +{ + "id": "step_policy_identity_sharp_s_casefold_noop_update", + "kind": "step", + "initial_state": { + "premise": null, + "policies": {}, + "version": 2 + }, + "prelude": [ + "use ß" + ], + "input": "use ss", + "expected": { + "decision": { + "kind": "update", + "changed": false + }, + "state": { + "premise": null, + "policies": { + "ss": "use" + }, + "version": 2 + } + } +} From bf561a30291f8ad58c96961fb9b00177e39721d2 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 24 Aug 2026 02:27:24 -0400 Subject: [PATCH 3/4] fix: align dev14 grammar contract parity --- src/decision.ts | 3 + src/grammar.ts | 12 ++- tests/api_parity.test.ts | 24 ++++++ tests/fixtures/.source-commit | 2 +- .../conformance/api/public-api-v2.json | 20 ++++- .../conformance/api/public-grammar-v1.json | 25 +++++- ..._decompose_opaque_premise_conjunction.json | 17 ++++ ...compose_opaque_premise_multi_sentence.json | 17 ++++ ...mar_decompose_opaque_premise_prohibit.json | 17 ++++ .../grammar_decompose_opaque_premise_use.json | 17 ++++ ..._set_premise_and_use_invalid_boundary.json | 5 +- tests/grammar-api-contract.test.ts | 78 +++++++++++++++---- tests/grammar-fixtures.test.ts | 12 ++- 13 files changed, 221 insertions(+), 28 deletions(-) create mode 100644 tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_conjunction.json create mode 100644 tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_multi_sentence.json create mode 100644 tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_prohibit.json create mode 100644 tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_use.json diff --git a/src/decision.ts b/src/decision.ts index 38590cd..8616612 100644 --- a/src/decision.ts +++ b/src/decision.ts @@ -20,6 +20,9 @@ export class SemanticFailure { static readonly REPLACEMENT_SOURCE_MISSING = 'replacement_source_missing'; } +Object.freeze(DecisionKind); +Object.freeze(SemanticFailure); + export class NoDirectiveDecision { readonly kind = DECISION_NO_DIRECTIVE; diff --git a/src/grammar.ts b/src/grammar.ts index 8e1828d..8ab6ed4 100644 --- a/src/grammar.ts +++ b/src/grammar.ts @@ -16,6 +16,9 @@ export class DirectiveSyntaxFailure { static readonly MALFORMED_DIRECTIVE = 'malformed_directive'; } +Object.freeze(DirectiveKind); +Object.freeze(DirectiveSyntaxFailure); + type DirectiveKindValue = | 'set_premise' | 'change_premise' @@ -78,7 +81,7 @@ export class CanonicalDirective { const kind = normalizeDirectiveKind(kindInput); const operands = normalizeCanonicalOperands(kind, operandsInput); const rendered = serializeCanonicalDirective(kind, operands); - if (containsMultipleCanonicalDirectives(rendered)) { + if (kind !== DirectiveKind.SET_PREMISE && containsMultipleCanonicalDirectives(rendered)) { throw new Error(`Operands do not produce a canonical ${kind} directive.`); } this.kind = kind; @@ -89,7 +92,6 @@ export class CanonicalDirective { } export class InvalidDirectiveSyntax { - readonly kind = 'invalid_directive_syntax'; readonly failure: string; readonly directive_kind: DirectiveKindValue | null; readonly missing_operand: string | null; @@ -241,7 +243,11 @@ 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; - if (containsMultipleCanonicalDirectives(trimmed)) return invalid(DirectiveSyntaxFailure.COMPOUND_DIRECTIVE); + // 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)) { + return invalid(DirectiveSyntaxFailure.COMPOUND_DIRECTIVE); + } const normalized = normalizedForMatching(trimmed); if (normalized === 'clear premise') return new CanonicalDirective('clear_premise', {}); diff --git a/tests/api_parity.test.ts b/tests/api_parity.test.ts index 054fd7b..dfa678f 100644 --- a/tests/api_parity.test.ts +++ b/tests/api_parity.test.ts @@ -42,6 +42,8 @@ type ExportMemberSpec = { signature?: SignatureSpec; shape_probes?: ShapeProbe[]; construction_probes?: Array>; + immutable_definition?: boolean; + member_values?: Record; }; type EngineMemberSpec = { @@ -163,6 +165,25 @@ function expectPortableCallableArity(fn: (...args: unknown[]) => unknown, signat ).toBeLessThanOrEqual(totalCount); } +function expectImmutableDefinition(value: unknown, memberValues: Record, label: string): void { + expect(value, `${label}: immutable definition should exist`).toBeTruthy(); + if (value == null || (typeof value !== 'object' && typeof value !== 'function')) return; + + for (const [memberName, expected] of Object.entries(memberValues)) { + const definition = value as Record; + expect(definition[memberName], `${label}.${memberName} has the wrong value`).toBe(expected); + let rejected = false; + try { + definition[memberName] = '__contract_mutation__'; + } catch { + rejected = true; + } + if (!rejected) definition[memberName] = expected; + expect(rejected, `${label}.${memberName} should reject mutation`).toBe(true); + expect(definition[memberName], `${label}.${memberName} was mutated`).toBe(expected); + } +} + function materializeProbeValue(value: unknown): unknown { if (typeof value === 'object' && value !== null && !Array.isArray(value)) { const maybeFixture = value as { fixture?: unknown }; @@ -282,6 +303,9 @@ describe('public API parity contract (conformance fixture)', () => { } else if (member.kind === 'class') { expect(typeof value, `Export '${exportName}' should be a class constructor`).toBe('function'); expect('prototype' in (value as object), `Export '${exportName}' should expose a prototype`).toBe(true); + if (member.immutable_definition) { + expectImmutableDefinition(value, member.member_values ?? {}, `Export '${exportName}'`); + } } } }); diff --git a/tests/fixtures/.source-commit b/tests/fixtures/.source-commit index fea6bfd..4d86ee8 100644 --- a/tests/fixtures/.source-commit +++ b/tests/fixtures/.source-commit @@ -1 +1 @@ -3048bba0a2b9d869263351232dddc3f73f15ee78 +e1e04bd6464aa46f5e3693d634faaa264d890f66 diff --git a/tests/fixtures/conformance/api/public-api-v2.json b/tests/fixtures/conformance/api/public-api-v2.json index fb04c35..1c1a2f7 100644 --- a/tests/fixtures/conformance/api/public-api-v2.json +++ b/tests/fixtures/conformance/api/public-api-v2.json @@ -72,7 +72,13 @@ "kind": "type_alias" }, "DecisionKind": { - "kind": "class" + "kind": "class", + "immutable_definition": true, + "member_values": { + "NO_DIRECTIVE": "no_directive", + "UPDATE": "update", + "ERROR": "error" + } }, "NoDirectiveDecision": { "kind": "class", @@ -150,7 +156,17 @@ ] }, "SemanticFailure": { - "kind": "class" + "kind": "class", + "immutable_definition": true, + "member_values": { + "PREMISE_ALREADY_SET": "premise_already_set", + "PREMISE_NOT_SET": "premise_not_set", + "ITEM_PROHIBITED": "item_prohibited", + "ITEM_ALREADY_IN_USE": "item_already_in_use", + "REPLACEMENT_SOURCE_PROHIBITED": "replacement_source_prohibited", + "REPLACEMENT_TARGET_PROHIBITED": "replacement_target_prohibited", + "REPLACEMENT_SOURCE_MISSING": "replacement_source_missing" + } }, "UpdateDecision": { "kind": "class", diff --git a/tests/fixtures/conformance/api/public-grammar-v1.json b/tests/fixtures/conformance/api/public-grammar-v1.json index d090587..0385f77 100644 --- a/tests/fixtures/conformance/api/public-grammar-v1.json +++ b/tests/fixtures/conformance/api/public-grammar-v1.json @@ -14,13 +14,32 @@ ], "members": { "DirectiveKind": { - "kind": "class" + "kind": "class", + "immutable_definition": true, + "member_values": { + "SET_PREMISE": "set_premise", + "CHANGE_PREMISE": "change_premise", + "USE_ITEM": "use_item", + "PROHIBIT_ITEM": "prohibit_item", + "REMOVE_POLICY": "remove_policy", + "REPLACE_USE": "replace_use", + "CLEAR_PREMISE": "clear_premise", + "RESET_POLICIES": "reset_policies", + "CLEAR_STATE": "clear_state" + } }, "DirectiveSyntaxFailure": { - "kind": "class" + "kind": "class", + "immutable_definition": true, + "member_values": { + "COMPOUND_DIRECTIVE": "compound_directive", + "MISSING_REQUIRED_OPERAND": "missing_required_operand", + "MALFORMED_DIRECTIVE": "malformed_directive" + } }, "DirectiveMetadata": { "kind": "class", + "public_fields": ["kind", "canonical_start", "operand_names"], "signature": { "params": [ {"name": "kind", "kind": "POSITIONAL_OR_KEYWORD", "has_default": false}, @@ -76,6 +95,7 @@ }, "CanonicalDirective": { "kind": "class", + "public_fields": ["kind", "operands", "text"], "signature": { "params": [ {"name": "kind", "kind": "POSITIONAL_OR_KEYWORD", "has_default": false}, @@ -248,6 +268,7 @@ }, "InvalidDirectiveSyntax": { "kind": "class", + "public_fields": ["failure", "directive_kind", "missing_operand"], "signature": { "params": [ {"name": "failure", "kind": "POSITIONAL_OR_KEYWORD", "has_default": true}, diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_conjunction.json b/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_conjunction.json new file mode 100644 index 0000000..c6da6f1 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_conjunction.json @@ -0,0 +1,17 @@ +{ + "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_multi_sentence.json b/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_multi_sentence.json new file mode 100644 index 0000000..e20135b --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_multi_sentence.json @@ -0,0 +1,17 @@ +{ + "id": "grammar_decompose_opaque_premise_multi_sentence", + "kind": "grammar", + "action": { + "fn": "decompose_directive", + "text": "set premise The system uses legacy tooling. Migration is planned." + }, + "expected": { + "directive": { + "text": "set premise The system uses legacy tooling. Migration is planned.", + "kind": "set_premise", + "operands": { + "value": "The system uses legacy tooling. Migration is planned." + } + } + } +} diff --git a/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_prohibit.json b/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_prohibit.json new file mode 100644 index 0000000..4a9337a --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_prohibit.json @@ -0,0 +1,17 @@ +{ + "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 new file mode 100644 index 0000000..27748c3 --- /dev/null +++ b/tests/fixtures/conformance/grammar/grammar_decompose_opaque_premise_use.json @@ -0,0 +1,17 @@ +{ + "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/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 89acb74..73e61ae 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,10 +9,11 @@ "input": "set premise vegetarian and use docker", "expected": { "decision": { - "kind": "no_directive" + "kind": "update", + "changed": true }, "state": { - "premise": null, + "premise": "vegetarian and use docker", "policies": {}, "version": 2 } diff --git a/tests/grammar-api-contract.test.ts b/tests/grammar-api-contract.test.ts index 67fc407..18581b1 100644 --- a/tests/grammar-api-contract.test.ts +++ b/tests/grammar-api-contract.test.ts @@ -6,13 +6,52 @@ import * as grammar from '../src/grammar.js'; type GrammarApiContract = { exports: { names: string[]; - members: Record; + members: Record; construction_probes?: Array> }>; }; }; const path = resolve(process.cwd(), 'tests', 'fixtures', 'conformance', 'api', 'public-grammar-v1.json'); const contract = JSON.parse(readFileSync(path, 'utf8')) as GrammarApiContract; +function constructorArgs(name: string, probe: Record): unknown[] { + if (Array.isArray(probe.args)) return probe.args; + const kwargs = probe.kwargs as Record | undefined; + if (kwargs === undefined) return []; + if (name === 'DirectiveMetadata') { + return [kwargs.kind, kwargs.canonical_start, kwargs.operand_names, ...(Object.keys(kwargs).includes('unexpected') ? [true] : [])]; + } + if (name === 'CanonicalDirective') { + return [kwargs.kind, kwargs.operands, ...(Object.keys(kwargs).includes('unexpected') ? [true] : [])]; + } + return [kwargs.failure, kwargs.directive_kind, kwargs.missing_operand, ...(Object.keys(kwargs).includes('unexpected') ? [true] : [])]; +} + +function constructGrammar(name: string, probe: Record): unknown { + const args = constructorArgs(name, probe); + if (name === 'DirectiveMetadata') return new grammar.DirectiveMetadata(...args as ['use_item', string, string[]]); + if (name === 'CanonicalDirective') return new grammar.CanonicalDirective(...args as [string, Record]); + return new grammar.InvalidDirectiveSyntax(...args as [string?, string?, string?]); +} + +function expectImmutableDefinition(value: unknown, memberValues: Record, label: string): void { + expect(value, `${label} is present`).toBeTruthy(); + if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return; + + const definition = value as Record; + for (const [memberName, expected] of Object.entries(memberValues)) { + expect(definition[memberName], `${label}.${memberName}`).toBe(expected); + let rejected = false; + try { + definition[memberName] = '__contract_mutation__'; + } catch { + rejected = true; + } + if (!rejected) definition[memberName] = expected; + expect(rejected, `${label}.${memberName} must reject mutation`).toBe(true); + expect(definition[memberName], `${label}.${memberName} after mutation`).toBe(expected); + } +} + describe('public grammar API parity contract (conformance fixture)', () => { it('exposes the canonical grammar exports', () => { const runtime = grammar as unknown as Record; @@ -41,13 +80,7 @@ describe('public grammar API parity contract (conformance fixture)', () => { const probes = (contract.exports.members.CanonicalDirective as Record) .construction_probes as Array>; for (const probe of probes) { - const kwargs = probe.kwargs as Record | undefined; - const args = Array.isArray(probe.args) - ? probe.args - : kwargs === undefined - ? [] - : [kwargs.kind, kwargs.operands, ...(Object.keys(kwargs).includes('unexpected') ? [true] : [])]; - const construct = () => new grammar.CanonicalDirective(...args as [string, Record]); + const construct = () => constructGrammar('CanonicalDirective', probe); if (probe.raises != null) { expect(construct).toThrowError(); continue; @@ -70,13 +103,7 @@ describe('public grammar API parity contract (conformance fixture)', () => { const probes = (contract.exports.members.DirectiveMetadata as Record) .construction_probes as Array>; for (const probe of probes) { - const kwargs = probe.kwargs as Record | undefined; - const args = Array.isArray(probe.args) - ? probe.args - : kwargs === undefined - ? [] - : [kwargs.kind, kwargs.canonical_start, kwargs.operand_names, ...(Object.keys(kwargs).includes('unexpected') ? [true] : [])]; - const construct = () => new grammar.DirectiveMetadata(...args as ['use_item', string, string[]]); + const construct = () => constructGrammar('DirectiveMetadata', probe); if (probe.raises != null) { expect(construct).toThrowError(TypeError); continue; @@ -92,6 +119,25 @@ 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); + expect(probe, `${name} requires a successful construction probe`).toBeDefined(); + if (probe === undefined) continue; + const actual = constructGrammar(name, probe) as object; + expect(Object.keys(actual).sort(), `${name} public fields`).toEqual([...member.public_fields].sort()); + } + }); + + it('enforces immutable grammar enum-like definitions', () => { + const runtime = grammar as unknown as Record; + for (const [name, member] of Object.entries(contract.exports.members)) { + if (!member.immutable_definition) continue; + expectImmutableDefinition(runtime[name], member.member_values ?? {}, `Grammar export '${name}'`); + } + }); + it('matches metadata and decomposition probes', () => { const metadataProbe = (contract.exports.members.get_directive_metadata as Record) .shape_probes[0].return_shape.items; @@ -110,7 +156,6 @@ describe('public grammar API parity contract (conformance fixture)', () => { expect(actual).toBeNull(); } else if (shape.kind === 'invalid_directive_syntax') { expect(actual).toEqual({ - kind: shape.kind, failure: shape.failure, directive_kind: shape.directive_kind ?? null, missing_operand: shape.missing_operand ?? null @@ -144,7 +189,6 @@ describe('public grammar API parity contract (conformance fixture)', () => { const actual = construct() as Record; const shape = probe.return_shape as Record; expect(actual).toMatchObject({ - kind: shape.kind, failure: shape.failure, directive_kind: shape.directive_kind, missing_operand: shape.missing_operand diff --git a/tests/grammar-fixtures.test.ts b/tests/grammar-fixtures.test.ts index 4c8aa1d..4b4ed4b 100644 --- a/tests/grammar-fixtures.test.ts +++ b/tests/grammar-fixtures.test.ts @@ -24,7 +24,17 @@ describe('grammar fixtures (conformance)', () => { return; } expect(fixture.payload.expected.error, `${fixture.name}: expected an error`).toBeUndefined(); - expect(result).toEqual(fixture.payload.expected.directive); + const expected = fixture.payload.expected.directive as Record; + if (result instanceof grammar.InvalidDirectiveSyntax) { + expect({ + kind: 'invalid_directive_syntax', + failure: result.failure, + directive_kind: result.directive_kind, + missing_operand: result.missing_operand + }).toEqual(expected); + } else { + expect(result).toEqual(expected); + } return; } From bfa443fcb377244ac20f8069b4140ba710938e12 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Mon, 24 Aug 2026 02:42:36 -0400 Subject: [PATCH 4/4] fix: expose immutable operand typing --- src/grammar.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/grammar.ts b/src/grammar.ts index 8ab6ed4..febeb12 100644 --- a/src/grammar.ts +++ b/src/grammar.ts @@ -30,7 +30,7 @@ type DirectiveKindValue = | 'reset_policies' | 'clear_state'; -type Operands = Record; +type Operands = Readonly>; const DIRECTIVE_KINDS = new Set([ DirectiveKind.SET_PREMISE, @@ -319,7 +319,7 @@ function normalizeCanonicalOperands(kind: DirectiveKindValue, operands: Record !expected.has(name)); if (missing.length > 0) throw new Error(`Missing required operands for ${kind}: ${missing.sort().join(', ')}`); if (unexpected.length > 0) throw new Error(`Unexpected operands for ${kind}: ${unexpected.sort().join(', ')}`); - const normalized: Operands = {}; + const normalized: Record = {}; for (const name of DIRECTIVE_SPECS[kind].operands) { const value = operands[name]; if (typeof value !== 'string') throw new Error(`Operand '${name}' for ${kind} must be a string.`);