diff --git a/ci/onboard-entry-composition-budget.json b/ci/onboard-entry-composition-budget.json index 81dee89018b..fbb8faae00d 100644 --- a/ci/onboard-entry-composition-budget.json +++ b/ci/onboard-entry-composition-budget.json @@ -1,20 +1,35 @@ { - "$comment": "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0\nProvider decisions remain until #9169. Messaging decisions remain until #9170. Policy decisions remain until #9172. The budget permits no gateway decisions.", - "gateway": {}, + "$comment": "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0\nProvider decisions remain until #9169. Messaging decisions remain until #9170. Gateway and policy decisions remain until #9172.", + "gateway": { + "preflight": 2, + "preflightAuthoritativeRebuildTarget": 1, + "runOnboard": 2 + }, "messaging": { "createSandboxWithBaseImageResolution": 9, - "runOnboard": 1 + "createSandboxWithBaseImageResolution.plan.rebindMessagingTokenDefs": 1, + "getOpenShellInstallDeps.hasRequiredOpenshellMessagingFeatures": 4, + "runOnboard": 1, + "runOnboard.finalizationDeps.verifyDeployment.getMessagingChannels": 1 }, "policy": { - "createSandboxWithBaseImageResolution": 6, - "runOnboard": 5 + "createOnboardPolicyApplication.getRecordedPolicyTier": 1, + "createSandboxWithBaseImageResolution": 7, + "preflightAuthoritativeRebuildTarget": 1, + "runOnboard": 6, + "sandboxCreateIntentResolver.getAgentPolicyPath": 1 }, "provider": { - "createSandboxWithBaseImageResolution": 15, - "handleNimLocalSelection": 32, - "handleRemoteProviderSelection": 76, - "handleRoutedSelection": 15, + "createSandboxWithBaseImageResolution": 20, + "handleNimLocalSelection": 36, + "handleRemoteProviderSelection": 84, + "handleRemoteProviderSelection.providerExistsInGateway": 1, + "handleRemoteProviderSelection.readGatewayProviderMetadata": 1, + "handleRoutedSelection": 16, + "handleVllmSelection.queryVllmModels": 1, + "preflightAuthoritativeRebuildTarget": 1, "runOnboard": 8, - "selectAndValidateOllamaModel": 18 + "runOnboard.providerInference.deps.needsBedrockRuntimeAdapter": 1, + "selectAndValidateOllamaModel": 19 } } diff --git a/scripts/checks/onboard-entry-composition.mts b/scripts/checks/onboard-entry-composition.mts index 197595c6ecb..ff352cd72b6 100644 --- a/scripts/checks/onboard-entry-composition.mts +++ b/scripts/checks/onboard-entry-composition.mts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; import ts from "typescript"; @@ -18,6 +19,36 @@ export type OnboardEntryCompositionViolation = { readonly actualCount: number; readonly budgetCount: number; }; +export type OnboardEntryCompositionCeiling = { + readonly declarations: OnboardEntryCompositionBudget; + readonly categoryTotals: Readonly>; + readonly globalTotal: number; +}; +export type OnboardEntryCompositionBudgetExpansion = + | { + readonly kind: "declaration"; + readonly category: OnboardDecisionCategory; + readonly declaration: string; + readonly budgetCount: number; + readonly baselineCount: number; + } + | { + readonly kind: "category"; + readonly category: OnboardDecisionCategory; + readonly budgetCount: number; + readonly baselineCount: number; + } + | { + readonly kind: "global"; + readonly budgetCount: number; + readonly baselineCount: number; + }; +export type CompositionGitResult = Readonly<{ + status: number | null; + stdout: string; + error?: string | null; +}>; +export type CompositionGitRunner = (args: readonly string[]) => CompositionGitResult; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const ENTRY_PATH = path.join(REPO_ROOT, "src/lib/onboard.ts"); @@ -25,39 +56,220 @@ const BUDGET_PATH = path.join(REPO_ROOT, "ci/onboard-entry-composition-budget.js const CATEGORIES = ["gateway", "messaging", "policy", "provider"] as const; const LOGICAL_OPERATORS = new Set([ ts.SyntaxKind.AmpersandAmpersandToken, + ts.SyntaxKind.AmpersandAmpersandEqualsToken, ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.QuestionQuestionToken, + ts.SyntaxKind.QuestionQuestionEqualsToken, ]); -const RECOVERY_NAME = /recover|recovery|repair|restore|retry|fallback|rollback/i; +const RECOVERY_NAMES = [ + "fallback", + "recover", + "recovery", + "repair", + "restore", + "retry", + "rollback", +] as const; +const GATEWAY_LIFECYCLE_NAMES = [ + "start", + "stop", + "restart", + "launch", + "destroy", + "remove", + "reset", + ...RECOVERY_NAMES, + "retire", + "terminate", + "kill", + "wait", + "ensure", + "attach", + "register", + "reuse", +] as const; +const GATEWAY_STATE_NAMES = [ + "health", + "ready", + "readiness", + "running", + "stale", + "process", + "runtime", + "lifecycle", +] as const; +const RECOVERY_FACTORY_NAMES = ["build", "create", "install", "make"] as const; +const RECOVERY_ACTION_METHOD_NAMES = [ + "apply", + "call", + "execute", + "perform", + ...RECOVERY_NAMES, + "run", + "start", +] as const; +const COMPOUND_ACTION_NAMES = [ + ...GATEWAY_LIFECYCLE_NAMES, + "apply", + "execute", + "perform", + "run", +] as const; -function declarationBody(node: ts.Node): ts.ConciseBody | undefined { - if (ts.isFunctionDeclaration(node)) return node.body; - if (!ts.isVariableStatement(node)) return undefined; - for (const declaration of node.declarationList.declarations) { - const initializer = declaration.initializer; - if (initializer && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))) { - return initializer.body; - } +function alternation(names: readonly string[]): string { + return names.join("|"); +} + +function titleCase(names: readonly string[]): string[] { + return names.map((name) => `${name[0].toUpperCase()}${name.slice(1)}`); +} + +const RECOVERY_NAME = new RegExp(alternation(RECOVERY_NAMES), "i"); +const RECOVERY_FACTORY_NAME = new RegExp(`^(?:${alternation(RECOVERY_FACTORY_NAMES)})`, "i"); +const RECOVERY_COMPOUND_ACTION = new RegExp( + `(?:And|Or)(?:${alternation(titleCase(RECOVERY_NAMES))})|(?:${alternation(titleCase(RECOVERY_NAMES))})[A-Za-z0-9]*(?:And|Or)(?:${alternation(titleCase(COMPOUND_ACTION_NAMES))})`, +); +const RECOVERY_ACTION_METHOD = new RegExp( + `^(?:${alternation(RECOVERY_ACTION_METHOD_NAMES)})$`, + "i", +); +const GATEWAY_AFTER_LIFECYCLE = new RegExp( + `(?:${alternation(GATEWAY_LIFECYCLE_NAMES)}).*gateway`, + "i", +); +const GATEWAY_BEFORE_LIFECYCLE_OR_STATE = new RegExp( + `gateway.*(?:${alternation([...GATEWAY_LIFECYCLE_NAMES, ...GATEWAY_STATE_NAMES])})`, + "i", +); + +type NamedScope = { + readonly name: string; + readonly node: ts.Node; +}; + +type DecisionScope = NamedScope & { + readonly prunedNodes: ReadonlySet; +}; + +type StaticAliasBinding = Readonly<{ + target: string | null; + declaration: ts.Node; +}>; + +type StaticAliases = ReadonlyMap>; + +function emptyDecisionCounts(): Record { + return Object.create(null) as Record; +} + +function functionBody(node: ts.Node): ts.ConciseBody | undefined { + if ( + ts.isArrowFunction(node) || + ts.isFunctionExpression(node) || + ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) || + ts.isConstructorDeclaration(node) + ) { + return node.body; } return undefined; } -function declarationName(node: ts.Node): string | null { - if (ts.isFunctionDeclaration(node)) return node.name?.text ?? null; - if (!ts.isVariableStatement(node)) return null; - for (const declaration of node.declarationList.declarations) { - if ( - ts.isIdentifier(declaration.name) && - declaration.initializer && - (ts.isArrowFunction(declaration.initializer) || - ts.isFunctionExpression(declaration.initializer)) - ) { - return declaration.name.text; - } +function staticPropertyName(name: ts.PropertyName): string | null { + if ( + ts.isIdentifier(name) || + ts.isStringLiteral(name) || + ts.isNumericLiteral(name) || + ts.isPrivateIdentifier(name) + ) { + return name.text; + } + if (!ts.isComputedPropertyName(name)) return null; + const expression = unwrapTransparentExpression(name.expression); + if ( + ts.isStringLiteral(expression) || + ts.isNoSubstitutionTemplateLiteral(expression) || + ts.isNumericLiteral(expression) + ) { + return expression.text; } return null; } +function propertyName(node: ts.Node): string | null { + if ( + !ts.isPropertyAssignment(node) && + !ts.isPropertyDeclaration(node) && + !ts.isMethodDeclaration(node) && + !ts.isGetAccessorDeclaration(node) && + !ts.isSetAccessorDeclaration(node) + ) { + return null; + } + return staticPropertyName(node.name) ?? "[computed]"; +} + +function callableScopes(owner: string, root: ts.Node): DecisionScope[] { + type MutableDecisionScope = NamedScope & { readonly prunedNodes: Set }; + const scopes: MutableDecisionScope[] = []; + + function visit( + node: ts.Node, + currentOwner: string, + enclosingScope?: MutableDecisionScope, + isRoot = false, + ): void { + const member = isRoot ? null : propertyName(node); + const callableOwner = member ? `${currentOwner}.${member}` : currentOwner; + const body = functionBody(node); + if (body) { + const scope: MutableDecisionScope = { + name: callableOwner, + node: body, + prunedNodes: new Set(), + }; + enclosingScope?.prunedNodes.add(body); + scopes.push(scope); + ts.forEachChild(node, (child) => + visit(child, callableOwner, child === body ? scope : enclosingScope), + ); + return; + } + ts.forEachChild(node, (child) => visit(child, callableOwner, enclosingScope)); + } + + visit(root, owner, undefined, true); + return scopes; +} + +function declarationOwner(declaration: ts.VariableDeclaration): string { + if (ts.isIdentifier(declaration.name)) return declaration.name.text; + if (declaration.initializer && ts.isCallExpression(declaration.initializer)) { + return calledName(declaration.initializer.expression) ?? "destructuredBinding"; + } + return "destructuredBinding"; +} + +function topLevelScopes(statement: ts.Statement): NamedScope[] { + if (ts.isVariableStatement(statement)) { + return statement.declarationList.declarations.map((declaration) => ({ + name: declarationOwner(declaration), + node: declaration, + })); + } + if (ts.isExportAssignment(statement)) { + return [{ name: "defaultExport", node: statement.expression }]; + } + const name = + (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name + ? statement.name.text + : ""; + return [{ name, node: statement }]; +} + function isGatewayLifecycleIdentifier(identifier: string): boolean { if (!/gateway/i.test(identifier)) return false; if ( @@ -75,22 +287,27 @@ function isGatewayLifecycleIdentifier(identifier: string): boolean { return false; } return ( - /^(?:chooseGateway|gatewayState)$/i.test(identifier) || - /(?:start|stop|restart|launch|destroy|recover|repair|retire|terminate|kill|wait|ensure|attach|register|reuse).*gateway/i.test( - identifier, - ) || - /gateway.*(?:start|stop|restart|launch|destroy|recover|repair|retire|terminate|kill|wait|health|ready|readiness|running|stale|process|runtime|lifecycle)/i.test( - identifier, - ) + /^(?:chooseGateway|gateway[.]?State)$/i.test(identifier) || + GATEWAY_AFTER_LIFECYCLE.test(identifier) || + GATEWAY_BEFORE_LIFECYCLE_OR_STATE.test(identifier) ); } -function identifierCategories(identifier: string): ReadonlySet { +function identifierCategories( + identifier: string, + aliases: StaticAliases, + location?: ts.Node, +): ReadonlySet { const categories = new Set(); - if (isGatewayLifecycleIdentifier(identifier)) categories.add("gateway"); - if (/messaging|channel/i.test(identifier)) categories.add("messaging"); - if (/policy|preset/i.test(identifier)) categories.add("policy"); - if (/provider|inference|nim|ollama|routed|model/i.test(identifier)) categories.add("provider"); + const resolved = location ? resolveStaticAlias(identifier, aliases, location) : identifier; + const candidates = new Set([identifier, resolved]); + for (const candidate of [...candidates]) candidates.add(candidate.replaceAll(".", "")); + for (const candidate of candidates) { + if (isGatewayLifecycleIdentifier(candidate)) categories.add("gateway"); + if (/messaging|channel/i.test(candidate)) categories.add("messaging"); + if (/policy|preset/i.test(candidate)) categories.add("policy"); + if (/provider|inference|nim|ollama|routed|model/i.test(candidate)) categories.add("provider"); + } return categories; } @@ -98,13 +315,288 @@ function isLogicalDecision(node: ts.Node): node is ts.BinaryExpression { return ts.isBinaryExpression(node) && LOGICAL_OPERATORS.has(node.operatorToken.kind); } -function isRecoveryCall(node: ts.Node): node is ts.CallExpression { - return ts.isCallExpression(node) && RECOVERY_NAME.test(node.expression.getText()); +function staticElementName(expression: ts.ElementAccessExpression): string | null { + const argument = expression.argumentExpression + ? unwrapTransparentExpression(expression.argumentExpression) + : undefined; + if (argument && (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))) { + return argument.text; + } + return null; +} + +function unwrapTransparentExpression(expression: ts.Expression): ts.Expression { + if ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) || + ts.isNonNullExpression(expression) + ) { + return unwrapTransparentExpression(expression.expression); + } + return expression; +} + +function staticReferenceName(expression: ts.Expression): string | null { + const reference = unwrapTransparentExpression(expression); + if (ts.isIdentifier(reference) || ts.isPrivateIdentifier(reference)) return reference.text; + if (ts.isPropertyAccessExpression(reference)) { + const receiver = staticReferenceName(reference.expression); + return receiver ? `${receiver}.${reference.name.text}` : null; + } + if (ts.isElementAccessExpression(reference)) { + const receiver = staticReferenceName(reference.expression); + const member = staticElementName(reference); + return receiver && member ? `${receiver}.${member}` : null; + } + if (ts.isCallExpression(reference) && calledName(reference.expression) === "bind") { + const receiver = calledReceiver(reference.expression); + return receiver ? staticReferenceName(receiver) : null; + } + return null; +} + +function isAliasScope(node: ts.Node): boolean { + return ( + ts.isSourceFile(node) || + ts.isBlock(node) || + ts.isModuleBlock(node) || + ts.isCaseBlock(node) || + ts.isCatchClause(node) || + ts.isForStatement(node) || + ts.isForInStatement(node) || + ts.isForOfStatement(node) || + ts.isFunctionLike(node) + ); +} + +function nearestAliasScope(node: ts.Node, functionScoped = false): ts.Node { + let candidate: ts.Node | undefined = node.parent; + while (candidate) { + if ( + ts.isSourceFile(candidate) || + (functionScoped ? ts.isFunctionLike(candidate) : isAliasScope(candidate)) + ) { + return candidate; + } + candidate = candidate.parent; + } + return node.getSourceFile(); +} + +function collectStaticAliases(sourceFile: ts.SourceFile): StaticAliases { + const aliases = new Map>(); + + function record( + scope: ts.Node, + identifier: string, + target: string | null, + declaration: ts.Node, + ): void { + const bindings = aliases.get(scope) ?? new Map(); + bindings.set(identifier, { target, declaration }); + aliases.set(scope, bindings); + } + + function recordBindingName( + name: ts.BindingName, + target: string | null, + declaration: ts.Node, + scope: ts.Node, + ): void { + if (ts.isIdentifier(name)) { + record(scope, name.text, target, declaration); + return; + } + if (ts.isArrayBindingPattern(name)) { + for (const element of name.elements) { + if (ts.isBindingElement(element)) { + recordBindingName(element.name, null, declaration, scope); + } + } + return; + } + for (const element of name.elements) { + const member = element.propertyName + ? staticPropertyName(element.propertyName) + : ts.isIdentifier(element.name) + ? element.name.text + : null; + recordBindingName( + element.name, + target && member && !element.dotDotDotToken ? `${target}.${member}` : null, + declaration, + scope, + ); + } + } + + function visit(node: ts.Node): void { + if (ts.isVariableDeclarationList(node)) { + const isConst = (node.flags & ts.NodeFlags.Const) !== 0; + const functionScoped = (node.flags & (ts.NodeFlags.Const | ts.NodeFlags.Let)) === 0; + for (const declaration of node.declarations) { + const target = + isConst && declaration.initializer + ? staticReferenceName(declaration.initializer) + : null; + recordBindingName( + declaration.name, + target, + declaration, + nearestAliasScope(declaration, functionScoped), + ); + } + } + if (ts.isFunctionLike(node)) { + for (const parameter of node.parameters) { + recordBindingName(parameter.name, null, parameter, node); + } + } + if (ts.isCatchClause(node) && node.variableDeclaration) { + recordBindingName( + node.variableDeclaration.name, + null, + node.variableDeclaration, + node, + ); + } + if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) { + record(nearestAliasScope(node), node.name.text, null, node); + } + if ((ts.isFunctionExpression(node) || ts.isClassExpression(node)) && node.name) { + record(node, node.name.text, null, node); + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return aliases; +} + +function findStaticAlias( + identifier: string, + aliases: StaticAliases, + location: ts.Node, +): StaticAliasBinding | undefined { + let scope: ts.Node | undefined = location; + while (scope) { + const binding = aliases.get(scope)?.get(identifier); + if (binding) return binding; + scope = scope.parent; + } + return undefined; +} + +function resolveStaticAlias(identifier: string, aliases: StaticAliases, location: ts.Node): string { + let resolved = identifier; + let resolutionLocation = location; + const visited = new Set(); + while (true) { + const separator = resolved.indexOf("."); + const root = separator === -1 ? resolved : resolved.slice(0, separator); + const suffix = separator === -1 ? "" : resolved.slice(separator); + const binding = findStaticAlias(root, aliases, resolutionLocation); + if (!binding?.target || visited.has(binding)) break; + visited.add(binding); + resolved = `${binding.target}${suffix}`; + resolutionLocation = binding.declaration; + } + return resolved; +} + +function resolvedStaticReferenceName( + expression: ts.Expression, + aliases: StaticAliases, +): string | null { + const reference = unwrapTransparentExpression(expression); + if (ts.isIdentifier(reference)) { + return resolveStaticAlias(reference.text, aliases, reference); + } + if (ts.isPrivateIdentifier(reference)) return reference.text; + if (ts.isPropertyAccessExpression(reference)) { + const receiver = resolvedStaticReferenceName(reference.expression, aliases); + return receiver ? `${receiver}.${reference.name.text}` : null; + } + if (ts.isElementAccessExpression(reference)) { + const receiver = resolvedStaticReferenceName(reference.expression, aliases); + const member = staticElementName(reference); + return receiver && member ? `${receiver}.${member}` : null; + } + return null; +} + +function calledName(expression: ts.Expression): string | null { + const callee = unwrapTransparentExpression(expression); + if (ts.isIdentifier(callee)) return callee.text; + if (ts.isPropertyAccessExpression(callee)) return callee.name.text; + if (ts.isElementAccessExpression(callee)) return staticElementName(callee); + return null; +} + +function calledReceiver(expression: ts.Expression): ts.Expression | null { + const callee = unwrapTransparentExpression(expression); + if (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) { + return callee.expression; + } + return null; +} + +function immediatelyBoundReceiver(expression: ts.Expression): ts.Expression | null { + const callee = unwrapTransparentExpression(expression); + if (!ts.isCallExpression(callee) || calledName(callee.expression) !== "bind") return null; + return calledReceiver(callee.expression); +} + +type RecoveryInvocation = ts.CallExpression | ts.TaggedTemplateExpression; + +function recoveryInvocationExpression(node: RecoveryInvocation): ts.Expression { + return ts.isCallExpression(node) ? node.expression : node.tag; +} + +function isRecoveryInvocation(node: ts.Node, aliases: StaticAliases): node is RecoveryInvocation { + if (!ts.isCallExpression(node) && !ts.isTaggedTemplateExpression(node)) return false; + const expression = recoveryInvocationExpression(node); + const boundReceiver = immediatelyBoundReceiver(expression); + const boundName = boundReceiver ? resolvedStaticReferenceName(boundReceiver, aliases) : null; + if (boundName && RECOVERY_NAME.test(boundName)) return true; + const callee = unwrapTransparentExpression(expression); + const called = calledName(callee); + const name = + called && ts.isIdentifier(callee) ? resolveStaticAlias(called, aliases, callee) : called; + if (name === null || (RECOVERY_FACTORY_NAME.test(name) && !RECOVERY_COMPOUND_ACTION.test(name))) { + return false; + } + if (RECOVERY_NAME.test(name)) return true; + const receiver = calledReceiver(expression); + const receiverName = receiver ? resolvedStaticReferenceName(receiver, aliases) : null; + return ( + receiverName !== null && RECOVERY_ACTION_METHOD.test(name) && RECOVERY_NAME.test(receiverName) + ); +} + +function isCatchHandlerInvocation(node: ts.Node): node is ts.CallExpression { + return ts.isCallExpression(node) && calledName(node.expression) === "catch"; +} + +function hasOptionalAccess(expression: ts.Expression): boolean { + const candidate = unwrapTransparentExpression(expression); + if (ts.isPropertyAccessExpression(candidate) || ts.isElementAccessExpression(candidate)) { + return candidate.questionDotToken !== undefined || hasOptionalAccess(candidate.expression); + } + return false; +} + +function isOptionalCall(node: ts.Node): node is ts.CallExpression { + return ( + ts.isCallExpression(node) && + (node.questionDotToken !== undefined || hasOptionalAccess(node.expression)) + ); } // Count branches, short-circuit operators, condition-controlled loops, try statements, and // named recovery calls. Sequencing loops do not choose onboarding behavior. -function isDecisionNode(node: ts.Node): boolean { +function isDecisionNode(node: ts.Node, aliases: StaticAliases): boolean { return ( ts.isIfStatement(node) || ts.isSwitchStatement(node) || @@ -114,32 +606,130 @@ function isDecisionNode(node: ts.Node): boolean { ts.isWhileStatement(node) || ts.isDoStatement(node) || ts.isTryStatement(node) || - isRecoveryCall(node) + isRecoveryInvocation(node, aliases) || + isCatchHandlerInvocation(node) || + isOptionalCall(node) ); } -function decisionNodeCategories(node: ts.Node): ReadonlySet { +function decisionNodeCategories( + node: ts.Node, + aliases: StaticAliases, +): ReadonlySet { const categories = new Set(); function addIdentifiers(candidate: ts.Node): void { - if (ts.isIdentifier(candidate)) { - for (const category of identifierCategories(candidate.text)) categories.add(category); + if (ts.isIdentifier(candidate) || ts.isPrivateIdentifier(candidate)) { + for (const category of identifierCategories(candidate.text, aliases, candidate)) { + categories.add(category); + } + return; + } + if (ts.isElementAccessExpression(candidate)) { + const name = staticElementName(candidate); + if (name) { + for (const category of identifierCategories(name, aliases)) categories.add(category); + const reference = resolvedStaticReferenceName(candidate, aliases); + for (const category of identifierCategories(reference ?? name, aliases)) { + categories.add(category); + } + } + addIdentifiers(candidate.expression); + if (!name && candidate.argumentExpression) addIdentifiers(candidate.argumentExpression); + return; + } + if (ts.isPropertyAccessExpression(candidate)) { + const reference = resolvedStaticReferenceName(candidate, aliases); + for (const category of identifierCategories( + reference ?? `${candidate.expression.getText()}${candidate.name.text}`, + aliases, + )) { + categories.add(category); + } + addIdentifiers(candidate.expression); + return; } ts.forEachChild(candidate, addIdentifiers); } function scanCondition(candidate: ts.Node, root: boolean): void { - if (!root && isDecisionNode(candidate)) return; - if (ts.isIdentifier(candidate)) { - for (const category of identifierCategories(candidate.text)) categories.add(category); + if (!root && isDecisionNode(candidate, aliases)) return; + if ( + ts.isIdentifier(candidate) || + ts.isPrivateIdentifier(candidate) || + ts.isPropertyAccessExpression(candidate) || + ts.isElementAccessExpression(candidate) + ) { + addIdentifiers(candidate); } ts.forEachChild(candidate, (child) => scanCondition(child, false)); } + function scanActionArgument(candidate: ts.Node): void { + const body = functionBody(candidate); + if (body) { + ts.forEachChild(candidate, (child) => { + if (child !== body) scanActionArgument(child); + }); + scanActions(body, true); + return; + } + if (ts.isIdentifier(candidate) || ts.isPrivateIdentifier(candidate)) { + addIdentifiers(candidate); + return; + } + if (ts.isCallExpression(candidate) || ts.isNewExpression(candidate)) { + addIdentifiers(candidate.expression); + for (const argument of candidate.arguments ?? []) scanActionArgument(argument); + return; + } + if (ts.isTaggedTemplateExpression(candidate)) { + addIdentifiers(candidate.tag); + scanActionArgument(candidate.template); + return; + } + if (ts.isPropertyAccessExpression(candidate) || ts.isElementAccessExpression(candidate)) { + addIdentifiers(candidate); + return; + } + if ( + ts.isParenthesizedExpression(candidate) || + ts.isAsExpression(candidate) || + ts.isSatisfiesExpression(candidate) || + ts.isNonNullExpression(candidate) + ) { + scanActionArgument(candidate.expression); + return; + } + if (ts.isSpreadElement(candidate) || ts.isSpreadAssignment(candidate)) { + scanActionArgument(candidate.expression); + return; + } + if (ts.isPropertyAssignment(candidate)) { + scanActionArgument(candidate.initializer); + return; + } + if (ts.isShorthandPropertyAssignment(candidate)) { + addIdentifiers(candidate.name); + return; + } + ts.forEachChild(candidate, scanActionArgument); + } + function scanActions(candidate: ts.Node, root: boolean): void { - if (!root && isDecisionNode(candidate)) return; + if (!root && isDecisionNode(candidate, aliases)) return; + if (ts.isIdentifier(candidate) || ts.isPrivateIdentifier(candidate)) { + addIdentifiers(candidate); + return; + } if (ts.isCallExpression(candidate) || ts.isNewExpression(candidate)) { addIdentifiers(candidate.expression); + for (const argument of candidate.arguments ?? []) scanActionArgument(argument); + return; + } + if (ts.isTaggedTemplateExpression(candidate)) { + addIdentifiers(candidate.tag); + scanActions(candidate.template, false); return; } if ( @@ -172,8 +762,10 @@ function decisionNodeCategories(node: ts.Node): ReadonlySet = new Set(), ): Record> { - const nameCategories = identifierCategories(name); + const nameCategories = identifierCategories(name, aliases); const counts: Record> = { - gateway: {}, - messaging: {}, - policy: {}, - provider: {}, + gateway: emptyDecisionCounts(), + messaging: emptyDecisionCounts(), + policy: emptyDecisionCounts(), + provider: emptyDecisionCounts(), }; function visit(node: ts.Node): void { - if (isDecisionNode(node)) { - const categories = new Set([...nameCategories, ...decisionNodeCategories(node)]); + if (prunedNodes.has(node)) return; + if (isDecisionNode(node, aliases)) { + const categories = new Set([...nameCategories, ...decisionNodeCategories(node, aliases)]); for (const category of categories) { counts[category][name] = (counts[category][name] ?? 0) + 1; } @@ -209,7 +809,7 @@ function decisionCounts( ts.forEachChild(node, visit); } - visit(body); + visit(scope); return counts; } @@ -227,21 +827,26 @@ export function collectOnboardEntryDecisions(sourceText: string): OnboardEntryCo true, ts.ScriptKind.TS, ); + const aliases = collectStaticAliases(sourceFile); const decisions: Record> = { - gateway: {}, - messaging: {}, - policy: {}, - provider: {}, + gateway: emptyDecisionCounts(), + messaging: emptyDecisionCounts(), + policy: emptyDecisionCounts(), + provider: emptyDecisionCounts(), }; for (const statement of sourceFile.statements) { - const name = declarationName(statement); - const body = declarationBody(statement); - if (!name || !body) continue; - const declarationCounts = decisionCounts(name, body); - for (const category of CATEGORIES) { - for (const [declaration, count] of Object.entries(declarationCounts[category])) { - decisions[category][declaration] = (decisions[category][declaration] ?? 0) + count; + for (const { name, node } of topLevelScopes(statement)) { + const callables = callableScopes(name, node); + const callableBodies = new Set(callables.map((scope) => scope.node)); + const scopes: DecisionScope[] = [{ name, node, prunedNodes: callableBodies }, ...callables]; + for (const scope of scopes) { + const declarationCounts = decisionCounts(scope.name, scope.node, aliases, scope.prunedNodes); + for (const category of CATEGORIES) { + for (const [declaration, count] of Object.entries(declarationCounts[category])) { + decisions[category][declaration] = (decisions[category][declaration] ?? 0) + count; + } + } } } } @@ -305,6 +910,80 @@ export function evaluateOnboardEntryComposition( return violations.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); } +export function combineOnboardEntryCompositionCeiling( + baseBudget: OnboardEntryCompositionBudget, + baseActual: OnboardEntryCompositionBudget, +): OnboardEntryCompositionCeiling { + const declarations = Object.fromEntries( + CATEGORIES.map((category) => { + const names = new Set([ + ...Object.keys(baseBudget[category]), + ...Object.keys(baseActual[category]), + ]); + return [ + category, + sortCounts( + Object.fromEntries( + [...names].map((declaration) => [ + declaration, + Math.max( + baseBudget[category][declaration] ?? 0, + baseActual[category][declaration] ?? 0, + ), + ]), + ), + ), + ]; + }), + ) as Record; + const categoryTotals = Object.fromEntries( + CATEGORIES.map((category) => [ + category, + Math.max(totalDecisions(baseBudget[category]), totalDecisions(baseActual[category])), + ]), + ) as Record; + const budgetTotal = CATEGORIES.reduce( + (total, category) => total + totalDecisions(baseBudget[category]), + 0, + ); + const actualTotal = CATEGORIES.reduce( + (total, category) => total + totalDecisions(baseActual[category]), + 0, + ); + return { declarations, categoryTotals, globalTotal: Math.max(budgetTotal, actualTotal) }; +} + +export function evaluateOnboardEntryCompositionBudgetExpansion( + budget: OnboardEntryCompositionBudget, + ceiling: OnboardEntryCompositionCeiling, +): OnboardEntryCompositionBudgetExpansion[] { + const expansions: OnboardEntryCompositionBudgetExpansion[] = []; + for (const category of CATEGORIES) { + for (const [declaration, budgetCount] of Object.entries(budget[category])) { + const baselineCount = ceiling.declarations[category][declaration] ?? 0; + if (budgetCount <= baselineCount) continue; + expansions.push({ kind: "declaration", category, declaration, budgetCount, baselineCount }); + } + const budgetCount = totalDecisions(budget[category]); + const baselineCount = ceiling.categoryTotals[category]; + if (budgetCount > baselineCount) { + expansions.push({ kind: "category", category, budgetCount, baselineCount }); + } + } + const budgetTotal = CATEGORIES.reduce( + (total, category) => total + totalDecisions(budget[category]), + 0, + ); + if (budgetTotal > ceiling.globalTotal) { + expansions.push({ + kind: "global", + budgetCount: budgetTotal, + baselineCount: ceiling.globalTotal, + }); + } + return expansions.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); +} + export function formatOnboardEntryCompositionViolations( violations: readonly OnboardEntryCompositionViolation[], ): string { @@ -323,9 +1002,89 @@ function totalDecisions(counts: OnboardDecisionCounts): number { return Object.values(counts).reduce((total, count) => total + count, 0); } +function runGit(args: readonly string[]): CompositionGitResult { + const result = spawnSync("git", [...args], { + cwd: REPO_ROOT, + encoding: "utf8", + timeout: 5_000, + }); + return { + status: result.status, + stdout: result.stdout ?? "", + error: result.error?.message ?? null, + }; +} + +export function resolveCompositionMergeBase( + git: CompositionGitRunner = runGit, + baseBranch = process.env.GITHUB_BASE_REF?.trim(), +): string { + const baseRef = baseBranch ? `origin/${baseBranch}` : "origin/main"; + const mergeBase = git(["merge-base", "HEAD", baseRef]); + if (mergeBase.error) { + throw new Error( + `could not run git to resolve the composition merge base against ${baseRef} (${mergeBase.error})`, + ); + } + if (mergeBase.status !== 0 || !mergeBase.stdout.trim()) { + throw new Error( + `could not resolve the composition merge base against ${baseRef}; fetch the base ref with sufficient history`, + ); + } + return mergeBase.stdout.trim(); +} + +export function mergeBaseCompositionCeiling( + git: CompositionGitRunner = runGit, + baseBranch = process.env.GITHUB_BASE_REF?.trim(), +): OnboardEntryCompositionCeiling { + const revision = resolveCompositionMergeBase(git, baseBranch); + function readBaseFile(relativePath: string): string { + const source = git(["show", `${revision}:${relativePath}`]); + if (source.error) { + throw new Error( + `could not run git to read ${relativePath} from composition merge base ${revision} (${source.error})`, + ); + } + if (source.status !== 0) { + throw new Error(`could not read ${relativePath} from composition merge base ${revision}`); + } + return source.stdout; + } + + const baseBudget = parseOnboardEntryCompositionBudget( + readBaseFile("ci/onboard-entry-composition-budget.json"), + ); + const baseActual = collectOnboardEntryDecisions(readBaseFile("src/lib/onboard.ts")); + return combineOnboardEntryCompositionCeiling(baseBudget, baseActual); +} + +function formatBudgetExpansions( + expansions: readonly OnboardEntryCompositionBudgetExpansion[], +): string { + return [ + "Onboarding entry composition budget must not expand relative to the merge base.", + "", + ...expansions.map((expansion) => + expansion.kind === "declaration" + ? `- ${expansion.declaration}: ${expansion.category} budget increased from ${expansion.baselineCount} to ${expansion.budgetCount}.` + : expansion.kind === "category" + ? `- ${expansion.category}: total budget increased from ${expansion.baselineCount} to ${expansion.budgetCount}.` + : `- all categories: total budget increased from ${expansion.baselineCount} to ${expansion.budgetCount}.`, + ), + ].join("\n"); +} + function main(): void { const actual = collectOnboardEntryDecisions(readFileSync(ENTRY_PATH, "utf8")); const budget = parseOnboardEntryCompositionBudget(readFileSync(BUDGET_PATH, "utf8")); + const baseline = mergeBaseCompositionCeiling(); + const expansions = evaluateOnboardEntryCompositionBudgetExpansion(budget, baseline); + if (expansions.length > 0) { + console.error(formatBudgetExpansions(expansions)); + process.exitCode = 1; + return; + } const violations = evaluateOnboardEntryComposition(actual, budget); if (violations.length > 0) { console.error(formatOnboardEntryCompositionViolations(violations)); diff --git a/test/onboard-entry-composition.test.ts b/test/onboard-entry-composition.test.ts index ba824fae7fd..0932515fbb1 100644 --- a/test/onboard-entry-composition.test.ts +++ b/test/onboard-entry-composition.test.ts @@ -5,9 +5,13 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { + combineOnboardEntryCompositionCeiling, collectOnboardEntryDecisions, evaluateOnboardEntryComposition, + evaluateOnboardEntryCompositionBudgetExpansion, + mergeBaseCompositionCeiling, parseOnboardEntryCompositionBudget, + resolveCompositionMergeBase, type OnboardEntryCompositionBudget, } from "../scripts/checks/onboard-entry-composition.mts"; @@ -29,19 +33,6 @@ describe("onboarding entry composition boundary", () => { ); expect(evaluateOnboardEntryComposition(actual, budget)).toEqual([]); - expect(actual).toEqual({ - gateway: {}, - messaging: { createSandboxWithBaseImageResolution: 9, runOnboard: 1 }, - policy: { createSandboxWithBaseImageResolution: 6, runOnboard: 5 }, - provider: { - createSandboxWithBaseImageResolution: 15, - handleNimLocalSelection: 32, - handleRemoteProviderSelection: 76, - handleRoutedSelection: 15, - runOnboard: 8, - selectAndValidateOllamaModel: 18, - }, - }); }); it("rejects a gateway action selected by a neutral condition", () => { @@ -60,6 +51,151 @@ describe("onboarding entry composition boundary", () => { expect(actual.gateway).toEqual({ runOnboard: 1 }); }); + it("checks every function in one variable statement", () => { + const actual = collectOnboardEntryDecisions( + "const first = () => undefined, second = () => { if (enabled) startGateway(); };", + ); + + expect(actual.gateway).toEqual({ second: 1 }); + }); + + it.each(["__proto__", "constructor", "toString", "hasOwnProperty"])( + "counts a gateway decision in the prototype-named declaration %s", + (declaration) => { + const actual = collectOnboardEntryDecisions( + `function ${declaration}(enabled: boolean) { if (enabled) startGateway(); }`, + ); + + expect(actual.gateway).toEqual({ [declaration]: 1 }); + }, + ); + + it.each([ + [ + "object method", + "const entry = { choose() { if (enabled) startGateway(); } };", + "entry.choose", + ], + [ + "object function property", + "const entry = { choose: () => { if (enabled) startGateway(); } };", + "entry.choose", + ], + ["class method", "class Entry { choose() { if (enabled) startGateway(); } }", "Entry.choose"], + [ + "class expression method", + "const Entry = class { choose() { if (enabled) startGateway(); } };", + "Entry.choose", + ], + ])("checks a gateway decision in a top-level %s", (_form, source, declaration) => { + const actual = collectOnboardEntryDecisions(source); + + expect(actual.gateway).toEqual({ [declaration]: 1 }); + }); + + it("uses a stable neutral name for a computed method", () => { + const compact = collectOnboardEntryDecisions( + "class Entry { [gatewayKey]() { if (enabled) startGateway(); } }", + ); + const spaced = collectOnboardEntryDecisions( + "class Entry { [ gatewayKey ]() { if (enabled) startGateway(); } }", + ); + + expect(compact).toEqual(spaced); + expect(compact.gateway).toEqual({ "Entry.[computed]": 1 }); + }); + + it.each([ + ['["startGateway"]', "Entry.startGateway", "if (enabled) run();"], + ["[`startGateway`]", "Entry.startGateway", "if (enabled) run();"], + ["[1]", "Entry.1", "if (enabled) startGateway();"], + ])("preserves the static computed method key %s", (key, declaration, body) => { + const actual = collectOnboardEntryDecisions(`class Entry { ${key}() { ${body} } }`); + + expect(actual.gateway).toEqual({ [declaration]: 1 }); + }); + + it("uses a stable static owner for a destructured call initializer", () => { + const actual = collectOnboardEntryDecisions( + "const { choose } = factory.createEntry(() => { if (enabled) startGateway(); });", + ); + + expect(actual.gateway).toEqual({ createEntry: 1 }); + }); + + it.each([ + ["return () => { if (enabled) startGateway(); };", "runOnboard"], + ["schedule(() => { if (enabled) startGateway(); });", "runOnboard"], + ["const nested = { choose() { if (enabled) startGateway(); } };", "runOnboard.choose"], + ])("checks a gateway decision in a nested callable body: %s", (body, declaration) => { + const actual = collectOnboardEntryDecisions(`function runOnboard() { ${body} }`); + + expect(actual.gateway).toEqual({ [declaration]: 1 }); + }); + + it.each([ + [ + "class field function", + "class Entry { choose = () => { if (enabled) startGateway(); }; }", + "Entry.choose", + ], + [ + "factory callback", + "const entry = createEntry(() => { if (enabled) startGateway(); });", + "entry", + ], + [ + "default export callback", + "export default () => { if (enabled) startGateway(); };", + "defaultExport", + ], + ["module statement", "if (enabled) startGateway();", ""], + ])("checks a gateway decision in a top-level %s", (_form, source, declaration) => { + const actual = collectOnboardEntryDecisions(source); + + expect(actual.gateway).toEqual({ [declaration]: 1 }); + }); + + it.each([ + [ + "function default parameter", + "function choose(value = enabled ? startGateway() : undefined) {}", + "choose", + ], + [ + "arrow default parameter", + "const choose = (value = enabled ? startGateway() : undefined) => value;", + "choose", + ], + [ + "exported variable initializer", + "export const choice = enabled ? startGateway() : stopGateway();", + "choice", + ], + [ + "object property initializer", + "const entry = { choice: enabled ? startGateway() : stopGateway() };", + "entry", + ], + [ + "class field initializer", + "class Entry { choice = enabled ? startGateway() : stopGateway(); }", + "Entry", + ], + ["class static block", "class Entry { static { if (enabled) startGateway(); } }", "Entry"], + ["computed method name", "class Entry { [enabled ? startGateway() : 'choose']() {} }", "Entry"], + ["decorator expression", "@(enabled ? startGateway() : decorate)\nclass Entry {}", "Entry"], + [ + "direct default export expression", + "export default enabled ? startGateway() : stopGateway();", + "defaultExport", + ], + ])("checks a gateway decision in a top-level %s", (_form, source, declaration) => { + const actual = collectOnboardEntryDecisions(source); + + expect(actual.gateway).toEqual({ [declaration]: 1 }); + }); + it("rejects a messaging action selected by a neutral condition", () => { const actual = collectOnboardEntryDecisions( "function choose(enabled: boolean) { if (enabled) configureMessaging(); }", @@ -68,6 +204,210 @@ describe("onboarding entry composition boundary", () => { expect(actual.messaging).toEqual({ choose: 1 }); }); + it.each([ + ["identifier", "const start = startGateway;"], + ["destructured", "const { start } = gateway;"], + ["stored bind", "const start = startGateway.bind(null);"], + ["optional-chain", "const start = gateway?.start;"], + ])("checks a gateway action through a static %s alias", (_form, alias) => { + const actual = collectOnboardEntryDecisions( + `${alias} function choose(enabled: boolean) { if (enabled) start(); }`, + ); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it.each([ + [ + "gateway alias first", + "function gatewayChoice(enabled: boolean) { const action = startGateway; if (enabled) action(); } function neutralChoice(enabled: boolean) { const action = reportError; if (enabled) action(); }", + ], + [ + "gateway alias last", + "function neutralChoice(enabled: boolean) { const action = reportError; if (enabled) action(); } function gatewayChoice(enabled: boolean) { const action = startGateway; if (enabled) action(); }", + ], + ])("resolves same-name aliases by lexical scope with %s", (_order, source) => { + const actual = collectOnboardEntryDecisions(source); + + expect(actual.gateway).toEqual({ gatewayChoice: 1 }); + }); + + it("keeps same-name aliases in separate category scopes", () => { + const actual = collectOnboardEntryDecisions( + "function gatewayChoice(enabled: boolean) { const action = startGateway; if (enabled) action(); } function messagingChoice(enabled: boolean) { const action = configureMessaging; if (enabled) action(); }", + ); + + expect(actual.gateway).toEqual({ gatewayChoice: 1 }); + expect(actual.messaging).toEqual({ messagingChoice: 1 }); + }); + + it("resolves a nested alias shadow in its lexical block", () => { + const actual = collectOnboardEntryDecisions( + "function choose(enabled: boolean) { const action = startGateway; if (enabled) action(); { const action = configureMessaging; if (enabled) action(); } if (enabled) action(); }", + ); + + expect(actual.gateway).toEqual({ choose: 2 }); + expect(actual.messaging).toEqual({ choose: 1 }); + }); + + it("resolves an alias chain at its declaration scope", () => { + const actual = collectOnboardEntryDecisions( + "const action = startGateway; function choose(enabled: boolean) { const selected = action; { const action = configureMessaging; if (enabled) selected(); } }", + ); + + expect(actual.gateway).toEqual({ choose: 1 }); + expect(actual.messaging).toEqual({}); + }); + + it("resolves repeated alias names through distinct lexical bindings", () => { + const actual = collectOnboardEntryDecisions( + "const action = startGateway; function choose(enabled: boolean) { const selected = action; { const action = selected; if (enabled) action(); } }", + ); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it("resolves a property alias through its receiver alias", () => { + const actual = collectOnboardEntryDecisions( + "const api = gateway; const start = api.start; function choose(enabled: boolean) { if (enabled) start(); }", + ); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it.each(["api.start()", 'api["start"]()'])( + "resolves the receiver alias in the gateway call %s", + (call) => { + const actual = collectOnboardEntryDecisions( + `const api = gateway; function choose(enabled: boolean) { if (enabled) ${call}; }`, + ); + + expect(actual.gateway).toEqual({ choose: 1 }); + }, + ); + + it.each(["api.running()", "api.state"])( + "resolves a receiver alias in the gateway condition %s", + (condition) => { + const actual = collectOnboardEntryDecisions( + `const api = gateway; function choose() { if (${condition}) start(); }`, + ); + + expect(actual.gateway).toEqual({ choose: 1 }); + }, + ); + + it("resolves a destructured alias through its receiver alias", () => { + const actual = collectOnboardEntryDecisions( + "const tools = messaging; const { configure: apply } = tools; function choose(enabled: boolean) { if (enabled) apply(); }", + ); + + expect(actual.messaging).toEqual({ choose: 1 }); + }); + + it("does not resolve a property member through a same-name alias", () => { + const actual = collectOnboardEntryDecisions( + "const start = startGateway; function choose(enabled: boolean) { if (enabled) logger.start(); }", + ); + + expect(actual.gateway).toEqual({}); + }); + + it.each([ + [ + "parameter", + "const start = startGateway; function choose(start: () => void, enabled: boolean) { if (enabled) start(); }", + ], + [ + "mutable local", + "const start = startGateway; function choose(enabled: boolean) { let start = reportError; if (enabled) start(); }", + ], + [ + "non-alias constant", + "const start = startGateway; function choose(enabled: boolean) { const start = () => reportError(); if (enabled) start(); }", + ], + [ + "function-scoped variable", + "const start = startGateway; function choose(enabled: boolean) { if (enabled) { var start = reportError; } if (enabled) start(); }", + ], + [ + "local function", + "const start = startGateway; function choose(enabled: boolean) { function start() {} if (enabled) start(); }", + ], + [ + "named class expression", + "const action = startGateway; const Entry = class action { choose(enabled: boolean) { if (enabled) action(); } };", + ], + ])("does not resolve an outer alias through a %s shadow", (_binding, source) => { + const actual = collectOnboardEntryDecisions(source); + + expect(actual.gateway).toEqual({}); + }); + + it("checks an optional gateway call through a static alias", () => { + const actual = collectOnboardEntryDecisions( + "const start = startGateway; function choose() { start?.(); }", + ); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it.each(["gateway?.start()", 'gateway?.["start"]()'])( + "checks the receiver-side optional gateway call %s", + (call) => { + const actual = collectOnboardEntryDecisions(`function choose() { ${call}; }`); + + expect(actual.gateway).toEqual({ choose: 1 }); + }, + ); + + it("keeps static aliases within their lexical scope", () => { + const actual = collectOnboardEntryDecisions(` + function chooseStart(enabled: boolean) { + const start = startGateway; + if (enabled) start(); + } + function reportOnly(enabled: boolean) { + const start = reportError; + if (enabled) start(); + } + `); + + expect(actual.gateway).toEqual({ chooseStart: 1 }); + }); + + it.each([ + ["receiver", "const service = gateway;", "if (enabled) service.start();"], + ["optional receiver", "const service = gateway;", "service?.start();"], + [ + "member", + "const service = gateway; const action = service.start;", + "if (enabled) action();", + ], + ["recovery receiver", "const service = gatewayRecovery;", "service.execute();"], + ])("checks a gateway action through a static %s alias", (_form, aliases, action) => { + const actual = collectOnboardEntryDecisions(` + function choose(enabled: boolean) { + ${aliases} + ${action} + } + `); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it("keeps a shadowed receiver alias out of gateway classification", () => { + const actual = collectOnboardEntryDecisions(` + const service = gateway; + function reportOnly(enabled: boolean) { + const service = reporter; + if (enabled) service.start(); + } + `); + + expect(actual.gateway).toEqual({}); + }); + it.each([ ["if", "if (enabled) startGateway();"], ["switch", "switch (mode) { case 'start': startGateway(); }"], @@ -148,6 +488,286 @@ describe("onboarding entry composition boundary", () => { expect(actual.gateway).toEqual({}); }); + it("does not classify recovery helper construction as a recovery decision", () => { + const actual = collectOnboardEntryDecisions( + "const gatewayRecovery = createGatewayRecoveryOrchestration({});", + ); + + expect(actual.gateway).toEqual({}); + }); + + it.each([ + ["gateway", "gatewayRecovery.execute()"], + ["messaging", "messagingRecovery.execute()"], + ["policy", "policyRecovery.run()"], + ["provider", "providerRecovery.execute()"], + ] as const)("classifies a %s recovery action by its receiver", (category, call) => { + const actual = collectOnboardEntryDecisions(`function choose() { ${call}; }`); + + expect(actual[category]).toEqual({ choose: 1 }); + }); + + it("does not classify a recovery helper method as a recovery action", () => { + const actual = collectOnboardEntryDecisions( + "function choose() { providerRecovery.providerNameToOptionKey(); }", + ); + + expect(actual.provider).toEqual({}); + }); + + it("does not resolve a recovery method name through a local alias", () => { + const actual = collectOnboardEntryDecisions(` + function choose() { + const execute = reportError; + gatewayRecovery.execute(); + } + `); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it("counts a direct promise recovery handler", () => { + const actual = collectOnboardEntryDecisions( + "function choose() { operation().catch(recoverGateway); }", + ); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it("does not count a promise handler that has no onboarding action", () => { + const actual = collectOnboardEntryDecisions( + "function choose() { operation().catch(reportError); }", + ); + + expect(actual.gateway).toEqual({}); + }); + + it.each(["(gatewayRecovery.execute)()", 'gatewayRecovery["execute"]()'])( + "classifies receiver recovery action form %s", + (call) => { + const actual = collectOnboardEntryDecisions(`function choose() { ${call}; }`); + + expect(actual.gateway).toEqual({ choose: 1 }); + }, + ); + + it.each([ + "restoreGateway", + "retryGateway", + "fallbackGateway", + "rollbackGateway", + "gatewayRestore", + "gatewayRetry", + "gatewayFallback", + "gatewayRollback", + ])("classifies the gateway recovery action %s", (action) => { + const actual = collectOnboardEntryDecisions(`function choose() { ${action}(); }`); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it.each([ + "if (enabled) schedule(startGateway);", + "if (enabled) schedule(() => startGateway());", + "if (enabled) schedule(startGateway());", + "if (enabled) schedule(startGateway.bind(null));", + "if (enabled) schedule([startGateway]);", + "if (enabled) schedule({ run: startGateway });", + "if (enabled) schedule({ ...{ run: startGateway } });", + "if (enabled) scheduleNested(schedule(startGateway));", + ])("checks a gateway action passed as an argument: %s", (decision) => { + const actual = collectOnboardEntryDecisions(`function choose() { ${decision} }`); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it.each(["&&=", "||=", "??="])( + "checks a gateway action behind logical assignment %s", + (operator) => { + const actual = collectOnboardEntryDecisions( + `function choose() { enabled ${operator} startGateway(); }`, + ); + + expect(actual.gateway).toEqual({ choose: 1 }); + }, + ); + + it.each([ + "recoverGateway!()", + "(recoverGateway as Callable)()", + "(recoverGateway satisfies Callable)()", + "(gatewayRecovery.execute as Callable)()", + 'if (enabled) gateway[("startGateway")]()', + "(recoverGateway as Callable)`now`", + ])("checks the wrapped static gateway invocation %s", (decision) => { + const actual = collectOnboardEntryDecisions(`function choose() { ${decision}; }`); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it.each([ + "gatewayRecovery[`execute`]()", + 'if (enabled) gateway["startGateway"]()', + "recoverGateway`now`", + ])("checks the static gateway invocation %s", (decision) => { + const actual = collectOnboardEntryDecisions(`function choose() { ${decision}; }`); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it("checks a private gateway recovery method", () => { + const actual = collectOnboardEntryDecisions( + "class Entry { #recoverGateway() {} choose() { this.#recoverGateway(); } }", + ); + + expect(actual.gateway).toEqual({ "Entry.choose": 1 }); + }); + + it("checks a gateway action in a for-loop incrementor", () => { + const actual = collectOnboardEntryDecisions( + "function choose() { for (; enabled; startGateway()) {} }", + ); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it.each(["createOrRecoverGateway", "buildOrRecoverGateway"])( + "classifies the compound gateway action %s", + (action) => { + const actual = collectOnboardEntryDecisions(`function choose() { ${action}(); }`); + + expect(actual.gateway).toEqual({ choose: 1 }); + }, + ); + + it("ignores a factory name with a lowercase compound-like sequence", () => { + const actual = collectOnboardEntryDecisions( + "function choose() { createProviderSupervisorRestoreHint(); }", + ); + + expect(actual.provider).toEqual({}); + }); + + it.each([ + "createGatewayRecoveryAndStart", + "buildGatewayRepairAndRun", + "makeGatewayRollbackAndExecute", + ])("classifies the reversed compound gateway action %s", (action) => { + const actual = collectOnboardEntryDecisions(`function choose() { ${action}(); }`); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it("checks a nested gateway action in a for-loop incrementor", () => { + const actual = collectOnboardEntryDecisions( + "function choose() { for (; enabled; schedule(startGateway())) {} }", + ); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it.each([ + "if (enabled) schedule((value = startGateway()) => value);", + "if (enabled) schedule({ [startGateway()]() {} });", + ])("checks a gateway action in a nested callable header: %s", (decision) => { + const actual = collectOnboardEntryDecisions(`function choose() { ${decision} }`); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it.each(["for (startGateway(); enabled;) {}", "for (let value = startGateway(); enabled;) {}"])( + "checks a gateway action in a for-loop initializer: %s", + (decision) => { + const actual = collectOnboardEntryDecisions(`function choose() { ${decision} }`); + + expect(actual.gateway).toEqual({ choose: 1 }); + }, + ); + + it.each(["gateway.start()", "gateway.recover()"])( + "combines a gateway receiver with lifecycle action %s", + (call) => { + const actual = collectOnboardEntryDecisions(`function choose() { if (enabled) ${call}; }`); + + expect(actual.gateway).toEqual({ choose: 1 }); + }, + ); + + it.each(["recoverGateway.call(null)", "recoverGateway.bind(null)()"])( + "checks the direct recovery invocation %s", + (call) => { + const actual = collectOnboardEntryDecisions(`function choose() { ${call}; }`); + + expect(actual.gateway).toEqual({ choose: 1 }); + }, + ); + + it.each(["createRecoveryGatewayAndStart", "buildRepairGatewayAndRun"])( + "classifies the gateway-interposed compound action %s", + (action) => { + const actual = collectOnboardEntryDecisions(`function choose() { ${action}(); }`); + + expect(actual.gateway).toEqual({ choose: 1 }); + }, + ); + + it.each([ + "createRecoveryGatewayAndRestart", + "createRecoveryGatewayAndStop", + "buildRepairGatewayAndLaunch", + "makeRollbackGatewayAndDestroy", + "installRestoreGatewayAndWait", + ])("classifies the compound lifecycle action %s", (action) => { + const actual = collectOnboardEntryDecisions(`function choose() { ${action}(); }`); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it.each([ + "if (gateway.running()) return;", + 'if (gateway["ready"]()) return;', + "switch (gateway.state) { default: break; }", + ])("combines gateway receiver and member in condition %s", (decision) => { + const actual = collectOnboardEntryDecisions(`function choose() { ${decision} }`); + + expect(actual.gateway).toEqual({ choose: 1 }); + }); + + it.each(["ensure", "attach", "register", "reuse"])( + "classifies the gateway lifecycle condition member %s", + (member) => { + for (const expression of [`gateway.${member}()`, `gateway["${member}"]()`]) { + const actual = collectOnboardEntryDecisions( + `function choose() { if (${expression}) return; }`, + ); + + expect(actual.gateway).toEqual({ choose: 1 }); + } + }, + ); + + it.each(["removeDockerDriverGatewayRegistration", "resetGatewayOwnerBinding"])( + "classifies the gateway lifecycle action %s", + (action) => { + const actual = collectOnboardEntryDecisions( + `function choose(enabled: boolean) { if (enabled) ${action}(); }`, + ); + + expect(actual.gateway).toEqual({ choose: 1 }); + }, + ); + + it.each(["removeGatewayCredential", "resetGatewayEndpoint"])( + "does not classify gateway configuration action %s as lifecycle", + (action) => { + const actual = collectOnboardEntryDecisions( + `function choose(enabled: boolean) { if (enabled) ${action}(); }`, + ); + + expect(actual.gateway).toEqual({}); + }, + ); + it("rejects a decision added within an allowed declaration", () => { const actual = collectOnboardEntryDecisions( "function handleRemoteProviderSelection(enabled: boolean) { if (enabled) useProvider(); if (enabled) useProviderAgain(); }", @@ -189,4 +809,169 @@ describe("onboarding entry composition boundary", () => { }, ]); }); + + it("permits reassigned allowance up to the merge-base source", () => { + const baseBudget = { + ...EMPTY_BUDGET, + gateway: { runOnboard: 1 }, + }; + const baseActual = { + ...EMPTY_BUDGET, + gateway: { runOnboard: 2 }, + }; + const baseline = combineOnboardEntryCompositionCeiling(baseBudget, baseActual); + + expect( + evaluateOnboardEntryCompositionBudgetExpansion( + { ...EMPTY_BUDGET, gateway: { runOnboard: 2 } }, + baseline, + ), + ).toEqual([]); + expect( + evaluateOnboardEntryCompositionBudgetExpansion( + { ...EMPTY_BUDGET, gateway: { runOnboard: 3 } }, + baseline, + ), + ).toEqual([ + { + kind: "category", + category: "gateway", + budgetCount: 3, + baselineCount: 2, + }, + { + kind: "declaration", + category: "gateway", + declaration: "runOnboard", + budgetCount: 3, + baselineCount: 2, + }, + { + kind: "global", + budgetCount: 3, + baselineCount: 2, + }, + ]); + }); + + it("rejects duplicated allowance after owner reassignment", () => { + const baseline = combineOnboardEntryCompositionCeiling( + { ...EMPTY_BUDGET, messaging: { oldOwner: 1 } }, + { ...EMPTY_BUDGET, messaging: { newOwner: 1 } }, + ); + + expect( + evaluateOnboardEntryCompositionBudgetExpansion( + { ...EMPTY_BUDGET, messaging: { newOwner: 1, oldOwner: 1 } }, + baseline, + ), + ).toEqual([ + { + kind: "category", + category: "messaging", + budgetCount: 2, + baselineCount: 1, + }, + { + kind: "global", + budgetCount: 2, + baselineCount: 1, + }, + ]); + }); + + it("rejects duplicated allowance after category reassignment", () => { + const baseline = combineOnboardEntryCompositionCeiling( + { ...EMPTY_BUDGET, provider: { oldOwner: 1 } }, + { ...EMPTY_BUDGET, messaging: { newOwner: 1 } }, + ); + + expect( + evaluateOnboardEntryCompositionBudgetExpansion( + { + ...EMPTY_BUDGET, + messaging: { newOwner: 1 }, + provider: { oldOwner: 1 }, + }, + baseline, + ), + ).toEqual([ + { + kind: "global", + budgetCount: 2, + baselineCount: 1, + }, + ]); + }); + + it("reports a Git execution failure while resolving the composition merge base", () => { + const calls: string[][] = []; + + expect(() => + resolveCompositionMergeBase((args) => { + calls.push([...args]); + return { status: 128, stdout: "", error: "spawn git ENOENT" }; + }, ""), + ).toThrow( + "could not run git to resolve the composition merge base against origin/main (spawn git ENOENT)", + ); + expect(calls).toEqual([["merge-base", "HEAD", "origin/main"]]); + }); + + it("fails closed when the composition merge-base history is unavailable", () => { + expect(() => resolveCompositionMergeBase(() => ({ status: 128, stdout: "" }), "")).toThrow( + "could not resolve the composition merge base against origin/main; fetch the base ref with sufficient history", + ); + }); + + it.each([ + "ci/onboard-entry-composition-budget.json", + "src/lib/onboard.ts", + ])("fails closed when %s is unavailable at the composition merge base", (missingPath) => { + const revision = "base-revision"; + const baseBudget = JSON.stringify(EMPTY_BUDGET); + const resultsByMissingPath = { + "ci/onboard-entry-composition-budget.json": [ + { status: 0, stdout: revision }, + { status: 128, stdout: "" }, + ], + "src/lib/onboard.ts": [ + { status: 0, stdout: revision }, + { status: 0, stdout: baseBudget }, + { status: 128, stdout: "" }, + ], + } as const; + const results = resultsByMissingPath[missingPath as keyof typeof resultsByMissingPath]; + const calls: string[][] = []; + const git = (args: readonly string[]) => { + calls.push([...args]); + return results[calls.length - 1]; + }; + + expect(() => mergeBaseCompositionCeiling(git, "")).toThrow( + `could not read ${missingPath} from composition merge base ${revision}`, + ); + }); + + it("reports a Git execution failure while reading a composition merge-base file", () => { + const revision = "base-revision"; + const relativePath = "ci/onboard-entry-composition-budget.json"; + const calls: string[][] = []; + const results = [ + { status: 0, stdout: revision }, + { status: null, stdout: "", error: "spawnSync git ETIMEDOUT" }, + ]; + const git = (args: readonly string[]) => { + calls.push([...args]); + return results[calls.length - 1]; + }; + + expect(() => mergeBaseCompositionCeiling(git, "")).toThrow( + `could not run git to read ${relativePath} from composition merge base ${revision} (spawnSync git ETIMEDOUT)`, + ); + expect(calls).toEqual([ + ["merge-base", "HEAD", "origin/main"], + ["show", "base-revision:ci/onboard-entry-composition-budget.json"], + ]); + }); });