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
5 changes: 5 additions & 0 deletions src/decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -92,6 +95,8 @@ function normalizeItemForMessage(value: string): string {
.replaceAll('`', "'")
.toLowerCase()
.replaceAll('ß', 'ss')
.replaceAll('ς', 'σ')
.replaceAll('ſ', 's')
.replace(/\s+/g, ' ')
.trim();
}
Expand Down
11 changes: 9 additions & 2 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
27 changes: 20 additions & 7 deletions src/grammar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -27,7 +30,7 @@ type DirectiveKindValue =
| 'reset_policies'
| 'clear_state';

type Operands = Record<string, string>;
type Operands = Readonly<Record<string, string>>;

const DIRECTIVE_KINDS = new Set<string>([
DirectiveKind.SET_PREMISE,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -144,7 +146,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 {
Expand All @@ -166,7 +175,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;
Expand Down Expand Up @@ -234,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', {});
Expand Down Expand Up @@ -306,7 +319,7 @@ function normalizeCanonicalOperands(kind: DirectiveKindValue, operands: Record<s
const unexpected = [...actual].filter((name) => !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<string, string> = {};
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.`);
Expand Down
24 changes: 24 additions & 0 deletions tests/api_parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ type ExportMemberSpec = {
signature?: SignatureSpec;
shape_probes?: ShapeProbe[];
construction_probes?: Array<Record<string, unknown>>;
immutable_definition?: boolean;
member_values?: Record<string, string>;
};

type EngineMemberSpec = {
Expand Down Expand Up @@ -163,6 +165,25 @@ function expectPortableCallableArity(fn: (...args: unknown[]) => unknown, signat
).toBeLessThanOrEqual(totalCount);
}

function expectImmutableDefinition(value: unknown, memberValues: Record<string, string>, 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<string, unknown>;
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 };
Expand Down Expand Up @@ -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}'`);
}
}
}
});
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/.source-commit
Original file line number Diff line number Diff line change
@@ -1 +1 @@
9fde330f701e3bdecf03f32b6e3874a6fe0c05f3
e1e04bd6464aa46f5e3693d634faaa264d890f66
20 changes: 18 additions & 2 deletions tests/fixtures/conformance/api/public-api-v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
25 changes: 23 additions & 2 deletions tests/fixtures/conformance/api/public-grammar-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -76,6 +95,7 @@
},
"CanonicalDirective": {
"kind": "class",
"public_fields": ["kind", "operands", "text"],
"signature": {
"params": [
{"name": "kind", "kind": "POSITIONAL_OR_KEYWORD", "has_default": false},
Expand Down Expand Up @@ -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},
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"id": "grammar_decompose_compatibility_keyword_rejected",
"kind": "grammar",
"action": {
"fn": "decompose_directive",
"text": "ⓤⓢⓔ docker"
},
"expected": {
"directive": null
}
}
Original file line number Diff line number Diff line change
@@ -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é"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"id": "grammar_decompose_decomposed_keyword_variant_rejected",
"kind": "grammar",
"action": {
"fn": "decompose_directive",
"text": "üse docker"
},
"expected": {
"directive": null
}
}
Original file line number Diff line number Diff line change
@@ -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é"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"id": "grammar_decompose_fullwidth_keyword_rejected",
"kind": "grammar",
"action": {
"fn": "decompose_directive",
"text": "use docker"
},
"expected": {
"directive": null
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
}
Loading
Loading