From d2e45a14527de79592a22bd9e7256d90263b702b Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 21 Apr 2026 04:30:07 -0400 Subject: [PATCH 1/3] fix: align canonical json serialization with python ordering --- src/engine.ts | 43 +++++++++++++++++++++++++----- tests/serialization_parity.test.ts | 41 ++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 tests/serialization_parity.test.ts diff --git a/src/engine.ts b/src/engine.ts index af4a302..f011683 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -54,7 +54,7 @@ class EngineImpl implements Engine { } exportJson(): string { - return JSON.stringify(sortKeysDeep(this._state)); + return stringifyCanonicalJson(sortKeysDeep(this._state)); } importJson(payload: string): void { @@ -301,12 +301,12 @@ export function getPremiseValue(state: EngineState): string | null { export function getPolicyItems(state: EngineState, value?: 'use' | 'prohibit' | null): string[] { if (value == null) { - return Object.keys(state.policies).sort((a, b) => a.localeCompare(b)); + return Object.keys(state.policies).sort(compareStringsByCodepoint); } return Object.entries(state.policies) .filter(([, policy]) => policy === value) .map(([item]) => item) - .sort((a, b) => a.localeCompare(b)); + .sort(compareStringsByCodepoint); } function initialState(): EngineState { @@ -362,7 +362,7 @@ function loadStateObject(raw: unknown): EngineState { normalizedPolicies[normalizeItem(key)] = value; } - const sortedEntries = Object.entries(normalizedPolicies).sort(([a], [b]) => a.localeCompare(b)); + const sortedEntries = Object.entries(normalizedPolicies).sort(([a], [b]) => compareStringsByCodepoint(a, b)); const sortedPolicies: Record = {}; for (const [key, value] of sortedEntries) { sortedPolicies[key] = value; @@ -497,7 +497,7 @@ function diagnosticPolicyContainsHints(policies: Record key.includes(probe)).sort((a, b) => a.localeCompare(b)); + const matches = Object.keys(policies).filter((key) => key.includes(probe)).sort(compareStringsByCodepoint); if (matches.length === 0) { return ''; } @@ -540,7 +540,7 @@ function sortKeysDeep(value: unknown): unknown { return value.map((v) => sortKeysDeep(v)); } if (value !== null && typeof value === 'object') { - const entries = Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b)); + const entries = Object.entries(value as Record).sort(([a], [b]) => compareStringsByCodepoint(a, b)); const out: Record = {}; for (const [k, v] of entries) { out[k] = sortKeysDeep(v); @@ -550,4 +550,35 @@ function sortKeysDeep(value: unknown): unknown { return value; } +function compareStringsByCodepoint(left: string, right: string): number { + const leftCodepoints = Array.from(left); + const rightCodepoints = Array.from(right); + const limit = Math.min(leftCodepoints.length, rightCodepoints.length); + + for (let idx = 0; idx < limit; idx += 1) { + const leftCodepoint = leftCodepoints[idx].codePointAt(0) as number; + const rightCodepoint = rightCodepoints[idx].codePointAt(0) as number; + if (leftCodepoint < rightCodepoint) { + return -1; + } + if (leftCodepoint > rightCodepoint) { + return 1; + } + } + + if (leftCodepoints.length < rightCodepoints.length) { + return -1; + } + if (leftCodepoints.length > rightCodepoints.length) { + return 1; + } + return 0; +} + +function stringifyCanonicalJson(value: unknown): string { + return JSON.stringify(value).replace(/[\u0080-\uFFFF]/g, (char) => + `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}` + ); +} + export type { Decision, EngineState, TranscriptResult }; diff --git a/tests/serialization_parity.test.ts b/tests/serialization_parity.test.ts new file mode 100644 index 0000000..cd31e42 --- /dev/null +++ b/tests/serialization_parity.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { createEngine, getPolicyItems } from '../src/index.js'; + +describe('serialization parity', () => { + it('orders policy keys by codepoint and escapes non-ascii like Python', () => { + const engine = createEngine(); + engine.importJson('{"premise":null,"policies":{"ä":"use","z":"use"},"version":2}'); + + expect(getPolicyItems(engine.state)).toEqual(['z', 'ä']); + expect(engine.exportJson()).toBe('{"policies":{"z":"use","\\u00e4":"use"},"premise":null,"version":2}'); + }); + + it('keeps export/import round-trip stable for non-ascii policy keys', () => { + const engine = createEngine(); + engine.importJson('{"premise":null,"policies":{"ä":"use","z":"use"},"version":2}'); + + const first = engine.exportJson(); + const restored = createEngine(); + restored.importJson(first); + const second = restored.exportJson(); + + expect(second).toBe(first); + }); + + it('produces deterministic canonical export across repeated runs', () => { + const payload = '{"premise":null,"policies":{"ä":"use","z":"use","alpha":"prohibit"},"version":2}'; + + const firstEngine = createEngine(); + firstEngine.importJson(payload); + const secondEngine = createEngine(); + secondEngine.importJson(payload); + + const first = firstEngine.exportJson(); + const second = secondEngine.exportJson(); + + expect(first).toBe(second); + expect(first).toBe( + '{"policies":{"alpha":"prohibit","z":"use","\\u00e4":"use"},"premise":null,"version":2}' + ); + }); +}); From 5c5f9b9738ee92b3a59b5ff58f4988bde41cdc37 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 21 Apr 2026 05:05:40 -0400 Subject: [PATCH 2/3] fix: reject empty normalized policy keys during import --- src/engine.ts | 6 +++++- tests/import_json_atomicity.test.ts | 23 +++++++++++++++++++++++ tests/serialization_parity.test.ts | 20 ++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/import_json_atomicity.test.ts diff --git a/src/engine.ts b/src/engine.ts index f011683..3356934 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -359,7 +359,11 @@ function loadStateObject(raw: unknown): EngineState { if (value !== 'use' && value !== 'prohibit') { throw new Error('Invalid state payload.'); } - normalizedPolicies[normalizeItem(key)] = value; + const normalizedKey = normalizeItem(key); + if (normalizedKey === '') { + throw new Error('Invalid state payload.'); + } + normalizedPolicies[normalizedKey] = value; } const sortedEntries = Object.entries(normalizedPolicies).sort(([a], [b]) => compareStringsByCodepoint(a, b)); diff --git a/tests/import_json_atomicity.test.ts b/tests/import_json_atomicity.test.ts new file mode 100644 index 0000000..a366bcd --- /dev/null +++ b/tests/import_json_atomicity.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { createEngine } from '../src/index.js'; + +describe('importJson atomicity', () => { + it('importJson is atomic when encountering invalid normalized keys', () => { + const engine = createEngine(); + engine.importJson('{"premise":null,"policies":{"Docker":"use"},"version":2}'); + + const snapshot = JSON.parse(JSON.stringify(engine.state)); + const invalidPayload = '{"premise":null,"policies":{"Docker":"use","a":"use"},"version":2}'; + + expect(() => engine.importJson(invalidPayload)).toThrowError('Invalid state payload.'); + expect(engine.state).toEqual(snapshot); + }); + + it('no partial policy insertion before failure', () => { + const engine = createEngine(); + const invalidPayload = '{"premise":null,"policies":{"Docker":"use","a":"use"},"version":2}'; + + expect(() => engine.importJson(invalidPayload)).toThrowError('Invalid state payload.'); + expect(engine.state.policies).toEqual({}); + }); +}); diff --git a/tests/serialization_parity.test.ts b/tests/serialization_parity.test.ts index cd31e42..4f565e4 100644 --- a/tests/serialization_parity.test.ts +++ b/tests/serialization_parity.test.ts @@ -38,4 +38,24 @@ describe('serialization parity', () => { '{"policies":{"alpha":"prohibit","z":"use","\\u00e4":"use"},"premise":null,"version":2}' ); }); + + it('rejects policy keys that normalize to empty during import', () => { + const cases = [ + '{"premise":null,"policies":{"A":"use"},"version":2}', + '{"premise":null,"policies":{"a":"use"},"version":2}', + '{"premise":null,"policies":{"A":"use","a":"use"},"version":2}', + '{"premise":null,"policies":{"the":"use"},"version":2}' + ]; + + for (const payload of cases) { + const engine = createEngine(); + expect(() => engine.importJson(payload)).toThrowError('Invalid state payload.'); + } + }); + + it('continues to accept valid non-empty normalized policy keys', () => { + const engine = createEngine(); + engine.importJson('{"premise":null,"policies":{"Docker":"use"},"version":2}'); + expect(engine.exportJson()).toBe('{"policies":{"docker":"use"},"premise":null,"version":2}'); + }); }); From d33ab1d2fa406c6a425697645adfeaf2d0931638 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Wed, 22 Apr 2026 22:52:28 -0400 Subject: [PATCH 3/3] chore: bump package version to 0.5.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index feee4bf..34f4ca7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rlippmann/context-compiler", - "version": "0.5.1", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rlippmann/context-compiler", - "version": "0.5.1", + "version": "0.5.2", "devDependencies": { "typescript": "^5.9.3", "vitest": "^3.2.4" diff --git a/package.json b/package.json index dad3066..242ff73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rlippmann/context-compiler", - "version": "0.5.1", + "version": "0.5.2", "description": "Deterministic TypeScript control layer for LLM apps. Compiles explicit user directives into authoritative context state before model calls.", "keywords": [ "llm",