@mondaydotcomorg/z2h-cli 0.30.0 → 0.32.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 (175) hide show
  1. package/dist/backend/handlers.d.ts +3 -0
  2. package/dist/backend/handlers.d.ts.map +1 -1
  3. package/dist/backend/handlers.js +7 -4
  4. package/dist/backend/parser/__tests__/ast.test.d.ts.map +1 -0
  5. package/dist/backend/parser/__tests__/parse.test.d.ts.map +1 -0
  6. package/dist/backend/parser/api-signatures.d.ts +4 -0
  7. package/dist/backend/parser/api-signatures.d.ts.map +1 -0
  8. package/dist/backend/parser/api-signatures.js +31 -0
  9. package/dist/backend/parser/api-usage.d.ts +5 -0
  10. package/dist/backend/parser/api-usage.d.ts.map +1 -0
  11. package/dist/backend/parser/api-usage.js +61 -0
  12. package/dist/backend/parser/bindings.d.ts +5 -0
  13. package/dist/backend/parser/bindings.d.ts.map +1 -0
  14. package/dist/backend/parser/bindings.js +84 -0
  15. package/dist/backend/parser/handler-declaration.d.ts.map +1 -0
  16. package/dist/backend/parser/member-path.d.ts +3 -0
  17. package/dist/backend/parser/member-path.d.ts.map +1 -0
  18. package/dist/backend/parser/member-path.js +31 -0
  19. package/dist/backend/parser/messages.d.ts.map +1 -0
  20. package/dist/backend/parser/parse.d.ts.map +1 -0
  21. package/dist/backend/{parse.js → parser/parse.js} +9 -9
  22. package/dist/backend/parser/static-value.d.ts +6 -0
  23. package/dist/backend/parser/static-value.d.ts.map +1 -0
  24. package/dist/backend/parser/static-value.js +117 -0
  25. package/dist/backend/parser/types.d.ts +53 -0
  26. package/dist/backend/parser/types.d.ts.map +1 -0
  27. package/dist/backend/parser/types.js +1 -0
  28. package/dist/backend/parser/utils.d.ts +19 -0
  29. package/dist/backend/parser/utils.d.ts.map +1 -0
  30. package/dist/backend/parser/utils.js +97 -0
  31. package/dist/backend/validate.d.ts +3 -3
  32. package/dist/backend/validate.d.ts.map +1 -1
  33. package/dist/backend/validate.js +18 -18
  34. package/dist/commands/__tests__/llm-migration.test.d.ts +2 -0
  35. package/dist/commands/__tests__/llm-migration.test.d.ts.map +1 -0
  36. package/dist/commands/backend.d.ts +2 -13
  37. package/dist/commands/backend.d.ts.map +1 -1
  38. package/dist/commands/backend.js +30 -21
  39. package/dist/commands/create.d.ts.map +1 -1
  40. package/dist/commands/create.js +1 -0
  41. package/dist/commands/deploy.d.ts.map +1 -1
  42. package/dist/commands/deploy.js +2 -8
  43. package/dist/commands/llm-migration.d.ts +6 -0
  44. package/dist/commands/llm-migration.d.ts.map +1 -0
  45. package/dist/commands/llm-migration.js +33 -0
  46. package/dist/commands/migrate.d.ts.map +1 -1
  47. package/dist/commands/migrate.js +6 -13
  48. package/dist/esm/backend/handlers.d.ts +3 -0
  49. package/dist/esm/backend/handlers.d.ts.map +1 -1
  50. package/dist/esm/backend/handlers.mjs +7 -4
  51. package/dist/esm/backend/parser/__tests__/ast.test.d.ts.map +1 -0
  52. package/dist/esm/backend/parser/__tests__/parse.test.d.ts.map +1 -0
  53. package/dist/esm/backend/parser/api-signatures.d.ts +4 -0
  54. package/dist/esm/backend/parser/api-signatures.d.ts.map +1 -0
  55. package/dist/esm/backend/parser/api-signatures.mjs +24 -0
  56. package/dist/esm/backend/parser/api-usage.d.ts +5 -0
  57. package/dist/esm/backend/parser/api-usage.d.ts.map +1 -0
  58. package/dist/esm/backend/parser/api-usage.mjs +58 -0
  59. package/dist/esm/backend/parser/bindings.d.ts +5 -0
  60. package/dist/esm/backend/parser/bindings.d.ts.map +1 -0
  61. package/dist/esm/backend/parser/bindings.mjs +81 -0
  62. package/dist/esm/backend/parser/handler-declaration.d.ts.map +1 -0
  63. package/dist/esm/backend/parser/member-path.d.ts +3 -0
  64. package/dist/esm/backend/parser/member-path.d.ts.map +1 -0
  65. package/dist/esm/backend/parser/member-path.mjs +29 -0
  66. package/dist/esm/backend/parser/messages.d.ts.map +1 -0
  67. package/dist/esm/backend/parser/parse.d.ts.map +1 -0
  68. package/dist/esm/backend/{parse.mjs → parser/parse.mjs} +1 -1
  69. package/dist/esm/backend/parser/static-value.d.ts +6 -0
  70. package/dist/esm/backend/parser/static-value.d.ts.map +1 -0
  71. package/dist/esm/backend/parser/static-value.mjs +114 -0
  72. package/dist/esm/backend/parser/types.d.ts +53 -0
  73. package/dist/esm/backend/parser/types.d.ts.map +1 -0
  74. package/dist/esm/backend/parser/types.mjs +1 -0
  75. package/dist/esm/backend/parser/utils.d.ts +19 -0
  76. package/dist/esm/backend/parser/utils.d.ts.map +1 -0
  77. package/dist/esm/backend/parser/utils.mjs +88 -0
  78. package/dist/esm/backend/validate.d.ts +3 -3
  79. package/dist/esm/backend/validate.d.ts.map +1 -1
  80. package/dist/esm/backend/validate.mjs +8 -8
  81. package/dist/esm/commands/__tests__/llm-migration.test.d.ts +2 -0
  82. package/dist/esm/commands/__tests__/llm-migration.test.d.ts.map +1 -0
  83. package/dist/esm/commands/backend.d.ts +2 -13
  84. package/dist/esm/commands/backend.d.ts.map +1 -1
  85. package/dist/esm/commands/backend.mjs +31 -22
  86. package/dist/esm/commands/create.d.ts.map +1 -1
  87. package/dist/esm/commands/create.mjs +1 -0
  88. package/dist/esm/commands/deploy.d.ts.map +1 -1
  89. package/dist/esm/commands/deploy.mjs +3 -8
  90. package/dist/esm/commands/llm-migration.d.ts +6 -0
  91. package/dist/esm/commands/llm-migration.d.ts.map +1 -0
  92. package/dist/esm/commands/llm-migration.mjs +27 -0
  93. package/dist/esm/commands/migrate.d.ts.map +1 -1
  94. package/dist/esm/commands/migrate.mjs +6 -12
  95. package/dist/esm/index.mjs +6 -0
  96. package/dist/esm/templates/app/usage.mjs +5 -0
  97. package/dist/esm/util/machine-identity.d.ts +2 -3
  98. package/dist/esm/util/machine-identity.d.ts.map +1 -1
  99. package/dist/esm/util/machine-identity.mjs +7 -4
  100. package/dist/esm/util/migrations-json.d.ts +28 -0
  101. package/dist/esm/util/migrations-json.d.ts.map +1 -0
  102. package/dist/esm/util/migrations-json.mjs +17 -0
  103. package/dist/index.js +6 -0
  104. package/dist/templates/app/usage.js +7 -0
  105. package/dist/util/machine-identity.d.ts +2 -3
  106. package/dist/util/machine-identity.d.ts.map +1 -1
  107. package/dist/util/machine-identity.js +11 -3
  108. package/dist/util/migrations-json.d.ts +28 -0
  109. package/dist/util/migrations-json.d.ts.map +1 -0
  110. package/dist/util/migrations-json.js +25 -0
  111. package/handler-api/signatures.json +26 -0
  112. package/migrations.json +6 -0
  113. package/package.json +1 -1
  114. package/src/backend/__tests__/handlers.test.ts +13 -2
  115. package/src/backend/__tests__/validate.test.ts +54 -0
  116. package/src/backend/handlers.ts +10 -5
  117. package/src/backend/parser/__tests__/ast.test.ts +252 -0
  118. package/src/backend/{__tests__ → parser/__tests__}/parse.test.ts +2 -2
  119. package/src/backend/parser/api-signatures.ts +23 -0
  120. package/src/backend/parser/api-usage.ts +73 -0
  121. package/src/backend/parser/bindings.ts +87 -0
  122. package/src/backend/parser/member-path.ts +31 -0
  123. package/src/backend/{parse.ts → parser/parse.ts} +1 -1
  124. package/src/backend/parser/static-value.ts +145 -0
  125. package/src/backend/parser/types.ts +66 -0
  126. package/src/backend/parser/utils.ts +106 -0
  127. package/src/backend/validate.ts +12 -8
  128. package/src/commands/__tests__/backend-deploy.test.ts +70 -5
  129. package/src/commands/__tests__/backend-validate.test.ts +11 -1
  130. package/src/commands/__tests__/create.test.ts +1 -0
  131. package/src/commands/__tests__/llm-migration.test.ts +98 -0
  132. package/src/commands/backend.ts +31 -20
  133. package/src/commands/create.ts +1 -0
  134. package/src/commands/deploy.ts +3 -9
  135. package/src/commands/llm-migration.ts +33 -0
  136. package/src/commands/migrate.ts +7 -25
  137. package/src/index.ts +7 -0
  138. package/src/migrations-llm/snowflake-service-to-backend-runner.md +168 -0
  139. package/src/templates/app/usage.json +1 -0
  140. package/src/util/machine-identity.ts +7 -3
  141. package/src/util/migrations-json.ts +45 -0
  142. package/dist/backend/__tests__/ast.test.d.ts.map +0 -1
  143. package/dist/backend/__tests__/parse.test.d.ts.map +0 -1
  144. package/dist/backend/ast.d.ts +0 -39
  145. package/dist/backend/ast.d.ts.map +0 -1
  146. package/dist/backend/ast.js +0 -149
  147. package/dist/backend/handler-declaration.d.ts.map +0 -1
  148. package/dist/backend/messages.d.ts.map +0 -1
  149. package/dist/backend/parse.d.ts.map +0 -1
  150. package/dist/esm/backend/__tests__/ast.test.d.ts.map +0 -1
  151. package/dist/esm/backend/__tests__/parse.test.d.ts.map +0 -1
  152. package/dist/esm/backend/ast.d.ts +0 -39
  153. package/dist/esm/backend/ast.d.ts.map +0 -1
  154. package/dist/esm/backend/ast.mjs +0 -144
  155. package/dist/esm/backend/handler-declaration.d.ts.map +0 -1
  156. package/dist/esm/backend/messages.d.ts.map +0 -1
  157. package/dist/esm/backend/parse.d.ts.map +0 -1
  158. package/src/backend/__tests__/ast.test.ts +0 -53
  159. package/src/backend/ast.ts +0 -201
  160. /package/dist/backend/{__tests__ → parser/__tests__}/ast.test.d.ts +0 -0
  161. /package/dist/backend/{__tests__ → parser/__tests__}/parse.test.d.ts +0 -0
  162. /package/dist/backend/{handler-declaration.d.ts → parser/handler-declaration.d.ts} +0 -0
  163. /package/dist/backend/{handler-declaration.js → parser/handler-declaration.js} +0 -0
  164. /package/dist/backend/{messages.d.ts → parser/messages.d.ts} +0 -0
  165. /package/dist/backend/{messages.js → parser/messages.js} +0 -0
  166. /package/dist/backend/{parse.d.ts → parser/parse.d.ts} +0 -0
  167. /package/dist/esm/backend/{__tests__ → parser/__tests__}/ast.test.d.ts +0 -0
  168. /package/dist/esm/backend/{__tests__ → parser/__tests__}/parse.test.d.ts +0 -0
  169. /package/dist/esm/backend/{handler-declaration.d.ts → parser/handler-declaration.d.ts} +0 -0
  170. /package/dist/esm/backend/{handler-declaration.mjs → parser/handler-declaration.mjs} +0 -0
  171. /package/dist/esm/backend/{messages.d.ts → parser/messages.d.ts} +0 -0
  172. /package/dist/esm/backend/{messages.mjs → parser/messages.mjs} +0 -0
  173. /package/dist/esm/backend/{parse.d.ts → parser/parse.d.ts} +0 -0
  174. /package/src/backend/{handler-declaration.ts → parser/handler-declaration.ts} +0 -0
  175. /package/src/backend/{messages.ts → parser/messages.ts} +0 -0
@@ -0,0 +1,145 @@
1
+ import { displayText, propertyKey, resolveOrSourceText, sourceTextOf } from './utils';
2
+ import { declarationAt } from './bindings';
3
+ import type { AnyNode } from 'acorn';
4
+ import type { Bindings, JsonValue, Resolved } from './types';
5
+
6
+ type Inner = (child: AnyNode) => Resolved<JsonValue> | undefined;
7
+
8
+ function resolveLiteral(node: AnyNode & { type: 'Literal' }): Resolved<JsonValue> | undefined {
9
+ return typeof node.value === 'string' ||
10
+ typeof node.value === 'number' ||
11
+ typeof node.value === 'boolean' ||
12
+ node.value === null
13
+ ? { value: node.value }
14
+ : undefined;
15
+ }
16
+
17
+ /** Template holes only interpolate scalars; a resolved object/array (e.g. `$or`) is shown unresolved instead of glued inline. */
18
+ function resolveTemplate(
19
+ node: AnyNode & { type: 'TemplateLiteral' },
20
+ source: string,
21
+ inner: Inner
22
+ ): Resolved<JsonValue> {
23
+ const text = node.quasis
24
+ .map((quasi, i) => {
25
+ const literalText = quasi.value.cooked ?? quasi.value.raw;
26
+ const hole = node.expressions[i];
27
+ if (!hole) {
28
+ return literalText;
29
+ }
30
+ const resolved = inner(hole);
31
+ const scalar =
32
+ resolved !== undefined &&
33
+ (typeof resolved.value === 'string' ||
34
+ typeof resolved.value === 'number' ||
35
+ typeof resolved.value === 'boolean' ||
36
+ resolved.value === null);
37
+ return literalText + (scalar ? displayText(resolved!.value) : `\${${sourceTextOf(hole, source)}}`);
38
+ })
39
+ .join('');
40
+ return { value: text };
41
+ }
42
+
43
+ /**
44
+ * Each element/property resolves independently, falling back to source text. A spread is kept
45
+ * visible under a `...`-prefixed key/value pair rather than silently dropped.
46
+ */
47
+ function resolveContainer(
48
+ node: AnyNode & { type: 'ArrayExpression' | 'ObjectExpression' },
49
+ source: string,
50
+ bindings: Bindings,
51
+ inner: Inner
52
+ ): Resolved<JsonValue> {
53
+ const fallbackText = (n: AnyNode) => bestSourceText(n, source, bindings);
54
+ if (node.type === 'ArrayExpression') {
55
+ return {
56
+ value: node.elements.map(element => (element ? resolveOrSourceText(element, source, inner, fallbackText) : null)),
57
+ };
58
+ }
59
+ const result: { [key: string]: JsonValue } = {};
60
+ for (const prop of node.properties) {
61
+ if (prop.type === 'SpreadElement') {
62
+ const spreadText = sourceTextOf(prop.argument, source);
63
+ result[`...${spreadText}`] = spreadText;
64
+ continue;
65
+ }
66
+ if (prop.type !== 'Property') {
67
+ continue;
68
+ }
69
+ const key = prop.computed
70
+ ? sourceTextOf(prop.key, source)
71
+ : propertyKey(prop.key, false) ?? sourceTextOf(prop.key, source);
72
+ result[key] = resolveOrSourceText(prop.value, source, inner, fallbackText);
73
+ }
74
+ return { value: result };
75
+ }
76
+
77
+ function joinBranches(
78
+ left: Resolved<JsonValue> | undefined,
79
+ right: Resolved<JsonValue> | undefined
80
+ ): Resolved<JsonValue> | undefined {
81
+ return left !== undefined && right !== undefined ? { value: { $or: [left.value, right.value] } } : undefined;
82
+ }
83
+
84
+ function resolveIdentifier(
85
+ node: AnyNode & { type: 'Identifier' },
86
+ bindings: Bindings,
87
+ source: string,
88
+ resolving: ReadonlySet<string>
89
+ ): Resolved<JsonValue> | undefined {
90
+ if (resolving.has(node.name)) {
91
+ return undefined;
92
+ }
93
+ const decl = declarationAt(bindings, node.name, node.start);
94
+ if (!decl || decl.kind !== 'const' || !decl.init || decl.path?.length !== 0) {
95
+ return undefined;
96
+ }
97
+ return staticValue(decl.init, bindings, source, new Set(resolving).add(node.name));
98
+ }
99
+
100
+ /** Source text for a node that failed to resolve — the const declaration's init when the node names one, else the node itself. */
101
+ export function bestSourceText(node: AnyNode, source: string, bindings: Bindings): string {
102
+ if (node.type === 'Identifier') {
103
+ const decl = declarationAt(bindings, node.name, node.start);
104
+ if (decl && decl.kind === 'const' && decl.init && decl.path?.length === 0) {
105
+ return sourceTextOf(decl.init, source);
106
+ }
107
+ }
108
+ return sourceTextOf(node, source);
109
+ }
110
+
111
+ export function staticValue(
112
+ node: AnyNode,
113
+ bindings: Bindings,
114
+ source: string,
115
+ resolving: ReadonlySet<string>
116
+ ): Resolved<JsonValue> | undefined {
117
+ const inner: Inner = child => staticValue(child, bindings, source, resolving);
118
+ switch (node.type) {
119
+ case 'Literal':
120
+ return resolveLiteral(node);
121
+ case 'TemplateLiteral':
122
+ return resolveTemplate(node, source, inner);
123
+ case 'ArrayExpression':
124
+ case 'ObjectExpression':
125
+ return resolveContainer(node, source, bindings, inner);
126
+ case 'BinaryExpression': {
127
+ if (node.operator !== '+') {
128
+ return undefined;
129
+ }
130
+ const left = inner(node.left);
131
+ const right = inner(node.right);
132
+ return typeof left?.value === 'string' && typeof right?.value === 'string'
133
+ ? { value: left.value + right.value }
134
+ : undefined;
135
+ }
136
+ case 'ConditionalExpression':
137
+ return joinBranches(inner(node.consequent), inner(node.alternate));
138
+ case 'LogicalExpression':
139
+ return joinBranches(inner(node.left), inner(node.right));
140
+ case 'Identifier':
141
+ return resolveIdentifier(node, bindings, source, resolving);
142
+ default:
143
+ return undefined;
144
+ }
145
+ }
@@ -0,0 +1,66 @@
1
+ import type { AnyNode, Expression, Node, VariableDeclaration } from 'acorn';
2
+
3
+ /** `"<domain>.<method>"` → parameter names by position, as documented for handler authors. */
4
+ export type ApiSignatures = Record<string, readonly string[]>;
5
+
6
+ // ─────────── Bindings ───────────
7
+
8
+ export interface Declaration {
9
+ kind: VariableDeclaration['kind'] | 'param';
10
+ init: Expression | null | undefined;
11
+ /**
12
+ * Property path from `init` to this binding: `[]` for `const x = init`,
13
+ * `['a', 'b']` for `const { a: { b: x } } = init`, `undefined` when the value
14
+ * is not a plain property read (array/rest/default patterns).
15
+ */
16
+ path: string[] | undefined;
17
+ scope: Node;
18
+ }
19
+
20
+ export interface Bindings {
21
+ declarations: Map<string, Declaration[]>;
22
+ /** Names reassigned or updated after declaration — never followed as a stable alias. */
23
+ unpinnable: Set<string>;
24
+ }
25
+
26
+ export interface PatternBinding {
27
+ name: string;
28
+ path: string[] | undefined;
29
+ }
30
+
31
+ // ─────────── Static values ───────────
32
+
33
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
34
+
35
+ /** Wraps a resolved value so "resolved to null" is distinguishable from "unresolved" (`undefined`). */
36
+ export type Resolved<T> = { readonly value: T };
37
+
38
+ // ─────────── Member paths ───────────
39
+
40
+ export interface MemberChain {
41
+ root: string;
42
+ parts: string[];
43
+ }
44
+
45
+ export interface PathResolver {
46
+ /**
47
+ * Dotted path of a member chain, following `const` aliases back to their
48
+ * source: with `const sf = ctx.api.v1.snowflake`, `sf.query` resolves to
49
+ * `ctx.api.v1.snowflake.query`. Destructuring counts too
50
+ * (`const { snowflake: sf } = ctx.api.v1`). `undefined` if any link is dynamic.
51
+ *
52
+ * Only single, never-reassigned, never-shadowed `const` bindings are followed —
53
+ * anything else is left as written. The handler's first parameter is treated as
54
+ * `ctx` even when renamed (`handler(context)` → `context.api.v1.db` resolves as
55
+ * `ctx.api.v1.db`).
56
+ */
57
+ pathOf(node: AnyNode): string | undefined;
58
+ }
59
+
60
+ // ─────────── API usage ───────────
61
+
62
+ /** Undocumented arguments or methods fall back to `arg<index>` keys. */
63
+ export type ApiCall = Record<string, JsonValue>;
64
+
65
+ /** `domain -> method -> calls`, later written into `usage.json` by deploy. */
66
+ export type ApiUsage = Record<string, Record<string, ApiCall[]>>;
@@ -0,0 +1,106 @@
1
+ import type { AnyNode, Node, Pattern } from 'acorn';
2
+ import type { JsonValue, MemberChain, PatternBinding, Resolved } from './types';
3
+
4
+ // ─────────── Source text ───────────
5
+
6
+ export const sourceTextOf = (node: AnyNode, source: string): string => source.slice(node.start, node.end);
7
+
8
+ export function displayText(value: JsonValue): string {
9
+ return typeof value === 'string' ? value : JSON.stringify(value);
10
+ }
11
+
12
+ export function resolveOrSourceText(
13
+ node: AnyNode,
14
+ source: string,
15
+ resolve: (node: AnyNode) => Resolved<JsonValue> | undefined,
16
+ fallbackText: (node: AnyNode) => string = n => sourceTextOf(n, source)
17
+ ): JsonValue {
18
+ const resolved = resolve(node);
19
+ return resolved !== undefined ? resolved.value : fallbackText(node);
20
+ }
21
+
22
+ // ─────────── Keys and member chains ───────────
23
+
24
+ /** Static property key of `obj.key` / `obj['key']` / `{ key: … }`; `undefined` when computed from a non-literal. */
25
+ export function propertyKey(key: AnyNode, computed: boolean): string | undefined {
26
+ if (key.type === 'Literal' && typeof key.value === 'string') {
27
+ return key.value;
28
+ }
29
+ if (!computed && key.type === 'Identifier') {
30
+ return key.name;
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ /** `a.b.c` / `a['b'].c` → root `a`, parts `[b, c]`; `undefined` if any link is dynamic or the root is not an identifier. */
36
+ export function memberChain(node: AnyNode): MemberChain | undefined {
37
+ if (node.type === 'ChainExpression') {
38
+ return memberChain(node.expression);
39
+ }
40
+ const parts: string[] = [];
41
+ let cur: AnyNode = node;
42
+ while (cur.type === 'MemberExpression') {
43
+ const key = propertyKey(cur.property, cur.computed);
44
+ if (key === undefined) {
45
+ return undefined;
46
+ }
47
+ parts.unshift(key);
48
+ cur = cur.object;
49
+ }
50
+ return cur.type === 'Identifier' ? { root: cur.name, parts } : undefined;
51
+ }
52
+
53
+ // ─────────── Binding patterns ───────────
54
+
55
+ /**
56
+ * Every identifier a binding pattern introduces, with its property path from the
57
+ * pattern's value. `path` is deliberately not defaulted: callers pass `[]` for
58
+ * the root and `undefined` once the pattern stops being a plain property read.
59
+ */
60
+ export function patternBindings(pattern: Pattern, path: string[] | undefined): PatternBinding[] {
61
+ switch (pattern.type) {
62
+ case 'Identifier':
63
+ return [{ name: pattern.name, path }];
64
+ case 'ObjectPattern':
65
+ return pattern.properties.flatMap(p => {
66
+ if (p.type === 'RestElement') {
67
+ return patternBindings(p.argument, undefined);
68
+ }
69
+ const key = propertyKey(p.key, p.computed);
70
+ return patternBindings(p.value, path && key !== undefined ? [...path, key] : undefined);
71
+ });
72
+ case 'ArrayPattern':
73
+ return pattern.elements.flatMap(e => (e ? patternBindings(e, undefined) : []));
74
+ case 'AssignmentPattern':
75
+ return patternBindings(pattern.left, undefined);
76
+ case 'RestElement':
77
+ return patternBindings(pattern.argument, undefined);
78
+ default:
79
+ // MemberExpression targets (`obj.x = …`) bind no names.
80
+ return [];
81
+ }
82
+ }
83
+
84
+ export const patternNames = (pattern: Pattern): string[] => patternBindings(pattern, []).map(b => b.name);
85
+
86
+ // ─────────── Scopes ───────────
87
+
88
+ const SCOPE_TYPES: ReadonlySet<string> = new Set([
89
+ 'Program',
90
+ 'BlockStatement',
91
+ 'StaticBlock',
92
+ 'SwitchStatement',
93
+ 'ForStatement',
94
+ 'ForInStatement',
95
+ 'ForOfStatement',
96
+ ]);
97
+
98
+ /** Nearest scope enclosing the last node of `ancestors` (which `fullAncestor` includes in the list). */
99
+ export function enclosingScope(ancestors: AnyNode[]): Node {
100
+ for (let i = ancestors.length - 2; i > 0; i--) {
101
+ if (SCOPE_TYPES.has(ancestors[i].type)) {
102
+ return ancestors[i];
103
+ }
104
+ }
105
+ return ancestors[0];
106
+ }
@@ -1,16 +1,22 @@
1
1
  import { simple as walkSimple } from 'acorn-walk';
2
2
  import { ALWAYS_ON_CHANNELS, DECLARED_INTEGRATIONS } from '@mondaydotcomorg/z2h-shared-utils';
3
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';
4
+ import { collectApiDomains, collectApiUsages } from './parser/api-usage';
5
+ import { collectBindings } from './parser/bindings';
6
+ import { findHandlerDeclaration, handlerContextParamName, NEVER_A_FUNCTION } from './parser/handler-declaration';
7
+ import { createPathResolver } from './parser/member-path';
8
+ import { messages } from './parser/messages';
9
+ import { parseOrThrow } from './parser/parse';
10
+ import type { ApiUsage } from './parser/types';
11
+
12
+ export type { ApiCall, ApiUsage } from './parser/types';
8
13
 
9
14
  const KNOWN_API_DOMAINS: readonly string[] = [...ALWAYS_ON_CHANNELS, ...DECLARED_INTEGRATIONS];
10
15
 
11
16
  export interface HandlerValidation {
12
17
  /** Every `ctx.api.v1.<domain>` the handler touches (known or not), sorted and unique. */
13
18
  apiDomains: string[];
19
+ apiUsages: ApiUsage;
14
20
  }
15
21
 
16
22
  /**
@@ -23,9 +29,6 @@ export interface HandlerValidation {
23
29
  * Unknown `ctx.api.v1.<domain>` usage is a warning, not a failure: this pass is
24
30
  * build-time DX feedback for agent-generated code, never a security boundary —
25
31
  * the isolate has no ambient capabilities regardless of what a handler contains.
26
- *
27
- * Returns the `ctx.api.v1.*` domains the handler touches so callers (integration
28
- * detection) can reuse the parse instead of re-scanning the source.
29
32
  */
30
33
  export function validateHandlerSource(file: string, source: string): HandlerValidation {
31
34
  const program = parseOrThrow(file, source);
@@ -53,6 +56,7 @@ export function validateHandlerSource(file: string, source: string): HandlerVali
53
56
 
54
57
  const paths = createPathResolver(bindings, handlerContextParamName(declaration));
55
58
  const apiDomains = collectApiDomains(program, paths);
59
+ const apiUsages = collectApiUsages(program, bindings, paths, source);
56
60
 
57
61
  for (const domain of apiDomains) {
58
62
  if (!KNOWN_API_DOMAINS.includes(domain)) {
@@ -60,5 +64,5 @@ export function validateHandlerSource(file: string, source: string): HandlerVali
60
64
  }
61
65
  }
62
66
 
63
- return { apiDomains: [...apiDomains].sort() };
67
+ return { apiDomains: [...apiDomains].sort(), apiUsages };
64
68
  }
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
- import { mkdtemp, rm, ensureDir, writeFile } from 'fs-extra';
3
+ import { mkdtemp, rm, ensureDir, writeFile, writeJson, readJson, pathExists } from 'fs-extra';
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
6
  import { deployBackend, hasHandlers } from '../backend';
@@ -23,7 +23,11 @@ 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' }, apiDomains: [] });
26
+ readHandlersMock.mockResolvedValue({
27
+ handlers: { call_snowflake: '// code' },
28
+ apiDomains: [],
29
+ apiUsages: { call_snowflake: {} },
30
+ });
27
31
  });
28
32
 
29
33
  describe('hasHandlers', () => {
@@ -60,7 +64,7 @@ describe('deployBackend (live)', () => {
60
64
  });
61
65
 
62
66
  it('skips upload when the handlers directory holds nothing', async () => {
63
- readHandlersMock.mockResolvedValue({ handlers: {}, apiDomains: [] });
67
+ readHandlersMock.mockResolvedValue({ handlers: {}, apiDomains: [], apiUsages: {} });
64
68
 
65
69
  await expect(
66
70
  deployBackend({
@@ -79,8 +83,9 @@ describe('deployBackend (live)', () => {
79
83
  await ensureDir(path.join(tmp, 'backend', 'handlers'));
80
84
  await writeFile(path.join(tmp, 'backend', 'handlers', 'call_snowflake.ts'), '// placeholder');
81
85
  readHandlersMock.mockResolvedValue({
82
- handlers: { call_snowflake: 'async function handler(ctx) { return ctx.api.v1.snowflake.runQuery("x"); }' },
86
+ handlers: { call_snowflake: 'async function handler(ctx) { return ctx.api.v1.snowflake.query("x"); }' },
83
87
  apiDomains: ['snowflake'],
88
+ apiUsages: { call_snowflake: { snowflake: { query: [{ sql: 'x' }] } } },
84
89
  });
85
90
 
86
91
  // snowflake is always-on — not a declared integration
@@ -97,6 +102,63 @@ describe('deployBackend (live)', () => {
97
102
  expect(mockSend).toHaveBeenCalled();
98
103
  const keys: string[] = mockSend.mock.calls.map((c: unknown[]) => (c[0] as { input: { Key: string } }).input.Key);
99
104
  expect(keys.some(k => k.includes('/backend-1/'))).toBe(true);
105
+ // usage.json stays local-only — the runner's S3 discovery still comes from meta.json.
106
+ expect(keys.some(k => k.endsWith('/usage.json'))).toBe(false);
107
+ expect(keys.some(k => k.endsWith('/meta.json'))).toBe(true);
108
+
109
+ const metaCall = mockSend.mock.calls.find((c: unknown[]) =>
110
+ (c[0] as { input: { Key: string } }).input.Key.endsWith('/meta.json')
111
+ );
112
+ const meta = JSON.parse((metaCall![0] as { input: { Body: string } }).input.Body);
113
+ expect(meta).toEqual({ version: 1, deployedBy: 'test-user', handlers: ['call_snowflake'] });
114
+
115
+ await expect(readJson(path.join(tmp, 'usage.json'))).resolves.toEqual({
116
+ appName: 'mf-my-app',
117
+ deployedBy: 'test-user',
118
+ apiUsages: { call_snowflake: { snowflake: { query: [{ sql: 'x' }] } } },
119
+ });
120
+ });
121
+
122
+ it('writes usage.json locally even for apps that had none yet', async () => {
123
+ await ensureDir(path.join(tmp, 'backend', 'handlers'));
124
+ await expect(pathExists(path.join(tmp, 'usage.json'))).resolves.toBe(false);
125
+
126
+ await deployBackend({
127
+ appDir: tmp,
128
+ appName: 'mf-my-app',
129
+ mfKeyPrefix: 'mf-my-app/1',
130
+ version: 1,
131
+ deployedBy: 'test-user',
132
+ isPreview: false,
133
+ });
134
+
135
+ expect(mockSend).toHaveBeenCalled();
136
+ await expect(readJson(path.join(tmp, 'usage.json'))).resolves.toEqual({
137
+ appName: 'mf-my-app',
138
+ deployedBy: 'test-user',
139
+ apiUsages: { call_snowflake: {} },
140
+ });
141
+ });
142
+
143
+ it('merges the local usage.json instead of overwriting it, and writes the merged result back locally', async () => {
144
+ await ensureDir(path.join(tmp, 'backend', 'handlers'));
145
+ await writeJson(path.join(tmp, 'usage.json'), { someOtherFeatureField: 'keep-me' });
146
+
147
+ await deployBackend({
148
+ appDir: tmp,
149
+ appName: 'mf-my-app',
150
+ mfKeyPrefix: 'mf-my-app/1',
151
+ version: 1,
152
+ deployedBy: 'test-user',
153
+ isPreview: false,
154
+ });
155
+
156
+ await expect(readJson(path.join(tmp, 'usage.json'))).resolves.toEqual({
157
+ someOtherFeatureField: 'keep-me',
158
+ appName: 'mf-my-app',
159
+ deployedBy: 'test-user',
160
+ apiUsages: { call_snowflake: {} },
161
+ });
100
162
  });
101
163
 
102
164
  it('tags a handler upload failure as deploy_backend_error, not register_error', async () => {
@@ -130,7 +192,7 @@ describe('deployBackend (preview)', () => {
130
192
  });
131
193
 
132
194
  it('skips upload when the handlers directory holds nothing', async () => {
133
- readHandlersMock.mockResolvedValue({ handlers: {}, apiDomains: [] });
195
+ readHandlersMock.mockResolvedValue({ handlers: {}, apiDomains: [], apiUsages: {} });
134
196
 
135
197
  await expect(
136
198
  deployBackend({
@@ -151,6 +213,7 @@ describe('deployBackend (preview)', () => {
151
213
  readHandlersMock.mockResolvedValue({
152
214
  handlers: { call_snowflake: 'async function handler(ctx) { return ctx.api.v1.monday.query("x"); }' },
153
215
  apiDomains: ['monday'],
216
+ apiUsages: { call_snowflake: { monday: { query: [{ query: 'x' }] } } },
154
217
  });
155
218
 
156
219
  await expect(
@@ -166,5 +229,7 @@ describe('deployBackend (preview)', () => {
166
229
  expect(mockSend).toHaveBeenCalled();
167
230
  const keys: string[] = mockSend.mock.calls.map((c: unknown[]) => (c[0] as { input: { Key: string } }).input.Key);
168
231
  expect(keys.some(k => k.includes('/MS/'))).toBe(true);
232
+ expect(keys.some(k => k.endsWith('/usage.json'))).toBe(false);
233
+ expect(keys.some(k => k.endsWith('/meta.json'))).toBe(true);
169
234
  });
170
235
  });
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
- import { mkdtemp, rm, ensureDir, writeFile } from 'fs-extra';
3
+ import { mkdtemp, rm, ensureDir, writeFile, writeJson, readJson } from 'fs-extra';
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
6
  import { backendValidateCommand } from '../backend';
@@ -14,6 +14,9 @@ vi.mock('../../util/tracker', () => ({
14
14
  trackEvent: vi.fn().mockResolvedValue(undefined),
15
15
  trackDuration: vi.fn(),
16
16
  }));
17
+ vi.mock('../../util/machine-identity', () => ({
18
+ whoAmI: () => 'test-user',
19
+ }));
17
20
 
18
21
  const mockSend = vi.fn();
19
22
  vi.mock('../../util/s3/client', () => ({
@@ -44,6 +47,7 @@ describe('backendValidateCommand', () => {
44
47
  });
45
48
 
46
49
  it('reports valid handlers and detected integrations without uploading', async () => {
50
+ await writeJson(path.join(tmp, 'package.json'), { name: 'my-app' });
47
51
  const dir = path.join(tmp, 'backend', 'handlers');
48
52
  await ensureDir(dir);
49
53
  await writeFile(
@@ -58,6 +62,12 @@ describe('backendValidateCommand', () => {
58
62
  integrations: ['monday'],
59
63
  });
60
64
  expect(mockSend).not.toHaveBeenCalled();
65
+
66
+ await expect(readJson(path.join(tmp, 'usage.json'))).resolves.toEqual({
67
+ appName: 'mf-my-app',
68
+ deployedBy: 'test-user',
69
+ apiUsages: { list_items: { monday: { query: [{ query: 'query { me { id } }' }] } } },
70
+ });
61
71
  });
62
72
 
63
73
  it('throws the same validation error deploy would', async () => {
@@ -95,6 +95,7 @@ describe('create', () => {
95
95
  expect(await pathExists(path.join(appDir, '.gitignore'))).toBe(true);
96
96
  expect(await pathExists(path.join(appDir, '.claude', 'settings.json'))).toBe(true);
97
97
  expect(await pathExists(path.join(appDir, 'CLAUDE.md'))).toBe(true);
98
+ await expect(readJson(path.join(appDir, 'usage.json'))).resolves.toEqual({});
98
99
 
99
100
  const pkg = await readJson(path.join(appDir, 'package.json'));
100
101
  expect(pkg.name).toBe('demo-app');
@@ -0,0 +1,98 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { mkdtemp, rm, writeFile } from 'fs-extra';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { llmMigrationCommand } from '../llm-migration';
7
+ import { output } from '../../util/logger';
8
+ import { resolveMigrationsJson } from '../../util/migrations-json';
9
+
10
+ vi.mock('../../util/logger', () => ({
11
+ output: vi.fn(),
12
+ }));
13
+ vi.mock('../../util/migrations-json', () => ({
14
+ resolveMigrationsJson: vi.fn(),
15
+ LLM_MIGRATION_TYPE: 'llm',
16
+ }));
17
+
18
+ const outputMock = vi.mocked(output);
19
+ const resolveMock = vi.mocked(resolveMigrationsJson);
20
+
21
+ describe('llmMigrationCommand', () => {
22
+ let tmp: string;
23
+
24
+ beforeEach(async () => {
25
+ tmp = await mkdtemp(path.join(os.tmpdir(), 'z2h-llm-migration-'));
26
+ outputMock.mockClear();
27
+ resolveMock.mockReset();
28
+ });
29
+
30
+ afterEach(async () => {
31
+ await rm(tmp, { recursive: true, force: true });
32
+ });
33
+
34
+ it('throws when migrations.json is missing', async () => {
35
+ resolveMock.mockResolvedValue(null);
36
+
37
+ await expect(llmMigrationCommand({ migration: 'any' })).rejects.toThrow(/migrations.json not found/);
38
+ });
39
+
40
+ it('throws when there is no entry, or the entry is not an llm migration', async () => {
41
+ resolveMock.mockResolvedValue({
42
+ migrationsJson: { migrations: { 'deterministic-one': { version: '0.1.0', description: 'd', migrate: 'x.js' } } },
43
+ migrationsDir: tmp,
44
+ });
45
+
46
+ await expect(llmMigrationCommand({ migration: 'missing' })).rejects.toThrow(/no LLM migration named/);
47
+ await expect(llmMigrationCommand({ migration: 'deterministic-one' })).rejects.toThrow(/no LLM migration named/);
48
+ });
49
+
50
+ it('lists only llm migrations when --migration is omitted', async () => {
51
+ resolveMock.mockResolvedValue({
52
+ migrationsJson: {
53
+ migrations: {
54
+ 'deterministic-one': { version: '0.1.0', description: 'd', migrate: 'x.js' },
55
+ alpha: { type: 'llm', version: '0.30.0', description: 'first', guide: 'a.md' },
56
+ beta: { type: 'llm', version: '0.30.0', description: 'second', guide: 'b.md' },
57
+ },
58
+ },
59
+ migrationsDir: tmp,
60
+ });
61
+
62
+ await llmMigrationCommand({});
63
+
64
+ expect(outputMock).toHaveBeenCalledWith(expect.stringContaining('alpha\n first'), {
65
+ ok: true,
66
+ migrations: [
67
+ { name: 'alpha', description: 'first' },
68
+ { name: 'beta', description: 'second' },
69
+ ],
70
+ });
71
+ });
72
+
73
+ it('outputs the guide resolved relative to migrations.json', async () => {
74
+ await writeFile(path.join(tmp, 'guide.md'), 'guide contents');
75
+ resolveMock.mockResolvedValue({
76
+ migrationsJson: {
77
+ migrations: { on: { type: 'llm', version: '0.30.0', description: 'on', guide: 'guide.md' } },
78
+ },
79
+ migrationsDir: tmp,
80
+ });
81
+
82
+ await llmMigrationCommand({ migration: 'on' });
83
+
84
+ expect(outputMock).toHaveBeenCalledWith('guide contents', { ok: true, description: 'on', guide: 'guide contents' });
85
+ });
86
+
87
+ it('propagates a missing guide file', async () => {
88
+ resolveMock.mockResolvedValue({
89
+ migrationsJson: {
90
+ migrations: { broken: { type: 'llm', version: '0.30.0', description: 'broken', guide: 'nope.md' } },
91
+ },
92
+ migrationsDir: tmp,
93
+ });
94
+
95
+ await expect(llmMigrationCommand({ migration: 'broken' })).rejects.toThrow(/ENOENT/);
96
+ expect(outputMock).not.toHaveBeenCalled();
97
+ });
98
+ });