@mondaydotcomorg/z2h-cli 0.27.0 → 0.28.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.
Files changed (116) hide show
  1. package/dist/backend/__tests__/ast.test.d.ts +2 -0
  2. package/dist/backend/__tests__/ast.test.d.ts.map +1 -0
  3. package/dist/backend/__tests__/parse.test.d.ts +2 -0
  4. package/dist/backend/__tests__/parse.test.d.ts.map +1 -0
  5. package/dist/backend/__tests__/validate.test.d.ts +2 -0
  6. package/dist/backend/__tests__/validate.test.d.ts.map +1 -0
  7. package/dist/backend/ast.d.ts +41 -0
  8. package/dist/backend/ast.d.ts.map +1 -0
  9. package/dist/backend/ast.js +152 -0
  10. package/dist/backend/detect-integrations.d.ts +8 -4
  11. package/dist/backend/detect-integrations.d.ts.map +1 -1
  12. package/dist/backend/detect-integrations.js +12 -15
  13. package/dist/backend/handler-declaration.d.ts +17 -0
  14. package/dist/backend/handler-declaration.d.ts.map +1 -0
  15. package/dist/backend/handler-declaration.js +83 -0
  16. package/dist/backend/handlers.d.ts +2 -0
  17. package/dist/backend/handlers.d.ts.map +1 -1
  18. package/dist/backend/handlers.js +7 -36
  19. package/dist/backend/messages.d.ts +10 -0
  20. package/dist/backend/messages.d.ts.map +1 -0
  21. package/dist/backend/messages.js +14 -0
  22. package/dist/backend/parse.d.ts +11 -0
  23. package/dist/backend/parse.d.ts.map +1 -0
  24. package/dist/backend/parse.js +66 -0
  25. package/dist/backend/rules/__tests__/snowflake-static-sql.test.d.ts +2 -0
  26. package/dist/backend/rules/__tests__/snowflake-static-sql.test.d.ts.map +1 -0
  27. package/dist/backend/rules/index.d.ts +5 -0
  28. package/dist/backend/rules/index.d.ts.map +1 -0
  29. package/dist/backend/rules/index.js +8 -0
  30. package/dist/backend/rules/snowflake-static-sql.d.ts +15 -0
  31. package/dist/backend/rules/snowflake-static-sql.d.ts.map +1 -0
  32. package/dist/backend/rules/snowflake-static-sql.js +75 -0
  33. package/dist/backend/rules/types.d.ts +29 -0
  34. package/dist/backend/rules/types.d.ts.map +1 -0
  35. package/dist/backend/rules/types.js +1 -0
  36. package/dist/backend/validate.d.ts +23 -0
  37. package/dist/backend/validate.d.ts.map +1 -0
  38. package/dist/backend/validate.js +64 -0
  39. package/dist/commands/__tests__/backend-validate.test.d.ts +2 -0
  40. package/dist/commands/__tests__/backend-validate.test.d.ts.map +1 -0
  41. package/dist/commands/backend.d.ts +6 -0
  42. package/dist/commands/backend.d.ts.map +1 -1
  43. package/dist/commands/backend.js +22 -2
  44. package/dist/constants.d.ts +1 -2
  45. package/dist/constants.d.ts.map +1 -1
  46. package/dist/constants.js +10 -6
  47. package/dist/esm/backend/__tests__/ast.test.d.ts +2 -0
  48. package/dist/esm/backend/__tests__/ast.test.d.ts.map +1 -0
  49. package/dist/esm/backend/__tests__/parse.test.d.ts +2 -0
  50. package/dist/esm/backend/__tests__/parse.test.d.ts.map +1 -0
  51. package/dist/esm/backend/__tests__/validate.test.d.ts +2 -0
  52. package/dist/esm/backend/__tests__/validate.test.d.ts.map +1 -0
  53. package/dist/esm/backend/ast.d.ts +41 -0
  54. package/dist/esm/backend/ast.d.ts.map +1 -0
  55. package/dist/esm/backend/ast.mjs +146 -0
  56. package/dist/esm/backend/detect-integrations.d.ts +8 -4
  57. package/dist/esm/backend/detect-integrations.d.ts.map +1 -1
  58. package/dist/esm/backend/detect-integrations.mjs +12 -15
  59. package/dist/esm/backend/handler-declaration.d.ts +17 -0
  60. package/dist/esm/backend/handler-declaration.d.ts.map +1 -0
  61. package/dist/esm/backend/handler-declaration.mjs +78 -0
  62. package/dist/esm/backend/handlers.d.ts +2 -0
  63. package/dist/esm/backend/handlers.d.ts.map +1 -1
  64. package/dist/esm/backend/handlers.mjs +7 -36
  65. package/dist/esm/backend/messages.d.ts +10 -0
  66. package/dist/esm/backend/messages.d.ts.map +1 -0
  67. package/dist/esm/backend/messages.mjs +12 -0
  68. package/dist/esm/backend/parse.d.ts +11 -0
  69. package/dist/esm/backend/parse.d.ts.map +1 -0
  70. package/dist/esm/backend/parse.mjs +64 -0
  71. package/dist/esm/backend/rules/__tests__/snowflake-static-sql.test.d.ts +2 -0
  72. package/dist/esm/backend/rules/__tests__/snowflake-static-sql.test.d.ts.map +1 -0
  73. package/dist/esm/backend/rules/index.d.ts +5 -0
  74. package/dist/esm/backend/rules/index.d.ts.map +1 -0
  75. package/dist/esm/backend/rules/index.mjs +6 -0
  76. package/dist/esm/backend/rules/snowflake-static-sql.d.ts +15 -0
  77. package/dist/esm/backend/rules/snowflake-static-sql.d.ts.map +1 -0
  78. package/dist/esm/backend/rules/snowflake-static-sql.mjs +73 -0
  79. package/dist/esm/backend/rules/types.d.ts +29 -0
  80. package/dist/esm/backend/rules/types.d.ts.map +1 -0
  81. package/dist/esm/backend/rules/types.mjs +1 -0
  82. package/dist/esm/backend/validate.d.ts +23 -0
  83. package/dist/esm/backend/validate.d.ts.map +1 -0
  84. package/dist/esm/backend/validate.mjs +62 -0
  85. package/dist/esm/commands/__tests__/backend-validate.test.d.ts +2 -0
  86. package/dist/esm/commands/__tests__/backend-validate.test.d.ts.map +1 -0
  87. package/dist/esm/commands/backend.d.ts +6 -0
  88. package/dist/esm/commands/backend.d.ts.map +1 -1
  89. package/dist/esm/commands/backend.mjs +23 -4
  90. package/dist/esm/constants.d.ts +1 -2
  91. package/dist/esm/constants.d.ts.map +1 -1
  92. package/dist/esm/constants.mjs +3 -6
  93. package/dist/esm/index.mjs +6 -2
  94. package/dist/index.js +5 -1
  95. package/package.json +5 -3
  96. package/src/backend/__tests__/ast.test.ts +53 -0
  97. package/src/backend/__tests__/detect-integrations.test.ts +12 -30
  98. package/src/backend/__tests__/handlers.test.ts +21 -34
  99. package/src/backend/__tests__/parse.test.ts +25 -0
  100. package/src/backend/__tests__/validate.test.ts +246 -0
  101. package/src/backend/ast.ts +204 -0
  102. package/src/backend/detect-integrations.ts +12 -14
  103. package/src/backend/handler-declaration.ts +104 -0
  104. package/src/backend/handlers.ts +9 -44
  105. package/src/backend/messages.ts +16 -0
  106. package/src/backend/parse.ts +82 -0
  107. package/src/backend/rules/__tests__/snowflake-static-sql.test.ts +175 -0
  108. package/src/backend/rules/index.ts +7 -0
  109. package/src/backend/rules/snowflake-static-sql.ts +78 -0
  110. package/src/backend/rules/types.ts +30 -0
  111. package/src/backend/validate.ts +73 -0
  112. package/src/commands/__tests__/backend-deploy.test.ts +5 -3
  113. package/src/commands/__tests__/backend-validate.test.ts +71 -0
  114. package/src/commands/backend.ts +23 -3
  115. package/src/constants.ts +5 -6
  116. package/src/index.ts +9 -2
@@ -0,0 +1,175 @@
1
+ import { parse } from 'acorn';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { collectBindings, createPathResolver } from '../../ast';
4
+ import { findHandlerDeclaration, handlerContextParamName } from '../../handler-declaration';
5
+ import { SnowflakeStaticSqlRule } from '../snowflake-static-sql';
6
+
7
+ const rule = new SnowflakeStaticSqlRule();
8
+
9
+ const check = (source: string) => {
10
+ const program = parse(source, { ecmaVersion: 'latest', sourceType: 'script', locations: true });
11
+ const bindings = collectBindings(program);
12
+ const declaration = findHandlerDeclaration(program.body);
13
+ return rule.check({
14
+ file: 'h.js',
15
+ program,
16
+ bindings,
17
+ paths: createPathResolver(bindings, declaration && handlerContextParamName(declaration)),
18
+ });
19
+ };
20
+
21
+ const inHandler = (body: string) => `async function handler(ctx) {\n${body}\n}`;
22
+
23
+ describe('snowflake/static-sql — accepted', () => {
24
+ it.each([
25
+ ['a string literal', 'return ctx.api.v1.snowflake.query("select 1");'],
26
+ ['a template literal without holes', 'return ctx.api.v1.snowflake.query(`select 1`);'],
27
+ [
28
+ 'a template literal whose holes are static',
29
+ 'const t = "items"; return ctx.api.v1.snowflake.query(`select * from ${t}`);',
30
+ ],
31
+ ['a local const', 'const sql = "select 1"; return ctx.api.v1.snowflake.query(sql);'],
32
+ [
33
+ 'a var declared before use',
34
+ 'var sql = "select 1"; return ctx.api.v1.snowflake.query(sql, undefined, { identifier: "x" });',
35
+ ],
36
+ ['concatenated literals', 'return ctx.api.v1.snowflake.query("select 1" + " from dual");'],
37
+ [
38
+ 'a variable chain',
39
+ 'const base = "select 1"; const sql = base + " limit 1"; return ctx.api.v1.snowflake.query(sql);',
40
+ ],
41
+ [
42
+ 'a conditional between two static queries',
43
+ 'const sql = ctx.input.all ? "select 1" : "select 2"; return ctx.api.v1.snowflake.query(sql);',
44
+ ],
45
+ [
46
+ 'a nullish fallback between static queries',
47
+ 'return ctx.api.v1.snowflake.query(A ?? "select 1"); const A = "select 2";',
48
+ ],
49
+ [
50
+ 'client values in params and options',
51
+ 'return ctx.api.v1.snowflake.query("select :id", { id: ctx.input.id }, { timeoutMs: ctx.input.t });',
52
+ ],
53
+ ['optional chaining on the channel', 'return ctx?.api?.v1?.snowflake?.query("select 1");'],
54
+ ])('accepts %s', (_label, body) => {
55
+ expect(check(inHandler(body))).toEqual([]);
56
+ });
57
+
58
+ it('accepts a top-level const shared by several calls', () => {
59
+ expect(
60
+ check(
61
+ `const SQL = "select 1";\n${inHandler(
62
+ 'await ctx.api.v1.snowflake.query(SQL); return ctx.api.v1.snowflake.query(SQL);'
63
+ )}`
64
+ )
65
+ ).toEqual([]);
66
+ });
67
+
68
+ it('ignores other snowflake members and other channels', () => {
69
+ expect(
70
+ check(inHandler('ctx.api.v1.snowflake.other(ctx.input.sql); return ctx.api.v1.monday.query(ctx.input.q);'))
71
+ ).toEqual([]);
72
+ });
73
+
74
+ it('accepts static SQL through a const alias of the channel', () => {
75
+ expect(
76
+ check(inHandler('const sf = ctx.api.v1.snowflake; return sf.query("select 1", { id: ctx.input.id });'))
77
+ ).toEqual([]);
78
+ });
79
+ });
80
+
81
+ describe('snowflake/static-sql — channel aliases', () => {
82
+ it.each([
83
+ ['a const alias of the channel', 'const sf = ctx.api.v1.snowflake; return sf.query(ctx.input.sql);'],
84
+ ['a const alias of the api root', 'const api = ctx.api.v1; return api.snowflake.query(ctx.input.sql);'],
85
+ ['a chain of const aliases', 'const api = ctx.api; const sf = api.v1.snowflake; return sf.query(ctx.input.sql);'],
86
+ ['a destructured channel', 'const { snowflake } = ctx.api.v1; return snowflake.query(ctx.input.sql);'],
87
+ ['a renamed destructured channel', 'const { snowflake: sf } = ctx.api.v1; return sf.query(ctx.input.sql);'],
88
+ ['a nested destructuring', 'const { api: { v1: { snowflake } } } = ctx; return snowflake.query(ctx.input.sql);'],
89
+ ['a destructured method', 'const { query } = ctx.api.v1.snowflake; return query(ctx.input.sql);'],
90
+ ['an optional-chained alias', 'const sf = ctx?.api?.v1?.snowflake; return sf.query(ctx.input.sql);'],
91
+ ['a computed string-literal channel', 'return ctx.api.v1["snowflake"].query(ctx.input.sql);'],
92
+ ['a computed alias', 'const sf = ctx.api.v1["snowflake"]; return sf.query(ctx.input.sql);'],
93
+ ])('sees through %s', (_label, body) => {
94
+ expect(check(inHandler(body))).toHaveLength(1);
95
+ });
96
+
97
+ it('follows a top-level const alias into the handler', () => {
98
+ expect(check(`const sf = ctx.api.v1.snowflake;\n${inHandler('return sf.query(ctx.input.sql);')}`)).toHaveLength(1);
99
+ });
100
+
101
+ it('sees through a renamed handler parameter', () => {
102
+ expect(
103
+ check('async function handler(context) { return context.api.v1.snowflake.query(context.input.sql); }')
104
+ ).toHaveLength(1);
105
+ });
106
+
107
+ it.each([
108
+ ['a let alias', 'let sf = ctx.api.v1.snowflake; return sf.query(ctx.input.sql);'],
109
+ ['a reassigned alias', 'var sf = ctx.api.v1.snowflake; sf = other; return sf.query(ctx.input.sql);'],
110
+ [
111
+ 'an alias shadowed by a parameter',
112
+ 'const sf = ctx.api.v1.snowflake; const run = (sf) => sf.query(ctx.input.sql); return run(fake);',
113
+ ],
114
+ [
115
+ 'an alias declared twice',
116
+ 'const sf = ctx.api.v1.snowflake; { const sf = fake; return sf.query(ctx.input.sql); }',
117
+ ],
118
+ ['an alias with a default value', 'const { snowflake: sf = fake } = ctx.api.v1; return sf.query(ctx.input.sql);'],
119
+ ])('does not follow %s — the binding cannot be pinned', (_label, body) => {
120
+ expect(check(inHandler(body))).toEqual([]);
121
+ });
122
+ });
123
+
124
+ describe('snowflake/static-sql — rejected', () => {
125
+ it.each([
126
+ ['ctx.input directly', 'return ctx.api.v1.snowflake.query(ctx.input.sql);'],
127
+ ['a variable bound to ctx.input', 'const sql = ctx.input.sql; return ctx.api.v1.snowflake.query(sql);'],
128
+ [
129
+ 'a template literal with a ctx.input hole',
130
+ 'return ctx.api.v1.snowflake.query(`select * from ${ctx.input.table}`);',
131
+ ],
132
+ ['concatenation with ctx.input', 'return ctx.api.v1.snowflake.query("select * from " + ctx.input.table);'],
133
+ [
134
+ 'a conditional with one dynamic branch',
135
+ 'return ctx.api.v1.snowflake.query(ctx.input.x ? "select 1" : ctx.input.sql);',
136
+ ],
137
+ ['a call result', 'return ctx.api.v1.snowflake.query(buildSql(ctx.input));'],
138
+ ['a destructured binding', 'const { sql } = ctx.input; return ctx.api.v1.snowflake.query(sql);'],
139
+ ['a reassigned variable', 'let sql = "select 1"; sql = ctx.input.sql; return ctx.api.v1.snowflake.query(sql);'],
140
+ [
141
+ 'a compound-assigned variable',
142
+ 'let sql = "select 1"; sql += ctx.input.where; return ctx.api.v1.snowflake.query(sql);',
143
+ ],
144
+ ['a function parameter', 'const run = (sql) => ctx.api.v1.snowflake.query(sql); return run(ctx.input.sql);'],
145
+ ['an undeclared identifier', 'return ctx.api.v1.snowflake.query(sql);'],
146
+ ['a spread argument', 'return ctx.api.v1.snowflake.query(...ctx.input.args);'],
147
+ ['a non-string literal', 'return ctx.api.v1.snowflake.query(1);'],
148
+ ['no arguments', 'return ctx.api.v1.snowflake.query();'],
149
+ ])('rejects %s', (_label, body) => {
150
+ const violations = check(inHandler(body));
151
+ expect(violations).toHaveLength(1);
152
+ expect(violations[0]).toMatch(/^h\.js:2: ctx\.api\.v1\.snowflake\.query must receive SQL written in the handler/);
153
+ });
154
+
155
+ it('rejects a variable shadowed by a parameter anywhere in the file', () => {
156
+ expect(
157
+ check(
158
+ `const sql = "select 1";\nfunction run(sql) { return ctx.api.v1.snowflake.query(sql); }\n${inHandler(
159
+ 'return run(ctx.input.sql);'
160
+ )}`
161
+ )
162
+ ).toHaveLength(1);
163
+ });
164
+
165
+ it('does not loop on self-referential bindings', () => {
166
+ expect(check(inHandler('const a = b; const b = a; return ctx.api.v1.snowflake.query(a);'))).toHaveLength(1);
167
+ });
168
+
169
+ it('reports every offending call with its own line', () => {
170
+ const violations = check(
171
+ inHandler('await ctx.api.v1.snowflake.query(ctx.input.a);\nreturn ctx.api.v1.snowflake.query(ctx.input.b);')
172
+ );
173
+ expect(violations.map(v => v.slice(0, 6))).toEqual(['h.js:2', 'h.js:3']);
174
+ });
175
+ });
@@ -0,0 +1,7 @@
1
+ import type { HandlerRule } from './types';
2
+ import { SnowflakeStaticSqlRule } from './snowflake-static-sql';
3
+
4
+ export type { HandlerRule, RuleContext } from './types';
5
+
6
+ /** Every rule the validator runs, in order. Add new capability rules here. */
7
+ export const HANDLER_RULES: readonly HandlerRule[] = [new SnowflakeStaticSqlRule()];
@@ -0,0 +1,78 @@
1
+ import type { AnyNode } from 'acorn';
2
+ import { simple as walkSimple } from 'acorn-walk';
3
+ import { type Bindings, lineOf } from '../ast';
4
+ import type { HandlerRule, RuleContext } from './types';
5
+
6
+ const SNOWFLAKE_QUERY = 'ctx.api.v1.snowflake.query';
7
+
8
+ const message = (file: string, line: number | undefined) =>
9
+ `${file}${line ? `:${line}` : ''}: ${SNOWFLAKE_QUERY} must receive SQL written in the handler — a string literal, ` +
10
+ `or a variable bound to one. Client-supplied values go through params (\`:name\` bindings) and options, never into the SQL text.`;
11
+
12
+ /**
13
+ * True when every possible value of `node` is text authored in this file:
14
+ * string literals, templates whose holes are themselves static, `+`
15
+ * concatenation, either branch of a conditional, and identifiers that resolve
16
+ * (within the file) to such values. Anything reaching outside the file —
17
+ * `ctx.input`, calls, member reads, destructuring — is not static.
18
+ */
19
+ function isStaticSql(node: AnyNode | null | undefined, bindings: Bindings, resolving: ReadonlySet<string>): boolean {
20
+ if (!node) {
21
+ return false;
22
+ }
23
+ const staticChild = (child: AnyNode | null | undefined) => isStaticSql(child, bindings, resolving);
24
+ switch (node.type) {
25
+ case 'Literal':
26
+ return typeof node.value === 'string';
27
+ case 'TemplateLiteral':
28
+ return node.expressions.every(staticChild);
29
+ case 'BinaryExpression':
30
+ return node.operator === '+' && staticChild(node.left) && staticChild(node.right);
31
+ case 'ConditionalExpression':
32
+ return staticChild(node.consequent) && staticChild(node.alternate);
33
+ case 'LogicalExpression':
34
+ return staticChild(node.left) && staticChild(node.right);
35
+ case 'Identifier': {
36
+ const { name } = node;
37
+ if (bindings.unpinnable.has(name) || resolving.has(name)) {
38
+ return false;
39
+ }
40
+ const decls = bindings.declarations.get(name);
41
+ if (!decls || decls.length === 0) {
42
+ return false;
43
+ }
44
+ const next = new Set(resolving).add(name);
45
+ return decls.every(d => d.path?.length === 0 && isStaticSql(d.init, bindings, next));
46
+ }
47
+ default:
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * `ctx.api.v1.snowflake.query(sql, params?, options?)` — the SQL text must live
54
+ * in the handler. The frontend chooses *which* handler to call and supplies
55
+ * `params` / `options`; it never ships SQL. Binding placeholders (`:name`) are
56
+ * how client values reach the query.
57
+ *
58
+ * The call is matched through `const` aliases of the channel
59
+ * (`const sf = ctx.api.v1.snowflake; sf.query(…)`), see `PathResolver`.
60
+ */
61
+ export class SnowflakeStaticSqlRule implements HandlerRule {
62
+ readonly name = 'snowflake/static-sql';
63
+
64
+ check({ file, program, bindings, paths }: RuleContext): string[] {
65
+ const violations: string[] = [];
66
+ walkSimple(program, {
67
+ CallExpression(node) {
68
+ if (paths.pathOf(node.callee) !== SNOWFLAKE_QUERY) {
69
+ return;
70
+ }
71
+ if (!isStaticSql(node.arguments[0], bindings, new Set())) {
72
+ violations.push(message(file, lineOf(node)));
73
+ }
74
+ },
75
+ });
76
+ return violations;
77
+ }
78
+ }
@@ -0,0 +1,30 @@
1
+ import type { Program } from 'acorn';
2
+ import type { Bindings, PathResolver } from '../ast';
3
+
4
+ export interface RuleContext {
5
+ /** Handler file name, used to prefix messages. */
6
+ file: string;
7
+ /** Parsed with `locations: true`, so rules can point at a line. */
8
+ program: Program;
9
+ /** Every variable binding in the file — see `collectBindings`. */
10
+ bindings: Bindings;
11
+ /** Resolves member chains through `const` aliases — see `createPathResolver`. */
12
+ paths: PathResolver;
13
+ }
14
+
15
+ /**
16
+ * A capability-specific check that runs once the handler is structurally valid
17
+ * (parses as a script, defines `handler`, imports nothing).
18
+ *
19
+ * Rules return human-readable violations rather than throwing so the validator
20
+ * can report every problem in one pass — agent-generated handlers are fixed in
21
+ * a loop, and one round-trip per violation is slow.
22
+ *
23
+ * Implement as a class (see `SnowflakeStaticSqlRule`) and add an instance to
24
+ * `HANDLER_RULES` in `./index.ts` — keeps every rule the same shape to read.
25
+ */
26
+ export interface HandlerRule {
27
+ /** Stable identifier, e.g. `snowflake/static-sql`. */
28
+ name: string;
29
+ check(ctx: RuleContext): string[];
30
+ }
@@ -0,0 +1,73 @@
1
+ import { simple as walkSimple } from 'acorn-walk';
2
+ import { ALWAYS_ON_CHANNELS, DECLARED_INTEGRATIONS } from '@mondaydotcomorg/z2h-shared-utils';
3
+ import { warn } from '../util/logger';
4
+ import { collectApiDomains, collectBindings, createPathResolver } from './ast';
5
+ import { findHandlerDeclaration, handlerContextParamName, NEVER_A_FUNCTION } from './handler-declaration';
6
+ import { messages } from './messages';
7
+ import { parseOrThrow } from './parse';
8
+ import { HANDLER_RULES } from './rules';
9
+
10
+ const KNOWN_API_DOMAINS: readonly string[] = [...ALWAYS_ON_CHANNELS, ...DECLARED_INTEGRATIONS];
11
+
12
+ export interface HandlerValidation {
13
+ /** Every `ctx.api.v1.<domain>` the handler touches (known or not), sorted and unique. */
14
+ apiDomains: string[];
15
+ }
16
+
17
+ /**
18
+ * Validates a handler's source before upload: real parsing (via acorn) catches
19
+ * syntax errors at build time instead of an opaque failure inside the isolate.
20
+ * Handlers run with no module system and no bundler — `handler` must be a plain
21
+ * top-level global, and imports/requires (which would resolve to nothing at
22
+ * runtime) are rejected.
23
+ *
24
+ * Once the handler is structurally sound, capability-specific rules from
25
+ * `./rules` run and every violation is reported at once.
26
+ *
27
+ * Unknown `ctx.api.v1.<domain>` usage is a warning, not a failure: this pass is
28
+ * build-time DX feedback for agent-generated code, never a security boundary —
29
+ * the isolate has no ambient capabilities regardless of what a handler contains.
30
+ *
31
+ * Returns the `ctx.api.v1.*` domains the handler touches so callers (integration
32
+ * detection) can reuse the parse instead of re-scanning the source.
33
+ */
34
+ export function validateHandlerSource(file: string, source: string): HandlerValidation {
35
+ const program = parseOrThrow(file, source);
36
+
37
+ const bindings = collectBindings(program);
38
+
39
+ walkSimple(program, {
40
+ CallExpression(node) {
41
+ if (node.callee.type === 'Identifier' && node.callee.name === 'require') {
42
+ throw new Error(messages.mustNotImport(file));
43
+ }
44
+ },
45
+ ImportExpression() {
46
+ throw new Error(messages.mustNotImport(file));
47
+ },
48
+ });
49
+
50
+ const declaration = findHandlerDeclaration(program.body);
51
+ if (!declaration) {
52
+ throw new Error(messages.mustDefine(file));
53
+ }
54
+ if (declaration.kind === 'variable' && (!declaration.init || NEVER_A_FUNCTION.has(declaration.init.type))) {
55
+ throw new Error(messages.notAFunction(file));
56
+ }
57
+
58
+ const paths = createPathResolver(bindings, handlerContextParamName(declaration));
59
+ const apiDomains = collectApiDomains(program, paths);
60
+
61
+ const violations = HANDLER_RULES.flatMap(rule => rule.check({ file, program, bindings, paths }));
62
+ if (violations.length > 0) {
63
+ throw new Error(violations.join('\n'));
64
+ }
65
+
66
+ for (const domain of apiDomains) {
67
+ if (!KNOWN_API_DOMAINS.includes(domain)) {
68
+ warn(`${file}: ctx.api.v1.${domain} is not a known capability (available: ${KNOWN_API_DOMAINS.join(', ')})`);
69
+ }
70
+ }
71
+
72
+ return { apiDomains: [...apiDomains].sort() };
73
+ }
@@ -23,7 +23,7 @@ const readHandlersMock = vi.mocked(readHandlers);
23
23
  // handlers directory is known to exist.
24
24
  beforeEach(() => {
25
25
  readHandlersMock.mockReset();
26
- readHandlersMock.mockResolvedValue({ handlers: { call_snowflake: '// code' } });
26
+ readHandlersMock.mockResolvedValue({ handlers: { call_snowflake: '// code' }, apiDomains: [] });
27
27
  });
28
28
 
29
29
  describe('hasHandlers', () => {
@@ -60,7 +60,7 @@ describe('deployBackend (live)', () => {
60
60
  });
61
61
 
62
62
  it('skips upload when the handlers directory holds nothing', async () => {
63
- readHandlersMock.mockResolvedValue({ handlers: {} });
63
+ readHandlersMock.mockResolvedValue({ handlers: {}, apiDomains: [] });
64
64
 
65
65
  await expect(
66
66
  deployBackend({
@@ -80,6 +80,7 @@ describe('deployBackend (live)', () => {
80
80
  await writeFile(path.join(tmp, 'backend', 'handlers', 'call_snowflake.ts'), '// placeholder');
81
81
  readHandlersMock.mockResolvedValue({
82
82
  handlers: { call_snowflake: 'async function handler(ctx) { return ctx.api.v1.snowflake.runQuery("x"); }' },
83
+ apiDomains: ['snowflake'],
83
84
  });
84
85
 
85
86
  // snowflake is always-on — not a declared integration
@@ -129,7 +130,7 @@ describe('deployBackend (preview)', () => {
129
130
  });
130
131
 
131
132
  it('skips upload when the handlers directory holds nothing', async () => {
132
- readHandlersMock.mockResolvedValue({ handlers: {} });
133
+ readHandlersMock.mockResolvedValue({ handlers: {}, apiDomains: [] });
133
134
 
134
135
  await expect(
135
136
  deployBackend({
@@ -149,6 +150,7 @@ describe('deployBackend (preview)', () => {
149
150
  await writeFile(path.join(tmp, 'backend', 'handlers', 'call_snowflake.ts'), '// placeholder');
150
151
  readHandlersMock.mockResolvedValue({
151
152
  handlers: { call_snowflake: 'async function handler(ctx) { return ctx.api.v1.monday.query("x"); }' },
153
+ apiDomains: ['monday'],
152
154
  });
153
155
 
154
156
  await expect(
@@ -0,0 +1,71 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { mkdtemp, rm, ensureDir, writeFile } from 'fs-extra';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { backendValidateCommand } from '../backend';
7
+ import { output } from '../../util/logger';
8
+
9
+ vi.mock('../../util/logger', () => ({
10
+ info: vi.fn(),
11
+ output: vi.fn(),
12
+ }));
13
+ vi.mock('../../util/tracker', () => ({
14
+ trackEvent: vi.fn().mockResolvedValue(undefined),
15
+ trackDuration: vi.fn(),
16
+ }));
17
+
18
+ const mockSend = vi.fn();
19
+ vi.mock('../../util/s3/client', () => ({
20
+ getS3Client: vi.fn(() => ({ send: mockSend })),
21
+ }));
22
+
23
+ const outputMock = vi.mocked(output);
24
+
25
+ describe('backendValidateCommand', () => {
26
+ let tmp: string;
27
+
28
+ beforeEach(async () => {
29
+ tmp = await mkdtemp(path.join(os.tmpdir(), 'z2h-backend-validate-'));
30
+ outputMock.mockClear();
31
+ mockSend.mockClear();
32
+ });
33
+
34
+ afterEach(async () => {
35
+ await rm(tmp, { recursive: true, force: true });
36
+ });
37
+
38
+ it('reports nothing to validate when there is no handlers directory', async () => {
39
+ await backendValidateCommand(tmp);
40
+ expect(outputMock).toHaveBeenCalledWith(expect.stringContaining('nothing to validate'), {
41
+ handlers: [],
42
+ integrations: [],
43
+ });
44
+ });
45
+
46
+ it('reports valid handlers and detected integrations without uploading', async () => {
47
+ const dir = path.join(tmp, 'backend', 'handlers');
48
+ await ensureDir(dir);
49
+ await writeFile(
50
+ path.join(dir, 'list_items.js'),
51
+ 'async function handler(ctx) { return ctx.api.v1.monday.query("query { me { id } }"); }'
52
+ );
53
+
54
+ await backendValidateCommand(tmp);
55
+
56
+ expect(outputMock).toHaveBeenCalledWith(expect.stringContaining('1 handler(s) valid: list_items'), {
57
+ handlers: ['list_items'],
58
+ integrations: ['monday'],
59
+ });
60
+ expect(mockSend).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('throws the same validation error deploy would', async () => {
64
+ const dir = path.join(tmp, 'backend', 'handlers');
65
+ await ensureDir(dir);
66
+ await writeFile(path.join(dir, 'bad.js'), 'export async function handler(ctx) { return 1; }');
67
+
68
+ await expect(backendValidateCommand(tmp)).rejects.toThrow(/must not (use )?export/);
69
+ expect(outputMock).not.toHaveBeenCalled();
70
+ });
71
+ });
@@ -4,7 +4,7 @@ import { PutObjectCommand } from '@aws-sdk/client-s3';
4
4
  import { info, output } from '../util/logger';
5
5
  import { mfAppName, getCwdAppName } from '../util/app-name';
6
6
  import { readHandlers } from '../backend/handlers';
7
- import { detectIntegrationsFromHandlers } from '../backend/detect-integrations';
7
+ import { detectIntegrations } from '../backend/detect-integrations';
8
8
  import { getS3Client } from '../util/s3/client';
9
9
  import { BUCKET_WRITE_NAME } from '../constants';
10
10
  import { invokeBackend } from '../backend/client';
@@ -44,8 +44,8 @@ async function readDetectAndUpload(
44
44
  return [];
45
45
  }
46
46
  info(`reading backend handlers ${logLabel}`);
47
- const { handlers } = await readHandlers(appDir);
48
- const integrations = detectIntegrationsFromHandlers(handlers);
47
+ const { handlers, apiDomains } = await readHandlers(appDir);
48
+ const integrations = detectIntegrations(apiDomains);
49
49
  if (Object.keys(handlers).length === 0) {
50
50
  return integrations;
51
51
  }
@@ -94,6 +94,26 @@ export async function deployBackend(opts: {
94
94
  }
95
95
  }
96
96
 
97
+ /**
98
+ * Run the same handler validation deploy runs (syntax, isolate constraints,
99
+ * handler rules, integration detection) against the working tree — without
100
+ * uploading anything. Throws on the first invalid handler.
101
+ */
102
+ export async function backendValidateCommand(appDir: string = process.cwd()): Promise<void> {
103
+ if (!hasHandlers(appDir)) {
104
+ output('[z2h-cli] no backend/handlers/ directory — nothing to validate', { handlers: [], integrations: [] });
105
+ return;
106
+ }
107
+ const { handlers, apiDomains } = await readHandlers(appDir);
108
+ const names = Object.keys(handlers).sort();
109
+ const integrations = detectIntegrations(apiDomains);
110
+ const list = (xs: string[]) => (xs.length ? xs.join(', ') : 'none');
111
+ output(`[z2h-cli] ${names.length} handler(s) valid: ${list(names)}\n[z2h-cli] integrations: ${list(integrations)}`, {
112
+ handlers: names,
113
+ integrations,
114
+ });
115
+ }
116
+
97
117
  export async function backendInvokeCommand(handlerName: string, opts: { input?: string } = {}): Promise<void> {
98
118
  const appName = currentAppName();
99
119
  let input: unknown = null;
package/src/constants.ts CHANGED
@@ -70,12 +70,11 @@ export const APP_NAME_PATTERN = /^[a-z][a-z0-9-]{1,49}$/;
70
70
 
71
71
  export const PREVIEW_APP_NAME_PREFIX = 'zth-preview-';
72
72
 
73
- // ─────────── Declared (OAuth-gated) backend integrations ─────
74
- // Closed set the CLI detects from handler sources and the broker validates on
75
- // register/update. Always-on channels (`db`, `llm`, `snowflake`) are not listed —
76
- // the runner builds those regardless. Shared via `@mondaydotcomorg/z2h-cli/constants`.
77
- export const DECLARED_INTEGRATIONS = ['monday'] as const;
78
- export type DeclaredIntegration = (typeof DECLARED_INTEGRATIONS)[number];
73
+ // ─────────── Backend channels — re-exported from shared-utils ─────
74
+ // Single source of truth lives in `@mondaydotcomorg/z2h-shared-utils` so the
75
+ // CLI detector and broker stay in lockstep. Kept here for existing
76
+ // `@mondaydotcomorg/z2h-cli/constants` consumers.
77
+ export { ALWAYS_ON_CHANNELS, DECLARED_INTEGRATIONS, type DeclaredIntegration } from '@mondaydotcomorg/z2h-shared-utils';
79
78
 
80
79
  // ─────────── Broker origin + npm registry (shared §3 contract) ─────
81
80
  // The bigbrain-zth broker fronts both the STS credential route and the npm
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@ import { generateZ2HTokenCommand } from './commands/generate-z2h-token';
17
17
  import { migrateCommand } from './commands/migrate';
18
18
  import { tagCommand } from './commands/tag';
19
19
  import { editCommand } from './commands/edit';
20
- import { backendInvokeCommand } from './commands/backend';
20
+ import { backendInvokeCommand, backendValidateCommand } from './commands/backend';
21
21
  import { doctorCommand } from './commands/doctor';
22
22
  import { errorLog, fail, getOutputMode, setOutputMode } from './util/logger';
23
23
  import { runCommand } from './util/run-command';
@@ -267,7 +267,14 @@ async function main(): Promise<void> {
267
267
 
268
268
  const backend = program
269
269
  .command('backend')
270
- .description("Inspect the app's backend: invoke a deployed handler by name");
270
+ .description("Inspect the app's backend: validate handler sources or invoke a deployed handler by name");
271
+
272
+ backend
273
+ .command('validate')
274
+ .description(
275
+ 'Validate backend/handlers/*.js exactly as deploy would (syntax, isolate rules, integrations) without uploading'
276
+ )
277
+ .action(runCommand({ data: cwdApp }, () => backendValidateCommand()));
271
278
 
272
279
  backend
273
280
  .command('invoke <handlerName>')