Skip to content

Commit 05e6447

Browse files
committed
fix: make tool boundary audit Bun 1.3 compatible
1 parent b6109c4 commit 05e6447

4 files changed

Lines changed: 151 additions & 53 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@
9898
"@next/swc-linux-x64-gnu": "16.2.12"
9999
},
100100
"devDependencies": {
101+
"@babel/parser": "7.29.2",
101102
"@biomejs/biome": "2.0.0-beta.5",
102103
"@clack/prompts": "1.7.0",
103104
"@octokit/rest": "^21.0.0",

scripts/check-tool-request-boundary.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ describe('tool request boundary audit', () => {
66
const violations = findToolRequestBoundaryViolations(`
77
const direct = mistralParserTool.request.body(params)
88
const computed = tool.request['headers'](params)
9+
const typedRequest = (customTool as ToolConfig).request
10+
const typed = typedRequest[\`method\`](params)
11+
const optional = tool.request?.url
912
const requestConfig = customTool.request
1013
const url = requestConfig.url
1114
const { body } = requestConfig
@@ -14,6 +17,8 @@ describe('tool request boundary audit', () => {
1417
expect(violations.map((violation) => violation.expression)).toEqual([
1518
'mistralParserTool.request.body',
1619
"tool.request['headers']",
20+
'typedRequest[\`method\`]',
21+
'tool.request?.url',
1722
'requestConfig.url',
1823
'body',
1924
])

scripts/check-tool-request-boundary.ts

Lines changed: 144 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import { readdirSync, readFileSync } from 'node:fs'
1111
import { dirname, extname, join, relative, resolve } from 'node:path'
1212
import { fileURLToPath } from 'node:url'
13-
import * as ts from 'typescript'
13+
import { parse } from '@babel/parser'
1414

1515
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
1616
const ROOT = resolve(SCRIPT_DIR, '..')
@@ -25,6 +25,13 @@ interface Violation {
2525
expression: string
2626
}
2727

28+
interface SyntaxNode extends Record<string, unknown> {
29+
type: string
30+
start?: number | null
31+
end?: number | null
32+
loc?: { start: { line: number } } | null
33+
}
34+
2835
function isProductionSource(path: string): boolean {
2936
const normalized = path.replaceAll('\\', '/')
3037
return (
@@ -47,113 +54,197 @@ function collectProductionSources(dir: string, found: string[] = []): string[] {
4754
return found
4855
}
4956

50-
function unwrapExpression(expression: ts.Expression): ts.Expression {
57+
function isSyntaxNode(value: unknown): value is SyntaxNode {
58+
return (
59+
typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string'
60+
)
61+
}
62+
63+
function getChildNodes(node: SyntaxNode): SyntaxNode[] {
64+
const children: SyntaxNode[] = []
65+
for (const value of Object.values(node)) {
66+
if (Array.isArray(value)) {
67+
for (const item of value) {
68+
if (isSyntaxNode(item)) children.push(item)
69+
}
70+
} else if (isSyntaxNode(value)) {
71+
children.push(value)
72+
}
73+
}
74+
return children
75+
}
76+
77+
function unwrapExpression(expression: SyntaxNode): SyntaxNode {
5178
let current = expression
5279
while (
53-
ts.isParenthesizedExpression(current) ||
54-
ts.isAsExpression(current) ||
55-
ts.isTypeAssertionExpression(current) ||
56-
ts.isNonNullExpression(current)
80+
[
81+
'ParenthesizedExpression',
82+
'TSAsExpression',
83+
'TSTypeAssertion',
84+
'TSNonNullExpression',
85+
'TSSatisfiesExpression',
86+
'TypeCastExpression',
87+
].includes(current.type) &&
88+
isSyntaxNode(current.expression)
5789
) {
5890
current = current.expression
5991
}
6092
return current
6193
}
6294

6395
function getStaticMemberAccess(
64-
expression: ts.Expression
65-
): { target: ts.Expression; member: string } | undefined {
96+
expression: SyntaxNode
97+
): { target: SyntaxNode; member: string } | undefined {
6698
const current = unwrapExpression(expression)
67-
if (ts.isPropertyAccessExpression(current)) {
68-
return { target: current.expression, member: current.name.text }
69-
}
7099
if (
71-
ts.isElementAccessExpression(current) &&
72-
current.argumentExpression &&
73-
(ts.isStringLiteral(current.argumentExpression) ||
74-
ts.isNoSubstitutionTemplateLiteral(current.argumentExpression))
100+
(current.type === 'MemberExpression' || current.type === 'OptionalMemberExpression') &&
101+
isSyntaxNode(current.object) &&
102+
isSyntaxNode(current.property)
75103
) {
76-
return { target: current.expression, member: current.argumentExpression.text }
104+
const property = current.property
105+
if (
106+
current.computed === false &&
107+
property.type === 'Identifier' &&
108+
typeof property.name === 'string'
109+
) {
110+
return { target: current.object, member: property.name }
111+
}
112+
if (
113+
current.computed === true &&
114+
property.type === 'StringLiteral' &&
115+
typeof property.value === 'string'
116+
) {
117+
return { target: current.object, member: property.value }
118+
}
119+
if (
120+
current.computed === true &&
121+
property.type === 'TemplateLiteral' &&
122+
Array.isArray(property.expressions) &&
123+
property.expressions.length === 0 &&
124+
Array.isArray(property.quasis) &&
125+
property.quasis.length === 1 &&
126+
isSyntaxNode(property.quasis[0])
127+
) {
128+
const value = property.quasis[0].value
129+
if (
130+
typeof value === 'object' &&
131+
value !== null &&
132+
'cooked' in value &&
133+
typeof value.cooked === 'string'
134+
) {
135+
return { target: current.object, member: value.cooked }
136+
}
137+
}
77138
}
78139
return undefined
79140
}
80141

81-
function isLikelyToolIdentifier(expression: ts.Expression): boolean {
142+
function isLikelyToolIdentifier(expression: SyntaxNode): boolean {
82143
const current = unwrapExpression(expression)
83-
return ts.isIdentifier(current) && (current.text === 'tool' || current.text.endsWith('Tool'))
144+
return (
145+
current.type === 'Identifier' &&
146+
typeof current.name === 'string' &&
147+
(current.name === 'tool' || current.name.endsWith('Tool'))
148+
)
84149
}
85150

86151
export function findToolRequestBoundaryViolations(source: string, file = 'source.ts'): Violation[] {
87152
const extension = extname(file)
88-
const scriptKind =
89-
extension === '.tsx'
90-
? ts.ScriptKind.TSX
91-
: extension === '.jsx'
92-
? ts.ScriptKind.JSX
93-
: ['.js', '.mjs', '.cjs'].includes(extension)
94-
? ts.ScriptKind.JS
95-
: ts.ScriptKind.TS
96-
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind)
153+
const syntaxTree = parse(source, {
154+
sourceFilename: file,
155+
sourceType: 'unambiguous',
156+
errorRecovery: true,
157+
plugins: [
158+
...(extension === '.jsx' || extension === '.tsx' ? (['jsx'] as const) : []),
159+
...(!['.js', '.jsx', '.mjs', '.cjs'].includes(extension) ? (['typescript'] as const) : []),
160+
],
161+
})
97162
const requestAliases = new Set<string>()
98163
const violations: Violation[] = []
99164
const seen = new Set<number>()
100165

101-
const report = (node: ts.Node) => {
102-
if (seen.has(node.getStart(sourceFile))) return
103-
seen.add(node.getStart(sourceFile))
104-
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
166+
const report = (node: SyntaxNode) => {
167+
if (typeof node.start !== 'number' || typeof node.end !== 'number' || !node.loc) return
168+
if (seen.has(node.start)) return
169+
seen.add(node.start)
105170
violations.push({
106171
file,
107-
line: line + 1,
108-
expression: node.getText(sourceFile),
172+
line: node.loc.start.line,
173+
expression: source.slice(node.start, node.end),
109174
})
110175
}
111176

112-
const collectAliases = (node: ts.Node) => {
113-
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
114-
const access = getStaticMemberAccess(node.initializer)
177+
const collectAliases = (node: SyntaxNode) => {
178+
if (
179+
node.type === 'VariableDeclarator' &&
180+
isSyntaxNode(node.id) &&
181+
node.id.type === 'Identifier' &&
182+
typeof node.id.name === 'string' &&
183+
isSyntaxNode(node.init)
184+
) {
185+
const access = getStaticMemberAccess(node.init)
115186
if (access?.member === 'request' && isLikelyToolIdentifier(access.target)) {
116-
requestAliases.add(node.name.text)
187+
requestAliases.add(node.id.name)
117188
}
118189
}
119-
ts.forEachChild(node, collectAliases)
190+
for (const child of getChildNodes(node)) collectAliases(child)
120191
}
121-
collectAliases(sourceFile)
192+
collectAliases(syntaxTree.program)
122193

123-
const visit = (node: ts.Node) => {
194+
const visit = (node: SyntaxNode) => {
124195
if (
125-
ts.isVariableDeclaration(node) &&
126-
ts.isObjectBindingPattern(node.name) &&
127-
node.initializer
196+
node.type === 'VariableDeclarator' &&
197+
isSyntaxNode(node.id) &&
198+
node.id.type === 'ObjectPattern' &&
199+
isSyntaxNode(node.init)
128200
) {
129-
const sourceAccess = getStaticMemberAccess(node.initializer)
201+
const sourceAccess = getStaticMemberAccess(node.init)
130202
const sourceIsToolRequest =
131203
sourceAccess?.member === 'request' && isLikelyToolIdentifier(sourceAccess.target)
132-
const source = unwrapExpression(node.initializer)
133-
const sourceIsToolRequestAlias = ts.isIdentifier(source) && requestAliases.has(source.text)
204+
const initializer = unwrapExpression(node.init)
205+
const sourceIsToolRequestAlias =
206+
initializer.type === 'Identifier' &&
207+
typeof initializer.name === 'string' &&
208+
requestAliases.has(initializer.name)
134209
if (sourceIsToolRequest || sourceIsToolRequestAlias) {
135-
for (const element of node.name.elements) {
136-
const property = element.propertyName ?? element.name
137-
if (ts.isIdentifier(property) && REQUEST_MEMBERS.has(property.text)) report(element)
210+
const properties = Array.isArray(node.id.properties) ? node.id.properties : []
211+
for (const property of properties) {
212+
if (
213+
!isSyntaxNode(property) ||
214+
property.type !== 'ObjectProperty' ||
215+
!isSyntaxNode(property.key)
216+
) {
217+
continue
218+
}
219+
const key = property.key
220+
const member =
221+
key.type === 'Identifier' && typeof key.name === 'string'
222+
? key.name
223+
: key.type === 'StringLiteral' && typeof key.value === 'string'
224+
? key.value
225+
: undefined
226+
if (member && REQUEST_MEMBERS.has(member)) report(property)
138227
}
139228
}
140229
}
141-
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
230+
if (node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') {
142231
const access = getStaticMemberAccess(node)
143232
if (access && REQUEST_MEMBERS.has(access.member)) {
144233
const target = unwrapExpression(access.target)
145234
const targetAccess = getStaticMemberAccess(target)
146235
if (
147236
targetAccess?.member === 'request' ||
148-
(ts.isIdentifier(target) && requestAliases.has(target.text))
237+
(target.type === 'Identifier' &&
238+
typeof target.name === 'string' &&
239+
requestAliases.has(target.name))
149240
) {
150241
report(node)
151242
}
152243
}
153244
}
154-
ts.forEachChild(node, visit)
245+
for (const child of getChildNodes(node)) visit(child)
155246
}
156-
visit(sourceFile)
247+
visit(syntaxTree.program)
157248

158249
return violations
159250
}

0 commit comments

Comments
 (0)