import type { AnyNode, BlockStatement, ModuleDeclaration, Pattern, Program, Statement } from "acorn" import { Result } from "effect" import type { Diagnostic } from "./codemode.js" import { parseProgram } from "./interpreter/execute.js" import { formatLocation } from "./interpreter/model.js" /** * Extracts every `tools.` referenced by a Code Mode program, in first-seen order. Fails on syntax errors, * dynamic bracket access (`tools[name]`), or aliasing/passing `tools` rather than calling `tools.(...)` directly. */ export function scanTools(code: string): Result.Result, Diagnostic> { if (code.trim().length === 0) { return Result.fail({ kind: "ParseError", message: "Code cannot be empty." }) } const parsed = parseProgram(code) if (!parsed.ok) return Result.fail(parsed.error) const found = new Set() const scopes: Array> = [collectScope(parsed.program, true)] const ancestors: Array = [] const isShadowed = () => scopes.some((scope) => scope.has("tools")) const fail = (node: AnyNode, message: string): Diagnostic => { const loc = node.loc?.start ? { line: node.loc.start.line, column: node.loc.start.column + 1 } : undefined return { kind: "UnsupportedSyntax", message: `${message}${loc ? formatLocation(loc) : ""}`, ...(loc ? { location: loc } : {}), } } const visit = (node: AnyNode | null | undefined): Diagnostic | undefined => { if (!node) return undefined if (node.type === "Identifier" && node.name === "tools" && !isShadowed()) { const parent = ancestors.at(-1) if (!isReferencePosition(node, parent)) return undefined const chain = readStaticToolCall(node, ancestors) if (!chain.ok) return fail(chain.node, chain.reason) found.add(chain.path) return undefined } const pushedScope = enterNodeScope(node) if (pushedScope) scopes.push(pushedScope) ancestors.push(node) const error = visitChildren(node, visit) ancestors.pop() if (pushedScope) scopes.pop() return error } const diagnostic = visit(parsed.program) if (diagnostic) return Result.fail(diagnostic) return Result.succeed(Array.from(found)) } function isReferencePosition(node: AnyNode, parent: AnyNode | undefined): boolean { if (!parent) return true if (parent.type === "MemberExpression" && parent.property === node && !parent.computed) return false if (parent.type === "Property" && parent.key === node && !parent.computed && !parent.shorthand) return false if (parent.type === "MethodDefinition" && parent.key === node && !parent.computed) return false if ( (parent.type === "LabeledStatement" || parent.type === "BreakStatement" || parent.type === "ContinueStatement") && parent.label === node ) { return false } if ( (parent.type === "FunctionDeclaration" || parent.type === "FunctionExpression" || parent.type === "ClassDeclaration" || parent.type === "ClassExpression") && parent.id === node ) { return false } if (parent.type === "VariableDeclarator" && parent.id === node) return false return true } function readStaticToolCall( root: AnyNode, ancestors: ReadonlyArray, ): { readonly ok: true; readonly path: string } | { readonly ok: false; readonly node: AnyNode; readonly reason: string } { const segments: Array = [] let current = root let index = ancestors.length - 1 while (index >= 0) { const parent = ancestors[index]! if (parent.type === "ChainExpression" && parent.expression === current) { current = parent index -= 1 continue } if (parent.type !== "MemberExpression" || parent.object !== current) break if (!parent.computed && parent.property.type === "Identifier") { segments.push(parent.property.name) current = parent index -= 1 continue } if (parent.computed && parent.property.type === "Literal" && typeof parent.property.value === "string") { segments.push(parent.property.value) current = parent index -= 1 continue } return { ok: false, node: parent.property, reason: "Schedule checks must reference tools statically (e.g. tools.slack.read_thread), not with dynamic bracket expressions.", } } const callParent = index >= 0 ? ancestors[index] : undefined if (segments.length === 0 || callParent?.type !== "CallExpression" || callParent.callee !== current) { return { ok: false, node: current, reason: "Schedule checks must call tools directly as tools.(...), not alias or pass the tools object.", } } return { ok: true, path: segments.join(".") } } function enterNodeScope(node: AnyNode): Set | undefined { if ( node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression" ) { const names = new Set() if (node.id) names.add(node.id.name) for (const param of node.params) collectPattern(param, names) if (node.body.type === "BlockStatement") { for (const name of collectScope(node.body, true)) names.add(name) } return names } if (node.type === "BlockStatement") return collectScope(node, false) if (node.type === "CatchClause") { const names = new Set() if (node.param) collectPattern(node.param, names) return names } if (node.type === "ForStatement" && node.init?.type === "VariableDeclaration" && node.init.kind !== "var") { const names = new Set() for (const decl of node.init.declarations) collectPattern(decl.id, names) return names } if ( (node.type === "ForInStatement" || node.type === "ForOfStatement") && node.left.type === "VariableDeclaration" && node.left.kind !== "var" ) { const names = new Set() for (const decl of node.left.declarations) collectPattern(decl.id, names) return names } return undefined } function collectScope(container: Program | BlockStatement, includeVar: boolean): Set { const names = new Set() for (const statement of container.body) { if (statement.type === "FunctionDeclaration" && statement.id) names.add(statement.id.name) if (statement.type === "ClassDeclaration" && statement.id) names.add(statement.id.name) if (statement.type === "VariableDeclaration" && (includeVar || statement.kind !== "var")) { for (const decl of statement.declarations) collectPattern(decl.id, names) } } if (includeVar) collectNestedVars(container.body, names) return names } function collectNestedVars(statements: ReadonlyArray, names: Set) { const walkStmt = (node: AnyNode | null | undefined) => { if (!node) return if ( node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression" ) { return } if (node.type === "VariableDeclaration" && node.kind === "var") { for (const decl of node.declarations) collectPattern(decl.id, names) } visitChildren(node, (child) => { walkStmt(child) return undefined }) } for (const stmt of statements) walkStmt(stmt) } function collectPattern(pattern: Pattern, names: Set) { switch (pattern.type) { case "Identifier": names.add(pattern.name) return case "AssignmentPattern": collectPattern(pattern.left, names) return case "RestElement": collectPattern(pattern.argument, names) return case "ArrayPattern": for (const element of pattern.elements) if (element) collectPattern(element, names) return case "ObjectPattern": for (const prop of pattern.properties) { if (prop.type === "RestElement") collectPattern(prop.argument, names) if (prop.type === "Property") collectPattern(prop.value, names) } return case "MemberExpression": return } } function visitChildren( node: AnyNode, visit: (child: AnyNode | null | undefined) => Diagnostic | undefined, ): Diagnostic | undefined { for (const [key, value] of Object.entries(node as unknown as Record)) { if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue if (Array.isArray(value)) { for (const item of value) { if (isAstNode(item)) { const error = visit(item) if (error) return error } } continue } if (isAstNode(value)) { const error = visit(value) if (error) return error } } return undefined } function isAstNode(value: unknown): value is AnyNode { return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string" }