fix(javascript): resolve exported callables through lexical bindings (#1351)
* fix(javascript): resolve exported callables through lexical bindings * fix(javascript): preserve export scope and hoisted writes * test(javascript): consolidate export binding assertions * fix(javascript): account for skipped initializers and loop binding writes * fix(javascript): preserve parameters copied into body variables
This commit is contained in:
parent
1c0cc8a675
commit
a9f5dad5a8
|
|
@ -286,6 +286,8 @@ const createState = (program: t.Program): JavaScriptSemanticAnalysisState => {
|
|||
callables: [],
|
||||
callableNodesById: new Map(),
|
||||
moduleLinks: [],
|
||||
moduleLinkBindings: new WeakMap(),
|
||||
conditionalInitializers: new WeakSet(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -297,9 +299,34 @@ const collectDefinitions = (
|
|||
currentSemanticScope(state.scopes),
|
||||
];
|
||||
const openedScopes = new WeakMap<t.Node, number>();
|
||||
const assignments: {
|
||||
readonly node: t.Node;
|
||||
readonly scope: JavaScriptSemanticScopeState;
|
||||
}[] = [];
|
||||
const functionBodies: {
|
||||
readonly body: t.BlockStatement;
|
||||
readonly scope: JavaScriptSemanticScopeState;
|
||||
readonly parameters: t.Function["params"];
|
||||
}[] = [];
|
||||
traverseJavaScriptAst(program, {
|
||||
enter: (node, parent) => {
|
||||
enter: (node, parent, readAncestors) => {
|
||||
let parentScope = currentSemanticScope(stack);
|
||||
if (
|
||||
parent !== null &&
|
||||
(t.isObjectMethod(parent) || t.isClassMethod(parent)) &&
|
||||
parent.computed &&
|
||||
parent.key === node &&
|
||||
parentScope.parentScopeId !== null
|
||||
) {
|
||||
// A computed method key is evaluated before entering the method's
|
||||
// parameter and body environments.
|
||||
const keyScope = state.scopesById.get(parentScope.parentScopeId);
|
||||
if (keyScope !== undefined) {
|
||||
stack.push(keyScope);
|
||||
openedScopes.set(node, 1);
|
||||
parentScope = keyScope;
|
||||
}
|
||||
}
|
||||
if (
|
||||
parent !== null &&
|
||||
t.isWithStatement(parent) &&
|
||||
|
|
@ -324,6 +351,12 @@ const collectDefinitions = (
|
|||
if (nested !== undefined) {
|
||||
stack.push(nested);
|
||||
openedScopes.set(node, (openedScopes.get(node) ?? 0) + 1);
|
||||
if (t.isBlockStatement(node) && t.isFunction(parent))
|
||||
functionBodies.push({
|
||||
body: node,
|
||||
scope: nested,
|
||||
parameters: parent.params,
|
||||
});
|
||||
}
|
||||
const scope = currentSemanticScope(stack);
|
||||
state.scopeByNode.set(node, scope);
|
||||
|
|
@ -335,12 +368,95 @@ const collectDefinitions = (
|
|||
state,
|
||||
});
|
||||
bindInnerDeclaration(node, parent, scope, state);
|
||||
if (
|
||||
t.isVariableDeclarator(node) &&
|
||||
node.init != null &&
|
||||
parent !== null &&
|
||||
t.isVariableDeclaration(parent, { kind: "var" }) &&
|
||||
hasConditionalInitializer(readAncestors())
|
||||
)
|
||||
state.conditionalInitializers.add(node.init);
|
||||
if (
|
||||
t.isAssignmentExpression(node) ||
|
||||
t.isUpdateExpression(node) ||
|
||||
t.isForOfStatement(node) ||
|
||||
t.isForInStatement(node)
|
||||
)
|
||||
assignments.push({ node, scope });
|
||||
},
|
||||
exit: (node) => {
|
||||
for (let count = openedScopes.get(node) ?? 0; count > 0; count--)
|
||||
stack.pop();
|
||||
},
|
||||
});
|
||||
// All declarations must exist before resolving writes, including hoisted
|
||||
// functions and local declarations that shadow an outer binding.
|
||||
for (const { node, scope } of assignments) bindAssignment(node, scope, state);
|
||||
for (const { body, scope, parameters } of functionBodies)
|
||||
for (const parameter of parameters)
|
||||
for (const identifier of assignedPatternIdentifiers(
|
||||
t.isTSParameterProperty(parameter) ? parameter.parameter : parameter,
|
||||
))
|
||||
copyParameterToBody({ identifier, body, scope, state });
|
||||
};
|
||||
|
||||
const copyParameterToBody = (input: {
|
||||
readonly identifier: t.Identifier;
|
||||
readonly body: t.BlockStatement;
|
||||
readonly scope: JavaScriptSemanticScopeState;
|
||||
readonly state: JavaScriptSemanticAnalysisState;
|
||||
}): void => {
|
||||
const { identifier, body, scope, state } = input;
|
||||
const binding = scope.bindings.get(identifier.name);
|
||||
if (
|
||||
binding === undefined ||
|
||||
binding.kind !== "variable" ||
|
||||
binding.definitions.some(({ kind }) => kind === "function")
|
||||
)
|
||||
return;
|
||||
// A separate body var environment starts with the parameter's value. The
|
||||
// real parameter identifier retains its scope and source location; entryBody
|
||||
// records when the copy occurs without inventing an expression in the AST.
|
||||
binding.initializers.unshift({
|
||||
node: identifier,
|
||||
projection: [],
|
||||
entryBody: body,
|
||||
});
|
||||
for (let index = binding.initializers.length - 1; index >= 0; index -= 1) {
|
||||
const initializer = binding.initializers[index];
|
||||
if (
|
||||
initializer !== undefined &&
|
||||
initializer.entryBody === undefined &&
|
||||
initializer.projection.length === 0 &&
|
||||
t.isIdentifier(initializer.node) &&
|
||||
resolveSemanticBindingState(
|
||||
state,
|
||||
initializer.node,
|
||||
initializer.node.name,
|
||||
) === binding
|
||||
)
|
||||
// Direct self-initialization preserves the prior value, including any
|
||||
// actual overwrite before it. Keep the source definition/reference facts.
|
||||
binding.initializers.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const hasConditionalInitializer = (ancestors: readonly t.Node[]): boolean => {
|
||||
// A hoisted var can remain undefined when control flow skips its initializer.
|
||||
// Conditions outside the owning callable/static block do not govern its body.
|
||||
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
|
||||
const node = ancestors[index];
|
||||
if (t.isFunction(node) || t.isProgram(node) || t.isStaticBlock(node))
|
||||
return false;
|
||||
if (
|
||||
t.isIfStatement(node) ||
|
||||
t.isLoop(node) ||
|
||||
t.isSwitchCase(node) ||
|
||||
t.isTryStatement(node)
|
||||
)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const bindOuterDeclaration = (
|
||||
|
|
@ -408,7 +524,14 @@ const bindInnerDeclaration = (
|
|||
mutable: true,
|
||||
kind: "catch",
|
||||
});
|
||||
else if (t.isAssignmentExpression(node)) {
|
||||
};
|
||||
|
||||
const bindAssignment = (
|
||||
node: t.Node,
|
||||
scope: JavaScriptSemanticScopeState,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): void => {
|
||||
if (t.isAssignmentExpression(node)) {
|
||||
if (t.isIdentifier(node.left))
|
||||
addAssignment(
|
||||
node.left,
|
||||
|
|
@ -426,8 +549,11 @@ const bindInnerDeclaration = (
|
|||
} else if (t.isUpdateExpression(node) && t.isIdentifier(node.argument))
|
||||
addAssignment(node.argument, node, scope, state);
|
||||
else if (t.isForOfStatement(node) || t.isForInStatement(node))
|
||||
for (const identifier of assignedPatternIdentifiers(node.left))
|
||||
addAssignment(identifier, node, scope, state);
|
||||
for (const pattern of t.isVariableDeclaration(node.left)
|
||||
? node.left.declarations.map(({ id }) => id)
|
||||
: [node.left])
|
||||
for (const identifier of assignedPatternIdentifiers(pattern))
|
||||
addAssignment(identifier, node, scope, state);
|
||||
};
|
||||
|
||||
const assignedPatternIdentifiers = (
|
||||
|
|
@ -539,12 +665,42 @@ const scopeKind = (
|
|||
if (t.isStaticBlock(node)) return "static-block";
|
||||
if (
|
||||
t.isBlockStatement(node) &&
|
||||
!(parent !== null && t.isFunction(parent) && parent.body === node)
|
||||
)
|
||||
return "block";
|
||||
parent !== null &&
|
||||
t.isFunction(parent) &&
|
||||
parent.body === node
|
||||
) {
|
||||
// Parameter expressions cannot see declarations in the function body.
|
||||
// Retain the outer function scope for parameters and callable ownership;
|
||||
// body declarations belong to a separate variable environment.
|
||||
return functionParametersHaveExpressions(parent) ? "function" : undefined;
|
||||
}
|
||||
if (t.isBlockStatement(node)) return "block";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const functionParametersHaveExpressions = (node: t.Function): boolean => {
|
||||
const pending: t.Node[] = [...node.params];
|
||||
while (pending.length > 0) {
|
||||
const parameter = pending.pop();
|
||||
if (t.isAssignmentPattern(parameter)) return true;
|
||||
if (t.isTSParameterProperty(parameter)) pending.push(parameter.parameter);
|
||||
else if (t.isRestElement(parameter)) pending.push(parameter.argument);
|
||||
else if (t.isArrayPattern(parameter)) {
|
||||
for (const element of parameter.elements)
|
||||
if (element !== null) pending.push(element);
|
||||
} else if (t.isObjectPattern(parameter)) {
|
||||
for (const property of parameter.properties) {
|
||||
if (t.isRestElement(property)) pending.push(property.argument);
|
||||
else {
|
||||
if (property.computed) return true;
|
||||
pending.push(property.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const bindImports = (
|
||||
node: t.ImportDeclaration,
|
||||
scope: JavaScriptSemanticScopeState,
|
||||
|
|
|
|||
|
|
@ -171,8 +171,10 @@ const mutationInitializers = (
|
|||
if (binding.initializers.length < 2) return binding.initializers;
|
||||
const mutationPosition = directMutationPosition(mutation, parents);
|
||||
if (mutationPosition === null) return binding.initializers;
|
||||
const positions = binding.initializers.map(({ node }) =>
|
||||
directInitializerPosition(node, parents),
|
||||
const positions = binding.initializers.map(({ node, entryBody }) =>
|
||||
entryBody === undefined
|
||||
? directInitializerPosition(node, parents)
|
||||
: { body: entryBody.body, index: -1 },
|
||||
);
|
||||
if (
|
||||
positions.some(
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
evaluateSemanticBinding,
|
||||
evaluateSemanticProvenance,
|
||||
} from "./javascriptSemanticValues.js";
|
||||
import { range, stringValue } from "./javascriptStaticAnalysisHelpers.js";
|
||||
import { range } from "./javascriptStaticAnalysisHelpers.js";
|
||||
import {
|
||||
propertyName,
|
||||
semanticStaticPropertyKey,
|
||||
|
|
@ -68,25 +68,33 @@ export const collectSemanticModuleLink = (
|
|||
): void => {
|
||||
if (t.isImportDeclaration(node)) collectImports(node, state);
|
||||
else if (t.isExportAllDeclaration(node))
|
||||
addModuleLink(state, {
|
||||
kind: "re-export",
|
||||
specifier: node.source.value,
|
||||
importedName: "*",
|
||||
localName: null,
|
||||
exportedName: "*",
|
||||
location: range(node),
|
||||
});
|
||||
addModuleLink(
|
||||
state,
|
||||
{
|
||||
kind: "re-export",
|
||||
specifier: node.source.value,
|
||||
importedName: "*",
|
||||
localName: null,
|
||||
exportedName: "*",
|
||||
location: range(node),
|
||||
},
|
||||
node,
|
||||
);
|
||||
else if (t.isExportNamedDeclaration(node)) collectNamedExports(node, state);
|
||||
else if (t.isExportDefaultDeclaration(node))
|
||||
addModuleLink(state, {
|
||||
kind: "export",
|
||||
specifier: null,
|
||||
importedName: null,
|
||||
localName: defaultDeclarationName(node.declaration),
|
||||
exportedName: "default",
|
||||
callableId: semanticCallableIdForNode(node.declaration),
|
||||
location: range(node),
|
||||
});
|
||||
addModuleLink(
|
||||
state,
|
||||
{
|
||||
kind: "export",
|
||||
specifier: null,
|
||||
importedName: null,
|
||||
localName: defaultDeclarationName(node.declaration),
|
||||
exportedName: "default",
|
||||
callableId: semanticCallableIdForNode(node.declaration),
|
||||
location: range(node),
|
||||
},
|
||||
node,
|
||||
);
|
||||
else if (t.isVariableDeclarator(node)) collectRequireLink(node, state);
|
||||
else if (t.isAssignmentExpression(node)) collectCommonJsExport(node, state);
|
||||
};
|
||||
|
|
@ -233,27 +241,35 @@ const collectImports = (
|
|||
state: JavaScriptSemanticAnalysisState,
|
||||
): void => {
|
||||
if (node.specifiers.length === 0)
|
||||
addModuleLink(state, {
|
||||
kind: "import",
|
||||
specifier: node.source.value,
|
||||
importedName: null,
|
||||
localName: null,
|
||||
exportedName: null,
|
||||
location: range(node),
|
||||
});
|
||||
addModuleLink(
|
||||
state,
|
||||
{
|
||||
kind: "import",
|
||||
specifier: node.source.value,
|
||||
importedName: null,
|
||||
localName: null,
|
||||
exportedName: null,
|
||||
location: range(node),
|
||||
},
|
||||
node,
|
||||
);
|
||||
for (const specifier of node.specifiers)
|
||||
addModuleLink(state, {
|
||||
kind: "import",
|
||||
specifier: node.source.value,
|
||||
importedName: t.isImportDefaultSpecifier(specifier)
|
||||
? "default"
|
||||
: t.isImportNamespaceSpecifier(specifier)
|
||||
? "*"
|
||||
: propertyName(specifier.imported),
|
||||
localName: specifier.local.name,
|
||||
exportedName: null,
|
||||
location: range(specifier),
|
||||
});
|
||||
addModuleLink(
|
||||
state,
|
||||
{
|
||||
kind: "import",
|
||||
specifier: node.source.value,
|
||||
importedName: t.isImportDefaultSpecifier(specifier)
|
||||
? "default"
|
||||
: t.isImportNamespaceSpecifier(specifier)
|
||||
? "*"
|
||||
: propertyName(specifier.imported),
|
||||
localName: specifier.local.name,
|
||||
exportedName: null,
|
||||
location: range(specifier),
|
||||
},
|
||||
node,
|
||||
);
|
||||
};
|
||||
|
||||
const collectNamedExports = (
|
||||
|
|
@ -262,25 +278,33 @@ const collectNamedExports = (
|
|||
): void => {
|
||||
if (node.declaration !== null && node.declaration !== undefined)
|
||||
for (const localName of declarationNames(node.declaration))
|
||||
addModuleLink(state, {
|
||||
kind: "export",
|
||||
specifier: null,
|
||||
importedName: null,
|
||||
localName,
|
||||
exportedName: localName,
|
||||
callableId: declarationCallableId(node.declaration, localName),
|
||||
location: range(node.declaration),
|
||||
});
|
||||
addModuleLink(
|
||||
state,
|
||||
{
|
||||
kind: "export",
|
||||
specifier: null,
|
||||
importedName: null,
|
||||
localName,
|
||||
exportedName: localName,
|
||||
callableId: declarationCallableId(node.declaration, localName),
|
||||
location: range(node.declaration),
|
||||
},
|
||||
node,
|
||||
);
|
||||
for (const specifier of node.specifiers)
|
||||
if (t.isExportSpecifier(specifier))
|
||||
addModuleLink(state, {
|
||||
kind: node.source == null ? "export" : "re-export",
|
||||
specifier: node.source?.value ?? null,
|
||||
importedName: propertyName(specifier.local),
|
||||
localName: node.source == null ? propertyName(specifier.local) : null,
|
||||
exportedName: propertyName(specifier.exported),
|
||||
location: range(specifier),
|
||||
});
|
||||
addModuleLink(
|
||||
state,
|
||||
{
|
||||
kind: node.source == null ? "export" : "re-export",
|
||||
specifier: node.source?.value ?? null,
|
||||
importedName: propertyName(specifier.local),
|
||||
localName: node.source == null ? propertyName(specifier.local) : null,
|
||||
exportedName: propertyName(specifier.exported),
|
||||
location: range(specifier),
|
||||
},
|
||||
node,
|
||||
);
|
||||
};
|
||||
|
||||
const collectRequireLink = (
|
||||
|
|
@ -290,14 +314,18 @@ const collectRequireLink = (
|
|||
const origin = semanticRequireOrigin(node.init, state);
|
||||
if (origin === undefined) return;
|
||||
for (const binding of requirePatternBindings(node.id, origin.importedPath))
|
||||
addModuleLink(state, {
|
||||
kind: "require",
|
||||
specifier: origin.specifier,
|
||||
importedName: binding.importedName,
|
||||
localName: binding.localName,
|
||||
exportedName: null,
|
||||
location: range(node),
|
||||
});
|
||||
addModuleLink(
|
||||
state,
|
||||
{
|
||||
kind: "require",
|
||||
specifier: origin.specifier,
|
||||
importedName: binding.importedName,
|
||||
localName: binding.localName,
|
||||
exportedName: null,
|
||||
location: range(node),
|
||||
},
|
||||
node,
|
||||
);
|
||||
};
|
||||
|
||||
const requirePatternBindings = (
|
||||
|
|
@ -334,15 +362,19 @@ const collectCommonJsExport = (
|
|||
const exportedName = commonJsExportName(node.left, state);
|
||||
if (exportedName === undefined) return;
|
||||
const origin = semanticRequireOrigin(node.right, state);
|
||||
addModuleLink(state, {
|
||||
kind: "commonjs-export",
|
||||
specifier: origin?.specifier ?? null,
|
||||
importedName: origin?.importedPath.at(-1) ?? null,
|
||||
localName: t.isIdentifier(node.right) ? node.right.name : null,
|
||||
exportedName,
|
||||
callableId: semanticCallableIdForNode(node.right),
|
||||
location: range(node),
|
||||
});
|
||||
addModuleLink(
|
||||
state,
|
||||
{
|
||||
kind: "commonjs-export",
|
||||
specifier: origin?.specifier ?? null,
|
||||
importedName: origin?.importedPath.at(-1) ?? null,
|
||||
localName: t.isIdentifier(node.right) ? node.right.name : null,
|
||||
exportedName,
|
||||
callableId: semanticCallableIdForNode(node.right),
|
||||
location: range(node),
|
||||
},
|
||||
node,
|
||||
);
|
||||
};
|
||||
|
||||
const addModuleLink = (
|
||||
|
|
@ -350,8 +382,18 @@ const addModuleLink = (
|
|||
link: Omit<JavaScriptSemanticModuleLink, "callableId"> & {
|
||||
readonly callableId?: string | null;
|
||||
},
|
||||
referenceNode: t.Node,
|
||||
): void => {
|
||||
state.moduleLinks.push({ ...link, callableId: link.callableId ?? null });
|
||||
const collected = { ...link, callableId: link.callableId ?? null };
|
||||
state.moduleLinks.push(collected);
|
||||
if (link.localName !== null) {
|
||||
const binding = resolveSemanticBindingState(
|
||||
state,
|
||||
referenceNode,
|
||||
link.localName,
|
||||
);
|
||||
if (binding !== undefined) state.moduleLinkBindings.set(collected, binding);
|
||||
}
|
||||
};
|
||||
|
||||
/** Deterministic callable identity shared by collection and return recovery. */
|
||||
|
|
|
|||
|
|
@ -93,19 +93,14 @@ export const resolveSemanticModuleCallables = (
|
|||
callables: readonly JavaScriptSemanticCallable[],
|
||||
): JavaScriptSemanticModuleLink[] => {
|
||||
const callableIds = new Set(callables.map(({ callableId }) => callableId));
|
||||
const root = state.scopes.find(({ kind }) => kind === "program");
|
||||
return state.moduleLinks.map((link) => {
|
||||
if (link.callableId !== null && callableIds.has(link.callableId))
|
||||
return link;
|
||||
if (link.localName === null || root === undefined)
|
||||
return { ...link, callableId: null };
|
||||
const named = callables.filter(
|
||||
({ name, containerScopeId }) =>
|
||||
name === link.localName && containerScopeId === root.scopeId,
|
||||
);
|
||||
if (named.length === 1)
|
||||
return { ...link, callableId: named[0]?.callableId ?? null };
|
||||
const binding = root.bindings.get(link.localName);
|
||||
// Named exports follow their lexical binding, including every assignment.
|
||||
// A declaration's ID or a callable's display name cannot prove that link.
|
||||
if (link.localName === null)
|
||||
return link.callableId !== null && callableIds.has(link.callableId)
|
||||
? link
|
||||
: { ...link, callableId: null };
|
||||
const binding = state.moduleLinkBindings.get(link);
|
||||
const resolved =
|
||||
binding === undefined
|
||||
? []
|
||||
|
|
@ -154,10 +149,24 @@ const callableIdsForBinding = (
|
|||
admitted: ReadonlySet<string>,
|
||||
seen: ReadonlySet<string>,
|
||||
): string[] => {
|
||||
if (seen.has(binding.bindingId) || binding.initializers.length !== 1)
|
||||
// A single initializer does not prove an assignment executes or replace
|
||||
// the unknown incoming value of a parameter/catch binding.
|
||||
if (
|
||||
seen.has(binding.bindingId) ||
|
||||
binding.initializers.length !== 1 ||
|
||||
binding.definitions.some(
|
||||
({ kind }) =>
|
||||
kind === "assignment" || kind === "parameter" || kind === "catch",
|
||||
)
|
||||
)
|
||||
return [];
|
||||
const initializer = binding.initializers[0];
|
||||
if (initializer === undefined || initializer.projection.length > 0) return [];
|
||||
if (
|
||||
initializer === undefined ||
|
||||
initializer.projection.length > 0 ||
|
||||
state.conditionalInitializers.has(initializer.node)
|
||||
)
|
||||
return [];
|
||||
return callableIdsForNode(
|
||||
initializer.node,
|
||||
state,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
interface JavaScriptSemanticInitializer {
|
||||
readonly node: t.Node;
|
||||
readonly projection: readonly (string | number | null)[];
|
||||
readonly entryBody?: t.BlockStatement;
|
||||
}
|
||||
|
||||
/** Mutable binding state used only while constructing the immutable IR. */
|
||||
|
|
@ -46,6 +47,11 @@ export interface JavaScriptSemanticAnalysisState {
|
|||
readonly callables: JavaScriptSemanticCallable[];
|
||||
readonly callableNodesById: Map<string, t.Node>;
|
||||
readonly moduleLinks: JavaScriptSemanticModuleLink[];
|
||||
readonly moduleLinkBindings: WeakMap<
|
||||
JavaScriptSemanticModuleLink,
|
||||
JavaScriptSemanticBindingState
|
||||
>;
|
||||
readonly conditionalInitializers: WeakSet<t.Node>;
|
||||
}
|
||||
|
||||
/** Return the active scope from a non-empty construction stack. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,618 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { javaScriptExportShapeComparisonResultSchema } from "../../../src/domain/javascript/javascriptExportShapeComparisonSchemas.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const comparisonEnvelope = z.object({
|
||||
normalized_result: javaScriptExportShapeComparisonResultSchema,
|
||||
});
|
||||
|
||||
describe("exported function binding resolution through the CLI", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "a function declaration exported by a specifier",
|
||||
exportName: "default",
|
||||
source: (count: number) =>
|
||||
`function current() { return { count: 0 }; }
|
||||
current = () => ({ count: ${String(count)} });
|
||||
export { current as default };`,
|
||||
},
|
||||
{
|
||||
name: "a directly exported function declaration",
|
||||
exportName: "current",
|
||||
source: (count: number) =>
|
||||
`export function current() { return { count: 0 }; }
|
||||
current = () => ({ count: ${String(count)} });`,
|
||||
},
|
||||
{
|
||||
name: "a named default function declaration",
|
||||
exportName: "default",
|
||||
source: (count: number) =>
|
||||
`export default function current() { return { count: 0 }; }
|
||||
current = () => ({ count: ${String(count)} });`,
|
||||
},
|
||||
{
|
||||
name: "a directly exported arrow binding",
|
||||
exportName: "current",
|
||||
source: (count: number) =>
|
||||
`export let current = () => ({ count: 0 });
|
||||
current = () => ({ count: ${String(count)} });`,
|
||||
},
|
||||
{
|
||||
name: "an arrow binding exported by a specifier",
|
||||
exportName: "default",
|
||||
source: (count: number) =>
|
||||
`let current = () => ({ count: 0 });
|
||||
current = () => ({ count: ${String(count)} });
|
||||
export { current as default };`,
|
||||
},
|
||||
{
|
||||
name: "a reassigned alias",
|
||||
exportName: "default",
|
||||
source: (count: number) =>
|
||||
`const original = () => ({ count: 0 });
|
||||
let current = original;
|
||||
current = () => ({ count: ${String(count)} });
|
||||
export { current as default };`,
|
||||
},
|
||||
{
|
||||
name: "a conditional function reassignment",
|
||||
exportName: "current",
|
||||
source: (count: number) =>
|
||||
`export function current() { return { count: 0 }; }
|
||||
if (true) current = () => ({ count: ${String(count)} });`,
|
||||
},
|
||||
])(
|
||||
"keeps $name uncertain after reassignment",
|
||||
async ({ source, exportName }) => {
|
||||
const fixture = await analyzeVersions(source, exportName);
|
||||
await expectRuntimeExports(fixture, (count) => ({ count }));
|
||||
const result = await compareThroughCli(fixture);
|
||||
expectUncertainComparison(result);
|
||||
for (const evidence of [fixture.input.left, fixture.input.right]) {
|
||||
const { normalized_result: analysis } = z
|
||||
.object({
|
||||
normalized_result: javascriptApplicationAnalysisResultSchema,
|
||||
})
|
||||
.parse(evidence);
|
||||
expect(
|
||||
analysis.graph.nodes
|
||||
.flatMap(({ observations }) => observations)
|
||||
.filter(
|
||||
({ properties }) =>
|
||||
properties.semantic_role === "export-return-shapes" &&
|
||||
properties.exported_name === exportName,
|
||||
),
|
||||
).toEqual([]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("does not associate a private function name with a numeric export in CLI or MCP", async () => {
|
||||
const fixture = await analyzeVersions(
|
||||
(count) =>
|
||||
`const hidden = function current() { return { count: 0 }; };
|
||||
const current = ${String(count)};
|
||||
export { current as default };`,
|
||||
"default",
|
||||
);
|
||||
await expectRuntimeExports(fixture, (count) => count);
|
||||
const cli = await compareThroughCli(fixture);
|
||||
expectUncertainComparison(cli);
|
||||
expect(await compareThroughMcp(fixture.input)).toEqual(cli);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stable exported function bindings through the CLI", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "an anonymous default",
|
||||
source: (count: number) =>
|
||||
`export default () => ({ kind: "result", count: ${String(count)} });`,
|
||||
},
|
||||
{
|
||||
name: "a named default declaration",
|
||||
source: (count: number) =>
|
||||
`export default function current() { return { kind: "result", count: ${String(count)} }; }`,
|
||||
},
|
||||
{
|
||||
name: "a private named default expression",
|
||||
source: (count: number) =>
|
||||
`export default (function current() { return { kind: "result", count: ${String(count)} }; });`,
|
||||
},
|
||||
{
|
||||
name: "a declaration alias",
|
||||
source: (count: number) =>
|
||||
`function original() { return { kind: "result", count: ${String(count)} }; }
|
||||
const current = original; export { current as default };`,
|
||||
},
|
||||
{
|
||||
name: "an unchanged mutable binding",
|
||||
source: (count: number) =>
|
||||
`let current = () => ({ kind: "result", count: ${String(count)} }); export { current as default };`,
|
||||
},
|
||||
{
|
||||
name: "a private function reached through its real binding",
|
||||
source: (count: number) =>
|
||||
`const current = function privateName() { return { kind: "result", count: ${String(count)} }; };
|
||||
const privateName = 99; const alias = current; export { alias as default };`,
|
||||
},
|
||||
{
|
||||
name: "a declaration sharing a private display name",
|
||||
source: (count: number) =>
|
||||
`const hidden = function current() { return { count: 99 }; };
|
||||
function current() { return { kind: "result", count: ${String(count)} }; }
|
||||
export { current as default };`,
|
||||
},
|
||||
])("retains exact comparison for $name", async ({ source }) => {
|
||||
const fixture = await analyzeVersions(source, "default");
|
||||
expectCountChange(await compareThroughCli(fixture));
|
||||
});
|
||||
});
|
||||
|
||||
describe("export bindings with loop and assignment-only writes", () => {
|
||||
it.each([
|
||||
["a skipped branch", "if (false)"],
|
||||
["a zero-iteration loop", "for (; false;)"],
|
||||
["an unselected switch case", "switch (0) { case 1:"],
|
||||
["an interrupted try block", "try { throw 1;"],
|
||||
])("does not infer a callable initialized in %s", async (_, prefix) => {
|
||||
const fixture = await analyzeVersions((count) => {
|
||||
const initializer = `var current = () => ({ count: ${String(count)} });`;
|
||||
const statement = prefix.startsWith("try")
|
||||
? `${prefix} ${initializer} } catch {}`
|
||||
: prefix.startsWith("switch")
|
||||
? `${prefix} ${initializer} }`
|
||||
: `${prefix} { ${initializer} }`;
|
||||
return `${statement} export { current as default };`;
|
||||
}, "default");
|
||||
await expectRuntimeExports(fixture, () => undefined);
|
||||
expectUncertainComparison(await compareThroughCli(fixture));
|
||||
});
|
||||
|
||||
it("retains a var initializer in an unconditional nested block", async () => {
|
||||
const fixture = await analyzeVersions(
|
||||
(count) =>
|
||||
`{ var current = () => ({ kind: "result", count: ${String(count)} }); }
|
||||
export { current as default };`,
|
||||
"default",
|
||||
);
|
||||
await expectRuntimeExports(fixture, (count) => ({ kind: "result", count }));
|
||||
expectCountChange(await compareThroughCli(fixture));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "a for-of var declaration",
|
||||
loop: (count: number) =>
|
||||
`for (var current of [() => ({ count: ${String(count)} })]) {}`,
|
||||
},
|
||||
{
|
||||
name: "a destructured for-of var declaration",
|
||||
loop: (count: number) =>
|
||||
`for (var { value: current } of [{ value: () => ({ count: ${String(count)} }) }]) {}`,
|
||||
},
|
||||
])("keeps $name uncertain", async ({ loop }) => {
|
||||
const fixture = await analyzeVersions(
|
||||
(count) =>
|
||||
`function current() { return { count: 0 }; }
|
||||
${loop(count)}
|
||||
module.exports = current;`,
|
||||
"default",
|
||||
"app.cjs",
|
||||
);
|
||||
await expectRuntimeExports(fixture, (count) => ({ count }));
|
||||
expectUncertainComparison(await compareThroughCli(fixture));
|
||||
});
|
||||
|
||||
it("does not associate a for-in key with the overwritten function", async () => {
|
||||
const fixture = await analyzeVersions(
|
||||
(count) =>
|
||||
`function current() { return { count: 0 }; }
|
||||
for (var current in { ${String(count)}: true }) {}
|
||||
module.exports = current;`,
|
||||
"default",
|
||||
"app.cjs",
|
||||
);
|
||||
await expectRuntimeExports(fixture, String);
|
||||
expectUncertainComparison(await compareThroughCli(fixture));
|
||||
});
|
||||
|
||||
it("preserves an outer export shadowed by a lexical loop declaration", async () => {
|
||||
const fixture = await analyzeVersions(
|
||||
(count) =>
|
||||
`export function current() { return { kind: "result", count: ${String(count)} }; }
|
||||
for (let current of [() => ({ count: 0 })]) { current = null; }`,
|
||||
"current",
|
||||
);
|
||||
await expectRuntimeExports(fixture, (count) => ({ kind: "result", count }));
|
||||
expectCountChange(await compareThroughCli(fixture));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "a parameter",
|
||||
source: (count: number) =>
|
||||
`function install(current) {
|
||||
if (false) current = () => ({ count: 0 });
|
||||
module.exports = current;
|
||||
}
|
||||
install(${String(count)});`,
|
||||
},
|
||||
{
|
||||
name: "a catch binding",
|
||||
source: (count: number) =>
|
||||
`try { throw ${String(count)}; } catch (current) {
|
||||
if (false) current = () => ({ count: 0 });
|
||||
module.exports = current;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "a parameter redeclared with var",
|
||||
source: (count: number) =>
|
||||
`function install(current) {
|
||||
if (false) { var current = () => ({ count: 0 }); }
|
||||
module.exports = current;
|
||||
}
|
||||
install(${String(count)});`,
|
||||
},
|
||||
])("does not infer $name from its only assignment", async ({ source }) => {
|
||||
const fixture = await analyzeVersions(source, "default", "app.cjs");
|
||||
await expectRuntimeExports(fixture, (count) => count);
|
||||
const cli = await compareThroughCli(fixture);
|
||||
expectUncertainComparison(cli);
|
||||
expect(await compareThroughMcp(fixture.input)).toEqual(cli);
|
||||
});
|
||||
});
|
||||
|
||||
describe("export binding lexical scope and declaration order", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "a write before a hoisted function declaration",
|
||||
source: (count: number) =>
|
||||
`current = () => ({ count: ${String(count)} });
|
||||
export function current() { return { count: 0 }; }`,
|
||||
},
|
||||
{
|
||||
name: "a nested write collected before its outer declaration",
|
||||
source: (count: number) =>
|
||||
`function replace() { current = () => ({ count: ${String(count)} }); }
|
||||
export function current() { return { count: 0 }; }
|
||||
replace();`,
|
||||
},
|
||||
])("keeps $name uncertain", async ({ source }) => {
|
||||
const fixture = await analyzeVersions(source, "current");
|
||||
await expectRuntimeExports(fixture, (count) => ({ count }));
|
||||
expectUncertainComparison(await compareThroughCli(fixture));
|
||||
});
|
||||
|
||||
it("does not let a later local declaration contaminate the exported outer binding", async () => {
|
||||
const fixture = await analyzeVersions(
|
||||
(count) =>
|
||||
`export function current() { return { kind: "result", count: ${String(count)} }; }
|
||||
function isolated() {
|
||||
current = () => ({ count: 9 });
|
||||
function current() { return { count: 0 }; }
|
||||
}
|
||||
isolated();`,
|
||||
"current",
|
||||
);
|
||||
await expectRuntimeExports(fixture, (count) => ({ kind: "result", count }));
|
||||
expectCountChange(await compareThroughCli(fixture));
|
||||
});
|
||||
|
||||
it("does not associate a nested CommonJS numeric export with an outer function", async () => {
|
||||
const fixture = await analyzeVersions(
|
||||
(count) =>
|
||||
`function current() { return { count: 0 }; }
|
||||
function install() { const current = ${String(count)}; module.exports = current; }
|
||||
install();`,
|
||||
"default",
|
||||
"app.cjs",
|
||||
);
|
||||
await expectRuntimeExports(fixture, (count) => count);
|
||||
const cli = await compareThroughCli(fixture);
|
||||
expectUncertainComparison(cli);
|
||||
expect(await compareThroughMcp(fixture.input)).toEqual(cli);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "a nested function-local alias",
|
||||
source: (count: number) =>
|
||||
`function current() { return { kind: "result", count: 0 }; }
|
||||
function install() {
|
||||
const actual = () => ({ kind: "result", count: ${String(count)} });
|
||||
const current = actual; module.exports = current;
|
||||
}
|
||||
install();`,
|
||||
},
|
||||
{
|
||||
name: "a block-local function",
|
||||
source: (count: number) =>
|
||||
`const current = 0;
|
||||
{ const current = () => ({ kind: "result", count: ${String(count)} });
|
||||
module.exports = current; }`,
|
||||
},
|
||||
])("compares $name through its real CommonJS binding", async ({ source }) => {
|
||||
const fixture = await analyzeVersions(source, "default", "app.cjs");
|
||||
await expectRuntimeExports(fixture, (count) => ({ kind: "result", count }));
|
||||
expectCountChange(await compareThroughCli(fixture));
|
||||
});
|
||||
});
|
||||
|
||||
describe("export writes in function evaluation contexts", () => {
|
||||
it.each([
|
||||
{ body: "var arg; arg.value = COUNT;", uncertain: true },
|
||||
{ body: "var arg = arg; arg.value = COUNT;", uncertain: true },
|
||||
{ body: "arg.value = COUNT; var arg;", uncertain: true },
|
||||
{ body: "arg.value = COUNT; var arg = {};", uncertain: true },
|
||||
{ body: "var arg = {}; arg.value = COUNT;", uncertain: false },
|
||||
{ body: "var arg; arg = {}; arg.value = COUNT;", uncertain: false },
|
||||
{ body: "function arg() {} arg.value = COUNT;", uncertain: false },
|
||||
])("retains parameter-copy timing for $body", async ({ body, uncertain }) => {
|
||||
const fixture = await analyzeVersions(
|
||||
(count) =>
|
||||
`const source = { value: 0 };
|
||||
export function current(arg = null, set = (arg = source)) {
|
||||
${body.replace("COUNT", String(count))}
|
||||
return { kind: "result", count: source.value };
|
||||
}`,
|
||||
"current",
|
||||
);
|
||||
await expectRuntimeExports(fixture, (count) => ({
|
||||
kind: "result",
|
||||
count: uncertain ? count : 0,
|
||||
}));
|
||||
for (const evidence of [fixture.input.left, fixture.input.right]) {
|
||||
const { normalized_result: analysis } = z
|
||||
.object({
|
||||
normalized_result: javascriptApplicationAnalysisResultSchema,
|
||||
})
|
||||
.parse(evidence);
|
||||
const observations = analysis.graph.nodes.flatMap(
|
||||
({ observations }) => observations,
|
||||
);
|
||||
expect(observations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
semantic_role: "export-return-shapes",
|
||||
exported_name: "current",
|
||||
static_return_shapes: [
|
||||
expect.objectContaining({
|
||||
fields: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "/count",
|
||||
state: uncertain ? "unknown" : "literal",
|
||||
value: uncertain ? null : 0,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
...["let current;", "var current;", "function current() {}"].map(
|
||||
(body) => ({
|
||||
name: `a parameter default before ${body}`,
|
||||
source: (count: number) =>
|
||||
`export function current() { return { count: 0 }; }
|
||||
function replace(value = (current = () => ({ count: ${String(count)} }))) {
|
||||
${body}
|
||||
}
|
||||
replace();`,
|
||||
}),
|
||||
),
|
||||
{
|
||||
name: "a closure invoked from a parameter default",
|
||||
source: (count: number) =>
|
||||
`export function current() { return { count: 0 }; }
|
||||
function replace(value = (() => { current = () => ({ count: ${String(count)} }); })()) {
|
||||
let current;
|
||||
}
|
||||
replace();`,
|
||||
},
|
||||
{
|
||||
name: "a computed destructuring parameter key",
|
||||
source: (count: number) =>
|
||||
`export function current() { return { count: 0 }; }
|
||||
function replace({ [current = () => ({ count: ${String(count)} })]: value }) {
|
||||
var current;
|
||||
}
|
||||
replace({});`,
|
||||
},
|
||||
{
|
||||
name: "an object method's computed key",
|
||||
source: (count: number) =>
|
||||
`export function current() { return { count: 0 }; }
|
||||
const object = { [current = () => ({ count: ${String(count)} })]() { let current; } };`,
|
||||
},
|
||||
{
|
||||
name: "a class method's computed key",
|
||||
source: (count: number) =>
|
||||
`export function current() { return { count: 0 }; }
|
||||
class Example { [current = () => ({ count: ${String(count)} })]() { var current; } }`,
|
||||
},
|
||||
])("keeps an outer export write in $name uncertain", async ({ source }) => {
|
||||
const fixture = await analyzeVersions(source, "current");
|
||||
await expectRuntimeExports(fixture, (count) => ({ count }));
|
||||
expectUncertainComparison(await compareThroughCli(fixture));
|
||||
});
|
||||
|
||||
it("resolves a CommonJS export in a parameter default outside the function body", async () => {
|
||||
const fixture = await analyzeVersions(
|
||||
(count) =>
|
||||
`function current() { return { kind: "result", count: ${String(count)} }; }
|
||||
function install(value = (module.exports = current)) { var current = 9; }
|
||||
install();`,
|
||||
"default",
|
||||
"app.cjs",
|
||||
);
|
||||
await expectRuntimeExports(fixture, (count) => ({ kind: "result", count }));
|
||||
expectCountChange(await compareThroughCli(fixture));
|
||||
});
|
||||
});
|
||||
|
||||
const expectRuntimeExports = async (
|
||||
fixture: Awaited<ReturnType<typeof analyzeVersions>>,
|
||||
expected: (count: number) => unknown,
|
||||
): Promise<void> => {
|
||||
// Execute only source-owned fixtures as an independent runtime oracle.
|
||||
for (const [path, exportName, count] of [
|
||||
[fixture.leftPath, fixture.input.left_export_name, 1],
|
||||
[fixture.rightPath, fixture.input.right_export_name, 2],
|
||||
] as const)
|
||||
expect(await runtimeExport(path, exportName)).toEqual(expected(count));
|
||||
};
|
||||
|
||||
const expectUncertainComparison = (
|
||||
result: Awaited<ReturnType<typeof compareThroughCli>>,
|
||||
): void => {
|
||||
expect(result).toMatchObject({
|
||||
left: { status: "unavailable" },
|
||||
right: { status: "unavailable" },
|
||||
summary: { added: 0, removed: 0, changed: 0, unknown: 1 },
|
||||
changes: [{ status: "unknown", path: "" }],
|
||||
coverage: { status: "partial" },
|
||||
});
|
||||
expect(result.property_inventories).toEqual([]);
|
||||
};
|
||||
|
||||
const expectCountChange = (
|
||||
result: Awaited<ReturnType<typeof compareThroughCli>>,
|
||||
): void => {
|
||||
expect(result).toMatchObject({
|
||||
left: { status: "selected" },
|
||||
right: { status: "selected" },
|
||||
summary: { added: 0, removed: 0, changed: 1, unknown: 0 },
|
||||
changes: [
|
||||
{
|
||||
path: "/count",
|
||||
status: "changed",
|
||||
left: { availability: "literal", value: 1 },
|
||||
right: { availability: "literal", value: 2 },
|
||||
},
|
||||
],
|
||||
coverage: { status: "complete-within-inputs" },
|
||||
});
|
||||
};
|
||||
|
||||
const runCli = async (args: readonly string[]): Promise<unknown> => {
|
||||
const { stdout } = await execute(
|
||||
process.execPath,
|
||||
[resolve("scripts/rea.mjs"), ...args],
|
||||
{
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
env: { ...process.env, REA_LOG_LEVEL: "silent" },
|
||||
},
|
||||
);
|
||||
return JSON.parse(stdout);
|
||||
};
|
||||
|
||||
const analyzeVersions = async (
|
||||
source: (count: number) => string,
|
||||
exportName: string,
|
||||
fileName = "app.mjs",
|
||||
) => {
|
||||
const root = await createTestTempDirectory("rea-exported-function-bindings-");
|
||||
const leftRoot = join(root, "left");
|
||||
const rightRoot = join(root, "right");
|
||||
await Promise.all([mkdir(leftRoot), mkdir(rightRoot)]);
|
||||
const leftPath = join(leftRoot, fileName);
|
||||
const rightPath = join(rightRoot, fileName);
|
||||
await Promise.all([
|
||||
writeFile(leftPath, source(1)),
|
||||
writeFile(rightPath, source(2)),
|
||||
]);
|
||||
const [left, right] = await Promise.all([
|
||||
runCli(["analyze-javascript-application", leftRoot, "--json"]),
|
||||
runCli(["analyze-javascript-application", rightRoot, "--json"]),
|
||||
]);
|
||||
return {
|
||||
root,
|
||||
leftPath,
|
||||
rightPath,
|
||||
input: {
|
||||
left,
|
||||
right,
|
||||
left_module_path: fileName,
|
||||
left_export_name: exportName,
|
||||
right_module_path: fileName,
|
||||
right_export_name: exportName,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const compareThroughCli = async (
|
||||
fixture: Awaited<ReturnType<typeof analyzeVersions>>,
|
||||
) => {
|
||||
const inputPath = join(fixture.root, "comparison.json");
|
||||
await writeFile(inputPath, JSON.stringify(fixture.input));
|
||||
return comparisonEnvelope.parse(
|
||||
await runCli(["compare-javascript-export-shapes", inputPath, "--json"]),
|
||||
).normalized_result;
|
||||
};
|
||||
|
||||
const compareThroughMcp = async (input: Record<string, unknown>) => {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [resolve("scripts/rea.mjs"), "mcp"],
|
||||
cwd: process.cwd(),
|
||||
env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" },
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({
|
||||
name: "exported-function-bindings",
|
||||
version: "1",
|
||||
});
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const response = await client.callTool({
|
||||
name: "compare_javascript_export_shapes",
|
||||
arguments: input,
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
return comparisonEnvelope.parse(response.structuredContent)
|
||||
.normalized_result;
|
||||
} finally {
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const runtimeExport = async (
|
||||
path: string,
|
||||
exportName: string,
|
||||
): Promise<unknown> => {
|
||||
const { stdout } = await execute(process.execPath, [
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`import { pathToFileURL } from 'node:url';
|
||||
const module = await import(pathToFileURL(process.argv[1]).href);
|
||||
const value = module[process.argv[2]];
|
||||
console.log(JSON.stringify({ value: typeof value === 'function' ? value() : value }));`,
|
||||
path,
|
||||
exportName,
|
||||
]);
|
||||
return z.object({ value: z.unknown().optional() }).parse(JSON.parse(stdout))
|
||||
.value;
|
||||
};
|
||||
Loading…
Reference in New Issue