@oxygen-agent/cli 1.365.3 → 1.575.19

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 (99) hide show
  1. package/README.md +1 -1
  2. package/dist/column-run-notices.d.ts +11 -0
  3. package/dist/column-run-notices.js +37 -0
  4. package/dist/command-manifest.js +13 -8
  5. package/dist/help.js +78 -16
  6. package/dist/index.js +3873 -514
  7. package/dist/skills.js +106 -1
  8. package/node_modules/@oxygen/formula/dist/coerce.d.ts +8 -0
  9. package/node_modules/@oxygen/formula/dist/coerce.js +10 -0
  10. package/node_modules/@oxygen/formula/dist/evaluate.d.ts +31 -0
  11. package/node_modules/@oxygen/formula/dist/evaluate.js +248 -0
  12. package/node_modules/@oxygen/formula/dist/expression.d.ts +64 -0
  13. package/node_modules/@oxygen/formula/dist/expression.js +428 -0
  14. package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +71 -0
  15. package/node_modules/@oxygen/formula/dist/formula-functions.js +1100 -0
  16. package/node_modules/@oxygen/formula/dist/index.d.ts +17 -0
  17. package/node_modules/@oxygen/formula/dist/index.js +17 -0
  18. package/node_modules/@oxygen/formula/dist/value-normalizers.d.ts +30 -0
  19. package/node_modules/@oxygen/formula/dist/value-normalizers.js +80 -0
  20. package/node_modules/@oxygen/formula/package.json +26 -0
  21. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +30 -0
  22. package/node_modules/@oxygen/recipe-sdk/dist/index.js +2 -2
  23. package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +60 -0
  24. package/node_modules/@oxygen/shared/dist/billing-anchors.js +135 -0
  25. package/node_modules/@oxygen/shared/dist/billing.d.ts +101 -5
  26. package/node_modules/@oxygen/shared/dist/billing.js +192 -8
  27. package/node_modules/@oxygen/shared/dist/call-outcomes.d.ts +59 -0
  28. package/node_modules/@oxygen/shared/dist/call-outcomes.js +73 -0
  29. package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
  30. package/node_modules/@oxygen/shared/dist/credit-guidance.js +3 -1
  31. package/node_modules/@oxygen/shared/dist/crm-reply-events.d.ts +35 -0
  32. package/node_modules/@oxygen/shared/dist/crm-reply-events.js +31 -0
  33. package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.d.ts +50 -0
  34. package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.js +65 -0
  35. package/node_modules/@oxygen/shared/dist/directory.d.ts +1 -1
  36. package/node_modules/@oxygen/shared/dist/directory.js +1 -0
  37. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +17 -1
  38. package/node_modules/@oxygen/shared/dist/hosted-ai.js +52 -3
  39. package/node_modules/@oxygen/shared/dist/index.d.ts +11 -0
  40. package/node_modules/@oxygen/shared/dist/index.js +15 -0
  41. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +77 -0
  42. package/node_modules/@oxygen/shared/dist/langfuse.js +231 -0
  43. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +31 -0
  44. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +56 -0
  45. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +5 -4
  46. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +5 -4
  47. package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +22 -0
  48. package/node_modules/@oxygen/shared/dist/linkedin-url.js +7 -4
  49. package/node_modules/@oxygen/shared/dist/log.js +56 -4
  50. package/node_modules/@oxygen/shared/dist/microsoft-consent-url.d.ts +7 -0
  51. package/node_modules/@oxygen/shared/dist/microsoft-consent-url.js +29 -0
  52. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +31 -0
  53. package/node_modules/@oxygen/shared/dist/object-storage.js +61 -0
  54. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +636 -0
  55. package/node_modules/@oxygen/shared/dist/plan-limits.js +199 -0
  56. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +89 -23
  57. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +88 -24
  58. package/node_modules/@oxygen/shared/dist/sequence-crm-events.d.ts +291 -0
  59. package/node_modules/@oxygen/shared/dist/sequence-crm-events.js +224 -0
  60. package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +42 -1
  61. package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
  62. package/node_modules/@oxygen/shared/dist/sequences.d.ts +287 -24
  63. package/node_modules/@oxygen/shared/dist/sequences.js +940 -60
  64. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +70 -0
  65. package/node_modules/@oxygen/shared/dist/spend-safety.js +106 -0
  66. package/node_modules/@oxygen/shared/dist/tags.d.ts +90 -1
  67. package/node_modules/@oxygen/shared/dist/tags.js +126 -6
  68. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  69. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  70. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.d.ts +1 -1
  71. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +4 -0
  72. package/node_modules/@oxygen/shared/dist/workspace-agents.d.ts +8 -7
  73. package/node_modules/@oxygen/shared/dist/workspace-agents.js +34 -7
  74. package/node_modules/@oxygen/shared/package.json +95 -0
  75. package/node_modules/@oxygen/workflows/dist/event-dispatch.d.ts +126 -0
  76. package/node_modules/@oxygen/workflows/dist/event-dispatch.js +173 -0
  77. package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +78 -0
  78. package/node_modules/@oxygen/workflows/dist/graph/expression.js +700 -0
  79. package/node_modules/@oxygen/workflows/dist/graph/index.d.ts +20 -0
  80. package/node_modules/@oxygen/workflows/dist/graph/index.js +20 -0
  81. package/node_modules/@oxygen/workflows/dist/graph/lint.d.ts +4 -0
  82. package/node_modules/@oxygen/workflows/dist/graph/lint.js +812 -0
  83. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +501 -0
  84. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +200 -0
  85. package/node_modules/@oxygen/workflows/dist/graph/params.d.ts +86 -0
  86. package/node_modules/@oxygen/workflows/dist/graph/params.js +173 -0
  87. package/node_modules/@oxygen/workflows/dist/graph/remap.d.ts +48 -0
  88. package/node_modules/@oxygen/workflows/dist/graph/remap.js +213 -0
  89. package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +46 -0
  90. package/node_modules/@oxygen/workflows/dist/graph/topology.js +280 -0
  91. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +270 -0
  92. package/node_modules/@oxygen/workflows/dist/graph/types.js +93 -0
  93. package/node_modules/@oxygen/workflows/dist/index.d.ts +113 -1
  94. package/node_modules/@oxygen/workflows/dist/index.js +179 -13
  95. package/node_modules/@oxygen/workflows/dist/tool-effects.d.ts +1 -0
  96. package/node_modules/@oxygen/workflows/dist/tool-effects.js +19 -0
  97. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +135 -4
  98. package/node_modules/@oxygen/workflows/package.json +4 -0
  99. package/package.json +10 -5
@@ -0,0 +1,812 @@
1
+ // The oxygen-workflows-v2 linter — the v2 sibling of lintWorkflowManifest, with
2
+ // the same shape (`{ ok, issues[] }`), the same `$.`-rooted JSON paths, and the
3
+ // same snake_case issue codes wherever a v1 rule carries over.
4
+ //
5
+ // NOT BROWSER-SAFE, deliberately: it reuses v1's validatePureFunctionSource and
6
+ // validateTrigger from ../index.js, which pulls node:vm and node:crypto. This is
7
+ // why ./index.ts (the "@oxygen/workflows/graph" entry) does not re-export this
8
+ // module — the editor lints through the API, not in the bundle.
9
+ import { validateFormulaExpression } from "@oxygen/formula";
10
+ import { isWorkflowToolRejected, validateOptionalMaxCredits, validatePureFunctionSource, validateTrigger, } from "../index.js";
11
+ import { UNARY_COMPARE_OPS, WORKFLOW_COMPARE_OPS, compileConditionPattern, parseScopePath, SCOPE_PATH_ROOTS, } from "./expression.js";
12
+ import { missingToolParams, unknownToolParams } from "./params.js";
13
+ import { danglingEdges, findIllegalCycles, loopBodyNodeIds, reachableNodeIds } from "./topology.js";
14
+ import { MAX_WORKFLOW_LOOP_ITERATIONS, MAX_WORKFLOW_NODE_RETRY_ATTEMPTS, MAX_WORKFLOW_NODE_RETRY_WAIT_SECONDS, RESERVED_EDGE_HANDLES, RESERVED_NODE_IDS, WORKFLOW_GRAPH_COMPILER_VERSION, WORKFLOW_GRAPH_MANIFEST_VERSION, LOOP_BODY_HANDLE, LOOP_DONE_HANDLE, } from "./types.js";
15
+ const NODE_KINDS = new Set([
16
+ "trigger",
17
+ "tool",
18
+ "filter",
19
+ "switch",
20
+ "loop",
21
+ "merge",
22
+ "set",
23
+ "wait",
24
+ "approval",
25
+ "code",
26
+ "workflow",
27
+ ]);
28
+ const MERGE_STRATEGIES = new Set(["append", "first", "wait_all"]);
29
+ const STEP_EFFECTS = new Set(["none", "external_read", "external_write"]);
30
+ const STEP_MODES = new Set(["dry_run", "live", "smoke_test"]);
31
+ // A condition tree is authored in a UI; anything deeper than this is either a
32
+ // generator gone wrong or an attempt to make the linter recurse forever.
33
+ const MAX_CONDITION_DEPTH = 12;
34
+ function isRecord(value) {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+ function isNonEmptyString(value) {
38
+ return typeof value === "string" && value.trim().length > 0;
39
+ }
40
+ /** Array.isArray narrows `unknown` to `any[]`; this keeps the elements `unknown`. */
41
+ function asArray(value) {
42
+ return Array.isArray(value) ? value : null;
43
+ }
44
+ function isCompareOp(value) {
45
+ return typeof value === "string" && WORKFLOW_COMPARE_OPS.has(value);
46
+ }
47
+ function isUnaryCompareOp(value) {
48
+ return UNARY_COMPARE_OPS.has(value);
49
+ }
50
+ export function lintWorkflowGraphManifest(value, options = {}) {
51
+ const issues = [];
52
+ const add = (path, code, message) => issues.push({ path, code, message });
53
+ if (!isRecord(value)) {
54
+ add("$", "invalid_manifest", "Workflow graph manifest must be an object.");
55
+ return { ok: false, issues };
56
+ }
57
+ if (value.manifest_version !== WORKFLOW_GRAPH_MANIFEST_VERSION) {
58
+ add("$.manifest_version", "invalid_manifest_version", `Workflow graph manifest version must be ${WORKFLOW_GRAPH_MANIFEST_VERSION}.`);
59
+ }
60
+ if (value.compiler_version !== WORKFLOW_GRAPH_COMPILER_VERSION) {
61
+ add("$.compiler_version", "invalid_compiler_version", `Workflow graph compiler_version must be '${WORKFLOW_GRAPH_COMPILER_VERSION}'.`);
62
+ }
63
+ lintWorkflowMetadata(value.workflow, add);
64
+ if (value.trigger !== undefined)
65
+ validateTrigger(value.trigger, "$.trigger", add);
66
+ validateOptionalMaxCredits(value.max_credits, "$.max_credits", add);
67
+ if (!isNonEmptyString(value.source_hash)) {
68
+ add("$.source_hash", "missing_source_hash", "Manifest source_hash is required.");
69
+ }
70
+ const nodesSound = lintNodes(value.nodes, add, options);
71
+ const edgesSound = lintEdges(value.edges, value.nodes, add);
72
+ // Graph-level analysis dereferences every node and edge, so it only runs once
73
+ // the element shapes are known good. Reporting "unreachable" on a manifest
74
+ // whose nodes are half-formed would bury the real error under cascades.
75
+ if (nodesSound && edgesSound) {
76
+ lintGraphTopology(value, add);
77
+ }
78
+ return { ok: issues.length === 0, issues };
79
+ }
80
+ export function assertWorkflowGraphManifest(value, options = {}) {
81
+ const result = lintWorkflowGraphManifest(value, options);
82
+ if (result.ok)
83
+ return;
84
+ const first = result.issues[0];
85
+ throw new Error(first ? `${first.code}: ${first.message}` : "Invalid workflow graph manifest.");
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // Manifest metadata
89
+ // ---------------------------------------------------------------------------
90
+ function lintWorkflowMetadata(workflow, add) {
91
+ if (!isRecord(workflow)) {
92
+ add("$.workflow", "missing_workflow", "Workflow metadata is required.");
93
+ return;
94
+ }
95
+ if (!isNonEmptyString(workflow.id)) {
96
+ add("$.workflow.id", "invalid_workflow_id", "Workflow id is required.");
97
+ }
98
+ if (!isNonEmptyString(workflow.name)) {
99
+ add("$.workflow.name", "invalid_workflow_name", "Workflow name is required.");
100
+ }
101
+ if (workflow.status !== undefined
102
+ && workflow.status !== "active"
103
+ && workflow.status !== "disabled") {
104
+ add("$.workflow.status", "invalid_workflow_status", "Workflow status must be active or disabled.");
105
+ }
106
+ }
107
+ // ---------------------------------------------------------------------------
108
+ // Nodes
109
+ // ---------------------------------------------------------------------------
110
+ /** Returns true when every node is shaped well enough for topology analysis. */
111
+ function lintNodes(// skipcq: JS-R1005
112
+ value, add, options) {
113
+ const nodes = asArray(value);
114
+ if (!nodes || nodes.length === 0) {
115
+ add("$.nodes", "missing_nodes", "At least one workflow node is required.");
116
+ return false;
117
+ }
118
+ const seenIds = new Set();
119
+ let triggerCount = 0;
120
+ let sound = true;
121
+ nodes.forEach((node, index) => {
122
+ const path = `$.nodes.${index}`;
123
+ if (!isRecord(node)) {
124
+ add(path, "invalid_node", "Workflow node must be an object.");
125
+ sound = false;
126
+ return;
127
+ }
128
+ if (!lintNodeIdentity(node, path, seenIds, add))
129
+ sound = false;
130
+ if (node.kind === "trigger")
131
+ triggerCount += 1;
132
+ if (typeof node.kind !== "string" || !NODE_KINDS.has(node.kind)) {
133
+ add(`${path}.kind`, "invalid_node_kind", `Node kind must be one of ${[...NODE_KINDS].join(", ")}.`);
134
+ return;
135
+ }
136
+ lintNodeByKind(node, path, add, options);
137
+ });
138
+ if (triggerCount === 0) {
139
+ add("$.nodes", "missing_trigger_node", "A workflow graph requires exactly one trigger node.");
140
+ }
141
+ else if (triggerCount > 1) {
142
+ add("$.nodes", "multiple_trigger_nodes", `A workflow graph must declare exactly one trigger node (found ${triggerCount}).`);
143
+ }
144
+ return sound;
145
+ }
146
+ function lintNodeIdentity(node, path, seenIds, add) {
147
+ let sound = true;
148
+ if (!isNonEmptyString(node.id)) {
149
+ add(`${path}.id`, "invalid_node_id", "Node id is required.");
150
+ sound = false;
151
+ }
152
+ else if (RESERVED_NODE_IDS.has(node.id)) {
153
+ // Node ids become keys on the run scope's `steps` object. "__proto__" and
154
+ // friends hit JavaScript's own object internals, so the node's output would
155
+ // be silently dropped and unreadable downstream while the run still reports
156
+ // success. Reject at authoring time instead.
157
+ add(`${path}.id`, "reserved_node_id", `Node id '${node.id}' is reserved (it collides with a JavaScript object key and would be dropped from node outputs). Choose another id.`);
158
+ sound = false;
159
+ }
160
+ else if (seenIds.has(node.id)) {
161
+ add(`${path}.id`, "duplicate_node_id", `Node id '${node.id}' is duplicated.`);
162
+ }
163
+ else {
164
+ seenIds.add(node.id);
165
+ }
166
+ if (!isNonEmptyString(node.name)) {
167
+ add(`${path}.name`, "invalid_node_name", "Node name is required.");
168
+ }
169
+ if (!isRecord(node.ui)
170
+ || typeof node.ui.x !== "number"
171
+ || typeof node.ui.y !== "number"
172
+ || !Number.isFinite(node.ui.x)
173
+ || !Number.isFinite(node.ui.y)) {
174
+ add(`${path}.ui`, "invalid_node_ui", "Node ui must be { x: number, y: number }.");
175
+ }
176
+ lintNodeRetry(node, path, add);
177
+ return sound;
178
+ }
179
+ /**
180
+ * A node's own retry budget.
181
+ *
182
+ * Bounded on both axes because both are a run's wall-clock: attempts multiply a
183
+ * provider call and the wait parks the run. Note what is NOT checked here —
184
+ * whether this node's effect may be retried at all. That decision belongs to the
185
+ * worker's isRetryableWorkflowStepError, which the budget is consulted after; a
186
+ * lint rule refusing `retry` on a live external write would only be a second,
187
+ * drifting opinion about the same safety rule.
188
+ */
189
+ function lintNodeRetry(node, path, add) {
190
+ if (node.retry === undefined)
191
+ return;
192
+ if (!isRecord(node.retry)) {
193
+ add(`${path}.retry`, "invalid_node_retry", "Node retry must be { max_attempts: number, wait_seconds?: number }.");
194
+ return;
195
+ }
196
+ const { max_attempts: attempts, wait_seconds: wait } = node.retry;
197
+ if (!isPositiveInteger(attempts)) {
198
+ add(`${path}.retry.max_attempts`, "invalid_node_retry", "Retry max_attempts must be a positive integer.");
199
+ }
200
+ else if (attempts > MAX_WORKFLOW_NODE_RETRY_ATTEMPTS) {
201
+ add(`${path}.retry.max_attempts`, "invalid_node_retry", `Retry max_attempts cannot exceed ${MAX_WORKFLOW_NODE_RETRY_ATTEMPTS}.`);
202
+ }
203
+ if (wait === undefined)
204
+ return;
205
+ if (typeof wait !== "number" || !Number.isFinite(wait) || wait < 0) {
206
+ add(`${path}.retry.wait_seconds`, "invalid_node_retry", "Retry wait_seconds must be a non-negative number.");
207
+ }
208
+ else if (wait > MAX_WORKFLOW_NODE_RETRY_WAIT_SECONDS) {
209
+ add(`${path}.retry.wait_seconds`, "invalid_node_retry", `Retry wait_seconds cannot exceed ${MAX_WORKFLOW_NODE_RETRY_WAIT_SECONDS}.`);
210
+ }
211
+ }
212
+ function lintNodeByKind(// skipcq: JS-R1005
213
+ node, path, add, options) {
214
+ switch (node.kind) {
215
+ case "trigger":
216
+ return;
217
+ case "tool":
218
+ lintToolNode(node, path, add, options);
219
+ return;
220
+ case "filter":
221
+ validateCondition(node.when, `${path}.when`, add);
222
+ return;
223
+ case "switch":
224
+ lintSwitchNode(node, path, add);
225
+ return;
226
+ case "loop":
227
+ lintLoopNode(node, path, add);
228
+ return;
229
+ case "merge":
230
+ if (typeof node.strategy !== "string" || !MERGE_STRATEGIES.has(node.strategy)) {
231
+ add(`${path}.strategy`, "invalid_merge_strategy", `Merge strategy must be one of ${[...MERGE_STRATEGIES].join(", ")}.`);
232
+ }
233
+ return;
234
+ case "set":
235
+ lintSetNode(node, path, add);
236
+ return;
237
+ case "wait":
238
+ lintWaitNode(node, path, add);
239
+ return;
240
+ case "approval":
241
+ lintApprovalNode(node, path, add);
242
+ return;
243
+ case "code":
244
+ // v2 `code` nodes run through the same sandbox as v1 transform steps, so
245
+ // they clear the same purity scan; every issue it raises is re-labelled to
246
+ // the v2 code so callers match on one code for "this source is not safe".
247
+ if (typeof node.run_source !== "string") {
248
+ add(`${path}.run_source`, "unsafe_code_source", "Code node run_source is required.");
249
+ return;
250
+ }
251
+ validatePureFunctionSource(node.run_source, `${path}.run_source`, (issuePath, _code, message) => add(issuePath, "unsafe_code_source", message));
252
+ return;
253
+ case "workflow":
254
+ if (!isNonEmptyString(node.workflow_id)) {
255
+ add(`${path}.workflow_id`, "invalid_workflow_ref", "Workflow node workflow_id is required.");
256
+ }
257
+ validateValueRefRecord(node.input, `${path}.input`, add);
258
+ // Authoring-time refusal, not just a runtime one. A sub-workflow needs its
259
+ // own durable run, lease, spend ceiling and cancellation semantics, none of
260
+ // which exist yet — so the worker rejects the node terminally. Without this
261
+ // rule a user could save the graph, arm a cron trigger on it, and discover
262
+ // the refusal at 03:00 on every delivery instead of at `workflows apply`.
263
+ // Remove BOTH refusals together when child runs ship.
264
+ add(`${path}.kind`, "unsupported_node_kind", "Sub-workflow nodes cannot run yet. Inline the child workflow's nodes for now.");
265
+ return;
266
+ default:
267
+ return;
268
+ }
269
+ }
270
+ function lintToolNode(node, path, add, options) {
271
+ if (!isNonEmptyString(node.tool)) {
272
+ add(`${path}.tool`, "missing_tool", "Tool node tool id is required.");
273
+ }
274
+ else if (isWorkflowToolRejected(node.tool)) {
275
+ add(`${path}.tool`, "unsupported_workflow_tool", `Tool '${node.tool}' is not allowed in workflow automations.`);
276
+ }
277
+ else if (options.isToolAllowed && !options.isToolAllowed(node.tool)) {
278
+ add(`${path}.tool`, "tool_not_allowed", `Tool '${node.tool}' is not available for workflow automations.`);
279
+ }
280
+ // Is the node actually filled in? Same injection contract as resolveToolEffect
281
+ // and for the same reason: the field list lives in the server tool registry,
282
+ // which the CLI and the browser bundle cannot carry. Without this the EDITOR
283
+ // refused an unconfigured node while apply accepted the identical manifest, and
284
+ // a CLI-authored one was never checked at all — a guarantee the GUI enforces
285
+ // alone is not a guarantee.
286
+ if (isNonEmptyString(node.tool) && options.resolveToolParamFields) {
287
+ const resolved = options.resolveToolParamFields(node.tool);
288
+ // Array.isArray does not narrow a `readonly T[]` union, so normalize once.
289
+ const bag = resolved == null
290
+ ? null
291
+ : Array.isArray(resolved)
292
+ ? { fields: resolved }
293
+ : resolved;
294
+ const fields = bag?.fields;
295
+ const closed = bag?.closed;
296
+ if (fields) {
297
+ const params = isRecord(node.params) ? node.params : {};
298
+ const shared = {
299
+ toolId: node.tool,
300
+ fields,
301
+ params,
302
+ ...(closed === undefined ? {} : { closed }),
303
+ };
304
+ // Its own code and its own sentence. Folding this into "still needs X" read
305
+ // as "oxygen_tables_list still needs 'limit' — oxygen_tables_list has no such
306
+ // input": the tool named twice, and "needs" saying the opposite of the truth.
307
+ for (const unknown of unknownToolParams(shared)) {
308
+ add(`${path}.params.${unknown}`, "unknown_tool_param", `${node.tool} has no input called '${unknown}'. Remove it or check the spelling.`);
309
+ }
310
+ for (const missing of missingToolParams(shared)) {
311
+ add(`${path}.params`, "incomplete_tool_params", `${node.tool} still needs ${missing}.`);
312
+ }
313
+ }
314
+ }
315
+ if (typeof node.effect !== "string" || !STEP_EFFECTS.has(node.effect)) {
316
+ add(`${path}.effect`, "invalid_step_effect", "Node effect is invalid.");
317
+ }
318
+ else if (typeof node.tool === "string" && node.tool.length > 0) {
319
+ // Shape alone is not enough: a node declaring a write as a read would pass
320
+ // the check above and then be refused by apply and the runtime, which both
321
+ // resolve the effect canonically. The v1 linter has always checked this;
322
+ // omitting it here would recreate the exact "lint approves, apply rejects"
323
+ // divergence one compiler version later.
324
+ // No resolver: skip, do not refuse. See the matching note in ../index.ts —
325
+ // the CLI compiles manifests locally and cannot classify effects, while apply
326
+ // and the runtime both refuse what they cannot classify.
327
+ const resolveEffect = options.resolveToolEffect;
328
+ if (resolveEffect) {
329
+ const canonicalEffect = resolveEffect(node.tool);
330
+ if (canonicalEffect !== null && node.effect !== canonicalEffect) {
331
+ add(`${path}.effect`, "tool_effect_mismatch", `Tool '${node.tool}' has canonical effect '${canonicalEffect}', not '${String(node.effect)}'.`);
332
+ }
333
+ }
334
+ }
335
+ if (node.mode !== undefined && (typeof node.mode !== "string" || !STEP_MODES.has(node.mode))) {
336
+ add(`${path}.mode`, "invalid_step_mode", "Node mode is invalid.");
337
+ }
338
+ validateOptionalMaxCredits(node.max_credits, `${path}.max_credits`, add);
339
+ validateValueRefRecord(node.params, `${path}.params`, add);
340
+ }
341
+ function lintSwitchNode(node, path, add) {
342
+ const cases = asArray(node.cases);
343
+ if (!cases || cases.length === 0) {
344
+ add(`${path}.cases`, "invalid_switch_case", "Switch node requires at least one case.");
345
+ return;
346
+ }
347
+ if (node.fallthrough !== undefined && typeof node.fallthrough !== "boolean") {
348
+ add(`${path}.fallthrough`, "invalid_switch_case", "Switch fallthrough must be a boolean.");
349
+ }
350
+ const seenCaseIds = new Set();
351
+ cases.forEach((entry, index) => {
352
+ const casePath = `${path}.cases.${index}`;
353
+ if (!isRecord(entry)) {
354
+ add(casePath, "invalid_switch_case", "Switch case must be an object.");
355
+ return;
356
+ }
357
+ if (!isNonEmptyString(entry.id)) {
358
+ add(`${casePath}.id`, "invalid_switch_case", "Switch case id is required.");
359
+ }
360
+ else if (RESERVED_EDGE_HANDLES.has(entry.id)) {
361
+ // A case id names the outgoing edge handle; colliding with a reserved
362
+ // handle would make the edge ambiguous between the case and the built-in.
363
+ add(`${casePath}.id`, "invalid_switch_case", `Switch case id '${entry.id}' is a reserved edge handle.`);
364
+ }
365
+ else if (seenCaseIds.has(entry.id)) {
366
+ add(`${casePath}.id`, "duplicate_switch_case_id", `Switch case id '${entry.id}' is duplicated.`);
367
+ }
368
+ else {
369
+ seenCaseIds.add(entry.id);
370
+ }
371
+ if (!isNonEmptyString(entry.label)) {
372
+ add(`${casePath}.label`, "invalid_switch_case", "Switch case label is required.");
373
+ }
374
+ validateCondition(entry.when, `${casePath}.when`, add);
375
+ });
376
+ }
377
+ function lintLoopNode(node, path, add) {
378
+ validateValueRef(node.over, `${path}.over`, add);
379
+ if (node.item_name !== undefined && !isNonEmptyString(node.item_name)) {
380
+ add(`${path}.item_name`, "invalid_value_ref", "Loop item_name must be a non-empty string.");
381
+ }
382
+ if (node.max_iterations !== undefined && !isPositiveInteger(node.max_iterations)) {
383
+ add(`${path}.max_iterations`, "invalid_loop_target", "Loop max_iterations must be a positive integer.");
384
+ }
385
+ else if (typeof node.max_iterations === "number"
386
+ && node.max_iterations > MAX_WORKFLOW_LOOP_ITERATIONS) {
387
+ // The interpreter clamps to the ceiling regardless, so accepting a larger
388
+ // bound would publish a number nothing honours: the usage estimator would
389
+ // price the run at the authored figure and refuse cron cadences over
390
+ // iterations that can never happen. Refusing here keeps the declared cap and
391
+ // the executed cap the same number.
392
+ add(`${path}.max_iterations`, "invalid_loop_target", `Loop max_iterations cannot exceed ${MAX_WORKFLOW_LOOP_ITERATIONS}.`);
393
+ }
394
+ if (node.concurrency !== undefined && !isPositiveInteger(node.concurrency)) {
395
+ add(`${path}.concurrency`, "invalid_loop_target", "Loop concurrency must be a positive integer.");
396
+ }
397
+ }
398
+ function lintSetNode(node, path, add) {
399
+ const fields = asArray(node.fields);
400
+ if (!fields || fields.length === 0) {
401
+ add(`${path}.fields`, "invalid_set_field", "Set node requires at least one field.");
402
+ return;
403
+ }
404
+ fields.forEach((field, index) => {
405
+ const fieldPath = `${path}.fields.${index}`;
406
+ if (!isRecord(field)) {
407
+ add(fieldPath, "invalid_set_field", "Set field must be an object.");
408
+ return;
409
+ }
410
+ if (!isNonEmptyString(field.key)) {
411
+ add(`${fieldPath}.key`, "invalid_set_field", "Set field key is required.");
412
+ }
413
+ else if (RESERVED_NODE_IDS.has(field.key)) {
414
+ add(`${fieldPath}.key`, "invalid_set_field", `Set field key '${field.key}' is reserved and would be dropped from the node output.`);
415
+ }
416
+ validateValueRef(field.value, `${fieldPath}.value`, add);
417
+ });
418
+ }
419
+ function lintApprovalNode(node, path, add) {
420
+ if (node.reason !== undefined)
421
+ validateValueRef(node.reason, `${path}.reason`, add);
422
+ if (node.request !== undefined)
423
+ validateValueRefRecord(node.request, `${path}.request`, add);
424
+ }
425
+ function lintWaitNode(node, path, add) {
426
+ const hasDuration = node.duration_seconds !== undefined;
427
+ const hasUntil = node.until !== undefined;
428
+ if (hasDuration === hasUntil) {
429
+ add(path, "invalid_wait", "Wait node requires exactly one of duration_seconds or until.");
430
+ return;
431
+ }
432
+ if (hasDuration) {
433
+ if (typeof node.duration_seconds !== "number"
434
+ || !Number.isFinite(node.duration_seconds)
435
+ || node.duration_seconds <= 0) {
436
+ add(`${path}.duration_seconds`, "invalid_wait", "Wait duration_seconds must be a positive number.");
437
+ }
438
+ return;
439
+ }
440
+ validateValueRef(node.until, `${path}.until`, add);
441
+ }
442
+ function isPositiveInteger(value) {
443
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
444
+ }
445
+ // ---------------------------------------------------------------------------
446
+ // Edges
447
+ // ---------------------------------------------------------------------------
448
+ /** Returns true when every edge is shaped well enough for topology analysis. */
449
+ function lintEdges(value, nodesValue, add) {
450
+ const edges = asArray(value);
451
+ if (!edges) {
452
+ add("$.edges", "invalid_edge", "Workflow edges must be an array.");
453
+ return false;
454
+ }
455
+ const nodes = indexNodesById(nodesValue);
456
+ const seenEdgeIds = new Set();
457
+ const seenConnections = new Set();
458
+ let sound = true;
459
+ edges.forEach((edge, index) => {
460
+ const path = `$.edges.${index}`;
461
+ if (!isRecord(edge)) {
462
+ add(path, "invalid_edge", "Workflow edge must be an object.");
463
+ sound = false;
464
+ return;
465
+ }
466
+ if (!isNonEmptyString(edge.id)) {
467
+ add(`${path}.id`, "invalid_edge", "Edge id is required.");
468
+ sound = false;
469
+ }
470
+ else if (seenEdgeIds.has(edge.id)) {
471
+ add(`${path}.id`, "duplicate_edge", `Edge id '${edge.id}' is duplicated.`);
472
+ }
473
+ else {
474
+ seenEdgeIds.add(edge.id);
475
+ }
476
+ const source = edge.source;
477
+ const target = edge.target;
478
+ if (!isNonEmptyString(source) || !isNonEmptyString(target)) {
479
+ add(path, "invalid_edge", "Edge source and target are required.");
480
+ sound = false;
481
+ return;
482
+ }
483
+ if (edge.source_handle !== undefined && !isNonEmptyString(edge.source_handle)) {
484
+ add(`${path}.source_handle`, "invalid_edge_handle", "Edge source_handle must be a string.");
485
+ return;
486
+ }
487
+ const connection = JSON.stringify([source, edge.source_handle ?? null, target]);
488
+ if (seenConnections.has(connection)) {
489
+ add(path, "duplicate_edge", `Edge from '${source}' to '${target}' is declared more than once.`);
490
+ }
491
+ else {
492
+ seenConnections.add(connection);
493
+ }
494
+ if (typeof edge.source_handle === "string") {
495
+ lintEdgeHandle({ source, target, handle: edge.source_handle, path, nodes }, add);
496
+ }
497
+ });
498
+ return sound;
499
+ }
500
+ function indexNodesById(value) {
501
+ const index = new Map();
502
+ for (const node of asArray(value) ?? []) {
503
+ // First declaration wins, matching topology.ts: duplicates are reported by
504
+ // lintNodes and must not change which node an edge is validated against.
505
+ if (isRecord(node) && isNonEmptyString(node.id) && !index.has(node.id))
506
+ index.set(node.id, node);
507
+ }
508
+ return index;
509
+ }
510
+ function lintEdgeHandle(input, add) {
511
+ const { handle, path } = input;
512
+ const sourceNode = input.nodes.get(input.source);
513
+ // Dangling source: reported once by lintGraphTopology, not again per handle.
514
+ if (!sourceNode)
515
+ return;
516
+ const sourceKind = sourceNode.kind;
517
+ if (handle === LOOP_BODY_HANDLE || handle === LOOP_DONE_HANDLE) {
518
+ if (sourceKind !== "loop") {
519
+ add(`${path}.source_handle`, "invalid_loop_target", `Handle '${handle}' is only valid on a loop node.`);
520
+ }
521
+ else if (handle === LOOP_BODY_HANDLE && input.source === input.target) {
522
+ add(`${path}.target`, "invalid_loop_target", "A loop's loop_body edge cannot target the loop node itself.");
523
+ }
524
+ return;
525
+ }
526
+ if (RESERVED_EDGE_HANDLES.has(handle))
527
+ return; // "error", valid on any node
528
+ if (sourceKind !== "switch") {
529
+ add(`${path}.source_handle`, "invalid_edge_handle", `Handle '${handle}' is only valid on a switch node (its case ids name its handles).`);
530
+ return;
531
+ }
532
+ const declaresCase = (asArray(sourceNode.cases) ?? []).some((entry) => isRecord(entry) && entry.id === handle);
533
+ if (!declaresCase) {
534
+ add(`${path}.source_handle`, "invalid_switch_case", `Edge handle '${handle}' does not name a case on switch node '${input.source}'.`);
535
+ }
536
+ }
537
+ // ---------------------------------------------------------------------------
538
+ // Graph-level rules
539
+ // ---------------------------------------------------------------------------
540
+ function lintGraphTopology(graph, add) {
541
+ const edgeIndex = new Map();
542
+ graph.edges.forEach((edge, index) => {
543
+ if (!edgeIndex.has(edge.id))
544
+ edgeIndex.set(edge.id, index);
545
+ });
546
+ for (const edge of danglingEdges(graph)) {
547
+ add(`$.edges.${edgeIndex.get(edge.id) ?? 0}`, "dangling_edge", `Edge '${edge.id}' references a node that does not exist ('${edge.source}' → '${edge.target}').`);
548
+ }
549
+ for (const component of findIllegalCycles(graph)) {
550
+ add("$.edges", "illegal_cycle", `Nodes ${component.map((id) => `'${id}'`).join(", ")} form a cycle. Only a loop node's body may edge back to the loop.`);
551
+ }
552
+ // A loop that never enters its body is a loop in name only — it would run zero
553
+ // iterations and silently drop everything the author put after it.
554
+ graph.nodes.forEach((node, index) => {
555
+ if (node.kind !== "loop")
556
+ return;
557
+ const entersBody = graph.edges.some((edge) => edge.source === node.id && edge.source_handle === LOOP_BODY_HANDLE);
558
+ if (!entersBody) {
559
+ add(`$.nodes.${index}`, "invalid_loop_target", `Loop node '${node.id}' has no loop_body edge, so its body would never run.`);
560
+ }
561
+ });
562
+ // An approval inside a loop body asks once PER ITERATION: the interpreter keys
563
+ // approvals by step id, which inside a body is `<node_id>#<iteration_path>`, so
564
+ // a 200-row loop parks 200 times and needs 200 human decisions. Refused at
565
+ // authoring time rather than at 03:00 on the first delivery, the same way the
566
+ // unrunnable sub-workflow node is. Gate the batch instead: approve once before
567
+ // the loop, then iterate.
568
+ const approvalNodeIds = new Set(graph.nodes.filter((node) => node.kind === "approval").map((node) => node.id));
569
+ if (approvalNodeIds.size > 0) {
570
+ const bodies = graph.nodes
571
+ .filter((node) => node.kind === "loop")
572
+ .map((node) => ({ loopId: node.id, body: loopBodyNodeIds(graph, node.id) }));
573
+ graph.nodes.forEach((node, index) => {
574
+ if (!approvalNodeIds.has(node.id))
575
+ return;
576
+ const enclosing = bodies.find((entry) => entry.body.has(node.id));
577
+ if (!enclosing)
578
+ return;
579
+ add(`$.nodes.${index}`, "approval_inside_loop", `Approval node '${node.id}' sits inside loop '${enclosing.loopId}', which would request one decision per iteration. `
580
+ + "Move the approval before the loop so one decision covers the whole batch.");
581
+ });
582
+ }
583
+ const reachable = reachableNodeIds(graph);
584
+ graph.nodes.forEach((node, index) => {
585
+ if (reachable.has(node.id))
586
+ return;
587
+ add(`$.nodes.${index}`, "unreachable_node", `Node '${node.id}' cannot be reached from the trigger.`);
588
+ });
589
+ }
590
+ // ---------------------------------------------------------------------------
591
+ // Value refs and conditions
592
+ // ---------------------------------------------------------------------------
593
+ function validateValueRefRecord(value, path, add) {
594
+ if (value === undefined) {
595
+ add(path, "invalid_value_ref", "Node bindings are required.");
596
+ return;
597
+ }
598
+ if (!isRecord(value)) {
599
+ add(path, "invalid_value_ref", "Node bindings must be an object of value refs.");
600
+ return;
601
+ }
602
+ for (const [key, entry] of Object.entries(value)) {
603
+ validateValueRef(entry, `${path}.${key}`, add);
604
+ }
605
+ }
606
+ function validateValueRef(value, path, add) {
607
+ if (!isRecord(value)) {
608
+ add(path, "invalid_value_ref", "Value ref must be an object.");
609
+ return;
610
+ }
611
+ switch (value.type) {
612
+ case "literal":
613
+ if (!("value" in value)) {
614
+ add(`${path}.value`, "invalid_value_ref", "Literal value ref requires a value.");
615
+ return;
616
+ }
617
+ // A structured literal may carry `{{ steps.x.output.y }}` tokens, which
618
+ // resolve at run time — so they are bindings and get the same path
619
+ // validation a `template` ref's tokens get. Without this the editor's one
620
+ // control that can express a per-row reference was also the one place a
621
+ // typo'd path failed silently, resolving to undefined mid-run.
622
+ //
623
+ // Only OUR tokens are checked: `{{column}}`, `{{RANDOM|a|b}}` and other
624
+ // foreign templating share the delimiters, travel inside these same
625
+ // parameters, and are none of the linter's business.
626
+ validateStructuredLiteralPaths(value.value, `${path}.value`, add);
627
+ return;
628
+ case "template":
629
+ if (typeof value.value !== "string") {
630
+ add(`${path}.value`, "invalid_value_ref", "Template value ref requires a string.");
631
+ return;
632
+ }
633
+ validateTemplatePaths(value.value, `${path}.value`, add);
634
+ return;
635
+ case "ref":
636
+ if (!isNonEmptyString(value.path)) {
637
+ add(`${path}.path`, "invalid_value_ref", "Ref value ref requires a path.");
638
+ return;
639
+ }
640
+ validateScopePath(value.path, `${path}.path`, add);
641
+ return;
642
+ case "formula":
643
+ if (!isNonEmptyString(value.expression)) {
644
+ add(`${path}.expression`, "invalid_value_ref", "Formula value ref requires an expression.");
645
+ return;
646
+ }
647
+ try {
648
+ // `paths: true` to match how the expression is EVALUATED. Without it the
649
+ // linter refused the exact formulas the runtime resolves — a graph binding
650
+ // `steps.enrich.output.email` failed at authoring time while being perfectly
651
+ // runnable, which is the worst of the two possible mismatches.
652
+ validateFormulaExpression(value.expression, { paths: true });
653
+ }
654
+ catch (error) {
655
+ add(`${path}.expression`, "invalid_formula", error instanceof Error ? error.message : "Formula expression is invalid.");
656
+ }
657
+ return;
658
+ case "context_profile":
659
+ if (value.path !== undefined)
660
+ validateOptionalRefPath(value.path, `${path}.path`, add);
661
+ return;
662
+ case "context_asset":
663
+ if (!isNonEmptyString(value.assetId)) {
664
+ add(`${path}.assetId`, "invalid_value_ref", "Context asset ref requires an assetId.");
665
+ }
666
+ if (value.path !== undefined)
667
+ validateOptionalRefPath(value.path, `${path}.path`, add);
668
+ return;
669
+ default:
670
+ add(`${path}.type`, "invalid_value_ref", "Value ref type is invalid.");
671
+ }
672
+ }
673
+ function validateScopePath(path, issuePath, add) {
674
+ const segments = parseScopePath(path);
675
+ if (!segments) {
676
+ add(issuePath, "invalid_value_ref", `Path '${path}' is not a valid scope path (it is malformed or reaches a reserved key).`);
677
+ return;
678
+ }
679
+ const root = segments[0] ?? "";
680
+ if (!SCOPE_PATH_ROOTS.has(root)) {
681
+ add(issuePath, "invalid_value_ref", `Path '${path}' must start with ${[...SCOPE_PATH_ROOTS].join(", ")}.`);
682
+ }
683
+ }
684
+ /** Paths inside a context value have no scope root — only syntax to check. */
685
+ function validateOptionalRefPath(path, issuePath, add) {
686
+ if (!isNonEmptyString(path) || !parseScopePath(path)) {
687
+ add(issuePath, "invalid_value_ref", "Context path is malformed or reaches a reserved key.");
688
+ }
689
+ }
690
+ function validateTemplatePaths(template, issuePath, add) {
691
+ for (const match of template.matchAll(/\{\{([^{}]*)\}\}/g)) {
692
+ const path = (match[1] ?? "").trim();
693
+ // "{{}}" is literal text by design, so it is not a binding to validate.
694
+ if (path)
695
+ validateScopePath(path, issuePath, add);
696
+ }
697
+ }
698
+ /**
699
+ * Validate the scope tokens inside a structured literal, at any depth.
700
+ *
701
+ * Mirrors validateTemplatePaths, but walks objects and arrays and ignores a
702
+ * token that is not rooted at one of our scope roots — the same discriminator
703
+ * resolveTokensDeep uses, so what the linter checks is exactly what the
704
+ * evaluator will try to resolve. A top-level literal STRING is skipped for the
705
+ * same reason the evaluator skips it: `template` owns that shape.
706
+ *
707
+ * DELIBERATELY LESS STRICT THAN A TEMPLATE REF, and the asymmetry is not an
708
+ * oversight. Inside a `template` every token is ours by definition, so a bad
709
+ * root there is an error. Inside a structured literal a non-scope root is
710
+ * indistinguishable from `{{column}}` or `{{RANDOM|a|b}}` — Oxygen's own
711
+ * documented merge-tag syntax, which travels in exactly these parameters — so
712
+ * flagging it would refuse legitimate outbound copy. A misspelled root is
713
+ * therefore passed through verbatim rather than rejected; the evaluator does the
714
+ * same, so it reaches the provider as visible text instead of vanishing into an
715
+ * empty string. Guessing which of `{{lop.x}}` and `{{company}}` was meant to be
716
+ * ours is not something a linter can do correctly.
717
+ */
718
+ function validateStructuredLiteralPaths(value, issuePath, add, depth = 0) {
719
+ if (depth > 12)
720
+ return;
721
+ if (typeof value === "string") {
722
+ // Depth 0 is the top-level literal string the evaluator leaves alone.
723
+ if (depth === 0)
724
+ return;
725
+ for (const match of value.matchAll(/\{\{([^{}]*)\}\}/g)) {
726
+ const path = (match[1] ?? "").trim();
727
+ if (!path)
728
+ continue;
729
+ const root = path.split(/[.[]/, 1)[0];
730
+ if (root !== undefined && SCOPE_PATH_ROOTS.has(root)) {
731
+ validateScopePath(path, issuePath, add);
732
+ }
733
+ }
734
+ return;
735
+ }
736
+ if (Array.isArray(value)) {
737
+ for (const entry of value)
738
+ validateStructuredLiteralPaths(entry, issuePath, add, depth + 1);
739
+ return;
740
+ }
741
+ if (isRecord(value)) {
742
+ for (const entry of Object.values(value)) {
743
+ validateStructuredLiteralPaths(entry, issuePath, add, depth + 1);
744
+ }
745
+ }
746
+ }
747
+ function validateCondition(// skipcq: JS-R1005
748
+ value, path, add, depth = 0) {
749
+ if (!isRecord(value)) {
750
+ add(path, "invalid_condition", "Condition must be an object.");
751
+ return;
752
+ }
753
+ if (depth > MAX_CONDITION_DEPTH) {
754
+ add(path, "invalid_condition", `Condition nesting exceeds ${MAX_CONDITION_DEPTH} levels.`);
755
+ return;
756
+ }
757
+ if (value.type === "group") {
758
+ if (value.op !== "all" && value.op !== "any") {
759
+ add(`${path}.op`, "invalid_condition", "Condition group op must be all or any.");
760
+ }
761
+ if (value.not !== undefined && typeof value.not !== "boolean") {
762
+ add(`${path}.not`, "invalid_condition", "Condition group not must be a boolean.");
763
+ }
764
+ if (!Array.isArray(value.children) || value.children.length === 0) {
765
+ add(`${path}.children`, "invalid_condition", "Condition group requires at least one child.");
766
+ return;
767
+ }
768
+ value.children.forEach((child, index) => {
769
+ validateCondition(child, `${path}.children.${index}`, add, depth + 1);
770
+ });
771
+ return;
772
+ }
773
+ if (value.type === "compare") {
774
+ validateValueRef(value.left, `${path}.left`, add);
775
+ if (!isCompareOp(value.op)) {
776
+ add(`${path}.op`, "invalid_condition", "Condition compare op is invalid.");
777
+ return;
778
+ }
779
+ if (isUnaryCompareOp(value.op)) {
780
+ if (value.right !== undefined) {
781
+ add(`${path}.right`, "invalid_condition", `Operator '${value.op}' takes no right operand.`);
782
+ }
783
+ return;
784
+ }
785
+ if (value.right === undefined) {
786
+ add(`${path}.right`, "invalid_condition", `Operator '${value.op}' requires a right operand.`);
787
+ return;
788
+ }
789
+ validateValueRef(value.right, `${path}.right`, add);
790
+ if (value.op === "matches")
791
+ validateMatchPattern(value.right, `${path}.right`, add);
792
+ return;
793
+ }
794
+ add(`${path}.type`, "invalid_condition", "Condition type must be group or compare.");
795
+ }
796
+ /**
797
+ * A literal `matches` pattern is checked at authoring time, so the author is told
798
+ * the pattern is unsupported instead of watching the condition quietly evaluate
799
+ * to false on every row. A computed pattern (template/ref/formula) is unknowable
800
+ * here and is guarded at run time by compileConditionPattern.
801
+ */
802
+ function validateMatchPattern(right, path, add) {
803
+ if (!isRecord(right) || right.type !== "literal")
804
+ return;
805
+ if (typeof right.value !== "string") {
806
+ add(`${path}.value`, "invalid_condition", "matches requires a string pattern.");
807
+ return;
808
+ }
809
+ if (!compileConditionPattern(right.value)) {
810
+ add(`${path}.value`, "invalid_condition", "Regex pattern is invalid or unsupported (patterns are capped at 200 characters and may not use lookaround, backreferences, or nested quantifiers).");
811
+ }
812
+ }