Files
openclaw/src/plugin-sdk/api-baseline-declaration-print.ts
T
Peter Steinberger 98b1e8b69c fix(plugin-sdk): make the API baseline contract path-free and surface-reachable (#121514)
* fix(plugin-sdk): make the API baseline hash path-free and surface-reachable

Manifest hashes now cover only surface-reachable, path-independent facts.
Rewrite closure from whole-file/path-keyed traversal to declaration-granular reachability with fail-toward-recall fallbacks and taint-tracked cycles.
Exclude source paths from the committed hash and split declaration printing into api-baseline-declaration-print.ts.

* fix(plugin-sdk): close side-effect and namespace-import gaps in baseline closure

Address ClawSweeper findings:
- Traverse side-effect imports before collecting globals.
- Normalize repo-owned namespace import types.

* refactor(plugin-sdk): rebase precision closure onto the JSONL contract
2026-08-10 07:44:53 -07:00

284 lines
8.4 KiB
TypeScript

// Declaration printing keeps compiler-rendered public SDK signatures stable.
import ts from "typescript";
import { normalizePluginSdkApiDeclarationText } from "./api-baseline-normalization.js";
const DECLARATION_TYPE_FORMAT_FLAGS =
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.MultilineObjectLiterals;
const DECLARATION_NODE_BUILDER_FLAGS = ts.NodeBuilderFlags.NoTruncation;
function declarationModifiers(node: ts.Node): readonly ts.Modifier[] | undefined {
return ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
}
function inferDeclarationTypeNode(
checker: ts.TypeChecker,
declaration: ts.Declaration,
explicitType: ts.TypeNode | undefined,
): ts.TypeNode | undefined {
return (
explicitType ??
checker.typeToTypeNode(
checker.getTypeAtLocation(declaration),
declaration,
DECLARATION_NODE_BUILDER_FLAGS,
)
);
}
function inferDeclarationReturnTypeNode(
checker: ts.TypeChecker,
declaration: ts.SignatureDeclaration,
explicitType: ts.TypeNode | undefined,
): ts.TypeNode | undefined {
if (explicitType) {
return explicitType;
}
const signature = checker.getSignatureFromDeclaration(declaration);
return signature
? checker.typeToTypeNode(
checker.getReturnTypeOfSignature(signature),
declaration,
DECLARATION_NODE_BUILDER_FLAGS,
)
: undefined;
}
function stripParameterInitializer(parameter: ts.ParameterDeclaration): ts.ParameterDeclaration {
return ts.factory.updateParameterDeclaration(
parameter,
declarationModifiers(parameter),
parameter.dotDotDotToken,
parameter.name,
parameter.questionToken,
parameter.type,
undefined,
);
}
function stripClassMemberImplementation(
checker: ts.TypeChecker,
member: ts.ClassElement,
): ts.ClassElement | null {
if (ts.isClassStaticBlockDeclaration(member)) {
return null;
}
if (ts.isConstructorDeclaration(member)) {
return ts.factory.updateConstructorDeclaration(
member,
declarationModifiers(member),
member.parameters.map(stripParameterInitializer),
undefined,
);
}
if (ts.isMethodDeclaration(member)) {
return ts.factory.updateMethodDeclaration(
member,
declarationModifiers(member),
member.asteriskToken,
member.name,
member.questionToken,
member.typeParameters,
member.parameters.map(stripParameterInitializer),
inferDeclarationReturnTypeNode(checker, member, member.type),
undefined,
);
}
if (ts.isGetAccessorDeclaration(member)) {
return ts.factory.updateGetAccessorDeclaration(
member,
declarationModifiers(member),
member.name,
member.parameters.map(stripParameterInitializer),
inferDeclarationReturnTypeNode(checker, member, member.type),
undefined,
);
}
if (ts.isSetAccessorDeclaration(member)) {
return ts.factory.updateSetAccessorDeclaration(
member,
declarationModifiers(member),
member.name,
member.parameters.map(stripParameterInitializer),
undefined,
);
}
if (ts.isPropertyDeclaration(member)) {
return ts.factory.updatePropertyDeclaration(
member,
declarationModifiers(member),
member.name,
member.questionToken ?? member.exclamationToken,
inferDeclarationTypeNode(checker, member, member.type),
undefined,
);
}
return member;
}
function stripClassImplementation(
checker: ts.TypeChecker,
declaration: ts.ClassDeclaration,
exportName: string,
): ts.ClassDeclaration {
const members = declaration.members.flatMap((member) => {
const stripped = stripClassMemberImplementation(checker, member);
return stripped ? [stripped] : [];
});
return ts.factory.updateClassDeclaration(
declaration,
declarationModifiers(declaration),
ts.factory.createIdentifier(exportName),
declaration.typeParameters,
declaration.heritageClauses,
members,
);
}
function renameStructuredDeclarationForExport(
checker: ts.TypeChecker,
declaration: ts.Declaration,
exportName: string,
): ts.Declaration {
const name = ts.factory.createIdentifier(exportName);
if (ts.isClassDeclaration(declaration)) {
return stripClassImplementation(checker, declaration, exportName);
}
if (ts.isInterfaceDeclaration(declaration)) {
return ts.factory.updateInterfaceDeclaration(
declaration,
declarationModifiers(declaration),
name,
declaration.typeParameters,
declaration.heritageClauses,
declaration.members,
);
}
if (ts.isEnumDeclaration(declaration)) {
return ts.factory.updateEnumDeclaration(
declaration,
declarationModifiers(declaration),
name,
declaration.members,
);
}
if (ts.isModuleDeclaration(declaration) && ts.isIdentifier(declaration.name)) {
return ts.factory.updateModuleDeclaration(
declaration,
declarationModifiers(declaration),
name,
declaration.body,
);
}
return declaration;
}
function ensureExportedDeclarationText(value: string): string {
return /^export\b/u.test(value) ? value : `export ${value}`;
}
function printTypeParameters(printer: ts.Printer, declaration: ts.TypeAliasDeclaration): string {
if (!declaration.typeParameters?.length) {
return "";
}
const sourceFile = declaration.getSourceFile();
const parameters = declaration.typeParameters.map((typeParameter) =>
printer.printNode(ts.EmitHint.Unspecified, typeParameter, sourceFile).trim(),
);
return `<${parameters.join(", ")}>`;
}
/** Render tuple-derived literal unions in declaration order, independent of compiler traversal. */
export function formatPluginSdkApiTypeAlias(
checker: ts.TypeChecker,
declaration: ts.TypeAliasDeclaration,
): string {
const type = checker.getTypeAtLocation(declaration);
if (
type.isUnion() &&
ts.isIndexedAccessTypeNode(declaration.type) &&
declaration.type.indexType.kind === ts.SyntaxKind.NumberKeyword
) {
const tuple = checker.getTypeFromTypeNode(declaration.type.objectType);
const members = checker.isTupleType(tuple)
? [...new Set(checker.getTypeArguments(tuple as ts.TypeReference))]
: [];
if (
members.length === type.types.length &&
members.every(
(member) =>
(member.isStringLiteral() || member.isNumberLiteral()) && type.types.includes(member),
)
) {
return members
.map((member) => checker.typeToString(member, declaration, DECLARATION_TYPE_FORMAT_FLAGS))
.join(" | ");
}
}
return checker.typeToString(type, declaration, DECLARATION_TYPE_FORMAT_FLAGS);
}
export function printPluginSdkExportDeclaration(
repoRoot: string,
checker: ts.TypeChecker,
printer: ts.Printer,
declaration: ts.Declaration,
exportName: string,
): string | null {
if (ts.isFunctionDeclaration(declaration)) {
const signatures = checker.getTypeAtLocation(declaration).getCallSignatures();
if (signatures.length === 0) {
return `export function ${exportName}();`;
}
return normalizePluginSdkApiDeclarationText(
repoRoot,
signatures
.map(
(signature) =>
`export function ${exportName}${checker.signatureToString(
signature,
declaration,
DECLARATION_TYPE_FORMAT_FLAGS,
)};`,
)
.join("\n"),
);
}
if (ts.isVariableDeclaration(declaration)) {
const type = checker.getTypeAtLocation(declaration);
const prefix =
declaration.parent && (ts.getCombinedNodeFlags(declaration.parent) & ts.NodeFlags.Const) !== 0
? "const"
: "let";
return normalizePluginSdkApiDeclarationText(
repoRoot,
`export ${prefix} ${exportName}: ${checker.typeToString(
type,
declaration,
DECLARATION_TYPE_FORMAT_FLAGS,
)};`,
);
}
if (ts.isTypeAliasDeclaration(declaration)) {
const typeParameters = printTypeParameters(printer, declaration);
return normalizePluginSdkApiDeclarationText(
repoRoot,
`export type ${exportName}${typeParameters} = ${formatPluginSdkApiTypeAlias(checker, declaration)};`,
);
}
const printableDeclaration = renameStructuredDeclarationForExport(
checker,
declaration,
exportName,
);
const text = printer
.printNode(ts.EmitHint.Unspecified, printableDeclaration, declaration.getSourceFile())
.trim();
if (!text) {
return null;
}
return normalizePluginSdkApiDeclarationText(repoRoot, ensureExportedDeclarationText(text));
}