fix(javascript): exclude TypeScript this annotations from runtime parameters (#2761)

* fix(javascript): exclude TypeScript this annotations from runtime parameters

* test(javascript): assert exact typed argument bindings

* test(apple): keep dyld slice regression portable

* fix(process): report no remaining time after a provider deadline fires

Node timers can fire while performance.now() is still a fraction of a
millisecond short of the monotonic deadline. ProviderStartupDeadline then
reported interruption "timeout" and an aborted signal while remainingMs()
returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0
(Hopper and Ghidra startup) could run past an elapsed deadline, and the
real-timer test "aborts a bounded wait when its real monotonic deadline
expires" failed intermittently in CI (expected 1 to be 0).

Once the deadline's own timer has classified a timeout, report zero
remaining time. A deterministic regression test fires the timer with the
monotonic clock 0.4 ms short of the deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ci): restore descriptor and fixture validation

* test(ci): retain verification coverage within lint limits

* fix(test): share admitted Ghidra profile installation fixtures

* fix(ci): isolate timing report fixtures from workflow state

---------

Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
hhang
2026-10-14 07:18:50 +08:00
committed by GitHub
co-authored by hhang Kaoru0822 Claude Opus 5.5 morluto
parent 02987d8e8c
commit 1ae9000587
23 changed files with 309 additions and 166 deletions
+3 -1
View File
@@ -154,7 +154,9 @@ node scripts/verify-windows-native-inspection.mjs
This artifact injects one access-denied disposition result while using a real
NTFS directory. The verifier checks retained ownership, recovery on a later
query, cached successful admission, and cleanup on environment teardown. It
does not reproduce or identify the host-dependent cause reported in #1701.
renames the retained probe and recreates its old path, checking that cleanup
removes the owned inode while preserving the replacement directory.
It does not reproduce or identify the host-dependent cause reported in #1701.
The fixture is stored under `.cache/` and is excluded from the shipped addon.
The native lane checks DACL readback independently, source write/replacement
+18 -4
View File
@@ -5,7 +5,9 @@ import {
mkdir,
mkdtemp,
readdir,
rmdir,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
@@ -52,7 +54,7 @@ try {
join(workspace, "dist", "windows", "WindowsNativeLoader.js"),
).href;
const source = `import assert from 'node:assert/strict';
import {readdirSync,renameSync,statSync} from 'node:fs';
import {existsSync,mkdirSync,readdirSync,renameSync,statSync} from 'node:fs';
import {join} from 'node:path';
import {systemWindowsNativeAuthority} from ${JSON.stringify(loader)};
const temp=process.env.TEMP;
@@ -63,14 +65,19 @@ assert.match(first.reason,/Delete owned object by handle failed.*Win32 5/s);
const [name,...extra]=readdirSync(temp);assert.ok(name);assert.equal(extra.length,0);
const path=join(temp,name);const identity=statSync(path,{bigint:true});
// Ownership follows the retained NTFS handle even when the directory moves.
// A replacement at its old pathname must remain caller-owned.
const moved=path+'-renamed';renameSync(path,moved);
assert.equal(statSync(moved,{bigint:true}).ino,identity.ino);
mkdirSync(path);const replacement=statSync(path,{bigint:true});
assert.notEqual(replacement.ino,identity.ino);
if(process.argv[2]!=='teardown'){
const second=systemWindowsNativeAuthority();assert.equal(second.available,true);
assert.equal(readdirSync(temp).length,0);
assert.equal(existsSync(moved),false);
assert.deepEqual(readdirSync(temp),[name]);
assert.equal(statSync(path,{bigint:true}).ino,replacement.ino);
assert.equal(systemWindowsNativeAuthority(),second);
}
console.log(JSON.stringify({mode:process.argv[2],firstFailed:true,retainedOwner:true,inode:identity.ino.toString()}));
console.log(JSON.stringify({mode:process.argv[2],firstFailed:true,retainedOwner:true,inode:identity.ino.toString(),replacementName:name,replacementInode:replacement.ino.toString()}));
`;
const fixture = join(workspace, "probe.mjs");
await writeFile(fixture, source);
@@ -86,10 +93,17 @@ console.log(JSON.stringify({mode:process.argv[2],firstFailed:true,retainedOwner:
}),
);
assert.equal(observed.retainedOwner, true);
assert.deepEqual(await readdir(temp), [observed.replacementName]);
const replacement = join(temp, observed.replacementName);
assert.equal(
(await stat(replacement, { bigint: true })).ino.toString(),
observed.replacementInode,
);
await rmdir(replacement);
assert.deepEqual(await readdir(temp), []);
}
process.stdout.write(
`${JSON.stringify({ ok: true, controlledDispositionFailure: true, retainedOwner: true, retryRecovers: true, successfulAdmissionCached: true, environmentTeardownCleansProbe: true, issue1701HostCauseReproduced: false })}\n`,
`${JSON.stringify({ ok: true, controlledDispositionFailure: true, retainedOwner: true, renamedOwnerCleanup: true, replacementPreserved: true, retryRecovers: true, successfulAdmissionCached: true, environmentTeardownCleansProbe: true, issue1701HostCauseReproduced: false })}\n`,
);
} finally {
await rm(workspace, { recursive: true, force: true });
+1 -1
View File
@@ -98,7 +98,7 @@ const textCoverage = z.strictObject({
text: z.string(),
reported_total_bytes: z.number().int().nonnegative().nullable(),
});
/** A DEX prototype: parameter descriptors followed by the return descriptor. */
/** A DEX prototype; hex parentheses also keep the advertised class valid in v mode. */
export const dexDescriptorSchema = z
.string()
.regex(
@@ -1,4 +1,5 @@
import * as t from "@babel/types";
import { runtimeFunctionParameters } from "./javascriptAstValues.js";
import { JavaScriptSymbolicBindings } from "./javascriptSymbolicBindings.js";
import {
symbolicVariants,
@@ -447,7 +448,9 @@ export const collectElectronWrappedCallPaths = (
const entry = `${key.value}.${name}`;
functionBody(
fn,
fn.node.params.map(() => symbolicUnknown("public API argument")),
runtimeFunctionParameters(fn.node).map(() =>
symbolicUnknown("public API argument"),
),
{
...memberContext,
entryNode: node,
@@ -7,6 +7,7 @@ import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import {
propertyName,
runtimeFunctionParameters,
semanticStaticPropertyKey,
} from "./javascriptAstValues.js";
import { stringValue } from "./javascriptStaticAnalysisHelpers.js";
@@ -38,15 +39,13 @@ export const collectJavaScriptExports = (
bindings?: JavaScriptSemanticAnalysisState,
): StaticExports => {
const exports = new Set<string>();
const parameter = t.isFunction(node)
? runtimeFunctionParameters(node)[1]
: undefined;
const factoryExports =
bindings !== undefined &&
t.isFunction(node) &&
t.isIdentifier(node.params[1])
? resolveSemanticBindingState(
bindings,
node.params[1],
node.params[1].name,
)?.bindingId
bindings !== undefined && t.isFunction(node) && t.isIdentifier(parameter)
? resolveSemanticBindingState(bindings, parameter, parameter.name)
?.bindingId
: undefined;
const isExportsAlias = (reference: t.Node | null): boolean => {
if (!t.isIdentifier(reference)) return false;
@@ -1,6 +1,14 @@
import * as t from "@babel/types";
import type { JavaScriptSemanticPrimitive } from "./javascriptSemanticValueTypes.js";
/** TypeScript receiver annotations occupy no positional runtime parameter slot. */
export const runtimeFunctionParameters = (
node: t.Function,
): t.Function["params"] =>
t.isIdentifier(node.params[0], { name: "this" })
? node.params.slice(1)
: node.params;
/**
* Remove wrappers that affect TypeScript or Flow checking but not runtime
* expression identity. The returned depth lets bounded evaluators account for
+7 -4
View File
@@ -1,6 +1,9 @@
import * as t from "@babel/types";
import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
import {
semanticStaticPropertyKey,
runtimeFunctionParameters,
} from "./javascriptAstValues.js";
import { moduleFactory } from "./javascriptStaticAnalysisHelpers.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import {
@@ -27,7 +30,7 @@ export const javascriptBootstrapAt = (
(!t.isFunctionExpression(call.callee) &&
!t.isArrowFunctionExpression(call.callee)) ||
!t.isBlockStatement(call.callee.body) ||
call.callee.params.length !== 0 ||
runtimeFunctionParameters(call.callee).length !== 0 ||
call.arguments.length !== 0
)
return undefined;
@@ -63,10 +66,10 @@ export const javascriptBootstrapAt = (
!t.isFunctionDeclaration(loader) ||
loader.id === null ||
loader.id === undefined ||
loader.params.length !== 1
runtimeFunctionParameters(loader).length !== 1
)
continue;
const parameter = loader.params[0];
const parameter = runtimeFunctionParameters(loader)[0];
if (!t.isIdentifier(parameter)) continue;
const loaderName = loader.id.name;
const loaderBindingId = bindingId(state, loader.id);
@@ -26,6 +26,7 @@ import {
import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js";
import {
readExactJavaScriptLiteral,
runtimeFunctionParameters,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
@@ -662,10 +663,10 @@ const identityFunction = (
!t.isFunction(node) ||
node.async ||
node.generator ||
node.params.length !== 1
runtimeFunctionParameters(node).length !== 1
)
return false;
const parameter = node.params[0];
const parameter = runtimeFunctionParameters(node)[0];
const result = t.isBlockStatement(node.body)
? node.body.body.length === 1 && t.isReturnStatement(node.body.body[0])
? node.body.body[0].argument
@@ -18,6 +18,7 @@ import {
} from "./javascriptSemanticProjection.js";
import {
semanticObjectPatternKeys,
runtimeFunctionParameters,
semanticStaticPropertyKey,
semanticWriteTargets,
} from "./javascriptAstValues.js";
@@ -717,7 +718,7 @@ function* collectDefinitionsSteps(
functionBodies.push({
body: node,
scope: nested,
parameters: parent.params,
parameters: runtimeFunctionParameters(parent),
});
}
const scope = currentSemanticScope(stack);
@@ -1076,7 +1077,7 @@ const scopeKind = (
};
const functionParametersHaveExpressions = (node: t.Function): boolean => {
const pending: t.Node[] = [...node.params];
const pending: t.Node[] = [...runtimeFunctionParameters(node)];
while (pending.length > 0) {
const parameter = pending.pop();
if (t.isAssignmentPattern(parameter)) return true;
@@ -1133,7 +1134,7 @@ const bindFunctionLocals = (
scope: JavaScriptSemanticScopeState,
state: JavaScriptSemanticAnalysisState,
): void => {
for (const parameter of node.params)
for (const parameter of runtimeFunctionParameters(node))
bindPattern({
pattern: t.isTSParameterProperty(parameter)
? parameter.parameter
@@ -6,7 +6,10 @@ import type {
} from "./javascriptSemanticIr.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js";
import { unwrapJavaScriptExpression } from "./javascriptAstValues.js";
import {
unwrapJavaScriptExpression,
runtimeFunctionParameters,
} from "./javascriptAstValues.js";
import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
@@ -194,14 +197,15 @@ const parameterAcceptsArgument = (
): boolean => {
const node = state.callableNodesById.get(callable.callableId);
if (!t.isFunction(node)) return false;
const parameterIndex = node.params.findIndex((parameter) =>
const parameters = runtimeFunctionParameters(node);
const parameterIndex = parameters.findIndex((parameter) =>
rangeContains(range(parameter), location),
);
if (parameterIndex === argumentIndex) return true;
return (
parameterIndex >= 0 &&
parameterIndex < argumentIndex &&
t.isRestElement(node.params[parameterIndex])
t.isRestElement(parameters[parameterIndex])
);
};
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import * as t from "@babel/types";
import { runtimeFunctionParameters } from "./javascriptAstValues.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import type { JavaScriptSemanticCallAnalysis } from "./javascriptSemanticCalls.js";
@@ -279,7 +280,7 @@ const relationShape = (
];
const callableParameterArity = (node: t.Node): number =>
t.isFunction(node) ? node.params.length : 0;
t.isFunction(node) ? runtimeFunctionParameters(node).length : 0;
const isAsyncCallable = (node: t.Node): boolean =>
t.isFunction(node) && node.async;
@@ -2,6 +2,7 @@ import * as t from "@babel/types";
import {
semanticObjectPatternKeys,
runtimeFunctionParameters,
semanticStaticPropertyKey,
} from "./javascriptAstValues.js";
import {
@@ -212,7 +213,8 @@ export const semanticIterationSources = (
else if (t.isYieldExpression(node) && node.delegate && node.argument != null)
add(sources, { node: node.argument, projection: [] }, node, {});
else if (t.isFunction(node))
for (const parameter of node.params) visit(context, parameter, undefined);
for (const parameter of runtimeFunctionParameters(node))
visit(context, parameter, undefined);
return sources;
};
@@ -1,4 +1,5 @@
import * as t from "@babel/types";
import { runtimeFunctionParameters } from "./javascriptAstValues.js";
import { javascriptBootstrapAt } from "./javascriptBootstrap.js";
import {
resolveSemanticBindingState,
@@ -304,7 +305,7 @@ const runtimeMetadata = (node: t.Node | null | undefined): RuntimeMetadata => {
asyncChunkKeys: [],
unknownAsyncChunkKeys: 0,
};
const parameter = node.params[0];
const parameter = runtimeFunctionParameters(node)[0];
const requireName = t.isIdentifier(parameter) ? parameter.name : null;
if (requireName === null)
return {
@@ -13,6 +13,7 @@ import type {
} from "./javascriptStaticAnalysisTypes.js";
import {
readExactJavaScriptLiteral,
runtimeFunctionParameters,
semanticStaticPropertyKey,
semanticStaticPropertyName,
unwrapJavaScriptExpression,
@@ -147,7 +148,7 @@ export const modulePropertyName = (
export const factoryRequireName = (
factory: t.FunctionExpression | t.ArrowFunctionExpression | t.ObjectMethod,
): string | null => {
const parameter = factory.params[2];
const parameter = runtimeFunctionParameters(factory)[2];
return t.isIdentifier(parameter) ? parameter.name : null;
};
@@ -1,6 +1,7 @@
import * as t from "@babel/types";
import {
semanticStaticPropertyKey,
runtimeFunctionParameters,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { javascriptBootstrapAt } from "./javascriptBootstrap.js";
@@ -387,7 +388,7 @@ export class JavaScriptSymbolicBindings {
invocationNode?: t.Node,
): SymbolicEnvironment {
const values = new Map<string, SymbolicValue>();
fn.node.params.forEach((param, index) => {
runtimeFunctionParameters(fn.node).forEach((param, index) => {
const target = t.isRestElement(param) ? param.argument : param;
if (!t.isIdentifier(target)) return;
const binding = resolveSemanticBindingState(
@@ -866,9 +867,7 @@ export class JavaScriptSymbolicBindings {
? {}
: { scopeId: this.state.scopeByNode.get(factory)!.scopeId }),
};
const first = factory.params[0];
const second = factory.params[1];
const third = factory.params[2];
const [first, second, third] = runtimeFunctionParameters(factory);
const binding = (n: t.Node | undefined) =>
t.isIdentifier(n)
? resolveSemanticBindingState(this.state, n, n.name)?.bindingId
+1 -14
View File
@@ -23,7 +23,7 @@ import {
type GhidraSeedCommitment,
type GhidraSeedReport,
} from "./GhidraAnalysisSeeds.js";
import type { GhidraInstallationHost } from "./GhidraInstallation.js";
import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixture.js";
import { GhidraProvider } from "./GhidraProvider.js";
const directories: string[] = [];
@@ -193,19 +193,6 @@ describe("Ghidra seed session verification", () => {
});
describe("Ghidra seed profile", () => {
const host: GhidraInstallationHost = {
platform: "linux",
architecture: "x64",
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
version: "21.0.11",
major: 21,
home: "/jdk-21",
bits: 64,
runtime: "jdk",
}),
};
const pe: BinaryTarget = {
path: "/fixture.exe",
sha256: "a".repeat(64),
+16
View File
@@ -0,0 +1,16 @@
import type { GhidraInstallationHost } from "./GhidraInstallation.js";
/** Simulated valid installation for profile tests; never starts Ghidra or Java. */
export const ghidraProfileInstallationHost: GhidraInstallationHost = {
platform: "linux",
architecture: "x64",
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
version: "21.0.11",
major: 21,
home: "/jdk-21",
bits: 64,
runtime: "jdk",
}),
};
+1 -16
View File
@@ -11,7 +11,7 @@ import {
ghidraProfileLanguageOverride,
sameGhidraLanguageOverride,
} from "./GhidraAnalysisProfile.js";
import type { GhidraInstallationHost } from "./GhidraInstallation.js";
import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixture.js";
import {
admitGhidraLanguageOverride,
parseGhidraLanguageDefinitions,
@@ -39,21 +39,6 @@ const X86_LDEFS = `<?xml version="1.0" encoding="UTF-8"?>
</language_definitions>
`;
// Fake installation inspection; the language catalog is a real directory.
const host: GhidraInstallationHost = {
platform: "linux",
architecture: "x64",
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
version: "21.0.11",
major: 21,
home: "/jdk-21",
bits: 64,
runtime: "jdk",
}),
};
const pe: BinaryTarget = {
path: "/fixture.exe",
sha256: "a".repeat(64),
+15 -17
View File
@@ -14,7 +14,7 @@ import {
ghidraMipsProfileParameters,
ghidraPinnedMipsLanguage,
} from "./GhidraMipsProfile.js";
import type { GhidraInstallationHost } from "./GhidraInstallation.js";
import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixture.js";
import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js";
import type { GhidraMipsSoftFloatReport } from "./GhidraMipsProfile.js";
import {
@@ -22,20 +22,6 @@ import {
ghidraProfileLanguageOverride,
} from "./GhidraAnalysisProfile.js";
// Fake installation inspection tests admission/profile contracts, not Ghidra.
const host: GhidraInstallationHost = {
platform: "linux",
architecture: "x64",
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
version: "21.0.11",
major: 21,
home: "/jdk-21",
bits: 64,
runtime: "jdk",
}),
};
const parsed = parseConfig({ GHIDRA_INSTALL_DIR: "/ghidra" });
if (!parsed.ok) throw parsed.error;
const config = parsed.value;
@@ -155,7 +141,13 @@ describe("bounded Ghidra MIPS admission", () => {
reason: expect.stringContaining(reason),
});
const resolved = await ghidra.resolveAnalysisProfile(value);
expect(resolved.ok).toBe(false);
expect(resolved).toMatchObject({
ok: false,
error: {
_tag: "AnalysisUnsupportedTargetError",
reason: expect.stringContaining(reason),
},
});
},
);
@@ -180,7 +172,13 @@ describe("recordless legacy MIPS32 admission", () => {
status: "unsupported",
});
const resolved = await ghidra.resolveAnalysisProfile(object);
expect(resolved.ok).toBe(false);
expect(resolved).toMatchObject({
ok: false,
error: {
_tag: "AnalysisUnsupportedTargetError",
reason: expect.stringContaining("ET_EXEC"),
},
});
});
it.each([
+9 -16
View File
@@ -16,7 +16,7 @@ import {
ghidraProfileLanguageOverride,
} from "./GhidraAnalysisProfile.js";
import { GhidraProvider } from "./GhidraProvider.js";
import type { GhidraInstallationHost } from "./GhidraInstallation.js";
import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixture.js";
// Abbreviated from Ghidra 12.1.4 Processors/PowerPC/data/languages/ppc.ldefs.
const PPC_LDEFS = `<?xml version="1.0" encoding="UTF-8"?>
@@ -32,20 +32,6 @@ const PPC_LDEFS = `<?xml version="1.0" encoding="UTF-8"?>
</language_definitions>
`;
// Fake installation inspection tests admission/profile contracts, not Ghidra.
const host: GhidraInstallationHost = {
platform: "linux",
architecture: "x64",
readText: () => "application.version=12.1.4\n",
executable: () => true,
probeJava: () => ({
version: "21.0.11",
major: 21,
home: "/jdk-21",
bits: 64,
runtime: "jdk",
}),
};
const configFor = (environment: NodeJS.ProcessEnv) => {
const parsed = parseConfig({ GHIDRA_INSTALL_DIR: "/ghidra", ...environment });
if (!parsed.ok) throw parsed.error;
@@ -65,6 +51,7 @@ const target = (change: Partial<PowerPcElfMetadata> = {}): BinaryTarget => ({
describe("bounded Ghidra PowerPC admission", () => {
it("binds the generic language instead of the loader's e500 choice", async () => {
const ghidra = new GhidraProvider(config, silentLogger, {}, host);
expect(ghidra.inspectAvailability()).toMatchObject({ status: "available" });
expect(ghidra.inspectTargetSupport(target()).status).toBe("supported");
const resolved = await ghidra.resolveAnalysisProfile(target());
if (!resolved.ok) throw resolved.error;
@@ -152,7 +139,13 @@ describe("bounded Ghidra PowerPC admission", () => {
reason: expect.stringContaining(reason),
});
const resolved = await ghidra.resolveAnalysisProfile(value);
expect(resolved.ok).toBe(false);
expect(resolved).toMatchObject({
ok: false,
error: {
_tag: "AnalysisUnsupportedTargetError",
reason: expect.stringContaining(reason),
},
});
},
);
+1 -15
View File
@@ -13,7 +13,7 @@ import { hopperLoaderArgsForTarget } from "../hopper/HopperAnalysisProfile.js";
import { IdaProvider } from "../ida/IdaProvider.js";
import { GhidraProvider } from "./GhidraProvider.js";
import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js";
import type { GhidraInstallationHost } from "./GhidraInstallation.js";
import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixture.js";
import {
ghidraPinnedLanguage,
ghidraProfileLanguageOverride,
@@ -49,20 +49,6 @@ const RISCV_LDEFS = `<?xml version="1.0" encoding="UTF-8"?>
</language_definitions>
`;
// Fake installation inspection tests admission/profile contracts, not Ghidra.
const host: GhidraInstallationHost = {
platform: "linux",
architecture: "x64",
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
version: "21.0.11",
major: 21,
home: "/jdk-21",
bits: 64,
runtime: "jdk",
}),
};
const configOf = (
installDir: string,
environment: Record<string, string> = {},
@@ -0,0 +1,140 @@
import { join } from "node:path";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
cliTest.for(["CLI", "stdio MCP"] as const)(
"maps arguments to runtime TypeScript parameters through %s",
async (surface, { cli, processes }) => {
const root = await createTestTempDirectory("rea-this-parameters-");
await writeFixtureFiles(root, {
"bundle.mts": [
"self.webpackChunkApp.push([[1], { 7: function(this: void, module: unknown, exports: Record<string, unknown>, require: (id: number) => unknown) { exports.answer = 42; } }, function(this: void, require: (id: number) => unknown) { require(7); }]);",
].join("\n"),
"interop.cts": [
"function identity<T>(this: void, value: T) { return value; }",
'const electron = identity(require("electron"));',
'electron.ipcRenderer.send("typed-identity");',
].join("\n"),
"ipc-wrapper.cts": [
'const { ipcRenderer } = require("electron");',
"function send(this: void, channel: string) { ipcRenderer.send(channel); }",
"function sendRest(this: void, ...channels: string[]) { ipcRenderer.send(...channels); }",
'send("typed-channel");',
'sendRest("typed-rest");',
].join("\n"),
"entry.mts": [
"function first(this: void, value: number) { return value; }",
"function rest(this: void, ...values: number[]) { return values; }",
"function destructured({ left }: { left: number }, right: number) { return left + right; }",
"function mixed(this: void, head: number, ...tail: number[]) { return [head, ...tail]; }",
"const a = first(7);",
"const b = rest(1, 2);",
"const c = destructured({ left: 3 }, 4);",
"const d = mixed(9, 10, 11);",
"console.log(JSON.stringify([a, b, c, d, first.length, rest.length, mixed.length]));",
].join("\n"),
});
const control = await processes.run(
process.execPath,
[join(root, "entry.mts")],
{ timeoutMs: 15_000 },
);
expect(control.exitCode, control.stderr).toBe(0);
expect(JSON.parse(control.stdout)).toEqual([
7,
[1, 2],
7,
[9, 10, 11],
1,
0,
1,
]);
const analysis = await analyzeJavaScriptDirectory(surface, cli, root);
const graph = analysis.semantic_graph;
const parameters = graph.nodes.filter(
(node) =>
node.kind === "parameter" && node.identity.module_path === "entry.mts",
);
expect(parameters.map((node) => node.label).sort()).toEqual([
"head",
"left",
"right",
"tail",
"value",
"values",
]);
const parameterIds = new Set(parameters.map((node) => node.node_id));
const flows = graph.relations.filter(
(relation) =>
relation.relation === "argument-to-parameter" &&
parameterIds.has(relation.target_node_id),
);
expect(
flows
.map((flow) => {
const source = graph.nodes.find(
(node) => node.node_id === flow.source_node_id,
);
const target = parameters.find(
(node) => node.node_id === flow.target_node_id,
);
const owner = graph.nodes.find(
(node) => node.node_id === target?.function_node_id,
);
return [
source?.identity.source_range?.start.line,
source?.label,
owner?.label,
target?.label,
];
})
.sort(),
).toEqual([
[5, "argument 0", "first", "value"],
[6, "argument 0", "rest", "values"],
[6, "argument 1", "rest", "values"],
[7, "argument 0", "destructured", "left"],
[7, "argument 1", "destructured", "right"],
[8, "argument 0", "mixed", "head"],
[8, "argument 1", "mixed", "tail"],
[8, "argument 2", "mixed", "tail"],
]);
expect(
analysis.graph.nodes
.flatMap((node) => node.observations)
.some(
(observation) =>
Array.isArray(observation.properties.exports) &&
observation.properties.exports.includes("answer"),
),
).toBe(true);
const channels = analysis.graph.nodes
.filter((node) => node.kind === "ipc-channel")
.flatMap((node) =>
node.observations.map((observation) => observation.properties.channel),
);
expect(channels).toEqual(
expect.arrayContaining(["typed-channel", "typed-rest", "typed-identity"]),
);
for (const [name, arity] of [
["first", 1],
["rest", 1],
["destructured", 2],
["mixed", 2],
] as const) {
const callable = graph.nodes.find(
(node) => node.kind === "function" && node.label === name,
);
expect(callable, name).toBeDefined();
const fingerprint = graph.fingerprints.find(
(value) => value.function_node_id === callable?.node_id,
);
expect(fingerprint?.components.parameter_arity, name).toBe(arity);
}
},
);
+52 -53
View File
@@ -17,6 +17,32 @@ import { workflowGit } from "../../support/workflowGit.js";
const execute = promisify(execFile);
const script = new URL("../../../scripts/ci/test-report.mjs", import.meta.url);
// Each report invocation owns its CI state and summary destination. Inherited
// workflow flags must not change fixture outcomes or append to the real job.
const executeTimingReport = async (
root: string,
environment: NodeJS.ProcessEnv = {},
) => {
const summary = join(root, "summary.md");
await writeFile(summary, "");
const output = await execute(process.execPath, [fileURLToPath(script)], {
cwd: root,
env: {
...process.env,
SHARD_RESULT: "success",
MERGE_RESULT: "success",
SOURCE_REQUIRED: "false",
SOURCE_RESULT: "",
CI_VERIFY_TEST_INVENTORY: "false",
CI_TEST_SELECTION: "",
...environment,
GITHUB_STEP_SUMMARY: summary,
},
});
return { ...output, summary: await readFile(summary, "utf8") };
};
const stepsSchema = z.array(
z.object({
name: z.string().optional(),
@@ -225,14 +251,8 @@ it("rejects successful shard reports with missing or duplicate portable files",
files: names.map((name) => ({ name, durationMs: 1 })),
}),
);
return execute(process.execPath, [fileURLToPath(script)], {
cwd: root,
env: {
...process.env,
SHARD_RESULT: "success",
MERGE_RESULT: "success",
CI_VERIFY_TEST_INVENTORY: "true",
},
return executeTimingReport(root, {
CI_VERIFY_TEST_INVENTORY: "true",
});
};
await expect(
@@ -308,17 +328,11 @@ it("exports portable timing history and removes it on a failed, partial, or inva
source?: string,
) => {
await writeFile(join(root, "test-timings.json"), JSON.stringify(value));
await writeFile(summary, "");
return execute(process.execPath, [fileURLToPath(script)], {
cwd: root,
env: {
...process.env,
GITHUB_STEP_SUMMARY: summary,
SHARD_RESULT: shards,
SOURCE_REQUIRED: String(source !== undefined),
SOURCE_RESULT: source ?? "",
MERGE_RESULT: merge,
},
return executeTimingReport(root, {
SHARD_RESULT: shards,
SOURCE_REQUIRED: String(source !== undefined),
SOURCE_RESULT: source ?? "",
MERGE_RESULT: merge,
});
};
await run(report);
@@ -458,25 +472,17 @@ it("recovers complete module costs from real blob reports without executing test
expect(file.durationMs).toBeCloseTo(
Object.values(file.phases).reduce((sum, value) => sum + value, 0),
);
const partial = await execute(process.execPath, [fileURLToPath(script)], {
cwd: root,
env: {
...process.env,
SHARD_RESULT: "skipped",
SOURCE_REQUIRED: "true",
SOURCE_RESULT: "success",
MERGE_RESULT: "success",
},
const partial = await executeTimingReport(root, {
SHARD_RESULT: "skipped",
SOURCE_REQUIRED: "true",
SOURCE_RESULT: "success",
});
expect(partial.stdout).toContain("Partial test diagnostics");
expect(partial.stdout).toContain("sample.test.js");
expect(partial.summary).toContain("Partial test diagnostics");
expect(partial.summary).toContain("sample.test.js");
await expect(
readFile(join(root, ".cache/ci-test-history/test-results.json")),
).rejects.toMatchObject({ code: "ENOENT" });
await execute(process.execPath, [fileURLToPath(script)], {
cwd: root,
env: { ...process.env, SHARD_RESULT: "success", MERGE_RESULT: "success" },
});
await executeTimingReport(root);
expect(
parseTestDurations(
await readFile(
@@ -508,10 +514,7 @@ it("recovers complete module costs from real blob reports without executing test
files: [{ state: "failed" }],
});
// Even a caller accidentally marking the merge successful cannot publish this report.
await execute(process.execPath, [fileURLToPath(script)], {
cwd: root,
env: { ...process.env, SHARD_RESULT: "success", MERGE_RESULT: "success" },
});
await executeTimingReport(root);
await expect(
readFile(join(root, ".cache/ci-test-history/test-results.json")),
).rejects.toMatchObject({ code: "ENOENT" });
@@ -543,21 +546,17 @@ it("records failed omissions from full reports without treating partial or unhan
};
const run = async (report: unknown, source = "failure") => {
await writeFile(join(root, "test-timings.json"), JSON.stringify(report));
await execute(process.execPath, [fileURLToPath(script)], {
cwd: root,
env: {
...process.env,
SHARD_RESULT: "failure",
SOURCE_REQUIRED: "true",
SOURCE_RESULT: source,
MERGE_RESULT: "failure",
CI_VERIFY_TEST_INVENTORY: "true",
CI_TEST_SELECTION: JSON.stringify(
planTestSelection(["src/sqlite/Provider.ts"], "pull_request", "fast"),
),
GITHUB_SHA: "merge-sha",
CI_PR_HEAD_SHA: "pr-head-sha",
},
await executeTimingReport(root, {
SHARD_RESULT: "failure",
SOURCE_REQUIRED: "true",
SOURCE_RESULT: source,
MERGE_RESULT: "failure",
CI_VERIFY_TEST_INVENTORY: "true",
CI_TEST_SELECTION: JSON.stringify(
planTestSelection(["src/sqlite/Provider.ts"], "pull_request", "fast"),
),
GITHUB_SHA: "merge-sha",
CI_PR_HEAD_SHA: "pr-head-sha",
});
return JSON.parse(
await readFile(