Files
scriptc/tests/harness/regex.test.ts
Chris Tateandmmamedel 9080986166 Add sparse UTF-16 indexing for large strings (#275)
* fix: index large unicode strings sparsely

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>

* fix: retain sparse anchors for long ascii prefixes

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>

* fix: skip sparse index for proven ASCII strings

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>

* fix: complete sparse UTF-16 string indexing

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>

* test: rebase static size contracts for sparse index

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>

* test: retain sparse checkpoints through ASCII prefix end

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>

* fix: retain sparse anchors across threshold append

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>

* fix: retain sparse indexes beyond cursor cache

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>

* fix: bound sparse string index residency

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>

* fix: isolate sparse string index residency

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>

* test: restore parseInt oracle outputs

* test: verify committed string oracle

- Regenerate the string case oracle with the active Node executable.\n- Compare the checked-in fixture byte-for-byte before native runtime checks.\n- Keep the Linux parseInt ULP allowance scoped to native output.

---------

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>
2026-08-31 17:12:52 -05:00

181 lines
7.9 KiB
TypeScript

/* Regex behavior that is scriptc-only by nature — deliberately NOT in the
* differential corpus (tests/corpus/1200.. holds everything Node-comparable):
*
* - The statefulness fence at runtime: test() on a g/y regex that flowed
* through a variable (the frontend only sees literal receivers) must
* abort with the fence message, never silently model lastIndex.
* - split() on a pattern with capture groups: Node splices the captured
* values into the result — scriptc throws a catchable TypeError naming
* the limitation instead, so the caught branch is asserted here.
* - The two size/stability pins: a regex-free program must not reference
* the regex runtime at all (byte-identical link line — its C names no
* ScrRegex symbol), and a regex-USING static binary pays libregexp
* (~110KB), never the ~620KB engine class: well under 300KB.
*
* SCRIPTC_SAN=1 builds the programs with ASan + the RC audit.
*/
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { promisify } from "node:util";
import { describe, expect, test } from "vitest";
import { compile } from "@scriptc/compiler";
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../..");
const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests");
const sanitize = process.env["SCRIPTC_SAN"] === "1";
const platformTest = process.env["SCRIPTC_PORTABLE_ONLY"] === "1" ? test.skip : test;
interface BuildResult {
binaryPath: string;
cPath: string;
}
/** Compiles an inline program (static by default — regex needs no flag). */
async function build(
name: string,
source: string,
opts: { sanitize?: boolean } = {},
): Promise<BuildResult> {
const san = opts.sanitize ?? sanitize;
const key = createHash("sha256")
.update(source)
.update(san ? "san" : "plain")
.digest("hex")
.slice(0, 16);
const outDir = join(cacheDir, `regex-${key}`);
mkdirSync(outDir, { recursive: true });
const file = join(outDir, `${name}.ts`);
writeFileSync(file, source);
// Pinned: the size/stability pins below grep the emitted C (no ScrRegex
// symbol) and weigh the C-lane binary — this suite measures the C
// backend's artifact by design.
const result = await compile(file, { outPath: join(outDir, name), outDir, sanitize: san, backend: "c" });
if (!result.ok) {
throw new Error(
"regex program failed to compile:\n" +
result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
);
}
return { binaryPath: result.binaryPath, cPath: result.cPath };
}
describe("regex (scriptc-only behavior)", () => {
test("test() on a g-flagged regex reaching runtime aborts with the fence message", async () => {
// The g flag is invisible to the frontend here: the regex flows through
// a function parameter, so the RUNTIME fence must catch it.
const r = await build(
"g-test-runtime",
`function check(re: RegExp, s: string): boolean {
return re.test(s);
}
console.log("before");
console.log(check(/a/g, "abc"));
`,
);
const err = await execFileAsync(r.binaryPath, []).then(
() => {
throw new Error("expected the g-flagged test() to abort");
},
(e: Error & { signal?: string; stdout?: string; stderr?: string }) => e,
);
expect(err.signal).toBe("SIGABRT");
expect(err.stdout).toContain("before");
expect(err.stderr).toContain(
"test() on a regex with the 'g' or 'y' flag is not supported",
);
});
test("split() with capture groups throws a catchable TypeError (Node would splice them in)", async () => {
const r = await build(
"split-captures",
`try {
const parts = "a,b".split(/(,)/);
console.log("unreachable", parts.length);
} catch {
console.log("caught");
}
`,
);
const { stdout } = await execFileAsync(r.binaryPath, []);
expect(stdout).toBe("caught\n");
});
test("a pattern the engine rejects aborts at first use with a SyntaxError message", async () => {
// tsc's parser (and V8) accept 300 capture groups; libregexp caps
// captures at 255, so the engine rejects the pattern at its LAZY
// compile on first use — Node would run it fine (the abort is the
// documented divergence surface: engine-level rejections happen at
// first use, not at program parse time).
const r = await build(
"lazy-compile-error",
`console.log("before");
console.log(/${"(a)".repeat(300)}/.test("a"));
`,
);
const err = await execFileAsync(r.binaryPath, []).then(
() => {
throw new Error("expected the invalid pattern to abort");
},
(e: Error & { signal?: string; stdout?: string; stderr?: string }) => e,
);
expect(err.signal).toBe("SIGABRT");
expect(err.stdout).toContain("before");
expect(err.stderr).toContain("SyntaxError: Invalid regular expression:");
});
platformTest("regex-free programs never reference the regex runtime; regex use stays in its size class", async () => {
// Measured on plain (non-ASan) builds. A regex-free program's C names
// no ScrRegex symbol, so its link line is the historical one (the
// compact static size class is pinned by island.test.ts). A regex-USING
// binary links libregexp + libunicode, but must stay far below the
// embedded-engine size class.
const [plainBuild, regexBuild] = await Promise.all([
build("size-plain", `console.log("hello", "world");\n`, { sanitize: false }),
build(
"size-regex",
`console.log("a-b c".replace(/[-\\s]/g, "_"), /\\p{L}+/u.test("héllo"));\n`,
{ sanitize: false },
),
]);
const plainC = readFileSync(plainBuild.cPath, "utf8");
expect(plainC).not.toContain("ScrRegex");
expect(plainC).not.toContain("scr_regex");
// The class bounds are page-granular (macOS rounds segments to 16KB,
// so a few hundred bytes of new runtime can tip a whole page): the net
// loop hooks, the console/process/child surface (the piped-stream
// slice included — scr_child.c is always linked), the
// fs-scandir/Math-static slice, the path.win32 port, and the Buffer
// numeric/encoding/search families (~11KB of always-linked bytes
// runtime) each cost pages — regex linkage would be a ~110KB jump
// above the static class, the engine a ~620KB one. The regex class
// carries scr_assert.c (the regex link switch has always pulled it),
// so the assert.throws shape machinery — the Comparison diff renderer
// and the throws/rejects helpers — re-based it by a page, and the
// dyn-assert machinery (the compact:false dyn renderer plus the real
// myers line-diff printer) re-based it by another; the primitive-
// prototype formatters (toExponential/toFixed0, Date.UTC, String.raw)
// re-based the static class by one more; the ambient-receiver stack,
// the %j dyn stringify walk, and the runtime-encoding readFileSync
// form (all in always-linked TUs) tipped the regex class one more
// page; positioned readSync's cross-platform pread seam tipped the
// Mach-O regex class by one further page. Native Response construction
// and mutable constructed Headers added another page to both Darwin
// classes; the Linux bounds were rebased by the same 16KB while
// preserving their existing cushion.
// The sparse UTF-16 string index adds its cache/allocator path to the
// always-linked string unit: the Ubuntu 24.04/clang Sandbox measures
// 423,488 bytes for the plain binary and 580,264 with regex linked.
// The bounds retain roughly one native page of growth; neither cushion
// can hide an engine-sized jump.
expect(statSync(plainBuild.binaryPath).size).toBeLessThan(
process.platform === "linux" ? 440_000 : 450_000,
);
expect(statSync(regexBuild.binaryPath).size).toBeLessThan(
process.platform === "linux" ? 601_000 : 590_000,
);
});
});