Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 31 additions & 33 deletions src/engine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { EngineState } from './types.js';
import type { WorkingMemory } from './types.js';
import { CanonicalDirective, DirectiveKind as GrammarDirectiveKind, decompose_directive } from './grammar.js';
import {
NoDirectiveDecision,
Expand All @@ -13,33 +13,33 @@ export const POLICY_USE = 'use' as const;
export const POLICY_PROHIBIT = 'prohibit' as const;

export class Engine {
private _state: EngineState;
#workingMemory: WorkingMemory;

constructor() {
if (arguments.length > 0) {
throw new TypeError('Engine constructor takes no arguments.');
}
this._state = initialState();
this.#workingMemory = initialState();
}

get premise(): string | null {
return this._state.premise;
return this.#workingMemory.premise;
}

get policies(): Record<string, 'use' | 'prohibit'> {
return { ...this._state.policies };
return { ...this.#workingMemory.policies };
}

export_json(): string {
return stringifyCanonicalJson(sortKeysDeep(this._state));
return stringifyCanonicalJson(sortKeysDeep(this.#workingMemory));
}

exportJson(): string {
return this.export_json();
}

import_json(payload: string): void {
this._state = loadStateJson(payload);
this.#workingMemory = loadStateJson(payload);
}

importJson(payload: string): void {
Expand All @@ -50,31 +50,31 @@ export class Engine {
if (!(directive instanceof CanonicalDirective)) {
throw new TypeError('apply_directive requires a CanonicalDirective.');
}
const previous = cloneState(this._state);
const previousState = cloneState(this.#workingMemory);
const failure = this.#semanticFailure(directive);
if (failure !== null) {
this._state = previous;
this.#workingMemory = previousState;
return failure;
}

this.#applyCanonicalDirective(directive);
return new UpdateDecision(!statesEqual(previous, this._state));
return new UpdateDecision(!statesEqual(previousState, this.#workingMemory));
}

applyDirective(directive: CanonicalDirective): SemanticDecision {
return this.apply_directive(directive);
}

#semanticFailure(directive: CanonicalDirective): SemanticErrorDecision | null {
if (directive.kind === GrammarDirectiveKind.SET_PREMISE && this._state.premise !== null) {
if (directive.kind === GrammarDirectiveKind.SET_PREMISE && this.#workingMemory.premise !== null) {
return new SemanticErrorDecision(
SemanticFailure.PREMISE_ALREADY_SET,
directive,
[this.#repair(GrammarDirectiveKind.CHANGE_PREMISE, { value: directive.operands.value })]
);
}

if (directive.kind === GrammarDirectiveKind.CHANGE_PREMISE && this._state.premise === null) {
if (directive.kind === GrammarDirectiveKind.CHANGE_PREMISE && this.#workingMemory.premise === null) {
return new SemanticErrorDecision(
SemanticFailure.PREMISE_NOT_SET,
directive,
Expand All @@ -84,7 +84,7 @@ export class Engine {

if (directive.kind === GrammarDirectiveKind.USE_ITEM) {
const itemKey = normalizeItem(directive.operands.item);
if (this._state.policies[itemKey] === POLICY_PROHIBIT) {
if (this.#workingMemory.policies[itemKey] === POLICY_PROHIBIT) {
return new SemanticErrorDecision(
SemanticFailure.ITEM_PROHIBITED,
directive,
Expand All @@ -98,7 +98,7 @@ export class Engine {

if (directive.kind === GrammarDirectiveKind.PROHIBIT_ITEM) {
const itemKey = normalizeItem(directive.operands.item);
if (this._state.policies[itemKey] === POLICY_USE) {
if (this.#workingMemory.policies[itemKey] === POLICY_USE) {
return new SemanticErrorDecision(
SemanticFailure.ITEM_ALREADY_IN_USE,
directive,
Expand All @@ -117,10 +117,10 @@ export class Engine {
const oldKey = normalizeItem(oldItem);
if (newKey === oldKey) return null;

if (this._state.policies[oldKey] === POLICY_PROHIBIT) {
if (this.#workingMemory.policies[oldKey] === POLICY_PROHIBIT) {
return new SemanticErrorDecision(SemanticFailure.REPLACEMENT_SOURCE_PROHIBITED, directive);
}
if (this._state.policies[newKey] === POLICY_PROHIBIT) {
if (this.#workingMemory.policies[newKey] === POLICY_PROHIBIT) {
return new SemanticErrorDecision(
SemanticFailure.REPLACEMENT_TARGET_PROHIBITED,
directive,
Expand All @@ -130,7 +130,7 @@ export class Engine {
]
);
}
if (this._state.policies[oldKey] !== POLICY_USE) {
if (this.#workingMemory.policies[oldKey] !== POLICY_USE) {
return new SemanticErrorDecision(SemanticFailure.REPLACEMENT_SOURCE_MISSING, directive);
}
}
Expand All @@ -144,40 +144,40 @@ export class Engine {

#applyCanonicalDirective(directive: CanonicalDirective): void {
if (directive.kind === GrammarDirectiveKind.SET_PREMISE || directive.kind === GrammarDirectiveKind.CHANGE_PREMISE) {
this._state.premise = sanitizePremiseValue(directive.operands.value);
this.#workingMemory.premise = sanitizePremiseValue(directive.operands.value);
return;
}
if (directive.kind === GrammarDirectiveKind.USE_ITEM) {
this._state.policies[normalizeItem(directive.operands.item)] = POLICY_USE;
this.#workingMemory.policies[normalizeItem(directive.operands.item)] = POLICY_USE;
return;
}
if (directive.kind === GrammarDirectiveKind.PROHIBIT_ITEM) {
this._state.policies[normalizeItem(directive.operands.item)] = POLICY_PROHIBIT;
this.#workingMemory.policies[normalizeItem(directive.operands.item)] = POLICY_PROHIBIT;
return;
}
if (directive.kind === GrammarDirectiveKind.REMOVE_POLICY) {
delete this._state.policies[normalizeItem(directive.operands.item)];
delete this.#workingMemory.policies[normalizeItem(directive.operands.item)];
return;
}
if (directive.kind === GrammarDirectiveKind.REPLACE_USE) {
const oldKey = normalizeItem(directive.operands.old_item);
const newKey = normalizeItem(directive.operands.new_item);
if (oldKey !== newKey) {
delete this._state.policies[oldKey];
this._state.policies[newKey] = POLICY_USE;
delete this.#workingMemory.policies[oldKey];
this.#workingMemory.policies[newKey] = POLICY_USE;
}
return;
}
if (directive.kind === GrammarDirectiveKind.CLEAR_PREMISE) {
this._state.premise = null;
this.#workingMemory.premise = null;
return;
}
if (directive.kind === GrammarDirectiveKind.RESET_POLICIES) {
this._state.policies = {};
this.#workingMemory.policies = {};
return;
}
if (directive.kind === GrammarDirectiveKind.CLEAR_STATE) {
this._state = initialState();
this.#workingMemory = initialState();
}
}

Expand All @@ -190,31 +190,31 @@ export class Engine {
}
}

function initialState(): EngineState {
function initialState(): WorkingMemory {
return {
premise: null,
policies: {},
version: 2
};
}

function cloneState(state: EngineState): EngineState {
function cloneState(state: WorkingMemory): WorkingMemory {
return {
premise: state.premise,
policies: { ...state.policies },
version: 2
};
}

function statesEqual(left: EngineState, right: EngineState): boolean {
function statesEqual(left: WorkingMemory, right: WorkingMemory): boolean {
if (left.premise !== right.premise) return false;
const leftKeys = Object.keys(left.policies);
const rightKeys = Object.keys(right.policies);
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every((key) => left.policies[key] === right.policies[key]);
}

function loadStateJson(payload: string): EngineState {
function loadStateJson(payload: string): WorkingMemory {
let raw: unknown;
try {
raw = JSON.parse(payload);
Expand All @@ -224,7 +224,7 @@ function loadStateJson(payload: string): EngineState {
return loadStateObject(raw);
}

function loadStateObject(raw: unknown): EngineState {
function loadStateObject(raw: unknown): WorkingMemory {
if (raw === null || typeof raw !== 'object') {
throw new Error('Invalid state payload.');
}
Expand Down Expand Up @@ -335,5 +335,3 @@ function stringifyCanonicalJson(value: unknown): string {
`\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`
);
}

export type { EngineState };
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export interface EngineState {
export interface WorkingMemory {
premise: string | null;
policies: Record<string, 'use' | 'prohibit'>;
version: 2;
Expand Down
27 changes: 27 additions & 0 deletions tests/private-engine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { Engine } from '../src/engine.js';

describe('Engine private working memory', () => {
it('does not expose its working memory as a runtime property', () => {
const engine = new Engine();
expect('_state' in engine).toBe(false);
expect('workingMemory' in engine).toBe(false);
expect(Object.getOwnPropertyNames(engine)).toEqual([]);
expect(Reflect.ownKeys(engine)).toEqual([]);
});

it('ignores externally assigned legacy-looking properties', () => {
const engine = new Engine();
const external = engine as unknown as Record<string, unknown>;
external._state = { premise: 'injected', policies: { docker: 'prohibit' }, version: 2 };
external._workingMemory = { premise: 'injected', policies: { docker: 'prohibit' }, version: 2 };

expect(engine.export_json()).toBe('{"policies":{},"premise":null,"version":2}');
expect(engine.step('use sqlite')).toMatchObject({ kind: 'update', changed: true });
expect(JSON.parse(engine.export_json())).toEqual({
policies: { sqlite: 'use' },
premise: null,
version: 2
});
});
});
Loading