@mondaydotcomorg/z2h-cli 0.27.1 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/__tests__/ast.test.d.ts +2 -0
- package/dist/backend/__tests__/ast.test.d.ts.map +1 -0
- package/dist/backend/__tests__/parse.test.d.ts +2 -0
- package/dist/backend/__tests__/parse.test.d.ts.map +1 -0
- package/dist/backend/__tests__/validate.test.d.ts +2 -0
- package/dist/backend/__tests__/validate.test.d.ts.map +1 -0
- package/dist/backend/ast.d.ts +41 -0
- package/dist/backend/ast.d.ts.map +1 -0
- package/dist/backend/ast.js +152 -0
- package/dist/backend/detect-integrations.d.ts +8 -4
- package/dist/backend/detect-integrations.d.ts.map +1 -1
- package/dist/backend/detect-integrations.js +12 -15
- package/dist/backend/handler-declaration.d.ts +17 -0
- package/dist/backend/handler-declaration.d.ts.map +1 -0
- package/dist/backend/handler-declaration.js +83 -0
- package/dist/backend/handlers.d.ts +2 -0
- package/dist/backend/handlers.d.ts.map +1 -1
- package/dist/backend/handlers.js +7 -36
- package/dist/backend/messages.d.ts +10 -0
- package/dist/backend/messages.d.ts.map +1 -0
- package/dist/backend/messages.js +14 -0
- package/dist/backend/parse.d.ts +11 -0
- package/dist/backend/parse.d.ts.map +1 -0
- package/dist/backend/parse.js +66 -0
- package/dist/backend/rules/__tests__/snowflake-static-sql.test.d.ts +2 -0
- package/dist/backend/rules/__tests__/snowflake-static-sql.test.d.ts.map +1 -0
- package/dist/backend/rules/index.d.ts +5 -0
- package/dist/backend/rules/index.d.ts.map +1 -0
- package/dist/backend/rules/index.js +8 -0
- package/dist/backend/rules/snowflake-static-sql.d.ts +15 -0
- package/dist/backend/rules/snowflake-static-sql.d.ts.map +1 -0
- package/dist/backend/rules/snowflake-static-sql.js +75 -0
- package/dist/backend/rules/types.d.ts +29 -0
- package/dist/backend/rules/types.d.ts.map +1 -0
- package/dist/backend/rules/types.js +1 -0
- package/dist/backend/validate.d.ts +23 -0
- package/dist/backend/validate.d.ts.map +1 -0
- package/dist/backend/validate.js +64 -0
- package/dist/commands/__tests__/backend-validate.test.d.ts +2 -0
- package/dist/commands/__tests__/backend-validate.test.d.ts.map +1 -0
- package/dist/commands/backend.d.ts +6 -0
- package/dist/commands/backend.d.ts.map +1 -1
- package/dist/commands/backend.js +22 -2
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +10 -3
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +2 -1
- package/dist/constants.d.ts +1 -2
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +10 -6
- package/dist/esm/backend/__tests__/ast.test.d.ts +2 -0
- package/dist/esm/backend/__tests__/ast.test.d.ts.map +1 -0
- package/dist/esm/backend/__tests__/parse.test.d.ts +2 -0
- package/dist/esm/backend/__tests__/parse.test.d.ts.map +1 -0
- package/dist/esm/backend/__tests__/validate.test.d.ts +2 -0
- package/dist/esm/backend/__tests__/validate.test.d.ts.map +1 -0
- package/dist/esm/backend/ast.d.ts +41 -0
- package/dist/esm/backend/ast.d.ts.map +1 -0
- package/dist/esm/backend/ast.mjs +146 -0
- package/dist/esm/backend/detect-integrations.d.ts +8 -4
- package/dist/esm/backend/detect-integrations.d.ts.map +1 -1
- package/dist/esm/backend/detect-integrations.mjs +12 -15
- package/dist/esm/backend/handler-declaration.d.ts +17 -0
- package/dist/esm/backend/handler-declaration.d.ts.map +1 -0
- package/dist/esm/backend/handler-declaration.mjs +78 -0
- package/dist/esm/backend/handlers.d.ts +2 -0
- package/dist/esm/backend/handlers.d.ts.map +1 -1
- package/dist/esm/backend/handlers.mjs +7 -36
- package/dist/esm/backend/messages.d.ts +10 -0
- package/dist/esm/backend/messages.d.ts.map +1 -0
- package/dist/esm/backend/messages.mjs +12 -0
- package/dist/esm/backend/parse.d.ts +11 -0
- package/dist/esm/backend/parse.d.ts.map +1 -0
- package/dist/esm/backend/parse.mjs +64 -0
- package/dist/esm/backend/rules/__tests__/snowflake-static-sql.test.d.ts +2 -0
- package/dist/esm/backend/rules/__tests__/snowflake-static-sql.test.d.ts.map +1 -0
- package/dist/esm/backend/rules/index.d.ts +5 -0
- package/dist/esm/backend/rules/index.d.ts.map +1 -0
- package/dist/esm/backend/rules/index.mjs +6 -0
- package/dist/esm/backend/rules/snowflake-static-sql.d.ts +15 -0
- package/dist/esm/backend/rules/snowflake-static-sql.d.ts.map +1 -0
- package/dist/esm/backend/rules/snowflake-static-sql.mjs +73 -0
- package/dist/esm/backend/rules/types.d.ts +29 -0
- package/dist/esm/backend/rules/types.d.ts.map +1 -0
- package/dist/esm/backend/rules/types.mjs +1 -0
- package/dist/esm/backend/validate.d.ts +23 -0
- package/dist/esm/backend/validate.d.ts.map +1 -0
- package/dist/esm/backend/validate.mjs +62 -0
- package/dist/esm/commands/__tests__/backend-validate.test.d.ts +2 -0
- package/dist/esm/commands/__tests__/backend-validate.test.d.ts.map +1 -0
- package/dist/esm/commands/backend.d.ts +6 -0
- package/dist/esm/commands/backend.d.ts.map +1 -1
- package/dist/esm/commands/backend.mjs +23 -4
- package/dist/esm/commands/create.d.ts.map +1 -1
- package/dist/esm/commands/create.mjs +11 -4
- package/dist/esm/commands/generate.d.ts.map +1 -1
- package/dist/esm/commands/generate.mjs +2 -1
- package/dist/esm/constants.d.ts +1 -2
- package/dist/esm/constants.d.ts.map +1 -1
- package/dist/esm/constants.mjs +3 -6
- package/dist/esm/index.mjs +6 -2
- package/dist/esm/util/broker/app.d.ts +12 -2
- package/dist/esm/util/broker/app.d.ts.map +1 -1
- package/dist/esm/util/broker/app.mjs +20 -2
- package/dist/index.js +5 -1
- package/dist/util/broker/app.d.ts +12 -2
- package/dist/util/broker/app.d.ts.map +1 -1
- package/dist/util/broker/app.js +20 -1
- package/package.json +4 -2
- package/src/backend/__tests__/ast.test.ts +53 -0
- package/src/backend/__tests__/detect-integrations.test.ts +12 -30
- package/src/backend/__tests__/handlers.test.ts +21 -34
- package/src/backend/__tests__/parse.test.ts +25 -0
- package/src/backend/__tests__/validate.test.ts +246 -0
- package/src/backend/ast.ts +204 -0
- package/src/backend/detect-integrations.ts +12 -14
- package/src/backend/handler-declaration.ts +104 -0
- package/src/backend/handlers.ts +9 -44
- package/src/backend/messages.ts +16 -0
- package/src/backend/parse.ts +82 -0
- package/src/backend/rules/__tests__/snowflake-static-sql.test.ts +175 -0
- package/src/backend/rules/index.ts +7 -0
- package/src/backend/rules/snowflake-static-sql.ts +78 -0
- package/src/backend/rules/types.ts +30 -0
- package/src/backend/validate.ts +73 -0
- package/src/commands/__tests__/backend-deploy.test.ts +5 -3
- package/src/commands/__tests__/backend-validate.test.ts +71 -0
- package/src/commands/__tests__/create.test.ts +46 -0
- package/src/commands/__tests__/edit.test.ts +6 -0
- package/src/commands/backend.ts +23 -3
- package/src/commands/create.ts +13 -4
- package/src/commands/generate.ts +2 -1
- package/src/constants.ts +5 -6
- package/src/index.ts +9 -2
- package/src/util/broker/__tests__/app.test.ts +29 -1
- package/src/util/broker/app.ts +24 -2
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { warn } from '../../util/logger';
|
|
3
|
+
import { validateHandlerSource } from '../validate';
|
|
4
|
+
|
|
5
|
+
vi.mock('../../util/logger');
|
|
6
|
+
vi.mock('../../util/tracker', () => ({
|
|
7
|
+
trackEvent: vi.fn().mockResolvedValue(undefined),
|
|
8
|
+
trackDuration: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
const warnMock = vi.mocked(warn);
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.clearAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const validate = (source: string) => validateHandlerSource('h.js', source);
|
|
17
|
+
|
|
18
|
+
describe('validateHandlerSource — accepted shapes', () => {
|
|
19
|
+
it.each([
|
|
20
|
+
['a function declaration', 'async function handler(ctx) { return 1; }'],
|
|
21
|
+
['a const arrow', 'const handler = async (ctx) => 1;'],
|
|
22
|
+
['a let function expression', 'let handler = async function (ctx) { return 1; };'],
|
|
23
|
+
['a var assignment', 'var handler = async function(ctx) { return 1; };'],
|
|
24
|
+
['a handler wrapped by a call', 'const handler = wrap(async (ctx) => 1); function wrap(f) { return f; }'],
|
|
25
|
+
])('accepts %s', (_label, source) => {
|
|
26
|
+
expect(() => validate(source)).not.toThrow();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('does not reject require() mentioned in a comment', () => {
|
|
30
|
+
expect(() => validate('// never call require() in here\nasync function handler(ctx) { return 1; }')).not.toThrow();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('does not reject "import" at the start of a line inside a string', () => {
|
|
34
|
+
expect(() =>
|
|
35
|
+
validate('const help = `\nimport nothing here\n`;\nasync function handler(ctx) { return help; }')
|
|
36
|
+
).not.toThrow();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('accepts sloppy-mode-only syntax, because the isolate runs sloppy scripts', () => {
|
|
40
|
+
expect(() => validate('function handler(ctx) { with (ctx) { return input; } }')).not.toThrow();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('validateHandlerSource — handler declaration', () => {
|
|
45
|
+
it('rejects a file that does not define handler', () => {
|
|
46
|
+
expect(() => validate('async function main(ctx) { return 1; }')).toThrow(/must define a handler/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('rejects a handler declared only inside another function', () => {
|
|
50
|
+
expect(() => validate('function outer() { async function handler(ctx) { return 1; } }')).toThrow(
|
|
51
|
+
/must define a handler/
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it.each([
|
|
56
|
+
['a number', 'const handler = 42;'],
|
|
57
|
+
['an object', 'const handler = { run() {} };'],
|
|
58
|
+
['an array', 'const handler = [];'],
|
|
59
|
+
['a template literal', 'const handler = `x`;'],
|
|
60
|
+
['nothing (uninitialized)', 'let handler;'],
|
|
61
|
+
])('rejects handler bound to %s', (_label, source) => {
|
|
62
|
+
expect(() => validate(source)).toThrow(/declares handler but it is not a function/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('validateHandlerSource — module syntax (the isolate compiles plain scripts)', () => {
|
|
67
|
+
it.each([
|
|
68
|
+
['an exported function declaration', 'export async function handler(ctx) { return 1; }'],
|
|
69
|
+
['an exported const', 'export const handler = async (ctx) => 1;'],
|
|
70
|
+
['a default-exported function', 'export default async function handler(ctx) { return 1; }'],
|
|
71
|
+
['an export specifier', 'async function handler(ctx) { return 1; }\nexport { handler };'],
|
|
72
|
+
['a default-exported identifier', 'async function handler(ctx) { return 1; }\nexport default handler;'],
|
|
73
|
+
])('rejects %s', (_label, source) => {
|
|
74
|
+
expect(() => validate(source)).toThrow(/must not export handler/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it.each([
|
|
78
|
+
['an import statement', "import { foo } from './utils';\nasync function handler(ctx) { return foo; }"],
|
|
79
|
+
['export * from', "export * from './utils';\nasync function handler(ctx) { return 1; }"],
|
|
80
|
+
['export { x } from', "export { foo } from './utils';\nasync function handler(ctx) { return 1; }"],
|
|
81
|
+
['a top-level require call', "const foo = require('./utils');\nasync function handler(ctx) { return foo; }"],
|
|
82
|
+
['a nested require call', "async function handler(ctx) { const foo = require('./utils'); return foo; }"],
|
|
83
|
+
['a dynamic import()', "async function handler(ctx) { const m = await import('./x'); return m; }"],
|
|
84
|
+
])('rejects %s', (_label, source) => {
|
|
85
|
+
expect(() => validate(source)).toThrow(/must not import/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it.each([
|
|
89
|
+
['an export of a non-handler name', 'export const helper = 1;\nasync function handler(ctx) { return helper; }'],
|
|
90
|
+
['an anonymous default export', 'export default async (ctx) => 1;'],
|
|
91
|
+
])('rejects %s', (_label, source) => {
|
|
92
|
+
expect(() => validate(source)).toThrow(/must not use export/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it.each([
|
|
96
|
+
['top-level await', 'const cfg = await Promise.resolve(1);\nasync function handler(ctx) { return cfg; }'],
|
|
97
|
+
['import.meta', 'async function handler(ctx) { return import.meta.url; }'],
|
|
98
|
+
])('rejects %s', (_label, source) => {
|
|
99
|
+
expect(() => validate(source)).toThrow(/uses module-only syntax/);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('validateHandlerSource — syntax errors', () => {
|
|
104
|
+
it('reports file, line and column', () => {
|
|
105
|
+
expect(() => validate('async function handler(ctx) {\n return ctx.input.(;\n}')).toThrow(
|
|
106
|
+
/^h\.js:2:19: not valid JavaScript — Unexpected token$/
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('rejects a top-level return, which V8 rejects in scripts', () => {
|
|
111
|
+
expect(() => validate('return 1;\nasync function handler(ctx) { return 1; }')).toThrow(/not valid JavaScript/);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('validateHandlerSource — capability rules', () => {
|
|
116
|
+
it('runs the rule set once the handler is structurally valid', () => {
|
|
117
|
+
expect(() => validate('async function handler(ctx) { return ctx.api.v1.snowflake.query(ctx.input.sql); }')).toThrow(
|
|
118
|
+
/snowflake\.query must receive SQL written in the handler/
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('runs snowflake SQL rules through a renamed handler parameter', () => {
|
|
123
|
+
expect(() =>
|
|
124
|
+
validate('async function handler(context) { return context.api.v1.snowflake.query(context.input.sql); }')
|
|
125
|
+
).toThrow(/snowflake\.query must receive SQL written in the handler/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('runs snowflake SQL rules through computed channel access', () => {
|
|
129
|
+
expect(() =>
|
|
130
|
+
validate('async function handler(ctx) { return ctx.api.v1["snowflake"].query(ctx.input.sql); }')
|
|
131
|
+
).toThrow(/snowflake\.query must receive SQL written in the handler/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('runs snowflake SQL rules through a wrapped handler with a renamed parameter', () => {
|
|
135
|
+
expect(() =>
|
|
136
|
+
validate(
|
|
137
|
+
'const handler = wrap(async (context) => context.api.v1.snowflake.query(context.input.sql)); function wrap(f) { return f; }'
|
|
138
|
+
)
|
|
139
|
+
).toThrow(/snowflake\.query must receive SQL written in the handler/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('reports structural problems before rule violations', () => {
|
|
143
|
+
expect(() => validate('async function main(ctx) { return ctx.api.v1.snowflake.query(ctx.input.sql); }')).toThrow(
|
|
144
|
+
/must define a handler/
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('still returns api domains when every rule passes', () => {
|
|
149
|
+
expect(
|
|
150
|
+
validate('async function handler(ctx) { return ctx.api.v1.snowflake.query("select 1", { id: ctx.input.id }); }')
|
|
151
|
+
.apiDomains
|
|
152
|
+
).toEqual(['snowflake']);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('validateHandlerSource — ctx.api.v1 domains', () => {
|
|
157
|
+
it('returns every domain touched, sorted and unique', () => {
|
|
158
|
+
const { apiDomains } = validate(
|
|
159
|
+
'async function handler(ctx) { await ctx.api.v1.db.put("k", 1); ctx.api.v1.monday.query("x"); return ctx.api.v1.db.get("k"); }'
|
|
160
|
+
);
|
|
161
|
+
expect(apiDomains).toEqual(['db', 'monday']);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('sees through optional chaining', () => {
|
|
165
|
+
expect(validate('async function handler(ctx) { return ctx?.api?.v1?.monday?.query("x"); }').apiDomains).toEqual([
|
|
166
|
+
'monday',
|
|
167
|
+
]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('ignores domains mentioned only in comments or strings', () => {
|
|
171
|
+
const { apiDomains } = validate(
|
|
172
|
+
'// TODO: use ctx.api.v1.monday later\nasync function handler(ctx) { return "ctx.api.v1.monday"; }'
|
|
173
|
+
);
|
|
174
|
+
expect(apiDomains).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('follows const aliases of the api object', () => {
|
|
178
|
+
expect(
|
|
179
|
+
validate('async function handler(ctx) { const { v1 } = ctx.api; return v1.monday.query("x"); }').apiDomains
|
|
180
|
+
).toEqual(['monday']);
|
|
181
|
+
expect(
|
|
182
|
+
validate('const api = ctx.api.v1;\nasync function handler(ctx) { const m = api.monday; return m.query("x"); }')
|
|
183
|
+
.apiDomains
|
|
184
|
+
).toEqual(['monday']);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('sees string-literal computed access', () => {
|
|
188
|
+
expect(
|
|
189
|
+
validate("async function handler(ctx) { return ctx.api.v1['snowflake'].query('select 1'); }").apiDomains
|
|
190
|
+
).toEqual(['snowflake']);
|
|
191
|
+
expect(validate('async function handler(ctx) { return ctx.api.v1["monday"].query("x"); }').apiDomains).toEqual([
|
|
192
|
+
'monday',
|
|
193
|
+
]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('sees domains through a renamed handler parameter', () => {
|
|
197
|
+
expect(validate('async function handler(context) { return context.api.v1.db.list(); }').apiDomains).toEqual(['db']);
|
|
198
|
+
expect(validate('const handler = async (context) => context.api.v1.monday.query("x");').apiDomains).toEqual([
|
|
199
|
+
'monday',
|
|
200
|
+
]);
|
|
201
|
+
expect(
|
|
202
|
+
validate('const handler = async function (context) { return context.api.v1.db.list(); };').apiDomains
|
|
203
|
+
).toEqual(['db']);
|
|
204
|
+
expect(validate('async function* handler(context) { yield context.api.v1.db.list(); }').apiDomains).toEqual(['db']);
|
|
205
|
+
expect(
|
|
206
|
+
validate('const handler = async function* (context) { yield context.api.v1.db.list(); };').apiDomains
|
|
207
|
+
).toEqual(['db']);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('sees domains when the handler function is wrapped or has a default argument', () => {
|
|
211
|
+
expect(
|
|
212
|
+
validate('const handler = wrap(async (context) => context.api.v1.db.list()); function wrap(f) { return f; }')
|
|
213
|
+
.apiDomains
|
|
214
|
+
).toEqual(['db']);
|
|
215
|
+
expect(validate('const handler = (0, async (context) => context.api.v1.monday.query("x"));').apiDomains).toEqual([
|
|
216
|
+
'monday',
|
|
217
|
+
]);
|
|
218
|
+
expect(validate('async function handler(context = {}) { return context.api.v1.db.list(); }').apiDomains).toEqual([
|
|
219
|
+
'db',
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('ignores lookalikes that are not ctx.api.v1.<domain>', () => {
|
|
224
|
+
const { apiDomains } = validate(
|
|
225
|
+
'async function handler(ctx) { const monday = 1; api.v1.monday; ctx.api.v2.monday; ctx.api.v1[monday]; return monday; }'
|
|
226
|
+
);
|
|
227
|
+
expect(apiDomains).toEqual([]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('does not warn for known domains', () => {
|
|
231
|
+
validate('async function handler(ctx) { return ctx.api.v1.snowflake.query("select 1"); }');
|
|
232
|
+
expect(warnMock).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('warns, but does not throw, for an unknown domain — this is DX feedback, not a security boundary', () => {
|
|
236
|
+
expect(() => validate('async function handler(ctx) { return ctx.api.v1.totallyMadeUp.get(); }')).not.toThrow();
|
|
237
|
+
expect(warnMock).toHaveBeenCalledWith(
|
|
238
|
+
expect.stringMatching(/ctx\.api\.v1\.totallyMadeUp is not a known capability/)
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('warns once per unknown domain, not once per usage', () => {
|
|
243
|
+
validate('async function handler(ctx) { ctx.api.v1.madeUp.get(); return ctx.api.v1.madeUp.post(); }');
|
|
244
|
+
expect(warnMock).toHaveBeenCalledTimes(1);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type { AnyNode, Expression, Node, Pattern, Program, VariableDeclaration } from 'acorn';
|
|
2
|
+
import { full as walkFull, simple as walkSimple } from 'acorn-walk';
|
|
3
|
+
|
|
4
|
+
/** 1-based line a node starts on; requires the program to be parsed with `locations: true`. */
|
|
5
|
+
export const lineOf = (node: Node): number | undefined => node.loc?.start.line;
|
|
6
|
+
|
|
7
|
+
// ─────────── Bindings ───────────
|
|
8
|
+
|
|
9
|
+
export interface Declaration {
|
|
10
|
+
kind: VariableDeclaration['kind'];
|
|
11
|
+
init: Expression | null | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Property path from `init` to this binding: `[]` for `const x = init`,
|
|
14
|
+
* `['a', 'b']` for `const { a: { b: x } } = init`, `undefined` when the value
|
|
15
|
+
* is not a plain property read (array/rest/default patterns).
|
|
16
|
+
*/
|
|
17
|
+
path: string[] | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Bindings {
|
|
21
|
+
/** Every `var|let|const <name> = …` in the file, by name. */
|
|
22
|
+
declarations: Map<string, Declaration[]>;
|
|
23
|
+
/** Names whose value cannot be pinned: reassigned, updated, or bound as parameters. */
|
|
24
|
+
unpinnable: Set<string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PatternBinding {
|
|
28
|
+
name: string;
|
|
29
|
+
path: string[] | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Every identifier a binding pattern introduces, with its property path from the
|
|
34
|
+
* pattern's value. `path` is deliberately not defaulted: callers pass `[]` for
|
|
35
|
+
* the root and `undefined` once the pattern stops being a plain property read.
|
|
36
|
+
*/
|
|
37
|
+
function patternBindings(pattern: Pattern, path: string[] | undefined): PatternBinding[] {
|
|
38
|
+
switch (pattern.type) {
|
|
39
|
+
case 'Identifier':
|
|
40
|
+
return [{ name: pattern.name, path }];
|
|
41
|
+
case 'ObjectPattern':
|
|
42
|
+
return pattern.properties.flatMap(p => {
|
|
43
|
+
if (p.type === 'RestElement') {
|
|
44
|
+
return patternBindings(p.argument, undefined);
|
|
45
|
+
}
|
|
46
|
+
const key = propertyKey(p.key, p.computed);
|
|
47
|
+
return patternBindings(p.value, path && key !== undefined ? [...path, key] : undefined);
|
|
48
|
+
});
|
|
49
|
+
case 'ArrayPattern':
|
|
50
|
+
return pattern.elements.flatMap(e => (e ? patternBindings(e, undefined) : []));
|
|
51
|
+
case 'AssignmentPattern':
|
|
52
|
+
return patternBindings(pattern.left, undefined);
|
|
53
|
+
case 'RestElement':
|
|
54
|
+
return patternBindings(pattern.argument, undefined);
|
|
55
|
+
default:
|
|
56
|
+
// MemberExpression targets (`obj.x = …`) bind no names.
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function propertyKey(key: AnyNode, computed: boolean): string | undefined {
|
|
62
|
+
if (key.type === 'Literal' && typeof key.value === 'string') {
|
|
63
|
+
return key.value;
|
|
64
|
+
}
|
|
65
|
+
if (!computed && key.type === 'Identifier') {
|
|
66
|
+
return key.name;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Every identifier a binding pattern introduces (`{ a, b: [c] } = …` → a, c). */
|
|
72
|
+
export const patternNames = (pattern: Pattern): string[] => patternBindings(pattern, []).map(b => b.name);
|
|
73
|
+
|
|
74
|
+
export function collectBindings(program: Program): Bindings {
|
|
75
|
+
const declarations = new Map<string, Declaration[]>();
|
|
76
|
+
const unpinnable = new Set<string>();
|
|
77
|
+
const markUnpinnable = (pattern: Pattern) => patternNames(pattern).forEach(n => unpinnable.add(n));
|
|
78
|
+
|
|
79
|
+
walkFull(program, node => {
|
|
80
|
+
switch (node.type) {
|
|
81
|
+
case 'VariableDeclaration':
|
|
82
|
+
for (const declarator of node.declarations) {
|
|
83
|
+
for (const { name, path } of patternBindings(declarator.id, [])) {
|
|
84
|
+
const list = declarations.get(name) ?? [];
|
|
85
|
+
list.push({ kind: node.kind, init: declarator.init, path });
|
|
86
|
+
declarations.set(name, list);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
case 'AssignmentExpression':
|
|
91
|
+
markUnpinnable(node.left);
|
|
92
|
+
break;
|
|
93
|
+
case 'UpdateExpression':
|
|
94
|
+
if (node.argument.type === 'Identifier') {
|
|
95
|
+
unpinnable.add(node.argument.name);
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
case 'ForInStatement':
|
|
99
|
+
case 'ForOfStatement':
|
|
100
|
+
if (node.left.type !== 'VariableDeclaration') {
|
|
101
|
+
markUnpinnable(node.left);
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
case 'FunctionDeclaration':
|
|
105
|
+
case 'FunctionExpression':
|
|
106
|
+
case 'ArrowFunctionExpression':
|
|
107
|
+
node.params.forEach(markUnpinnable);
|
|
108
|
+
break;
|
|
109
|
+
case 'CatchClause':
|
|
110
|
+
if (node.param) {
|
|
111
|
+
markUnpinnable(node.param);
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return { declarations, unpinnable };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─────────── Member paths ───────────
|
|
121
|
+
|
|
122
|
+
interface MemberChain {
|
|
123
|
+
root: string;
|
|
124
|
+
parts: string[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `a.b.c` / `a['b'].c` → root `a`, parts `[b, c]`; `undefined` if any link is dynamic or the root is not an identifier. */
|
|
128
|
+
function memberChain(node: AnyNode): MemberChain | undefined {
|
|
129
|
+
if (node.type === 'ChainExpression') {
|
|
130
|
+
return memberChain(node.expression);
|
|
131
|
+
}
|
|
132
|
+
const parts: string[] = [];
|
|
133
|
+
let cur: AnyNode = node;
|
|
134
|
+
while (cur.type === 'MemberExpression') {
|
|
135
|
+
const key = propertyKey(cur.property, cur.computed);
|
|
136
|
+
if (key === undefined) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
parts.unshift(key);
|
|
140
|
+
cur = cur.object;
|
|
141
|
+
}
|
|
142
|
+
return cur.type === 'Identifier' ? { root: cur.name, parts } : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface PathResolver {
|
|
146
|
+
/**
|
|
147
|
+
* Dotted path of a member chain, following `const` aliases back to their
|
|
148
|
+
* source: with `const sf = ctx.api.v1.snowflake`, `sf.query` resolves to
|
|
149
|
+
* `ctx.api.v1.snowflake.query`. Destructuring counts too
|
|
150
|
+
* (`const { snowflake: sf } = ctx.api.v1`). `undefined` if any link is dynamic.
|
|
151
|
+
*
|
|
152
|
+
* Only single, never-reassigned, never-shadowed `const` bindings are followed —
|
|
153
|
+
* anything else is left as written, so a `let` alias or a parameter of the same
|
|
154
|
+
* name is never mistaken for the channel. The handler's first parameter is
|
|
155
|
+
* treated as `ctx` even when renamed (`handler(context)` → `context.api.v1.db`
|
|
156
|
+
* resolves as `ctx.api.v1.db`).
|
|
157
|
+
*/
|
|
158
|
+
pathOf(node: AnyNode): string | undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function createPathResolver(bindings: Bindings, contextParam?: string): PathResolver {
|
|
162
|
+
function resolveChain(node: AnyNode, resolving: ReadonlySet<string>): string | undefined {
|
|
163
|
+
const chain = memberChain(node);
|
|
164
|
+
if (!chain) {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
return [resolveRoot(chain.root, resolving), ...chain.parts].join('.');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolveRoot(name: string, resolving: ReadonlySet<string>): string {
|
|
171
|
+
if (contextParam !== undefined && name === contextParam) {
|
|
172
|
+
return 'ctx';
|
|
173
|
+
}
|
|
174
|
+
if (bindings.unpinnable.has(name) || resolving.has(name)) {
|
|
175
|
+
return name;
|
|
176
|
+
}
|
|
177
|
+
const decls = bindings.declarations.get(name);
|
|
178
|
+
if (!decls || decls.length !== 1) {
|
|
179
|
+
return name;
|
|
180
|
+
}
|
|
181
|
+
const [{ kind, init, path }] = decls;
|
|
182
|
+
if (kind !== 'const' || !init || !path) {
|
|
183
|
+
return name;
|
|
184
|
+
}
|
|
185
|
+
const base = resolveChain(init, new Set(resolving).add(name));
|
|
186
|
+
return base ? [base, ...path].join('.') : name;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { pathOf: node => resolveChain(node, new Set()) };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Every `ctx.api.v1.<domain>` the program touches (known or not), through `paths`. */
|
|
193
|
+
export function collectApiDomains(program: Program, paths: PathResolver): Set<string> {
|
|
194
|
+
const apiDomains = new Set<string>();
|
|
195
|
+
walkSimple(program, {
|
|
196
|
+
MemberExpression(node) {
|
|
197
|
+
const [obj, api, version, domain] = paths.pathOf(node)?.split('.') ?? [];
|
|
198
|
+
if (obj === 'ctx' && api === 'api' && version === 'v1' && domain) {
|
|
199
|
+
apiDomains.add(domain);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
return apiDomains;
|
|
204
|
+
}
|
|
@@ -1,22 +1,20 @@
|
|
|
1
|
-
import { DECLARED_INTEGRATIONS } from '
|
|
1
|
+
import { DECLARED_INTEGRATIONS, type DeclaredIntegration } from '@mondaydotcomorg/z2h-shared-utils';
|
|
2
|
+
|
|
3
|
+
const declared: ReadonlySet<string> = new Set(DECLARED_INTEGRATIONS);
|
|
4
|
+
const isDeclaredIntegration = (domain: string): domain is DeclaredIntegration => declared.has(domain);
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
|
-
* Integrations inferred from handler
|
|
7
|
+
* Integrations inferred from handler sources at deploy time — whatever
|
|
5
8
|
* `ctx.api.v1.<name>` the handlers touch (from `DECLARED_INTEGRATIONS`) is
|
|
6
9
|
* registered with the platform. There is no `integrations.json` and no declare
|
|
7
10
|
* CLI step.
|
|
8
11
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
12
|
+
* `apiDomains` comes from the handler parse in `validateHandlerSource`, so a
|
|
13
|
+
* domain mentioned only in a comment or string does not count as usage.
|
|
14
|
+
*
|
|
15
|
+
* Only OAuth-gated integrations belong in the result. `db`, `llm`, and
|
|
16
|
+
* `snowflake` are always-on channels and are never declared.
|
|
11
17
|
*/
|
|
12
|
-
export function
|
|
13
|
-
|
|
14
|
-
for (const source of Object.values(handlers)) {
|
|
15
|
-
for (const name of DECLARED_INTEGRATIONS) {
|
|
16
|
-
if (new RegExp(`\\bctx\\.api\\.v1\\.${name}\\b`).test(source)) {
|
|
17
|
-
found.add(name);
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
return [...found].sort();
|
|
18
|
+
export function detectIntegrations(apiDomains: Iterable<string>): DeclaredIntegration[] {
|
|
19
|
+
return [...new Set(apiDomains)].filter(isDeclaredIntegration).sort();
|
|
22
20
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnyNode,
|
|
3
|
+
ArrowFunctionExpression,
|
|
4
|
+
Expression,
|
|
5
|
+
FunctionDeclaration,
|
|
6
|
+
FunctionExpression,
|
|
7
|
+
ModuleDeclaration,
|
|
8
|
+
Pattern,
|
|
9
|
+
Statement,
|
|
10
|
+
} from 'acorn';
|
|
11
|
+
|
|
12
|
+
/** True for the `handler` identifier specifically — used everywhere a node might name the binding. */
|
|
13
|
+
const isHandlerIdentifier = (candidate: AnyNode | null | undefined): boolean =>
|
|
14
|
+
candidate?.type === 'Identifier' && candidate.name === 'handler';
|
|
15
|
+
|
|
16
|
+
export type HandlerDeclaration =
|
|
17
|
+
| { kind: 'function'; params: FunctionDeclaration['params'] }
|
|
18
|
+
| { kind: 'variable'; init: Expression | null | undefined };
|
|
19
|
+
|
|
20
|
+
/** The top-level declaration that binds `handler`, if any. */
|
|
21
|
+
export function findHandlerDeclaration(
|
|
22
|
+
body: readonly (Statement | ModuleDeclaration)[]
|
|
23
|
+
): HandlerDeclaration | undefined {
|
|
24
|
+
for (const node of body) {
|
|
25
|
+
if (node.type === 'FunctionDeclaration' && isHandlerIdentifier(node.id)) {
|
|
26
|
+
return { kind: 'function', params: node.params };
|
|
27
|
+
}
|
|
28
|
+
if (node.type === 'VariableDeclaration') {
|
|
29
|
+
const declarator = node.declarations.find(d => isHandlerIdentifier(d.id));
|
|
30
|
+
if (declarator) {
|
|
31
|
+
return { kind: 'variable', init: declarator.init };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `export function handler`, `export const handler`, `export { handler }`, `export default handler`. */
|
|
39
|
+
export function exportsHandler(node: Statement | ModuleDeclaration): boolean {
|
|
40
|
+
if (node.type === 'ExportNamedDeclaration') {
|
|
41
|
+
return (
|
|
42
|
+
(node.declaration != null && findHandlerDeclaration([node.declaration]) !== undefined) ||
|
|
43
|
+
node.specifiers.some(s => isHandlerIdentifier(s.local) || isHandlerIdentifier(s.exported))
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (node.type === 'ExportDefaultDeclaration') {
|
|
47
|
+
const { declaration } = node;
|
|
48
|
+
return (
|
|
49
|
+
(declaration.type === 'FunctionDeclaration' && isHandlerIdentifier(declaration.id)) ||
|
|
50
|
+
isHandlerIdentifier(declaration)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Peel `wrap(fn)`, `(0, fn)`, and the like until a function expression remains. */
|
|
57
|
+
function asFunction(expr: Expression | null | undefined): FunctionExpression | ArrowFunctionExpression | undefined {
|
|
58
|
+
if (!expr) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
switch (expr.type) {
|
|
62
|
+
case 'FunctionExpression':
|
|
63
|
+
case 'ArrowFunctionExpression':
|
|
64
|
+
return expr;
|
|
65
|
+
case 'SequenceExpression':
|
|
66
|
+
return asFunction(expr.expressions.at(-1));
|
|
67
|
+
case 'CallExpression': {
|
|
68
|
+
for (const arg of expr.arguments) {
|
|
69
|
+
if (arg.type === 'SpreadElement') {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const fn = asFunction(arg);
|
|
73
|
+
if (fn) {
|
|
74
|
+
return fn;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function firstParamName(params: readonly Pattern[]): string | undefined {
|
|
85
|
+
let first: Pattern | undefined = params[0];
|
|
86
|
+
if (first?.type === 'AssignmentPattern') {
|
|
87
|
+
first = first.left;
|
|
88
|
+
}
|
|
89
|
+
return first?.type === 'Identifier' ? first.name : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** First positional parameter of `handler`, when it's a plain identifier (`ctx`, `context`, …). */
|
|
93
|
+
export function handlerContextParamName(declaration: HandlerDeclaration): string | undefined {
|
|
94
|
+
const params = declaration.kind === 'function' ? declaration.params : asFunction(declaration.init)?.params;
|
|
95
|
+
return params ? firstParamName(params) : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Values that can never be a function, so `const handler = <this>` is caught at build time. */
|
|
99
|
+
export const NEVER_A_FUNCTION: ReadonlySet<string> = new Set([
|
|
100
|
+
'Literal',
|
|
101
|
+
'ObjectExpression',
|
|
102
|
+
'ArrayExpression',
|
|
103
|
+
'TemplateLiteral',
|
|
104
|
+
]);
|
package/src/backend/handlers.ts
CHANGED
|
@@ -1,38 +1,15 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { readdir, readFile } from 'node:fs/promises';
|
|
3
3
|
import { pathExists } from 'fs-extra';
|
|
4
|
+
import { validateHandlerSource } from './validate';
|
|
4
5
|
|
|
5
6
|
const HANDLERS_DIR = path.join('backend', 'handlers');
|
|
6
7
|
|
|
7
8
|
export interface ReadHandlersResult {
|
|
8
9
|
/** handler name (filename without .js) → JS source */
|
|
9
10
|
handlers: Record<string, string>;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* True if the source defines a `handler` function or variable at the top level.
|
|
14
|
-
* Handlers run in a V8 isolate with no module system — `handler` must be a plain
|
|
15
|
-
* global, not an export.
|
|
16
|
-
*/
|
|
17
|
-
function definesHandler(source: string): boolean {
|
|
18
|
-
return /\bfunction\s+handler\b/.test(source) || /\b(const|let|var)\s+handler\b/.test(source);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* True if `handler` is declared with an `export` prefix. It reads as valid source but
|
|
23
|
-
* the isolate has no module system, so an exported handler is simply invisible to the
|
|
24
|
-
* runner — caught here rather than failing opaquely at invoke time.
|
|
25
|
-
*/
|
|
26
|
-
function exportsHandler(source: string): boolean {
|
|
27
|
-
return /\bexport\s+(default\s+)?(async\s+)?(function\s+handler\b|(?:const|let|var)\s+handler\b)/.test(source);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* True if the source contains any `import` or `require` call. Neither works in
|
|
32
|
-
* the V8 isolate — reach the outside world through `ctx` capabilities instead.
|
|
33
|
-
*/
|
|
34
|
-
function hasImports(source: string): boolean {
|
|
35
|
-
return /^\s*import\b/m.test(source) || /\brequire\s*\(/.test(source);
|
|
11
|
+
/** Every `ctx.api.v1.<domain>` any handler touches, sorted and unique. */
|
|
12
|
+
apiDomains: string[];
|
|
36
13
|
}
|
|
37
14
|
|
|
38
15
|
/**
|
|
@@ -43,7 +20,7 @@ function hasImports(source: string): boolean {
|
|
|
43
20
|
export async function readHandlers(consumerDir: string): Promise<ReadHandlersResult> {
|
|
44
21
|
const handlersDir = path.join(consumerDir, HANDLERS_DIR);
|
|
45
22
|
if (!(await pathExists(handlersDir))) {
|
|
46
|
-
return { handlers: {} };
|
|
23
|
+
return { handlers: {}, apiDomains: [] };
|
|
47
24
|
}
|
|
48
25
|
|
|
49
26
|
const entries = await readdir(handlersDir);
|
|
@@ -53,30 +30,18 @@ export async function readHandlers(consumerDir: string): Promise<ReadHandlersRes
|
|
|
53
30
|
}
|
|
54
31
|
const jsFiles = entries.filter(f => f.endsWith('.js'));
|
|
55
32
|
if (jsFiles.length === 0) {
|
|
56
|
-
return { handlers: {} };
|
|
33
|
+
return { handlers: {}, apiDomains: [] };
|
|
57
34
|
}
|
|
58
35
|
|
|
59
36
|
const handlers: Record<string, string> = {};
|
|
37
|
+
const apiDomains = new Set<string>();
|
|
60
38
|
for (const file of jsFiles) {
|
|
61
39
|
const handlerName = path.parse(file).name;
|
|
62
40
|
const source = await readFile(path.join(handlersDir, file), 'utf8');
|
|
63
41
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
throw new Error(
|
|
67
|
-
`${file} must not export handler — the isolate has no module system; declare it as a plain global, e.g. async function handler(ctx) { ... }`
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
if (!definesHandler(source)) {
|
|
71
|
-
throw new Error(`${file} must define a handler — e.g. async function handler(ctx) { ... }`);
|
|
72
|
-
}
|
|
73
|
-
if (hasImports(source)) {
|
|
74
|
-
throw new Error(
|
|
75
|
-
`${file} must not import other files or packages — handlers are self-contained; reach the outside world through ctx capabilities`
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
42
|
+
const validation = validateHandlerSource(file, source);
|
|
43
|
+
validation.apiDomains.forEach(d => apiDomains.add(d));
|
|
79
44
|
handlers[handlerName] = source;
|
|
80
45
|
}
|
|
81
|
-
return { handlers };
|
|
46
|
+
return { handlers, apiDomains: [...apiDomains].sort() };
|
|
82
47
|
}
|