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
121 changes: 117 additions & 4 deletions packages/cli/tests/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@
*
* Executable surface is exactly what the runner certifies today: print / let /
* assign / for / if / while / each / return / portable arithmetic / portable
* array-literal binding / literal in-bounds array index reads. Constructs the
* runner does not yet execute over PRODUCTION IR (branch/try/throw, fmt
* interpolation, whole-array rendering, objects, dynamic array index reads)
* ABSTAIN -> exit 2, by design.
* array-literal binding / literal in-bounds array index reads / array `.length`
* (value AND as a for-range bound) / for-counter dynamic index reads (`xs[i]`).
* Constructs the runner does not yet execute over PRODUCTION IR (branch/try/throw,
* fmt interpolation, whole-array rendering, objects, NON-counter dynamic index
* reads, arithmetic-on-counter index, string `.length`) ABSTAIN -> exit 2.
*
* Every expected stdout byte below was verified empirically against the built
* runner before this oracle was authored (the `(1/3)*3 != 1` lesson).
Expand Down Expand Up @@ -234,6 +235,56 @@ describe('kern run — executes a void main and replays stdout (exit 0)', () =>
expect(r.status).toBe(0);
expect(r.stderr).toBe('');
});

test('ARRAY LENGTH: reads the element count', () => {
const r = runProgram(['let name=xs value="[1,2,3]"', 'print value="xs.length"']);
expect(r.stdout).toBe('3\n');
expect(r.status).toBe(0);
expect(r.stderr).toBe('');
});

test('ARRAY LENGTH: an empty array reads 0', () => {
const r = runProgram(['let name=xs value="[]"', 'print value="xs.length"']);
expect(r.stdout).toBe('0\n');
expect(r.status).toBe(0);
expect(r.stderr).toBe('');
});

test('ARRAY LENGTH: a nested array counts TOP-LEVEL elements (not leaves)', () => {
const r = runProgram(['let name=xs value="[[1,2],[3,4,5]]"', 'print value="xs.length"']);
expect(r.stdout).toBe('2\n');
expect(r.status).toBe(0);
expect(r.stderr).toBe('');
});

test('ARRAY LENGTH: the length value flows into arithmetic', () => {
const r = runProgram(['let name=xs value="[1,2,3]"', 'print value="xs.length - 1"']);
expect(r.stdout).toBe('2\n');
expect(r.status).toBe(0);
expect(r.stderr).toBe('');
});

test('DYNAMIC INDEX: iterate an array by for-counter over its length (headline)', () => {
const r = runProgram([
'let name=xs value="[10,20,30]"',
'for name=i from="0" to="xs.length"',
' print value="xs[i]"',
]);
expect(r.stdout).toBe('10\n20\n30\n');
expect(r.status).toBe(0);
expect(r.stderr).toBe('');
});

test('DYNAMIC INDEX: a reverse for-counter reads back-to-front', () => {
const r = runProgram([
'let name=xs value="[10,20,30]"',
'for name=i from="2" to="-1" step="-1"',
' print value="xs[i]"',
]);
expect(r.stdout).toBe('30\n20\n10\n');
expect(r.status).toBe(0);
expect(r.stderr).toBe('');
});
});

// ── FAIL-CLOSE ATOMICITY: abstain produces NO stdout, exit 2 ──────────────────
Expand Down Expand Up @@ -308,6 +359,68 @@ describe('kern run — abstains atomically on non-portable ops (exit 2, no stdou
expect(r.stdout).toBe('');
expect(r.status).toBe(2);
});

test('STRING `.length` abstains (JS UTF-16 units vs Python code points)', () => {
// ASCII happens to agree, but the runner rule is arrays-only: a string
// receiver fails closed so an astral case can never silently diverge.
const r = runProgram(['let name=s value="\\"hello\\""', 'print value="s.length"']);
expect(r.stdout).toBe('');
expect(r.status).toBe(2);
});

test('ASTRAL string `.length` abstains (the real divergence: JS 2 vs Python 1)', () => {
const r = runProgram(['let name=s value="\\"😀\\""', 'print value="s.length"']);
expect(r.stdout).toBe('');
expect(r.status).toBe(2);
});

test('OPTIONAL `xs?.length` abstains (outside the portable domain)', () => {
const r = runProgram(['let name=xs value="[1,2,3]"', 'print value="xs?.length"']);
expect(r.stdout).toBe('');
expect(r.status).toBe(2);
});

test("COMPUTED `xs['length']` abstains (a string-literal index is not certified)", () => {
const r = runProgram(['let name=xs value="[1,2,3]"', 'print value="xs[\'length\']"']);
expect(r.stdout).toBe('');
expect(r.status).toBe(2);
});

test('a NON-`length` member on an array (`xs.foo`) abstains', () => {
const r = runProgram(['let name=xs value="[1,2,3]"', 'print value="xs.foo"']);
expect(r.stdout).toBe('');
expect(r.status).toBe(2);
});

test('ATOMICITY: an OUT-OF-BOUNDS for-counter iteration suppresses ALL prior stdout', () => {
// for i in 0..5 over a length-3 array: at i=3 TS reads undefined, Python raises.
// The 10/20/30 from i=0..2 must NOT leak — the whole program abstains.
const r = runProgram(['let name=xs value="[10,20,30]"', 'for name=i from="0" to="5"', ' print value="xs[i]"']);
expect(r.stdout).toBe('');
expect(r.status).toBe(2);
});

test('a NEGATIVE for-counter (reverse past 0) abstains', () => {
const r = runProgram([
'let name=xs value="[10,20,30]"',
'for name=i from="2" to="-2" step="-1"',
' print value="xs[i]"',
]);
expect(r.stdout).toBe('');
expect(r.status).toBe(2);
});

test('ARITHMETIC on a for-counter index (`xs[i + 1]`) abstains (out of slice)', () => {
const r = runProgram(['let name=xs value="[10,20,30]"', 'for name=i from="0" to="2"', ' print value="xs[i + 1]"']);
expect(r.stdout).toBe('');
expect(r.status).toBe(2);
});

test('a NON-counter (plain let) index abstains even when in-bounds', () => {
const r = runProgram(['let name=xs value="[10,20,30]"', 'let name=j value="4 / 2"', 'print value="xs[j]"']);
expect(r.stdout).toBe('');
expect(r.status).toBe(2);
});
});

// ── ENTRY RESOLUTION: deterministic diagnostics, exit 2, no stdout ────────────
Expand Down
29 changes: 27 additions & 2 deletions packages/core/src/ir/semantics/for.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ import type { IRNode } from '../../types.js';
import type { ValueIR } from '../../value-ir.js';
import {
childEnv,
defineBinding,
defineIntBinding,
getBinding,
hasBinding,
isIntProvenanced,
type NodeContract,
type NodeFixture,
registerContract,
type SemanticEnv,
} from './index.js';
import { isSafeIntegerLiteralIndex } from './portable-scalar.js';
import { referenceRunSequence } from './reference-runner.js';
import { emptyTrace, type Trace } from './trace.js';

Expand Down Expand Up @@ -61,7 +63,30 @@ function evalValue(expr: ValueIR, env: SemanticEnv): unknown {
if (!hasBinding(env, expr.name)) throw new Error(`for: binding "${expr.name}" not found in env`);
return getBinding(env, expr.name);
}
case 'member': {
if (expr.optional || expr.object.kind !== 'ident' || expr.property !== 'length') {
throw new Error('for: unsupported member expression in range bound');
}
if (!hasBinding(env, expr.object.name)) throw new Error(`for: binding "${expr.object.name}" not found in env`);
const array = getBinding(env, expr.object.name);
if (!Array.isArray(array)) throw new Error('for: range bound .length requires an array binding');
return array.length;
}
case 'index': {
// A range-bound array index must be PORTABLE for 3-leg parity, the SAME gate
// as the body index reader (portable-scalar): a bare safe-integer LITERAL or
// a bare integer-provenanced ident (a for-counter). A plain `let` ident can
// be a Python float (`let j = 4/2` is 2.0), so `for to=xs[j]` would read
// xs[2] in JS/ref but raise TypeError in Python (`range(0, xs[2.0])`) — it
// must abstain. (`bounds[0]` literal indices still pass.)
if (
!isSafeIntegerLiteralIndex(expr.index) &&
!(expr.index.kind === 'ident' && isIntProvenanced(env, expr.index.name))
) {
throw new Error(
'for: range-bound array index must be a safe-integer literal or an integer-provenanced loop counter',
);
}
const target = evalValue(expr.object, env);
const index = evalValue(expr.index, env);
if (Array.isArray(target) && typeof index === 'number') return target[index];
Expand Down Expand Up @@ -105,7 +130,7 @@ function forEffects(ir: IRNode, env: SemanticEnv): Trace {
// emitted TS/Python loops. (Previously this forked `new Map(env.bindings)`,
// discarding outer mutations: a `sum += i` accumulator returned 0, not 15.)
const iterEnv = childEnv(env);
defineBinding(iterEnv, name, i);
defineIntBinding(iterEnv, name, i);

const childTrace = referenceRunSequence(children, iterEnv);
out.events.push(...childTrace.events);
Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/ir/semantics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type { CompletionRecord, Trace } from './trace.js';
*/
export interface SemanticEnv {
bindings: Map<string, unknown>;
intProvenance?: Set<string>;
/**
* Enclosing lexical scope, if any. A `let` binds in THIS scope's `bindings`;
* reads and `assign` walk up `parent` to the declaring scope (write-through).
Expand Down Expand Up @@ -55,6 +56,7 @@ export interface SemanticEnv {
export function makeEnv(overrides: Partial<SemanticEnv> = {}): SemanticEnv {
return {
bindings: overrides.bindings ? cloneBindings(overrides.bindings) : new Map(),
intProvenance: overrides.intProvenance ? new Set(overrides.intProvenance) : new Set(),
seed: overrides.seed ?? 0,
now: overrides.now ?? 0,
};
Expand All @@ -79,7 +81,11 @@ function cloneBindings(bindings: Map<string, unknown>): Map<string, unknown> {
* mutations to outer bindings write through to where they were declared.
*/
export function childEnv(parent: SemanticEnv): SemanticEnv {
return { bindings: new Map(), parent, seed: parent.seed, now: parent.now };
// `intProvenance` is PER-SCOPE binding metadata (which names declared in THIS
// scope are guaranteed safe integers). A child starts EMPTY — it does not clone
// the parent's set; `isIntProvenanced` walks `declaringScope` first, so a
// counter declared in an outer scope is still found from a nested scope.
return { bindings: new Map(), intProvenance: new Set(), parent, seed: parent.seed, now: parent.now };
}

/** The nearest scope in the chain that declares `name`, or undefined if unbound. */
Expand Down Expand Up @@ -108,6 +114,13 @@ export function getBinding(env: SemanticEnv, name: string): unknown {
/** Declare `name` in the INNERMOST scope (`let`). Overwrites a same-scope binding. */
export function defineBinding(env: SemanticEnv, name: string, value: unknown): void {
env.bindings.set(name, value);
env.intProvenance?.delete(name);
}

/** Declare `name` in the INNERMOST scope and mark it as a guaranteed safe integer. */
export function defineIntBinding(env: SemanticEnv, name: string, value: unknown): void {
env.bindings.set(name, value);
(env.intProvenance ??= new Set()).add(name);
}

/**
Expand All @@ -119,6 +132,13 @@ export function defineBinding(env: SemanticEnv, name: string, value: unknown): v
export function assignBinding(env: SemanticEnv, name: string, value: unknown): void {
const scope = declaringScope(env, name) ?? env;
scope.bindings.set(name, value);
scope.intProvenance?.delete(name);
}

/** True iff `name` is declared in a scope that marks it as a guaranteed safe integer. */
export function isIntProvenanced(env: SemanticEnv, name: string): boolean {
const scope = declaringScope(env, name);
return scope?.intProvenance?.has(name) ?? false;
}

/** Delete `name` from the INNERMOST scope only (scope teardown). */
Expand Down
79 changes: 49 additions & 30 deletions packages/core/src/ir/semantics/portable-scalar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
} from '../../decimal/probe-gates.js';
import type { ValueIR } from '../../value-ir.js';
import type { SemanticEnv } from './index.js';
import { getBinding, hasBinding } from './index.js';
import { getBinding, hasBinding, isIntProvenanced } from './index.js';

export type PortableScalar = string | number | boolean | null;

Expand Down Expand Up @@ -217,7 +217,7 @@ export function sameType(a: PortableScalar, b: PortableScalar): boolean {
* index-reads are excluded too (they can resolve to a Python float). So a computed
* or variable index ABSTAINS; dynamic indexing is deferred to a slice that proves
* exact integer arithmetic (e.g. BigInt-checked) or carries integer provenance. */
function isSafeIntegerLiteralIndex(node: ValueIR): boolean {
export function isSafeIntegerLiteralIndex(node: ValueIR): boolean {
if (node.kind !== 'numLit' || node.bigint) return false;
if (!/^[0-9]+$/.test(node.raw)) return false;
const n = Number(node.raw);
Expand Down Expand Up @@ -256,20 +256,29 @@ export function evalPortableValue(node: ValueIR, env: SemanticEnv): PortableScal
? evalPortableValue(node.consequent, env)
: evalPortableValue(node.alternate, env);
case 'member': {
// Error-substrate Slice 1 — the ONLY admitted member read in the portable
// domain is `<caughtErrorBinding>.message` (a non-optional `.message` on
// an ident resolving to a tagged caught-error value). It returns the
// EVALUATED LITERAL message stored when the explicit `throw new Error("…")`
// was caught — byte-identical to TS `e.message` and Python `str(e)`.
// EVERYTHING else (a different property, an optional `?.`, a non-ident
// object, an ident that is not a caught error) throws → the runner
// ABSTAINS. This is the fail-close fence: `e.name`/`e.stack`/`e` (bare)
// and any non-caught-error member access never produce a one-leg value.
// Member reads are admitted only for the explicit portable slices:
// `<arrayBinding>.length` and `<caughtErrorBinding>.message`. Both must be
// non-optional reads on a bare identifier. Everything else throws -> the
// runner ABSTAINS rather than producing a one-leg value.
if (node.optional) throw new Error('portable: optional member access is outside the portable scalar domain');
if (node.object.kind !== 'ident') {
throw new Error('portable: member access is only admitted on a caught-error binding');
throw new Error('portable: member access is only admitted on an array or caught-error binding');
}
// Resolve the binding explicitly (mirrors the `index` case) so an UNBOUND
// receiver fails with a precise "binding not found" rather than the generic
// out-of-domain message. Either way the runner abstains; this is diagnostics.
if (!hasBinding(env, node.object.name)) {
throw new Error(`portable: binding "${node.object.name}" not found`);
}
const obj = getBinding(env, node.object.name);
if (Array.isArray(obj)) {
// Array `.length` is portable; string `.length` is not (JS counts UTF-16
// code units while Python counts code points), so only arrays pass here.
if (node.property !== 'length') {
throw new Error(`portable: array has no portable property "${node.property}" (only .length is admitted)`);
}
return obj.length;
}
if (!isCaughtErrorValue(obj)) {
throw new Error(`portable: member access on "${node.object.name}" is outside the portable scalar domain`);
}
Expand All @@ -281,34 +290,44 @@ export function evalPortableValue(node: ValueIR, env: SemanticEnv): PortableScal
return obj.message;
}
case 'index': {
// Array INDEX read (slice-2b). Certify ONLY an in-bounds, non-negative,
// safe-integer index whose SOURCE is a BARE integer LITERAL, into an
// ident-bound array, returning a PORTABLE SCALAR element. Everything else
// throws -> the runner ABSTAINS.
// Array INDEX read. Certify an in-bounds, non-negative, safe-integer index
// into an ident-bound array, returning a PORTABLE SCALAR element. The index
// SOURCE must be either (slice-2b) a BARE integer LITERAL, or (dynamic-index
// slice) a BARE ident that is INTEGER-PROVENANCED — currently the live
// counter of an enclosing `for`, which the for-contract guarantees is a safe
// integer. Everything else throws -> the runner ABSTAINS.
//
// The index is restricted to a literal ({@link isSafeIntegerLiteralIndex})
// because of TS<->Python divergences verified on real node+python3:
// Why not any ident, and why arithmetic still abstains — TS<->Python
// divergences verified on real node+python3:
// - INT vs FLOAT: Python list indices MUST be int — `xs[1.0]`, `xs[4/2]`
// (Python `/` is float), and any ident bound to a float raise TypeError
// in Python while JS + the reference collapse `1.0 === 1` and read xs[1].
// (Python `/` is float), and any PLAIN-let ident bound to a float raise
// TypeError in Python while JS + the reference collapse `1.0 === 1`. A
// for-counter is exempt because its provenance proves it is an int; a
// plain `let` is NOT provenanced (and `let j = i` is not transitive).
// - integer `%` diverges on a negative operand (`5 % -3` is 2 in JS, -1 in
// Python), and `+`/`-`/`*` over safe literals can overflow 2^53 and round
// in JS while Python stays exact — so ARITHMETIC indices abstain.
// Python), and `+`/`-`/`*` over safe values can overflow 2^53 and round
// in JS while Python stays exact — so ARITHMETIC indices (`xs[i+1]`)
// abstain even on a counter (provenance proves int-ness, not closure).
// - JS has no int/float distinction and the emitters preserve the source
// numeric form, so the reference cannot tell a Python int from a float by
// VALUE — hence the syntactic literal gate, not a value check.
// Idents / nested index-reads abstain (a binding can hold a Python float);
// dynamic indexing is a later slice. Then OOB / NEGATIVE are caught at runtime
// (TS undefined vs Py IndexError / wraparound). Object restricted to an
// array-binding ident, so OBJECT-position nesting (`xs[0][1]`) and string
// index (`s[0]`) abstain; a nested-array element is not a portable scalar, so
// VALUE — hence the syntactic literal / provenance gate, not a value check.
// Provenance proves INTEGER-NESS, not IN-BOUNDS-ness: OOB / NEGATIVE indices
// are caught at runtime below (TS undefined vs Py IndexError / wraparound),
// and the throw propagates atomically. Object restricted to an array-binding
// ident, so OBJECT-position nesting (`xs[0][1]`) and string index (`s[0]`)
// abstain; a nested-array element is not a portable scalar, so
// `assertPortableScalar` abstains on it.
if (node.optional) throw new Error('portable: optional index access is outside the portable scalar domain');
if (node.object.kind !== 'ident') {
throw new Error('portable: index access is only admitted on an array-binding identifier');
}
if (!isSafeIntegerLiteralIndex(node.index)) {
throw new Error('portable: array index must be a bare non-negative safe-integer literal');
if (
!isSafeIntegerLiteralIndex(node.index) &&
!(node.index.kind === 'ident' && isIntProvenanced(env, node.index.name))
) {
throw new Error(
'portable: array index must be a bare non-negative safe-integer literal or an integer-provenanced loop counter',
);
}
if (!hasBinding(env, node.object.name)) {
throw new Error(`portable: binding "${node.object.name}" not found`);
Expand Down
Loading