mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-25 15:37:32 +00:00
Docs: prototype generated plugin SDK reference (#51877)
* Chore: unblock synced main checks * Docs: add plugin SDK docs implementation plan * Docs: scaffold plugin SDK reference phase 1 * Docs: mark plugin SDK reference surfaces unstable * Docs: prototype generated plugin SDK reference * docs(plugin-sdk): replace generated reference with api baseline * docs(plugin-sdk): drop generated reference plan * docs(plugin-sdk): align api baseline flow with config docs --------- Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
459
src/plugin-sdk/api-baseline.ts
Normal file
459
src/plugin-sdk/api-baseline.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
import {
|
||||
pluginSdkDocMetadata,
|
||||
resolvePluginSdkDocImportSpecifier,
|
||||
type PluginSdkDocCategory,
|
||||
type PluginSdkDocEntrypoint,
|
||||
} from "../../scripts/lib/plugin-sdk-doc-metadata.ts";
|
||||
import { pluginSdkEntrypoints } from "../../scripts/lib/plugin-sdk-entries.mjs";
|
||||
|
||||
export type PluginSdkApiExportKind =
|
||||
| "class"
|
||||
| "const"
|
||||
| "enum"
|
||||
| "function"
|
||||
| "interface"
|
||||
| "namespace"
|
||||
| "type"
|
||||
| "unknown"
|
||||
| "variable";
|
||||
|
||||
export type PluginSdkApiSourceLink = {
|
||||
line: number;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type PluginSdkApiExport = {
|
||||
declaration: string | null;
|
||||
exportName: string;
|
||||
kind: PluginSdkApiExportKind;
|
||||
source: PluginSdkApiSourceLink | null;
|
||||
};
|
||||
|
||||
export type PluginSdkApiModule = {
|
||||
category: PluginSdkDocCategory;
|
||||
entrypoint: PluginSdkDocEntrypoint;
|
||||
exports: PluginSdkApiExport[];
|
||||
importSpecifier: string;
|
||||
source: PluginSdkApiSourceLink;
|
||||
};
|
||||
|
||||
export type PluginSdkApiBaseline = {
|
||||
generatedBy: "scripts/generate-plugin-sdk-api-baseline.ts";
|
||||
modules: PluginSdkApiModule[];
|
||||
};
|
||||
|
||||
export type PluginSdkApiBaselineRender = {
|
||||
baseline: PluginSdkApiBaseline;
|
||||
json: string;
|
||||
jsonl: string;
|
||||
};
|
||||
|
||||
export type PluginSdkApiBaselineWriteResult = {
|
||||
changed: boolean;
|
||||
wrote: boolean;
|
||||
jsonPath: string;
|
||||
statefilePath: string;
|
||||
};
|
||||
|
||||
const GENERATED_BY = "scripts/generate-plugin-sdk-api-baseline.ts" as const;
|
||||
const DEFAULT_JSON_OUTPUT = "docs/.generated/plugin-sdk-api-baseline.json";
|
||||
const DEFAULT_STATEFILE_OUTPUT = "docs/.generated/plugin-sdk-api-baseline.jsonl";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRepoRoot(): string {
|
||||
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
}
|
||||
|
||||
function relativePath(repoRoot: string, filePath: string): string {
|
||||
return path.relative(repoRoot, filePath).split(path.sep).join(path.posix.sep);
|
||||
}
|
||||
|
||||
function createCompilerContext(repoRoot: string) {
|
||||
const configPath = ts.findConfigFile(
|
||||
repoRoot,
|
||||
(filePath) => ts.sys.fileExists(filePath),
|
||||
"tsconfig.json",
|
||||
);
|
||||
assert(configPath, "Could not find tsconfig.json");
|
||||
const configFile = ts.readConfigFile(configPath, (filePath) => ts.sys.readFile(filePath));
|
||||
if (configFile.error) {
|
||||
throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n"));
|
||||
}
|
||||
const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, repoRoot);
|
||||
const program = ts.createProgram(parsedConfig.fileNames, parsedConfig.options);
|
||||
return {
|
||||
checker: program.getTypeChecker(),
|
||||
printer: ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }),
|
||||
program,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSourceLink(
|
||||
repoRoot: string,
|
||||
program: ts.Program,
|
||||
filePath: string,
|
||||
start: number,
|
||||
): PluginSdkApiSourceLink {
|
||||
const sourceFile = program.getSourceFile(filePath);
|
||||
assert(sourceFile, `Unable to read source file for ${relativePath(repoRoot, filePath)}`);
|
||||
const line = sourceFile.getLineAndCharacterOfPosition(start).line + 1;
|
||||
return {
|
||||
line,
|
||||
path: relativePath(repoRoot, filePath),
|
||||
};
|
||||
}
|
||||
|
||||
function inferExportKind(
|
||||
symbol: ts.Symbol,
|
||||
declaration: ts.Declaration | undefined,
|
||||
): PluginSdkApiExportKind {
|
||||
if (declaration) {
|
||||
switch (declaration.kind) {
|
||||
case ts.SyntaxKind.ClassDeclaration:
|
||||
return "class";
|
||||
case ts.SyntaxKind.EnumDeclaration:
|
||||
return "enum";
|
||||
case ts.SyntaxKind.FunctionDeclaration:
|
||||
return "function";
|
||||
case ts.SyntaxKind.InterfaceDeclaration:
|
||||
return "interface";
|
||||
case ts.SyntaxKind.ModuleDeclaration:
|
||||
return "namespace";
|
||||
case ts.SyntaxKind.TypeAliasDeclaration:
|
||||
return "type";
|
||||
case ts.SyntaxKind.VariableDeclaration: {
|
||||
const variableStatement = declaration.parent?.parent;
|
||||
if (
|
||||
variableStatement &&
|
||||
ts.isVariableStatement(variableStatement) &&
|
||||
(ts.getCombinedNodeFlags(variableStatement.declarationList) & ts.NodeFlags.Const) !== 0
|
||||
) {
|
||||
return "const";
|
||||
}
|
||||
return "variable";
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (symbol.flags & ts.SymbolFlags.Function) {
|
||||
return "function";
|
||||
}
|
||||
if (symbol.flags & ts.SymbolFlags.Class) {
|
||||
return "class";
|
||||
}
|
||||
if (symbol.flags & ts.SymbolFlags.Interface) {
|
||||
return "interface";
|
||||
}
|
||||
if (symbol.flags & ts.SymbolFlags.TypeAlias) {
|
||||
return "type";
|
||||
}
|
||||
if (symbol.flags & ts.SymbolFlags.ConstEnum || symbol.flags & ts.SymbolFlags.RegularEnum) {
|
||||
return "enum";
|
||||
}
|
||||
if (symbol.flags & ts.SymbolFlags.Variable) {
|
||||
return "variable";
|
||||
}
|
||||
if (symbol.flags & ts.SymbolFlags.NamespaceModule || symbol.flags & ts.SymbolFlags.ValueModule) {
|
||||
return "namespace";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function resolveSymbolAndDeclaration(
|
||||
checker: ts.TypeChecker,
|
||||
symbol: ts.Symbol,
|
||||
): {
|
||||
declaration: ts.Declaration | undefined;
|
||||
resolvedSymbol: ts.Symbol;
|
||||
} {
|
||||
const resolvedSymbol =
|
||||
symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
|
||||
const declarations = resolvedSymbol.getDeclarations() ?? symbol.getDeclarations() ?? [];
|
||||
const declaration = declarations.find((candidate) => candidate.kind !== ts.SyntaxKind.SourceFile);
|
||||
return { declaration, resolvedSymbol };
|
||||
}
|
||||
|
||||
function printNode(
|
||||
checker: ts.TypeChecker,
|
||||
printer: ts.Printer,
|
||||
declaration: ts.Declaration,
|
||||
): string | null {
|
||||
if (ts.isFunctionDeclaration(declaration)) {
|
||||
const signatures = checker.getTypeAtLocation(declaration).getCallSignatures();
|
||||
if (signatures.length === 0) {
|
||||
return `export function ${declaration.name?.text ?? "anonymous"}();`;
|
||||
}
|
||||
return signatures
|
||||
.map(
|
||||
(signature) =>
|
||||
`export function ${declaration.name?.text ?? "anonymous"}${checker.signatureToString(signature)};`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
if (ts.isVariableDeclaration(declaration)) {
|
||||
const name = declaration.name.getText();
|
||||
const type = checker.getTypeAtLocation(declaration);
|
||||
const prefix =
|
||||
declaration.parent && (ts.getCombinedNodeFlags(declaration.parent) & ts.NodeFlags.Const) !== 0
|
||||
? "const"
|
||||
: "let";
|
||||
return `export ${prefix} ${name}: ${checker.typeToString(type, declaration, ts.TypeFormatFlags.NoTruncation)};`;
|
||||
}
|
||||
|
||||
if (ts.isInterfaceDeclaration(declaration)) {
|
||||
return `export interface ${declaration.name.text}`;
|
||||
}
|
||||
|
||||
if (ts.isClassDeclaration(declaration)) {
|
||||
return `export class ${declaration.name?.text ?? "AnonymousClass"}`;
|
||||
}
|
||||
|
||||
if (ts.isEnumDeclaration(declaration)) {
|
||||
return `export enum ${declaration.name.text}`;
|
||||
}
|
||||
|
||||
if (ts.isModuleDeclaration(declaration)) {
|
||||
return `export namespace ${declaration.name.getText()}`;
|
||||
}
|
||||
|
||||
if (ts.isTypeAliasDeclaration(declaration)) {
|
||||
const type = checker.getTypeAtLocation(declaration);
|
||||
const rendered = `export type ${declaration.name.text} = ${checker.typeToString(
|
||||
type,
|
||||
declaration,
|
||||
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.MultilineObjectLiterals,
|
||||
)};`;
|
||||
if (rendered.length > 1200) {
|
||||
return `export type ${declaration.name.text} = /* see source */`;
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
const text = printer
|
||||
.printNode(ts.EmitHint.Unspecified, declaration, declaration.getSourceFile())
|
||||
.trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return text.length > 1200
|
||||
? `${text.slice(0, 1175).trimEnd()}\n/* truncated; see source */`
|
||||
: text;
|
||||
}
|
||||
|
||||
function buildExportSurface(params: {
|
||||
checker: ts.TypeChecker;
|
||||
printer: ts.Printer;
|
||||
program: ts.Program;
|
||||
repoRoot: string;
|
||||
symbol: ts.Symbol;
|
||||
}): PluginSdkApiExport {
|
||||
const { checker, printer, program, repoRoot, symbol } = params;
|
||||
const { declaration, resolvedSymbol } = resolveSymbolAndDeclaration(checker, symbol);
|
||||
return {
|
||||
declaration: declaration ? printNode(checker, printer, declaration) : null,
|
||||
exportName: symbol.getName(),
|
||||
kind: inferExportKind(resolvedSymbol, declaration),
|
||||
source: declaration
|
||||
? buildSourceLink(
|
||||
repoRoot,
|
||||
program,
|
||||
declaration.getSourceFile().fileName,
|
||||
declaration.getStart(),
|
||||
)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function sortExports(left: PluginSdkApiExport, right: PluginSdkApiExport): number {
|
||||
const kindRank: Record<PluginSdkApiExportKind, number> = {
|
||||
function: 0,
|
||||
const: 1,
|
||||
variable: 2,
|
||||
type: 3,
|
||||
interface: 4,
|
||||
class: 5,
|
||||
enum: 6,
|
||||
namespace: 7,
|
||||
unknown: 8,
|
||||
};
|
||||
|
||||
const byKind = kindRank[left.kind] - kindRank[right.kind];
|
||||
if (byKind !== 0) {
|
||||
return byKind;
|
||||
}
|
||||
return left.exportName.localeCompare(right.exportName);
|
||||
}
|
||||
|
||||
function buildModuleSurface(params: {
|
||||
checker: ts.TypeChecker;
|
||||
printer: ts.Printer;
|
||||
program: ts.Program;
|
||||
repoRoot: string;
|
||||
entrypoint: PluginSdkDocEntrypoint;
|
||||
}): PluginSdkApiModule {
|
||||
const { checker, printer, program, repoRoot, entrypoint } = params;
|
||||
const metadata = pluginSdkDocMetadata[entrypoint];
|
||||
const importSpecifier = resolvePluginSdkDocImportSpecifier(entrypoint);
|
||||
const moduleSourcePath = path.join(repoRoot, "src", "plugin-sdk", `${entrypoint}.ts`);
|
||||
const sourceFile = program.getSourceFile(moduleSourcePath);
|
||||
assert(sourceFile, `Missing source file for ${importSpecifier}`);
|
||||
|
||||
const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
|
||||
assert(moduleSymbol, `Unable to resolve module symbol for ${importSpecifier}`);
|
||||
|
||||
const exports = checker
|
||||
.getExportsOfModule(moduleSymbol)
|
||||
.filter((symbol) => symbol.getName() !== "__esModule")
|
||||
.map((symbol) =>
|
||||
buildExportSurface({
|
||||
checker,
|
||||
printer,
|
||||
program,
|
||||
repoRoot,
|
||||
symbol,
|
||||
}),
|
||||
)
|
||||
.toSorted(sortExports);
|
||||
|
||||
return {
|
||||
category: metadata.category,
|
||||
entrypoint,
|
||||
exports,
|
||||
importSpecifier,
|
||||
source: buildSourceLink(repoRoot, program, moduleSourcePath, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function buildJsonlLines(baseline: PluginSdkApiBaseline): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const moduleSurface of baseline.modules) {
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
category: moduleSurface.category,
|
||||
entrypoint: moduleSurface.entrypoint,
|
||||
importSpecifier: moduleSurface.importSpecifier,
|
||||
recordType: "module",
|
||||
sourceLine: moduleSurface.source.line,
|
||||
sourcePath: moduleSurface.source.path,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const exportSurface of moduleSurface.exports) {
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
declaration: exportSurface.declaration,
|
||||
entrypoint: moduleSurface.entrypoint,
|
||||
exportName: exportSurface.exportName,
|
||||
importSpecifier: moduleSurface.importSpecifier,
|
||||
kind: exportSurface.kind,
|
||||
recordType: "export",
|
||||
sourceLine: exportSurface.source?.line ?? null,
|
||||
sourcePath: exportSurface.source?.path ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export async function renderPluginSdkApiBaseline(params?: {
|
||||
repoRoot?: string;
|
||||
}): Promise<PluginSdkApiBaselineRender> {
|
||||
const repoRoot = params?.repoRoot ?? resolveRepoRoot();
|
||||
validateMetadata();
|
||||
const { checker, printer, program } = createCompilerContext(repoRoot);
|
||||
const modules = (Object.keys(pluginSdkDocMetadata) as PluginSdkDocEntrypoint[])
|
||||
.map((entrypoint) =>
|
||||
buildModuleSurface({
|
||||
checker,
|
||||
printer,
|
||||
program,
|
||||
repoRoot,
|
||||
entrypoint,
|
||||
}),
|
||||
)
|
||||
.toSorted((left, right) => left.importSpecifier.localeCompare(right.importSpecifier));
|
||||
|
||||
const baseline: PluginSdkApiBaseline = {
|
||||
generatedBy: GENERATED_BY,
|
||||
modules,
|
||||
};
|
||||
|
||||
return {
|
||||
baseline,
|
||||
json: `${JSON.stringify(baseline, null, 2)}\n`,
|
||||
jsonl: `${buildJsonlLines(baseline).join("\n")}\n`,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadCurrentFile(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function validateMetadata(): void {
|
||||
const canonicalEntrypoints = new Set<string>(pluginSdkEntrypoints);
|
||||
const metadataEntrypoints = new Set<string>(Object.keys(pluginSdkDocMetadata));
|
||||
|
||||
for (const entrypoint of metadataEntrypoints) {
|
||||
assert(
|
||||
canonicalEntrypoints.has(entrypoint),
|
||||
`Metadata entrypoint ${entrypoint} is not exported in the Plugin SDK.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function writePluginSdkApiBaselineStatefile(params?: {
|
||||
repoRoot?: string;
|
||||
check?: boolean;
|
||||
jsonPath?: string;
|
||||
statefilePath?: string;
|
||||
}): Promise<PluginSdkApiBaselineWriteResult> {
|
||||
const repoRoot = params?.repoRoot ?? resolveRepoRoot();
|
||||
const jsonPath = path.resolve(repoRoot, params?.jsonPath ?? DEFAULT_JSON_OUTPUT);
|
||||
const statefilePath = path.resolve(repoRoot, params?.statefilePath ?? DEFAULT_STATEFILE_OUTPUT);
|
||||
const rendered = await renderPluginSdkApiBaseline({ repoRoot });
|
||||
const currentJson = await loadCurrentFile(jsonPath);
|
||||
const currentJsonl = await loadCurrentFile(statefilePath);
|
||||
const changed = currentJson !== rendered.json || currentJsonl !== rendered.jsonl;
|
||||
|
||||
if (params?.check) {
|
||||
return {
|
||||
changed,
|
||||
wrote: false,
|
||||
jsonPath,
|
||||
statefilePath,
|
||||
};
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(jsonPath), { recursive: true });
|
||||
await fs.writeFile(jsonPath, rendered.json, "utf8");
|
||||
await fs.writeFile(statefilePath, rendered.jsonl, "utf8");
|
||||
|
||||
return {
|
||||
changed,
|
||||
wrote: true,
|
||||
jsonPath,
|
||||
statefilePath,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user