/**
* @file effects.js
* @description Classifies the effects a body performs, for the two features
* that need to reason about them: compiler contracts and Avenx Rewind.
*
* `ContractValidator` has always asked "does this expression have side
* effects?" as a yes/no question. Rewind asks a sharper one: *which* effects
* are there, where are they, and can a rewind put them back? A `state.count++`
* is a side effect and is perfectly reversible; a `localStorage.setItem()` is
* a side effect and is not. Both questions are answered from the same pattern
* table so the two features can never disagree about what an effect is.
*
* ## What this is not
*
* It is not a JavaScript parser. It is a string- and comment-aware scan, in
* the same spirit as `atlas/resolve.js`, and it is deliberately conservative:
* a pattern it cannot classify is reported, never assumed harmless. The one
* place that judgement is inverted is the *tail effect* — a request whose
* result the action returns or awaits is the very thing whose failure drives
* the rewind, so flagging it would warn about the intended design.
* @module lib/compiler/rewind/effects
*/
/**
* Known non-deterministic identifiers and expressions.
*
* Moved here from `ContractValidator` so that the `deterministic` contract and
* any future consumer read the same list.
* @type {RegExp[]}
*/
export const NON_DETERMINISTIC_PATTERNS = [
/\bMath\.random\s*\(/,
/\bDate\.now\s*\(/,
/\bnew\s+Date\s*\(/,
/\bDate\s*\(/,
/\bperformance\.now\s*\(/,
/\bcrypto\.getRandomValues\s*\(/,
/\bcrypto\.randomUUID\s*\(/,
];
/**
* Known side-effecting / impure patterns in expressions.
*
* This is the `pure` contract's list and its meaning is unchanged: any of
* these makes an expression impure, assignment included.
* @type {RegExp[]}
*/
export const IMPURE_PATTERNS = [
/\bwindow\s*\./,
/\bdocument\s*\./,
/\blocalStorage\s*\./,
/\bsessionStorage\s*\./,
/\bfetch\s*\(/,
/\bXMLHttpRequest\b/,
/\bnavigator\.sendBeacon\s*\(/,
/(?<![!=><])=(?![=><])/, // assignment = (excluding ==, ===, !=, !==, <=, >=, =>)
/\+\+/,
/--/,
/\+=|-=|\*=\/=/,
];
/**
* How an effect relates to a rewind.
* @readonly
* @enum {string}
*/
export const EffectKind = {
/** A bridge event. Listeners have already run; a rewind cannot un-notify them. */
EMIT: 'emit',
/** Browser storage. Written outside the reactive graph. */
STORAGE: 'storage',
/** Direct DOM or `window` access. */
DOM: 'dom',
/** A timer whose callback runs after the transaction has ended. */
TIMER: 'timer',
/** A request whose result the action neither returns nor awaits. */
REQUEST: 'request',
/**
* A state write inside a promise continuation. It happens after the
* transaction's dynamic extent has closed, so the journal never sees it.
*/
DEFERRED_WRITE: 'deferred-write',
};
/**
* The effect patterns Rewind reports, in scan order.
*
* `test` is applied to the masked source; `label` is what a diagnostic prints.
* Ordered longest-prefix-first so `sessionStorage` is not reported as
* `Storage` twice.
* @type {Array<{kind: string, pattern: RegExp, label: string}>}
*/
const IRREVERSIBLE_PATTERNS = [
{ kind: EffectKind.EMIT, pattern: /\bemit\s*\(\s*(['"`])([^'"`]*)\1/g, label: 'emit' },
{ kind: EffectKind.STORAGE, pattern: /\b(localStorage|sessionStorage)\s*\.\s*(setItem|removeItem|clear)\s*\(/g, label: 'storage' },
{ kind: EffectKind.DOM, pattern: /\bdocument\s*\.\s*[A-Za-z_$][\w$]*/g, label: 'document' },
{ kind: EffectKind.DOM, pattern: /\bwindow\s*\.\s*[A-Za-z_$][\w$]*/g, label: 'window' },
{ kind: EffectKind.TIMER, pattern: /\b(setTimeout|setInterval)\s*\(/g, label: 'timer' },
];
/**
* Request-like calls, which are only irreversible when their result escapes
* the action unobserved.
* @type {RegExp}
*/
const REQUEST_PATTERN = /\b(fetch|XMLHttpRequest)\s*\(|\bnavigator\s*\.\s*sendBeacon\s*\(/g;
/**
* Promise continuations, where a state write lands outside the transaction.
* @type {RegExp}
*/
const CONTINUATION_PATTERN = /\.\s*(then|catch|finally)\s*\(/g;
/**
* Replaces a run of source with spaces, preserving newlines so that every
* offset in the masked text still points at the same line as in the original.
* @param {string} text - The run to blank out.
* @returns {string} The blanked run.
*/
function blank(text) {
return text.replace(/[^\n]/g, ' ');
}
/**
* Masks string literals, template literals and comments.
*
* Without this an `emit` inside a string, or a `document.` written in a
* comment explaining why it is *not* used, would be reported as an effect.
* @param {string} source - The body source.
* @returns {string} The source with literals and comments blanked, same length.
*/
export function maskLiterals(source) {
if (typeof source !== 'string' || source === '') return '';
let out = '';
let i = 0;
while (i < source.length) {
const ch = source[i];
if (ch === '/' && source[i + 1] === '/') {
const end = source.indexOf('\n', i);
const stop = end === -1 ? source.length : end;
out += blank(source.slice(i, stop));
i = stop;
continue;
}
if (ch === '/' && source[i + 1] === '*') {
const end = source.indexOf('*/', i + 2);
const stop = end === -1 ? source.length : end + 2;
out += blank(source.slice(i, stop));
i = stop;
continue;
}
if (ch === '"' || ch === "'" || ch === '`') {
const quote = ch;
let j = i + 1;
while (j < source.length) {
if (source[j] === '\\') {
j += 2;
continue;
}
if (source[j] === quote) {
j += 1;
break;
}
j += 1;
}
// Keep the quotes so callers can still recognise a literal argument.
out += quote + blank(source.slice(i + 1, Math.max(i + 1, j - 1))) + (j <= source.length ? quote : '');
i = j;
continue;
}
out += ch;
i += 1;
}
return out;
}
/**
* Whether the statement containing an offset is returned or awaited.
*
* The transaction outcome *is* the value the action hands back, so a request
* in that position is not a loose effect — it is the mechanism. Scans back to
* the nearest statement boundary rather than parsing, which is enough to tell
* `return api.save()` from `api.save();`.
* @param {string} masked - The masked body source.
* @param {number} offset - Where the call starts.
* @returns {boolean} True when the call's result is returned or awaited.
*/
function isTailPosition(masked, offset) {
let start = 0;
for (let i = offset - 1; i >= 0; i--) {
const ch = masked[i];
if (ch === ';' || ch === '{' || ch === '}') {
start = i + 1;
break;
}
}
const head = masked.slice(start, offset);
return /(^|[\s(=,])(return|await)\s+[^;]*$/.test(head) || /^\s*(return|await)\b/.test(head);
}
/**
* Turns an offset into a 1-based line number within the body.
* @param {string} source - The body source.
* @param {number} offset - A character offset.
* @returns {number} The 1-based line.
*/
function lineAt(source, offset) {
let line = 1;
for (let i = 0; i < offset && i < source.length; i++) {
if (source[i] === '\n') line += 1;
}
return line;
}
/**
* Finds every effect in a body that a rewind cannot undo.
*
* Returns descriptors rather than booleans because the diagnostic's whole
* value is naming what will be left behind, and where.
* @param {string} source - The action body source.
* @param {object} [options] - Scan options.
* @param {number} [options.baseLine] - Line the body starts on in its file, so
* reported lines are file lines rather than body lines. 1-based, defaults to 1.
* @returns {Array<{kind: string, label: string, text: string, line: number}>}
* The effects, in source order.
*/
export function findIrreversibleEffects(source, options = {}) {
if (typeof source !== 'string' || source.trim() === '') return [];
const baseLine = typeof options.baseLine === 'number' && options.baseLine > 0 ? options.baseLine : 1;
const masked = maskLiterals(source);
/** @type {Array<{kind: string, label: string, text: string, line: number, offset: number}>} */
const found = [];
for (const entry of IRREVERSIBLE_PATTERNS) {
const pattern = new RegExp(entry.pattern.source, entry.pattern.flags);
let match;
while ((match = pattern.exec(masked)) !== null) {
// `emit('name')` keeps its quotes through masking but loses the name, so
// the original text is read back at the same offset.
const text = source.slice(match.index, match.index + match[0].length).trim();
found.push({
kind: entry.kind,
label: entry.label,
text,
line: baseLine + lineAt(source, match.index) - 1,
offset: match.index,
});
if (match[0].length === 0) pattern.lastIndex += 1;
}
}
REQUEST_PATTERN.lastIndex = 0;
let request;
while ((request = REQUEST_PATTERN.exec(masked)) !== null) {
if (isTailPosition(masked, request.index)) continue;
found.push({
kind: EffectKind.REQUEST,
label: 'request',
text: source.slice(request.index, request.index + request[0].length).trim(),
line: baseLine + lineAt(source, request.index) - 1,
offset: request.index,
});
}
found.sort((a, b) => a.offset - b.offset);
// `offset` is scan bookkeeping; callers report a line and a location.
for (const entry of found) delete entry.offset;
return found;
}
/**
* Finds promise continuations, where a state write escapes the transaction.
*
* A write made inside `.then(...)` runs after the action has already returned,
* so the journal — which follows the dynamic extent of the call — never sees
* it. That is a completeness problem, not an irreversibility one, which is why
* it is reported separately, under AVX_W42.
* @param {string} source - The action body source.
* @param {object} [options] - Scan options.
* @param {number} [options.baseLine] - Line the body starts on in its file.
* @returns {Array<{kind: string, text: string, line: number}>} The continuations.
*/
export function findDeferredWrites(source, options = {}) {
if (typeof source !== 'string' || source.trim() === '') return [];
const baseLine = typeof options.baseLine === 'number' && options.baseLine > 0 ? options.baseLine : 1;
const masked = maskLiterals(source);
const results = [];
CONTINUATION_PATTERN.lastIndex = 0;
let match;
while ((match = CONTINUATION_PATTERN.exec(masked)) !== null) {
const body = masked.slice(match.index);
// Only a continuation that assigns something can hide a write. A bare
// `.catch(reportError)` is not a completeness problem.
if (!/(?<![!=><])=(?![=>])|\+\+|--|\.\s*(push|pop|shift|unshift|splice|sort|reverse|set|add|delete|clear)\s*\(/.test(
body.slice(0, 400),
)) {
continue;
}
results.push({
kind: EffectKind.DEFERRED_WRITE,
text: source.slice(match.index, match.index + match[0].length).trim(),
line: baseLine + lineAt(source, match.index) - 1,
});
}
return results;
}