Skip to content

Commit

Permalink
Adding pcode, a pseudo code interpreter, biggest thing since sliced b…
Browse files Browse the repository at this point in the history
…read! And a prng for Math.random.
  • Loading branch information
pvdz committed Sep 7, 2024
1 parent 136821d commit 96b7815
Show file tree
Hide file tree
Showing 50 changed files with 3,457 additions and 196 deletions.
45 changes: 36 additions & 9 deletions src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@ import { phase1 } from './normalize/phase1.mjs';
import { phase2 } from './normalize/phase2.mjs';
import { phase3 } from './normalize/phase3.mjs';
import { phase1_1 } from './normalize/phase1_1.mjs';
import { ASSERT } from '../tests/utils.mjs';
import { freeFuncs } from './reduce_static/free_funcs.mjs';

let rngSeed = 1;
function prng() {
// Note: keep in sync with the test $prng func. We'll probably consolidate them later.
// Super simple PRNG which we shove into Math.random for our eval tests
// We use the same algo for inlining in preval so it ought to lead to the same outcome..? tbd if that holds (:
// https://pvdz.ee/weblog/456
ASSERT(rngSeed !== 0, 'do not call xorshift with zero');
rngSeed = rngSeed ^ rngSeed << 13;
rngSeed = rngSeed ^ rngSeed >> 17;
rngSeed = rngSeed ^ rngSeed << 5;
// Note: bitwise ops are 32bit in JS so we divide the result by the max 32bit number to get a number [0..1>
return ((rngSeed >>> 0) % 0b1111111111111111) / 0b1111111111111111;
}

export function preval({ entryPointFile, stdio, verbose, verboseTracing, resolve, req, stopAfterNormalize, refTracing, options = {} }) {
if (stdio) setStdio(stdio, verbose);
Expand All @@ -19,7 +35,7 @@ export function preval({ entryPointFile, stdio, verbose, verboseTracing, resolve
setRiskyRules(!!(options.risky ?? true));

{
const { logDir, logPasses, logPhases, maxPass, cloneLimit, allowEval, unrollLimit, implicitThisIdent, unrollTrueLimit, refTest, risky, ...rest } = options;
const { logDir, logPasses, logPhases, maxPass, cloneLimit, allowEval, unrollLimit, implicitThisIdent, unrollTrueLimit, refTest, pcodeTest, risky, prngSeed, ...rest } = options;
if (JSON.stringify(rest) !== '{}') throw new Error(`Preval: Unsupported options received: ${JSON.stringify(rest)}`);
}

Expand All @@ -28,6 +44,8 @@ export function preval({ entryPointFile, stdio, verbose, verboseTracing, resolve
console.log(options);
}

if (options.prngSeed) rngSeed = options.prngSeed;

const entryPoint = resolve(entryPointFile);

const modules = new Map([
Expand Down Expand Up @@ -68,6 +86,8 @@ export function preval({ entryPointFile, stdio, verbose, verboseTracing, resolve
// Was used for discovering code that wasn't normalized. Currently unused.
special: {},
lastAst: {todo: 'updateMe'},
// Compiled function data per file, Record<fname, Map<pid, {name:string, pcode}>
pcodeData: {},
};

const normalizeQueue = [entryPoint]; // Order is not relevant in this phase
Expand Down Expand Up @@ -98,7 +118,7 @@ export function preval({ entryPointFile, stdio, verbose, verboseTracing, resolve
const fdata = parseCode(preCode, nextFname);
contents.lastAst = fdata.tenkoOutput.ast;
prepareNormalization(fdata, resolve, req, false, {unrollTrueLimit: options.unrollTrueLimit}); // I want a phase1 because I want the scope tracking set up for normalizing bindings
phaseNormalize(fdata, nextFname, { allowEval: options.allowEval });
phaseNormalize(fdata, nextFname, prng, { allowEval: options.allowEval, prngSeed: options.prngSeed });

mod.children = new Set(fdata.imports.values());
mod.fdata = fdata;
Expand Down Expand Up @@ -241,25 +261,29 @@ export function preval({ entryPointFile, stdio, verbose, verboseTracing, resolve
// Slow; serialize and parse to verify each cycle
//parseCode(tmat(fdata.tenkoOutput.ast, true), fname);

// Phase 1 does mostly noop analysis to reset information that may have gone stale after each transform
++phase1s;
phase1(fdata, resolve, req, firstAfterParse, passes, phase1s, !firstAfterParse && options.refTest); // I want a phase1 because I want the scope tracking set up for normalizing bindings
phase1_1(fdata, resolve, req, firstAfterParse, passes, phase1s, !firstAfterParse && options.refTest);
phase1(fdata, resolve, req, firstAfterParse, passes, phase1s, !firstAfterParse && options.refTest, !firstAfterParse && options.pcodeTest, verboseTracing);
phase1_1(fdata, resolve, req, firstAfterParse, passes, phase1s, !firstAfterParse && options.refTest, !firstAfterParse && options.pcodeTest, verboseTracing);
// In a pcode test we have to run the pcode plugin here because we don't want ot run all of phase2
if (options.pcodeTest) freeFuncs(fdata, prng, !!options.prngSeed);
contents.lastPhase1Ast = fdata.tenkoOutput.ast;

options?.onAfterPhase(1, passes, phaseLoop, fdata, false, options);

if (options.refTest) {
// Test runner only cares about the first pass up to here
if (options.refTest || options.pcodeTest) {
// For refTest, the test runner only cares about the first pass up to here
// For pcodeTest, the test will be based on the AST after phase1 (with meta data)
break;
}

firstAfterParse = false;

changed = phase2(program, fdata, resolve, req, {unrollLimit: options.unrollLimit, implicitThisIdent: options.implicitThisIdent, unrollTrueLimit: options.unrollTrueLimit});
changed = phase2(program, fdata, resolve, req, prng, {unrollLimit: options.unrollLimit, implicitThisIdent: options.implicitThisIdent, unrollTrueLimit: options.unrollTrueLimit, rngSeed});
options?.onAfterPhase(2, passes, phaseLoop, fdata, changed, options);
if (!changed) {
// Force a normalize pass before moving to phase3. Loop if it changed anything anyways.
changed = phaseNormalize(fdata, fname, { allowEval: options.allowEval });
changed = phaseNormalize(fdata, fname, prng, { allowEval: options.allowEval, prngSeed: options.prngSeed });
options?.onAfterPhase(2.1, passes, phaseLoop, fdata, false, options);
if (changed) vlog('The pre-phase3 normal did change something! starting from phase0');
}
Expand All @@ -281,6 +305,8 @@ export function preval({ entryPointFile, stdio, verbose, verboseTracing, resolve
throw e;
}

contents.pcodeData[fname] = fdata.pcodeOutput;

options.onPassEnd?.(outCode, passes, fi, options, contents);

changed = outCode !== inputCode;
Expand All @@ -295,7 +321,7 @@ export function preval({ entryPointFile, stdio, verbose, verboseTracing, resolve

const fdata = parseCode(inputCode, fname);
prepareNormalization(fdata, resolve, req, false, {unrollTrueLimit: options.unrollTrueLimit}); // I want a phase1 because I want the scope tracking set up for normalizing bindings
phaseNormalize(fdata, fname, { allowEval: options.allowEval });
phaseNormalize(fdata, fname, prng, { allowEval: options.allowEval, rngSeed: options.prngSeed });

options.onAfterNormalize?.(fdata, passes + 1, fi, options, contents);

Expand Down Expand Up @@ -327,6 +353,7 @@ export function preval({ entryPointFile, stdio, verbose, verboseTracing, resolve
log('This is a ref test. Stopping after first pass.');
}
}

log('\nPreval ran for', passes, 'passes');
});
}
Expand Down
176 changes: 175 additions & 1 deletion src/normalize/normalize.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ const BUILTIN_COERCE_FIRST_TO_NUMBER_MEMBER = new Set([
'Math.trunc',
]);

export function phaseNormalize(fdata, fname, { allowEval = true }) {
export function phaseNormalize(fdata, fname, prng, { allowEval = true, prngSeed = 1 }) {
let changed = false; // Was the AST updated? We assume that updates can not be circular and repeat until nothing changes.
let somethingChanged = false; // Did phase2 change anything at all?

Expand Down Expand Up @@ -2636,6 +2636,129 @@ export function phaseNormalize(fdata, fname, { allowEval = true }) {
}
break;
}
break;
}

case 'Math.random': {
// We can special case Math.random with args to be excluded as a
// way for users to control math.randoms that should be left alone.
if (prngSeed && args.length === 0) {
// If in global but not in a loop:
if (funcStack.length === 1 && loopStack[loopStack.length-1] === null) {
rule('Calling `Math.random` in global space can be replaced with a pseudo rng value');
example('Math.random()', '0.12345');
before(node, body[i]);

const finalParent = wrapExpressionAs(
wrapKind,
varInitAssignKind,
varInitAssignId,
wrapLhs,
varOrAssignKind,
AST.primitive(prng()),
);
body.splice(
i,
1,
// Do not ignore the args. If there are any, make sure to preserve their side effects. If any.
// If it was called with a spread, make sure the spread still happens.
...args.map((anode) => AST.expressionStatement(anode.type === 'SpreadElement' ? AST.arrayExpression(anode) : anode)),
finalParent,
);

after(node, body[i]);
assertNoDupeNodes(AST.blockStatement(body), 'body');
return true;
}
}
break;
}

case 'Math.floor': {
if (args.length > 0 && AST.isPrimitive(args[0])) {
rule('Calling `Math.floor` with a primitive can be resolved');
example('Math.floor(5.3842)', '5');
before(node, body[i]);

const finalParent = wrapExpressionAs(
wrapKind,
varInitAssignKind,
varInitAssignId,
wrapLhs,
varOrAssignKind,
AST.primitive(Math.floor(AST.getPrimitiveValue(args[0]))),
);
body.splice(
i,
1,
// Do not ignore the args. If there are any, make sure to preserve their side effects. If any.
// If it was called with a spread, make sure the spread still happens.
...args.map((anode) => AST.expressionStatement(anode.type === 'SpreadElement' ? AST.arrayExpression(anode) : anode)),
finalParent,
);

after(node, body[i]);
assertNoDupeNodes(AST.blockStatement(body), 'body');
return true;
}
}

case 'Math.ceil': {
if (args.length > 0 && AST.isPrimitive(args[0])) {
rule('Calling `Math.ceil` with a primitive can be resolved');
example('Math.ceil(5.3842)', '6');
before(node, body[i]);

const finalParent = wrapExpressionAs(
wrapKind,
varInitAssignKind,
varInitAssignId,
wrapLhs,
varOrAssignKind,
AST.primitive(Math.ceil(AST.getPrimitiveValue(args[0]))),
);
body.splice(
i,
1,
// Do not ignore the args. If there are any, make sure to preserve their side effects. If any.
// If it was called with a spread, make sure the spread still happens.
...args.map((anode) => AST.expressionStatement(anode.type === 'SpreadElement' ? AST.arrayExpression(anode) : anode)),
finalParent,
);

after(node, body[i]);
assertNoDupeNodes(AST.blockStatement(body), 'body');
return true;
}
}

case 'Math.round': {
if (args.length > 0 && AST.isPrimitive(args[0])) {
rule('Calling `Math.round` with a primitive can be resolved');
example('Math.round(5.3842)', '5');
before(node, body[i]);

const finalParent = wrapExpressionAs(
wrapKind,
varInitAssignKind,
varInitAssignId,
wrapLhs,
varOrAssignKind,
AST.primitive(Math.round(AST.getPrimitiveValue(args[0]))),
);
body.splice(
i,
1,
// Do not ignore the args. If there are any, make sure to preserve their side effects. If any.
// If it was called with a spread, make sure the spread still happens.
...args.map((anode) => AST.expressionStatement(anode.type === 'SpreadElement' ? AST.arrayExpression(anode) : anode)),
finalParent,
);

after(node, body[i]);
assertNoDupeNodes(AST.blockStatement(body), 'body');
return true;
}
}
}
}
Expand Down Expand Up @@ -5881,6 +6004,29 @@ export function phaseNormalize(fdata, fname, { allowEval = true }) {
return true;
}

// Probably too late to enforce this unary rule now. Stuff depends on wanting to inline Infinity/NaN
//if (node.operator === '-' && AST.isNumber(node.argument)) {
// // Ok, ignore this for negative number literals (but not NaN/Infinity)
//} else if (!['VariableDeclaration', 'ExpressionStatement', 'AssignmentExpression'].includes(parentNode.type)) {
// vlog('Because parent is', parentNode.type);
//
// rule('A unary expression must be a statement, assignment, or var decl unless it is an actual negative number');
// example('return -foo;', 'const tmp = -foo; return tmp;');
// before(body[i]);
//
// // Force this to be a statement, assignment, or var decl
// const tmpName = createFreshVar('tmpUnaryArg', fdata);
//
// const finalNode = AST.identifier(tmpName);
// const finalParent = wrapExpressionAs(wrapKind, varInitAssignKind, varInitAssignId, wrapLhs, varOrAssignKind, finalNode);
// body[i] = finalParent;
// body.splice(i, 0, AST.variableDeclaration(tmpName, node, 'const'));
//
// after(body[i]);
// after(body[i+1]);
// return true;
//}

return false;
}

Expand Down Expand Up @@ -8329,6 +8475,34 @@ export function phaseNormalize(fdata, fname, { allowEval = true }) {
return true;
}

// Enforce the situation where Unary minus is only allowed on numeric literals, not NaN/Infinity/anything else.
// Probably too late to want to do this now. Runs into infinite transform loops
// trying to put Infinity back into the Return argument "because it's a primitive".
//if (
// (node.argument.type === 'Identifier' && node.argument.name !== 'arguments') ||
// AST.isNumber(node.argument) ||
// (node.argument.type === 'UnaryExpression' && AST.isNumber(node.argument.argument)) ||
// AST.isStringLiteral(node.argument) ||
// AST.isBoolean(node.argument) ||
// AST.isNull(node.argument)
//) {
// // This is a fine return arg
//} else {
// rule('Return argument must be very simple');
// example('return -xyz;', 'const tmp = -xyz; return xyz;');
// before(body[i]);
// console.log(node.argument)
//
// const tmpName = createFreshVar('tmpReturnArg', fdata);
// body.splice(i, 0, AST.variableDeclaration(tmpName, node.argument, 'const'));
// node.argument = AST.identifier(tmpName);
//
// after(body[i]);
// after(body[i+1]);
// assertNoDupeNodes(AST.blockStatement(body), 'body');
// return true;
//}

if (AST.isComplexNode(node.argument) || (node.argument.type === 'Identifier' && node.argument.name === 'arguments')) {
rule('Return argument must be simple');
example('return $()', 'let tmp = $(); return tmp;');
Expand Down
2 changes: 2 additions & 0 deletions src/normalize/parse.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,7 @@ export function parseCode(code, fname) {
exports: undefined, // phase1
globallyUniqueNamingRegistry: undefined, // phase1. every binding is assigned a (module) globally unique name and meta data for this binding is stored here by that name
reports: [],
/** @property {Map<pid, {name: string, pcode: Pcode}> pcodeOutput */
pcodeOutput: {},
};
}
6 changes: 4 additions & 2 deletions src/normalize/phase1.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import { addLabelReference, registerGlobalLabel } from '../labels.mjs';
// It does replace Identifier nodes in the AST that are $$123 param names with a special custom Param node
// It runs twice; once for actual input code and once on normalized code.

export function phase1(fdata, resolve, req, firstAfterParse, passes, phase1s, refTest) {
export function phase1(fdata, resolve, req, firstAfterParse, passes, phase1s, refTest, pcodeTest, verboseTracing) {
const ast = fdata.tenkoOutput.ast;

const start = Date.now();
Expand Down Expand Up @@ -119,6 +119,8 @@ export function phase1(fdata, resolve, req, firstAfterParse, passes, phase1s, re
firstAfterParse +
', refTest=' +
!!refTest +
', pcodeTest=' +
!!pcodeTest +
') :: ' +
fdata.fname +
', pass=' + passes + ', phase1s=', phase1s, ', len:', fdata.len, '\n##################################\n\n\n',
Expand All @@ -139,7 +141,7 @@ export function phase1(fdata, resolve, req, firstAfterParse, passes, phase1s, re
resetUid();

const tracingValueBefore = VERBOSE_TRACING;
if (passes > 1 || phase1s > 1) {
if (!verboseTracing && (passes > 1 || phase1s > 1)) {
vlog('(Disabling verbose tracing for phase 1 after the first pass)');
setVerboseTracing(false);
}
Expand Down
5 changes: 4 additions & 1 deletion src/normalize/phase2.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import { objlitInlining } from '../reduce_static/objlit_inlining.mjs';
import { constAliasing } from '../reduce_static/const_aliasing.mjs';
import { unusedAssigns } from '../reduce_static/unused_assigns.mjs';
import { recursiveFuncs } from '../reduce_static/recursive_funcs.mjs';
import { freeFuncs } from '../reduce_static/free_funcs.mjs';

//import { phasePrimitiveArgInlining } from '../reduce_static/phase_primitive_arg_inlining.mjs';

Expand Down Expand Up @@ -129,7 +130,7 @@ export function phase2(program, fdata, resolve, req, prng, options) {

return r;
}
function _phase2(fdata, prng, options = {}) {
function _phase2(fdata, prng, options = {prngSeed: 1}) {
// Initially we only care about bindings whose writes have one var decl and only assignments otherwise
// Due to normalization, the assignments will be a statement. The var decl can not contain an assignment as init.
// Elimination of var decls or assignments will be deferred. This way we can preserve parent/node
Expand Down Expand Up @@ -201,6 +202,8 @@ function _phase2(fdata, prng, options = {}) {
});

const action = (
freeFuncs(fdata, prng, !!options.prngSeed) || // Do this first...?

coercials(fdata) ||
resolveBoundValueSet(fdata) ||
removeUnusedConstants(fdata) ||
Expand Down
Loading

0 comments on commit 96b7815

Please sign in to comment.