From d648bcd2e41ec0b45bc02a69dee0d7206fc64f8f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:32:29 +0800 Subject: [PATCH] test: add source-built Hopper conformance fixtures --- package.json | 2 + scripts/build-conformance-fixtures.mjs | 223 ++++++++++++++++++++++++ scripts/lib/conformance-fixtures.d.mts | 6 + scripts/lib/conformance-fixtures.mjs | 30 ++++ scripts/verify-conformance-fixtures.mjs | 68 ++++++++ tests/conformance/README.md | 16 ++ tests/conformance/c/fixture.c | 19 ++ tests/conformance/napi/fixture.c | 17 ++ tests/conformance/objc/fixture.m | 21 +++ tests/conformance/swift/fixture.swift | 11 ++ tests/conformance/versions/v1.c | 9 + tests/conformance/versions/v2.c | 10 ++ tests/conformanceFixtures.test.ts | 33 ++++ 13 files changed, 465 insertions(+) create mode 100644 scripts/build-conformance-fixtures.mjs create mode 100644 scripts/lib/conformance-fixtures.d.mts create mode 100644 scripts/lib/conformance-fixtures.mjs create mode 100644 scripts/verify-conformance-fixtures.mjs create mode 100644 tests/conformance/README.md create mode 100644 tests/conformance/c/fixture.c create mode 100644 tests/conformance/napi/fixture.c create mode 100644 tests/conformance/objc/fixture.m create mode 100644 tests/conformance/swift/fixture.swift create mode 100644 tests/conformance/versions/v1.c create mode 100644 tests/conformance/versions/v2.c create mode 100644 tests/conformanceFixtures.test.ts diff --git a/package.json b/package.json index 466e87615..187b0c8c6 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,8 @@ "test:watch": "vitest", "typecheck": "tsc --noEmit", "verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs", + "build:fixtures": "node scripts/build-conformance-fixtures.mjs", + "verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs", "verify:package": "npm run build && node scripts/verify-package.mjs", "config:print": "node scripts/print-mcp-config.mjs", "docs:generate": "typedoc", diff --git a/scripts/build-conformance-fixtures.mjs b/scripts/build-conformance-fixtures.mjs new file mode 100644 index 000000000..11b4f0889 --- /dev/null +++ b/scripts/build-conformance-fixtures.mjs @@ -0,0 +1,223 @@ +import { execFile } from "node:child_process"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { arch, platform } from "node:os"; +import { join, relative, resolve } from "node:path"; +import { promisify } from "node:util"; + +import { + generateLargeFixture, + LARGE_FIXTURE_COUNT, + sha256, + sourceDigest, +} from "./lib/conformance-fixtures.mjs"; + +const exec = promisify(execFile); +const root = process.cwd(); +const sourceRoot = join(root, "tests/conformance"); +const outputRoot = resolve( + process.env.REA_CONFORMANCE_BUILD ?? join(root, "build/conformance"), +); +if (platform() !== "darwin") + throw new Error( + "Mach-O conformance fixtures require macOS and Xcode command-line tools", + ); +await rm(outputRoot, { recursive: true, force: true }); +await mkdir(outputRoot, { recursive: true }); + +const clang = (await exec("xcrun", ["--find", "clang"])).stdout.trim(); +const clangVersion = (await exec(clang, ["--version"])).stdout.split("\n")[0]; +const sdkPath = (await exec("xcrun", ["--show-sdk-path"])).stdout.trim(); +const architecture = arch(); +const common = [ + "-arch", + architecture, + "-isysroot", + sdkPath, + "-O0", + "-g", + "-fno-inline", +]; +const fixtures = []; + +await build( + "c", + ["c/fixture.c"], + [...common, join(sourceRoot, "c/fixture.c"), "-o", join(outputRoot, "c")], + { + expectations: { + symbols: ["_rea_entry", "_rea_branch", "_rea_leaf", "_rea_c_global"], + strings: ["REA_C_ENTRY", "REA_C_LEAF"], + }, + }, +); +await build( + "version-v1", + ["versions/v1.c"], + [ + ...common, + join(sourceRoot, "versions/v1.c"), + "-o", + join(outputRoot, "version-v1"), + ], + { + expectations: { + symbols: ["_rea_version_entry", "_rea_version_leaf"], + strings: ["REA_VERSION_ONE"], + }, + }, +); +await build( + "version-v2", + ["versions/v2.c"], + [ + ...common, + join(sourceRoot, "versions/v2.c"), + "-o", + join(outputRoot, "version-v2"), + ], + { + expectations: { + symbols: ["_rea_version_entry", "_rea_version_leaf", "_rea_added"], + strings: ["REA_VERSION_TWO"], + }, + }, +); + +const largeSource = generateLargeFixture(); +const largePath = join(outputRoot, "large.c"); +await writeFile(largePath, largeSource); +await build( + "large", + [{ path: "generated/large.c", content: largeSource }], + [...common, largePath, "-o", join(outputRoot, "large")], + { + expectations: { + symbolPrefix: "_rea_page_", + symbolCount: LARGE_FIXTURE_COUNT, + stringPrefix: "REA_PAGE_", + stringCount: LARGE_FIXTURE_COUNT, + }, + }, +); + +await build( + "objc", + ["objc/fixture.m"], + [ + ...common, + "-fobjc-arc", + join(sourceRoot, "objc/fixture.m"), + "-framework", + "Foundation", + "-o", + join(outputRoot, "objc"), + ], + { + expectations: { + symbols: ["_OBJC_CLASS_$_REAWidget"], + strings: ["REAWidget", "REAWidgetDelegate", "performAction:error:"], + }, + }, +); +await build( + "napi", + ["napi/fixture.c"], + [ + ...common, + "-bundle", + "-undefined", + "dynamic_lookup", + join(sourceRoot, "napi/fixture.c"), + "-o", + join(outputRoot, "fixture.node"), + ], + { + expectations: { + symbols: ["_napi_register_module_v1"], + strings: ["reaProbeOne", "reaProbeTwo", "reaProbeThree"], + }, + }, +); + +try { + const swiftc = (await exec("xcrun", ["--find", "swiftc"])).stdout.trim(); + const swiftVersion = (await exec(swiftc, ["--version"])).stdout.split( + "\n", + )[0]; + await build( + "swift", + ["swift/fixture.swift"], + [ + "-sdk", + sdkPath, + "-Onone", + "-g", + join(sourceRoot, "swift/fixture.swift"), + "-o", + join(outputRoot, "swift"), + ], + { + expectations: { + strings: [ + "REA_SWIFT_EXECUTE", + "REAService", + "REARecord", + "REAState", + "REAProtocol", + ], + }, + compiler: swiftc, + compilerVersion: swiftVersion, + }, + ); +} catch (cause) { + process.stderr.write( + `Skipping optional Swift fixture: ${cause instanceof Error ? cause.message : String(cause)}\n`, + ); +} + +const manifest = { + schemaVersion: 1, + platform: platform(), + architecture, + generatedAt: new Date(0).toISOString(), + fixtures, +}; +await writeFile( + join(outputRoot, "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, +); +process.stdout.write( + `${JSON.stringify({ outputRoot, fixtureCount: fixtures.length })}\n`, +); + +async function build(name, sources, args, options) { + const compiler = options.compiler ?? clang; + const compilerVersion = options.compilerVersion ?? clangVersion; + const normalized = await Promise.all( + sources.map(async (source) => { + if (typeof source !== "string") return source; + return { + path: source, + content: await readFile(join(sourceRoot, source), "utf8"), + }; + }), + ); + await exec(compiler, args, { maxBuffer: 10 * 1024 * 1024 }); + const artifactPath = args.at(-1); + if (typeof artifactPath !== "string") + throw new Error(`Missing output for ${name}`); + const artifact = await readFile(artifactPath); + fixtures.push({ + name, + artifact: relative(outputRoot, artifactPath), + artifactSha256: sha256(artifact), + sourceSha256: sourceDigest(normalized), + sources: normalized.map(({ path, content }) => ({ + path, + sha256: sha256(content), + })), + compiler: { path: compiler, version: compilerVersion, arguments: args }, + expectations: options.expectations, + }); +} diff --git a/scripts/lib/conformance-fixtures.d.mts b/scripts/lib/conformance-fixtures.d.mts new file mode 100644 index 000000000..949e300ce --- /dev/null +++ b/scripts/lib/conformance-fixtures.d.mts @@ -0,0 +1,6 @@ +export const LARGE_FIXTURE_COUNT: number; +export function sha256(value: string | Uint8Array): string; +export function generateLargeFixture(count?: number): string; +export function sourceDigest( + sources: readonly { readonly path: string; readonly content: string }[], +): string; diff --git a/scripts/lib/conformance-fixtures.mjs b/scripts/lib/conformance-fixtures.mjs new file mode 100644 index 000000000..64952dadb --- /dev/null +++ b/scripts/lib/conformance-fixtures.mjs @@ -0,0 +1,30 @@ +import { createHash } from "node:crypto"; + +export const LARGE_FIXTURE_COUNT = 1_205; + +export const sha256 = (value) => + createHash("sha256").update(value).digest("hex"); + +export const generateLargeFixture = (count = LARGE_FIXTURE_COUNT) => { + if (!Number.isInteger(count) || count < 1) + throw new Error("Fixture count must be a positive integer"); + const declarations = Array.from( + { length: count }, + (_, index) => + `__attribute__((noinline, used)) int rea_page_${String(index).padStart(4, "0")}(void) { puts("REA_PAGE_${String(index).padStart(4, "0")}"); return ${index}; }`, + ); + return [ + "#include ", + ...declarations, + "int main(void) { return rea_page_0000(); }", + "", + ].join("\n"); +}; + +export const sourceDigest = (sources) => { + const canonical = [...sources] + .sort(({ path: left }, { path: right }) => left.localeCompare(right)) + .map(({ path, content }) => `${path}\0${sha256(content)}\n`) + .join(""); + return sha256(canonical); +}; diff --git a/scripts/verify-conformance-fixtures.mjs b/scripts/verify-conformance-fixtures.mjs new file mode 100644 index 000000000..7cf09addc --- /dev/null +++ b/scripts/verify-conformance-fixtures.mjs @@ -0,0 +1,68 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { promisify } from "node:util"; + +import { sha256 } from "./lib/conformance-fixtures.mjs"; + +const exec = promisify(execFile); +const manifestPath = resolve( + process.argv[2] ?? "build/conformance/manifest.json", +); +const manifest = JSON.parse(await readFile(manifestPath, "utf8")); +if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.fixtures)) + throw new Error("Unsupported conformance manifest"); +const root = dirname(manifestPath); + +for (const fixture of manifest.fixtures) { + const artifactPath = join(root, fixture.artifact); + const artifact = await readFile(artifactPath); + if (sha256(artifact) !== fixture.artifactSha256) + throw new Error(`${fixture.name}: artifact hash mismatch`); + const fileOutput = (await exec("file", [artifactPath])).stdout; + if (!fileOutput.includes("Mach-O")) + throw new Error(`${fixture.name}: not Mach-O`); + const symbols = ( + await exec("nm", ["-gU", artifactPath], { maxBuffer: 20 * 1024 * 1024 }) + ).stdout; + const strings = ( + await exec("strings", [artifactPath], { maxBuffer: 20 * 1024 * 1024 }) + ).stdout; + for (const symbol of fixture.expectations.symbols ?? []) + if (!symbols.includes(symbol)) + throw new Error(`${fixture.name}: missing symbol ${symbol}`); + for (const value of fixture.expectations.strings ?? []) + if (!strings.includes(value)) + throw new Error(`${fixture.name}: missing string ${value}`); + verifyCount( + symbols, + fixture.expectations.symbolPrefix, + fixture.expectations.symbolCount, + fixture.name, + ); + verifyCount( + strings, + fixture.expectations.stringPrefix, + fixture.expectations.stringCount, + fixture.name, + ); +} + +const v1 = manifest.fixtures.find(({ name }) => name === "version-v1"); +const v2 = manifest.fixtures.find(({ name }) => name === "version-v2"); +if (v1?.artifactSha256 === v2?.artifactSha256) + throw new Error("Version-pair artifacts unexpectedly have equal hashes"); +process.stdout.write( + `${JSON.stringify({ verified: manifest.fixtures.length, manifestPath })}\n`, +); + +function verifyCount(output, prefix, expected, fixture) { + if (prefix === undefined || expected === undefined) return; + const count = output + .split("\n") + .filter((line) => line.includes(prefix)).length; + if (count !== expected) + throw new Error( + `${fixture}: expected ${expected} ${prefix} records, received ${count}`, + ); +} diff --git a/tests/conformance/README.md b/tests/conformance/README.md new file mode 100644 index 000000000..88d64f28a --- /dev/null +++ b/tests/conformance/README.md @@ -0,0 +1,16 @@ +# Source-built conformance fixtures + +These sources are deliberately small semantic oracles for REA providers. Run +`npm run verify:fixtures` on macOS to compile them into the ignored +`build/conformance/` directory, emit a hash-and-toolchain manifest, and verify +their Mach-O symbols and strings with native command-line tools. + +Only source files are versioned. Generated binaries, generated large-fixture +source, debug information, and manifests must remain under `build/`. + +The C fixture fixes a known call chain and strings. The version pair fixes an +added symbol, changed string, and changed call relationship. The generated +large fixture contains 1,205 uniquely named functions and strings so provider +pagination can be checked at boundaries of 500, 1,000, and 1,205. Objective-C, +Swift, and N-API-like fixtures provide metadata-oriented inputs without package +or network dependencies. diff --git a/tests/conformance/c/fixture.c b/tests/conformance/c/fixture.c new file mode 100644 index 000000000..38cfc2df3 --- /dev/null +++ b/tests/conformance/c/fixture.c @@ -0,0 +1,19 @@ +#include + +volatile int rea_c_global = 7; + +__attribute__((noinline, used)) int rea_leaf(int value) { + puts("REA_C_LEAF"); + return value + rea_c_global; +} + +__attribute__((noinline, used)) int rea_branch(int value) { + return value > 3 ? rea_leaf(value) : rea_leaf(-value); +} + +__attribute__((noinline, used)) int rea_entry(void) { + puts("REA_C_ENTRY"); + return rea_branch(5); +} + +int main(void) { return rea_entry() == 12 ? 0 : 1; } diff --git a/tests/conformance/napi/fixture.c b/tests/conformance/napi/fixture.c new file mode 100644 index 000000000..c78c9f7f4 --- /dev/null +++ b/tests/conformance/napi/fixture.c @@ -0,0 +1,17 @@ +typedef void *napi_env; +typedef void *napi_value; +typedef napi_value (*napi_callback)(napi_env, void *); +extern int napi_create_function(napi_env, const char *, unsigned long, + napi_callback, void *, napi_value *); +extern int napi_set_named_property(napi_env, napi_value, const char *, napi_value); +static napi_value rea_probe(napi_env env, void *info) { return (napi_value)info; } +__attribute__((visibility("default"))) napi_value napi_register_module_v1( + napi_env env, napi_value exports) { + const char *names[] = {"reaProbeOne", "reaProbeTwo", "reaProbeThree"}; + for (int i = 0; i < 3; i++) { + napi_value function = 0; + napi_create_function(env, names[i], 0, rea_probe, 0, &function); + napi_set_named_property(env, exports, names[i], function); + } + return exports; +} diff --git a/tests/conformance/objc/fixture.m b/tests/conformance/objc/fixture.m new file mode 100644 index 000000000..bc9f5ea2d --- /dev/null +++ b/tests/conformance/objc/fixture.m @@ -0,0 +1,21 @@ +#import +@protocol REAWidgetDelegate +- (void)widgetDidFinish; +@end +@interface REAWidget : NSObject +@property(nonatomic, weak) id delegate; +- (BOOL)performAction:(NSString *)action error:(NSError **)error; +@end +@implementation REAWidget +- (BOOL)performAction:(NSString *)action error:(NSError **)error { + NSLog(@"REA_OBJC_ACTION:%@", action); + return action.length > 0; +} +@end +int main(void) { + @autoreleasepool { + Protocol *protocol = @protocol(REAWidgetDelegate); + NSLog(@"%@", NSStringFromProtocol(protocol)); + return [[[REAWidget alloc] init] performAction:@"run" error:0] ? 0 : 1; + } +} diff --git a/tests/conformance/swift/fixture.swift b/tests/conformance/swift/fixture.swift new file mode 100644 index 000000000..f7d9e0a9c --- /dev/null +++ b/tests/conformance/swift/fixture.swift @@ -0,0 +1,11 @@ +import Darwin + +protocol REAProtocol { func execute() -> Int } +struct REARecord { let value: Int } +enum REAState { case ready, finished } +final class REAService: REAProtocol { + @inline(never) func execute() -> Int { print("REA_SWIFT_EXECUTE"); return 42 } +} +extension REARecord { @inline(never) func doubled() -> Int { value * 2 } } +let service = REAService() +exit(service.execute() == 42 ? 0 : 1) diff --git a/tests/conformance/versions/v1.c b/tests/conformance/versions/v1.c new file mode 100644 index 000000000..f9d2162bd --- /dev/null +++ b/tests/conformance/versions/v1.c @@ -0,0 +1,9 @@ +#include +__attribute__((noinline, used)) int rea_version_leaf(void) { + puts("REA_VERSION_ONE"); + return 1; +} +__attribute__((noinline, used)) int rea_version_entry(void) { + return rea_version_leaf(); +} +int main(void) { return rea_version_entry() == 1 ? 0 : 1; } diff --git a/tests/conformance/versions/v2.c b/tests/conformance/versions/v2.c new file mode 100644 index 000000000..8d5828652 --- /dev/null +++ b/tests/conformance/versions/v2.c @@ -0,0 +1,10 @@ +#include +__attribute__((noinline, used)) int rea_version_leaf(void) { + puts("REA_VERSION_TWO"); + return 2; +} +__attribute__((noinline, used)) int rea_added(void) { return 40; } +__attribute__((noinline, used)) int rea_version_entry(void) { + return rea_version_leaf() + rea_added(); +} +int main(void) { return rea_version_entry() == 42 ? 0 : 1; } diff --git a/tests/conformanceFixtures.test.ts b/tests/conformanceFixtures.test.ts new file mode 100644 index 000000000..6b02b9ec0 --- /dev/null +++ b/tests/conformanceFixtures.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { + generateLargeFixture, + LARGE_FIXTURE_COUNT, + sha256, + sourceDigest, +} from "../scripts/lib/conformance-fixtures.mjs"; + +describe("source-built conformance fixtures", () => { + it("generates a deterministic large pagination fixture", () => { + const first = generateLargeFixture(); + const second = generateLargeFixture(); + + expect(first).toBe(second); + expect(first.match(/int rea_page_/gu)).toHaveLength(LARGE_FIXTURE_COUNT); + expect(first).toContain("rea_page_0000"); + expect(first).toContain("rea_page_1204"); + expect(() => generateLargeFixture(0)).toThrow(/positive integer/u); + }); + + it("hashes source manifests independently of input ordering", () => { + const left = [ + { path: "b.c", content: "b" }, + { path: "a.c", content: "a" }, + ]; + const right = [...left].reverse(); + + expect(sourceDigest(left)).toBe(sourceDigest(right)); + expect(sourceDigest(left)).toMatch(/^[0-9a-f]{64}$/u); + expect(sha256("fixture")).not.toBe(sha256("changed")); + }); +});