// What a fleet dashboard uses, derived from its own code when it is built // (RFC 0006 registration-names-what-it-uses, task 122 K, task 123 K): every // kind its `fleet.query` and `fleet.watch` calls name, and every operationId // its `fleet.call` and `fleet.live` calls name, read as literals. // `fleet.state.get`/`put` use nothing a registration lists. A call the build // cannot read, or any other use of the client, fails the build rather than // leave `dashboard.json` short of what the code reaches. import ts from "typescript"; export const FLEET_MODULE = "@norskvideo/ctl-sdk/fleet"; const KIND_CALLS = new Set(["query", "watch"]); const OPERATION_CALLS = new Set(["call", "live"]); const STATE_CALLS = new Set(["get", "put"]); export interface DashboardUses { kinds: string[]; operations: string[]; } export type UsesResult = { status: "ok"; value: DashboardUses } | { status: "error"; error: string[] }; function literalKinds(node: ts.Expression): string[] | undefined { if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return [node.text]; if (!ts.isArrayLiteralExpression(node)) return undefined; const kinds: string[] = []; for (const element of node.elements) { if (!ts.isStringLiteral(element) && !ts.isNoSubstitutionTemplateLiteral(element)) return undefined; kinds.push(element.text); } return kinds; } function kindsOfSelection(arg: ts.Expression | undefined): string[] | undefined { if (arg === undefined || !ts.isObjectLiteralExpression(arg)) return undefined; for (const property of arg.properties) { if (ts.isPropertyAssignment(property) && property.name.getText() === "kind") { return literalKinds(property.initializer); } } return undefined; } export function dashboardUses(files: readonly { path: string; source: string }[]): UsesResult { const kinds = new Set(); const operations = new Set(); const problems: string[] = []; for (const { path, source } of files) { const sf = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true); const where = (node: ts.Node) => `${path}:${sf.getLineAndCharacterOfPosition(node.getStart()).line + 1}`; // The names the module's client is bound to here: the `fleet` export, and // consts holding a `fleetClient(...)`. const clients = new Set(); const factories = new Set(); for (const statement of sf.statements) { if (!ts.isImportDeclaration(statement)) continue; if (!ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== FLEET_MODULE) continue; const named = statement.importClause?.namedBindings; if (named === undefined) continue; if (ts.isNamespaceImport(named)) { problems.push( `${where(named)}: import the fleet client by name (\`import { fleet }\`), so its calls can be read`, ); continue; } for (const element of named.elements) { const imported = (element.propertyName ?? element.name).text; if (imported === "fleet") clients.add(element.name.text); if (imported === "fleetClient") factories.add(element.name.text); } } if (clients.size === 0 && factories.size === 0) continue; const visit = (node: ts.Node): void => { if ( ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer !== undefined && ts.isCallExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && factories.has(node.initializer.expression.text) ) { clients.add(node.name.text); for (const arg of node.initializer.arguments) visit(arg); return; } if ( ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && clients.has(node.expression.expression.text) ) { const method = node.expression.name.text; if (KIND_CALLS.has(method)) { const found = kindsOfSelection(node.arguments[0]); if (found === undefined) { problems.push( `${where(node)}: ${node.expression.getText()} must name its kind as a literal, or a list of literals, in the selection it is passed, so the dashboard's uses can be derived`, ); } else { for (const kind of found) kinds.add(kind); } for (const arg of node.arguments) visit(arg); return; } if (OPERATION_CALLS.has(method)) { const operation = node.arguments[1]; if ( operation !== undefined && (ts.isStringLiteral(operation) || ts.isNoSubstitutionTemplateLiteral(operation)) ) { operations.add(operation.text); } else { problems.push( `${where(node)}: ${node.expression.getText()} must name its operationId as a literal, so the dashboard's uses can be derived`, ); } for (const arg of node.arguments) visit(arg); return; } } // fleet.state.get(...) / .put(...): the host's shared state, which a // registration does not list. if ( ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && STATE_CALLS.has(node.expression.name.text) && ts.isPropertyAccessExpression(node.expression.expression) && node.expression.expression.name.text === "state" && ts.isIdentifier(node.expression.expression.expression) && clients.has(node.expression.expression.expression.text) ) { for (const arg of node.arguments) visit(arg); return; } // fleet.instance(): which instance the page is (RFC 0008), which a // registration does not list either. if ( ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "instance" && ts.isIdentifier(node.expression.expression) && clients.has(node.expression.expression.text) ) { for (const arg of node.arguments) visit(arg); return; } if (ts.isIdentifier(node) && (clients.has(node.text) || factories.has(node.text))) { const parent = node.parent; const declaring = ts.isImportSpecifier(parent) || (ts.isVariableDeclaration(parent) && parent.name === node); if (!declaring) { problems.push( `${where(node)}: ${node.text} is used other than by calling its query, watch, call, live, instance or state.get/put, so the build cannot see what it reaches`, ); } } ts.forEachChild(node, visit); }; ts.forEachChild(sf, visit); } return problems.length > 0 ? { status: "error", error: problems } : { status: "ok", value: { kinds: [...kinds].sort(), operations: [...operations].sort() } }; }