From 60546000801e6e22b3c5a7bbdd27c21d8e0d3955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 15:24:53 +0200 Subject: [PATCH 1/4] fix(codegen): defer forced shadow inlining under minsize --- .github/workflows/test.yml | 6 ++ crates/perry-codegen/src/function.rs | 84 +++++++++++++++++- crates/perry/tests/minsize_inline_policy.rs | 21 +++++ scripts/test-minsize-inline-policy.mjs | 93 ++++++++++++++++++++ test-files/test_gap_minsize_inline_policy.ts | 40 +++++++++ 5 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 crates/perry/tests/minsize_inline_policy.rs create mode 100644 scripts/test-minsize-inline-policy.mjs create mode 100644 test-files/test_gap_minsize_inline_policy.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 80e63da524..e88aea79b1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1431,6 +1431,12 @@ jobs: - uses: ./.github/actions/setup-llvm22 if: steps.scope.outputs.rust_work == 'true' + - name: Install pinned Node for native regression oracles + if: steps.scope.outputs.rust_work == 'true' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version-file: .node-version + - name: Install sccache if: steps.scope.outputs.rust_work == 'true' uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 diff --git a/crates/perry-codegen/src/function.rs b/crates/perry-codegen/src/function.rs index cacf5cfb3e..541869cda4 100644 --- a/crates/perry-codegen/src/function.rs +++ b/crates/perry-codegen/src/function.rs @@ -862,6 +862,13 @@ impl LlFunction { /// internal/private definitions so cross-unit calls bind (mirror of /// `render_fn_external`). pub fn define_header(&self, force_external: bool) -> String { + self.define_header_with_size_attrs( + force_external, + crate::linker::application_size_function_attrs(), + ) + } + + fn define_header_with_size_attrs(&self, force_external: bool, size_attrs: &str) -> String { let param_str = self .params .iter() @@ -895,16 +902,23 @@ impl LlFunction { // small helpers eligible; let LLVM decide for larger generated bodies. // The separate pre-statepoint admission already has its own budget. let force_inline = self.force_inline && self.estimated_ir_bytes() <= 8 * 1024; - let attrs = if self.pre_statepoint_inline || (force_inline && !rs4gc) { + // Even a bounded small body can grow many cold callers when forced + // into them. Under -Oz, let LLVM's minsize cost model choose ordinary + // shadow-root inlines. This is not `noinline`: profitable inlines are + // still allowed. Keep explicitly admitted pre-statepoint inlines and + // native-root hints unchanged; their early-pass contract is separate. + let minsize = size_attrs + .split_ascii_whitespace() + .any(|attr| attr == "minsize"); + let attrs = if self.pre_statepoint_inline || (force_inline && !rs4gc && !minsize) { " alwaysinline" } else if self.no_inline { " noinline" - } else if self.inline_hint || force_inline { + } else if self.inline_hint || (force_inline && rs4gc) { " inlinehint" } else { "" }; - let size_attrs = crate::linker::application_size_function_attrs(); // The native-stack walker recovers frames through the x29 chain, so // every generated function must link one; without the attribute, // textual-IR input gets no frame-pointer default from the clang @@ -1306,6 +1320,70 @@ mod define_header_tests { } } + #[test] + fn minsize_defers_ordinary_shadow_inlining_to_llvm_for_both_renderers() { + let _shadow = crate::codegen::helpers::NativeRootsPin::shadow(); + let mut function = probe(); + function.force_inline = true; + function.linkage = "internal".to_string(); + assert!(function.estimated_ir_bytes() <= 8 * 1024); + + for external in [false, true] { + for size_attrs in ["", " optsize", " optsize minsize"] { + let header = function.define_header_with_size_attrs(external, size_attrs); + assert_eq!(header.contains("internal"), !external, "{header}"); + assert_eq!( + header.contains(" alwaysinline"), + !size_attrs.contains("minsize"), + "only minsize should defer forced shadow inlining: {header}" + ); + assert!(!header.contains(" inlinehint"), "{header}"); + assert!(!header.contains(" noinline"), "{header}"); + assert!(header.contains(size_attrs), "size attributes must survive"); + } + } + } + + #[test] + fn minsize_preserves_native_root_hints_and_pre_statepoint_admission() { + use crate::codegen::helpers::NativeRootsPin; + for native in [false, true] { + let _pin = if native { + NativeRootsPin::native() + } else { + NativeRootsPin::shadow() + }; + for external in [false, true] { + let mut function = probe(); + function.force_inline = true; + let header = function.define_header_with_size_attrs(external, " optsize minsize"); + assert_eq!(header.contains(" inlinehint"), native, "{header}"); + assert!(!header.contains(" alwaysinline"), "{header}"); + + function.pre_statepoint_inline = true; + let admitted = function.define_header_with_size_attrs(external, " optsize minsize"); + assert!(admitted.contains(" alwaysinline"), "{admitted}"); + assert!(!admitted.contains(" inlinehint"), "{admitted}"); + } + } + } + + #[test] + fn minsize_preserves_explicit_noinline_and_hot_hint_requests() { + let _shadow = crate::codegen::helpers::NativeRootsPin::shadow(); + let mut function = probe(); + function.force_inline = true; + function.no_inline = true; + assert!(function + .define_header_with_size_attrs(false, " optsize minsize") + .contains(" noinline")); + function.no_inline = false; + function.inline_hint = true; + assert!(function + .define_header_with_size_attrs(false, " optsize minsize") + .contains(" inlinehint")); + } + /// The property that was actually lost, asserted directly (#7982) — in /// **both** lowerings, neither of them dark. /// diff --git a/crates/perry/tests/minsize_inline_policy.rs b/crates/perry/tests/minsize_inline_policy.rs new file mode 100644 index 0000000000..daafe4ff63 --- /dev/null +++ b/crates/perry/tests/minsize_inline_policy.rs @@ -0,0 +1,21 @@ +//! Independent minsize policy coverage through both actual LLVM transports. +use std::{path::Path, process::Command}; + +#[test] +fn standalone_minsize_inline_policy_regression() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let output = Command::new("node") + .arg(root.join("scripts/test-minsize-inline-policy.mjs")) + .env("PERRY_BIN", env!("CARGO_BIN_EXE_perry")) + .env("PERRY_WORKSPACE_ROOT", &root) + .env("PERRY_TEST_BUILD_RUNTIME", "1") + .current_dir(&root) + .output() + .expect("run bounded native minsize regression"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/scripts/test-minsize-inline-policy.mjs b/scripts/test-minsize-inline-policy.mjs new file mode 100644 index 0000000000..a7315e87c6 --- /dev/null +++ b/scripts/test-minsize-inline-policy.mjs @@ -0,0 +1,93 @@ +// Standalone native/IR gate: no application-specific source or patched IR. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { prepareRequireRuntime } from './test-require-runtime.mjs'; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +assert.equal(process.versions.node, fs.readFileSync(path.join(root, '.node-version'), 'utf8').trim().replace(/^v/, '')); +prepareRequireRuntime(root); +const compiler = process.env.PERRY_BIN ?? path.join(root, 'target/perry-dev/perry'); +const work = fs.mkdtempSync(path.join(os.tmpdir(), 'perry-minsize-inline-')); +const source = path.join(work, 'fixture.ts'); +fs.copyFileSync(path.join(root, 'test-files/test_gap_minsize_inline_policy.ts'), source); +const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const compilerHash = hash(compiler); +const baseEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('PERRY_'))); +if (process.env.PERRY_RUNTIME_DIR) baseEnv.PERRY_RUNTIME_DIR = process.env.PERRY_RUNTIME_DIR; +// Optional local toolchains may include wasm-host in their coherent graph. +const wasmArgs = process.env.PERRY_TEST_WASM === '1' ? ['--enable-wasm-runtime'] : []; +const rows = []; +let passed = false; +function run(label, executable, args, timeout, extraEnv = {}) { + const result = spawnSync(executable, args, { cwd: work, env: { ...baseEnv, ...extraEnv }, + encoding: 'utf8', timeout, killSignal: 'SIGKILL', maxBuffer: 16 * 1024 * 1024 }); + fs.writeFileSync(path.join(work, `${label}.stdout`), result.stdout ?? ''); + fs.writeFileSync(path.join(work, `${label}.stderr`), result.stderr ?? ''); + assert(!result.error && result.status === 0, + `${label}: ${result.error ?? result.status}; signal=${result.signal}\n${result.stderr?.slice(-2000)}`); + return result; +} +try { + const oracle = run('node', process.execPath, [source], 20000).stdout; + assert(oracle.includes('inline-policy-native-complete'), 'Node must finish'); + for (const roots of ['shadow', 'native']) { + for (const transport of ['1', 'native']) { + for (const opt of ['s', 'z']) { + const label = `${roots}-${transport}-O${opt}`; + const output = path.join(work, `${label}${process.platform === 'win32' ? '.exe' : ''}`); + const irDir = path.join(work, `${label}-ir`); + fs.mkdirSync(irDir); + const compileEnv = { PERRY_LL_OPT_LEVEL: opt, PERRY_LLVM_INPROCESS: transport, + PERRY_FULL_OUTLINE_IC: '1', PERRY_KEEP_SYMBOLS: '1', PERRY_SAVE_LL: irDir, + PERRY_MODULE_JOBS: '1', PERRY_CODEGEN_UNIT_JOBS: '1', + ...(roots === 'shadow' ? { PERRY_RS4GC: '0', PERRY_SHADOW_STACK: '1', PERRY_INLINE_SHADOW_SLOT: '0' } : {}), + }; + run(`compile-${label}`, compiler, ['compile', source, '-o', output, + '--cache-dir', path.join(work, `cache-${label}`), '--no-auto-optimize', '--no-color', ...wasmArgs], + 120000, compileEnv); + const files = fs.readdirSync(irDir).filter(name => name.endsWith('.ll')); + assert.equal(files.length, 1, 'one complete fixture module must be retained'); + const ir = fs.readFileSync(path.join(irDir, files[0]), 'utf8'); + const headers = ir.split('\n').filter(line => + /^define .*@perry_fn_[^(]*__(?:identityLeaf|throwLeaf)\(/.test(line)); + assert.equal(headers.length, 2, 'both ordinary forced-inline witnesses must be present'); + for (const header of headers) { + assert.equal(header.includes(' minsize'), opt === 'z', header); + assert.equal(header.includes(' alwaysinline'), roots === 'shadow' && opt === 's', header); + assert.equal(header.includes(' inlinehint'), roots === 'native', header); + } + const strategy = ir.includes('gc "statepoint-example"'); + assert.equal(strategy, roots === 'native', 'requested root mode must actually be emitted'); + // Native execution must not load the source to satisfy the oracle. + fs.renameSync(source, source + '.hidden'); + try { + for (const moving of [false, true]) { + const runLabel = `${label}-${moving ? 'moving' : 'ordinary'}`; + const actual = run(runLabel, output, [], 20000, moving ? { + PERRY_GC_SCHEDULE_SEED: '7', PERRY_GC_SCHEDULE_RATE: '0.05', PERRY_GC_SCHEDULE_ALLOC_KB: '0', + PERRY_GC_DIAG: '1', PERRY_GC_VERIFY_EVACUATION: '1', PERRY_GC_PROTECT_FROMSPACE: '1', + } : {}); + assert.equal(actual.stdout, oracle, `${runLabel}: exact Node mismatch`); + const counters = actual.stderr.match(/\[gc-schedule\] done:.*copying_minors=(\d+) moved_objects=(\d+) loop_polls=(\d+)/); + if (moving) assert(counters && counters.slice(1).every(n => Number(n) > 0), 'collection must actually move objects'); + const row = { roots, transport, opt, moving, bytes: fs.statSync(output).size, + sha256: hash(output), copyingMinors: Number(counters?.[1] ?? 0), + movedObjects: Number(counters?.[2] ?? 0), loopPolls: Number(counters?.[3] ?? 0) }; + rows.push(row); console.log('PASS minsize-inline ' + JSON.stringify(row)); + } + } finally { fs.renameSync(source + '.hidden', source); } + } + } + } + assert.equal(rows.length, 16); + assert.equal(hash(compiler), compilerHash, 'compiler must not change during validation'); + passed = true; +} finally { + fs.writeFileSync(path.join(work, 'result.json'), JSON.stringify({ passed, compiler, compilerHash, rows }, null, 2) + '\n'); + console.log(`Retained minsize-inline evidence: ${work}`); +} diff --git a/test-files/test_gap_minsize_inline_policy.ts b/test-files/test_gap_minsize_inline_policy.ts new file mode 100644 index 0000000000..39e215d3d0 --- /dev/null +++ b/test-files/test_gap_minsize_inline_policy.ts @@ -0,0 +1,40 @@ +// Size policy must preserve values, callbacks, exceptions, and live roots. +function textLeaf(left: string, right: string) { + return left.toUpperCase() + ":" + right.toLowerCase(); +} +function numberLeaf(left: number, right: number) { + return Math.imul((left + 17) | 0, (right ^ 123) | 0) >>> 0; +} +function identityLeaf(value: any) { + return value; +} +function throwLeaf(value: any): never { + throw value; +} +function callbackLeaf(callback: (n: number) => number, value: number) { + return callback(value) + callback(value + 1); +} + +let checksum = 0; +for (let batch = 0; batch < 4; batch++) { + const retained: any[] = []; + const marker = { batch, text: textLeaf("héLLo", "WoRLD") }; + for (let i = 0; i < 1000; i++) { + const row = { value: i, text: "row-" + i }; + const same = identityLeaf(row); + if (same !== row) throw new Error("identity mismatch"); + retained.push(same); + checksum = (checksum + numberLeaf(i, batch) + numberLeaf(i + 1, batch + 1)) >>> 0; + } + const captured = retained[999]; + const closureResult = callbackLeaf(n => numberLeaf(n, captured.value), batch); + let caught = false; + try { + throwLeaf(marker); + } catch (error) { + caught = error === marker; + } + if (!caught) throw new Error("exception identity mismatch"); + console.log(batch, checksum, closureResult, textLeaf(captured.text, marker.text), caught); +} +console.log("inline-policy-native-complete", checksum); From 92e2161c98ce7bdd9bbe298e994153f48b2a9c00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 15:26:03 +0200 Subject: [PATCH 2/4] docs(changelog): describe minsize inline policy for PR 10076 --- changelog.d/10076-minsize-inline-policy.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 changelog.d/10076-minsize-inline-policy.md diff --git a/changelog.d/10076-minsize-inline-policy.md b/changelog.d/10076-minsize-inline-policy.md new file mode 100644 index 0000000000..5fcf0d7579 --- /dev/null +++ b/changelog.d/10076-minsize-inline-policy.md @@ -0,0 +1,10 @@ +Let LLVM choose ordinary shadow-root inlining under `-Oz` instead of forcing +every small-body candidate into its callers. Profitable inlines remain allowed; +normal optimization levels, `-Os`, native-root hints, explicit attributes, and +the separately admitted pre-statepoint inline path keep their existing behavior. + +The shared definition-header renderer carries the policy into textual IR and +native LLVM construction. Independent header tests and a pinned-Node native +fixture cover both transports, both root modes, Os/Oz controls, exception and +object identity, callbacks, and actual moving collections. Scoped native CI +installs the repository's exact Node oracle. From e13d47726abb9e58304249d0f3fe0c4f693ed2b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 15:45:55 +0200 Subject: [PATCH 3/4] test(codegen): resolve grouped LLVM function attributes --- scripts/test-minsize-inline-policy.mjs | 44 ++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/scripts/test-minsize-inline-policy.mjs b/scripts/test-minsize-inline-policy.mjs index a7315e87c6..362a61a7d6 100644 --- a/scripts/test-minsize-inline-policy.mjs +++ b/scripts/test-minsize-inline-policy.mjs @@ -8,8 +8,45 @@ import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { prepareRequireRuntime } from './test-require-runtime.mjs'; +// Text emission spells attributes inline; LLVM's native printer interns groups. +function functionAttributes(ir, header) { + const groups = new Map(); + for (const [, id, body] of ir.matchAll(/^attributes #(\d+) = \{(.*)\}$/gm)) { + assert(!groups.has(id), `duplicate LLVM attribute group #${id}`); + groups.set(id, body); + } + const unquoted = text => text.replace(/"(?:[^"\\]|\\.)*"/g, ''); + const expanded = unquoted(header).replace(/#(\d+)\b/g, (_, id) => { + assert(groups.has(id), `missing LLVM attribute group #${id}`); + return unquoted(groups.get(id)); + }); + return new Set(expanded.split(/\s+/)); +} + +function testAttributeParser() { + const inline = functionAttributes('', 'define double @f(double %x) minsize optsize {'); + assert(inline.has('minsize') && inline.has('optsize')); + assert(!inline.has('alwaysinline')); + const grouped = functionAttributes('attributes #9 = { alwaysinline optsize }', + 'define double @f(double %x) #9 {'); + assert(grouped.has('alwaysinline') && grouped.has('optsize')); + assert(!grouped.has('minsize')); + const mixed = functionAttributes('attributes #2 = { minsize "label"="alwaysinline" }', + 'define double @f(double %x) inlinehint #2 {'); + assert(mixed.has('minsize') && mixed.has('inlinehint') && !mixed.has('alwaysinline')); + assert.throws(() => functionAttributes('', 'define void @f() #9 {'), /missing.*#9/); + assert.throws(() => functionAttributes('attributes #9 = { broken', 'define void @f() #9 {'), /missing.*#9/); + assert.throws(() => functionAttributes('attributes #9 = { minsize }\nattributes #9 = { optsize }', + 'define void @f() #9 {'), /duplicate.*#9/); +} + const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); assert.equal(process.versions.node, fs.readFileSync(path.join(root, '.node-version'), 'utf8').trim().replace(/^v/, '')); +testAttributeParser(); +if (process.argv.includes('--self-test')) { + console.log('PASS minsize-inline attribute parser: inline/grouped/mixed and negative controls'); + process.exit(0); +} prepareRequireRuntime(root); const compiler = process.env.PERRY_BIN ?? path.join(root, 'target/perry-dev/perry'); const work = fs.mkdtempSync(path.join(os.tmpdir(), 'perry-minsize-inline-')); @@ -57,9 +94,10 @@ try { /^define .*@perry_fn_[^(]*__(?:identityLeaf|throwLeaf)\(/.test(line)); assert.equal(headers.length, 2, 'both ordinary forced-inline witnesses must be present'); for (const header of headers) { - assert.equal(header.includes(' minsize'), opt === 'z', header); - assert.equal(header.includes(' alwaysinline'), roots === 'shadow' && opt === 's', header); - assert.equal(header.includes(' inlinehint'), roots === 'native', header); + const attributes = functionAttributes(ir, header); + assert.equal(attributes.has('minsize'), opt === 'z', header); + assert.equal(attributes.has('alwaysinline'), roots === 'shadow' && opt === 's', header); + assert.equal(attributes.has('inlinehint'), roots === 'native', header); } const strategy = ir.includes('gc "statepoint-example"'); assert.equal(strategy, roots === 'native', 'requested root mode must actually be emitted'); From 0f40434a245f2872cc3b4fb69e66008380a96132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 16:44:02 +0200 Subject: [PATCH 4/4] fix(ci): prepare minsize native runtime before fixture timeout --- .github/workflows/test.yml | 4 +- changelog.d/10076-minsize-inline-policy.md | 4 +- scripts/test-require-runtime.test.mjs | 53 ++++++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e88aea79b1..7a8e01b079 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1486,10 +1486,10 @@ jobs: # RFC-2945 abort guards that a JS throw trips — the opposite of the # shipped semantics. See the longer note in `cargo-test`. if printf '%s\n' "$SUITES" | grep -qE '^(perry|perry-stdlib) '; then - if printf '%s\n' "$SUITES" | grep -qE '^perry (bun_text_modules|import_meta_require_value) '; then + if printf '%s\n' "$SUITES" | grep -qE '^perry (bun_text_modules|import_meta_require_value|minsize_inline_policy) '; then # Prepare all require providers in one graph, outside the fixture's # timeout. A second perry-dev graph inside cargo test exceeded its - # ten-minute bound on fresh runners (#9989/#9990). + # ten-minute bound on fresh runners (#9989/#9990/#10076). cargo build --release -p perry -p perry-runtime -p perry-stdlib \ -p perry-runtime-static -p perry-stdlib-static \ -p perry-ext-events -p perry-ext-http -p perry-ext-net \ diff --git a/changelog.d/10076-minsize-inline-policy.md b/changelog.d/10076-minsize-inline-policy.md index 5fcf0d7579..7167520568 100644 --- a/changelog.d/10076-minsize-inline-policy.md +++ b/changelog.d/10076-minsize-inline-policy.md @@ -7,4 +7,6 @@ The shared definition-header renderer carries the policy into textual IR and native LLVM construction. Independent header tests and a pinned-Node native fixture cover both transports, both root modes, Os/Oz controls, exception and object identity, callbacks, and actual moving collections. Scoped native CI -installs the repository's exact Node oracle. +installs the repository's exact Node oracle and prepares the complete coherent +provider graph before entering the bounded fixture. Workflow-selection tests +prevent a second runtime build from consuming the native-test deadline. diff --git a/scripts/test-require-runtime.test.mjs b/scripts/test-require-runtime.test.mjs index 98462da0cb..6e3aacb5d5 100644 --- a/scripts/test-require-runtime.test.mjs +++ b/scripts/test-require-runtime.test.mjs @@ -59,3 +59,56 @@ test('unwind-enabled debug profiles remain rejected', t => { assert.notEqual(result.status, 0); assert.match(result.stderr, /panic=abort runtime profile/); }); + +// Exercise the checked-in workflow branch with Cargo stubbed: this proves the +// setup protocol, not that a release archive was built or linked successfully. +function ciSetup(suites, cargoExit = 0) { + const workflow = fs.readFileSync(path.join(root, '.github/workflows/test.yml'), 'utf8'); + const scoped = workflow.match(/^ {6}- name: Run scoped integration suites\n[\s\S]*?^ {10}status=0$/m); + assert(scoped, 'scoped integration setup must be present'); + const start = scoped[0].indexOf(' if printf'); + assert(start >= 0, 'runtime selection must be present'); + const setup = scoped[0].slice(start); + const script = `set -eu +cargo() { printf 'cargo:%s\\n' "$*"; return ${cargoExit}; } +${setup} +printf 'prepared:%s\\nruntime:%s\\n' "\${PERRY_TEST_RUNTIME_PREBUILT-unset}" "\${PERRY_RUNTIME_DIR-unset}" +`; + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('PERRY_'))); + return spawnSync('bash', ['-c', script], { cwd: root, env: { ...env, SUITES: suites }, + encoding: 'utf8', timeout: 10_000 }); +} + +test('scoped CI prepares coherent providers for each standalone native consumer', () => { + for (const suite of ['bun_text_modules', 'import_meta_require_value', 'minsize_inline_policy']) { + const result = ciSetup(`perry-codegen typed_feedback 300\nperry ${suite} 1500`); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /prepared:1\n/, suite); + assert(result.stdout.includes(`runtime:${root}target/release\n`), result.stdout); + const calls = result.stdout.split('\n').filter(line => line.startsWith('cargo:')); + assert.equal(calls.length, 1, suite); + assert.match(calls[0], /^cargo:build --release /); + for (const name of ['perry', 'perry-runtime', 'perry-stdlib', 'perry-runtime-static', + 'perry-stdlib-static', 'perry-ext-events', 'perry-ext-http', 'perry-ext-net', + 'perry-ext-typescript', 'perry-ext-ws', 'perry-ext-zlib']) { + assert(calls[0].includes(`-p ${name} `), name); + } + assert.match(calls[0], /--features perry-stdlib\/external-net-pump$/); + } +}); + +test('scoped CI does not mark unrelated or partial runtime setup prepared', () => { + for (const suites of ['', 'perry-codegen minsize_inline_policy 300', + 'perry minsize_inline_policy_extra 1500', 'perry unrelated 1500']) { + const result = ciSetup(suites); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /prepared:unset\n/, suites); + assert.doesNotMatch(result.stdout, /-p perry-ext-/); + } +}); + +test('scoped CI propagates provider-build failure before declaring prepared', () => { + const result = ciSetup('perry minsize_inline_policy 1500', 73); + assert.equal(result.status, 73, result.stderr); + assert.doesNotMatch(result.stdout, /^prepared:/m); +});