agentopology 0.1.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 (174) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +450 -0
  3. package/dist/analyzer/__tests__/analyzer.test.d.ts +2 -0
  4. package/dist/analyzer/__tests__/analyzer.test.d.ts.map +1 -0
  5. package/dist/analyzer/__tests__/analyzer.test.js +258 -0
  6. package/dist/analyzer/__tests__/analyzer.test.js.map +1 -0
  7. package/dist/analyzer/index.d.ts +44 -0
  8. package/dist/analyzer/index.d.ts.map +1 -0
  9. package/dist/analyzer/index.js +308 -0
  10. package/dist/analyzer/index.js.map +1 -0
  11. package/dist/bindings/__tests__/bindings.test.d.ts +2 -0
  12. package/dist/bindings/__tests__/bindings.test.d.ts.map +1 -0
  13. package/dist/bindings/__tests__/bindings.test.js +2089 -0
  14. package/dist/bindings/__tests__/bindings.test.js.map +1 -0
  15. package/dist/bindings/claude-code.d.ts +12 -0
  16. package/dist/bindings/claude-code.d.ts.map +1 -0
  17. package/dist/bindings/claude-code.js +2063 -0
  18. package/dist/bindings/claude-code.js.map +1 -0
  19. package/dist/bindings/codex.d.ts +18 -0
  20. package/dist/bindings/codex.d.ts.map +1 -0
  21. package/dist/bindings/codex.js +1633 -0
  22. package/dist/bindings/codex.js.map +1 -0
  23. package/dist/bindings/copilot-cli.d.ts +20 -0
  24. package/dist/bindings/copilot-cli.d.ts.map +1 -0
  25. package/dist/bindings/copilot-cli.js +1515 -0
  26. package/dist/bindings/copilot-cli.js.map +1 -0
  27. package/dist/bindings/cursor.d.ts +17 -0
  28. package/dist/bindings/cursor.d.ts.map +1 -0
  29. package/dist/bindings/cursor.js +1715 -0
  30. package/dist/bindings/cursor.js.map +1 -0
  31. package/dist/bindings/gemini-cli.d.ts +18 -0
  32. package/dist/bindings/gemini-cli.d.ts.map +1 -0
  33. package/dist/bindings/gemini-cli.js +1327 -0
  34. package/dist/bindings/gemini-cli.js.map +1 -0
  35. package/dist/bindings/index.d.ts +20 -0
  36. package/dist/bindings/index.d.ts.map +1 -0
  37. package/dist/bindings/index.js +33 -0
  38. package/dist/bindings/index.js.map +1 -0
  39. package/dist/bindings/kiro.d.ts +17 -0
  40. package/dist/bindings/kiro.d.ts.map +1 -0
  41. package/dist/bindings/kiro.js +1624 -0
  42. package/dist/bindings/kiro.js.map +1 -0
  43. package/dist/bindings/openclaw.d.ts +18 -0
  44. package/dist/bindings/openclaw.d.ts.map +1 -0
  45. package/dist/bindings/openclaw.js +1578 -0
  46. package/dist/bindings/openclaw.js.map +1 -0
  47. package/dist/bindings/types.d.ts +33 -0
  48. package/dist/bindings/types.d.ts.map +1 -0
  49. package/dist/bindings/types.js +23 -0
  50. package/dist/bindings/types.js.map +1 -0
  51. package/dist/cli/index.d.ts +19 -0
  52. package/dist/cli/index.d.ts.map +1 -0
  53. package/dist/cli/index.js +679 -0
  54. package/dist/cli/index.js.map +1 -0
  55. package/dist/docs/__tests__/docs.test.d.ts +2 -0
  56. package/dist/docs/__tests__/docs.test.d.ts.map +1 -0
  57. package/dist/docs/__tests__/docs.test.js +438 -0
  58. package/dist/docs/__tests__/docs.test.js.map +1 -0
  59. package/dist/docs/content.d.ts +16 -0
  60. package/dist/docs/content.d.ts.map +1 -0
  61. package/dist/docs/content.js +3273 -0
  62. package/dist/docs/content.js.map +1 -0
  63. package/dist/docs/index.d.ts +17 -0
  64. package/dist/docs/index.d.ts.map +1 -0
  65. package/dist/docs/index.js +117 -0
  66. package/dist/docs/index.js.map +1 -0
  67. package/dist/exporters/__tests__/exporters.test.d.ts +2 -0
  68. package/dist/exporters/__tests__/exporters.test.d.ts.map +1 -0
  69. package/dist/exporters/__tests__/exporters.test.js +731 -0
  70. package/dist/exporters/__tests__/exporters.test.js.map +1 -0
  71. package/dist/exporters/index.d.ts +13 -0
  72. package/dist/exporters/index.d.ts.map +1 -0
  73. package/dist/exporters/index.js +18 -0
  74. package/dist/exporters/index.js.map +1 -0
  75. package/dist/exporters/json.d.ts +8 -0
  76. package/dist/exporters/json.d.ts.map +1 -0
  77. package/dist/exporters/json.js +25 -0
  78. package/dist/exporters/json.js.map +1 -0
  79. package/dist/exporters/markdown.d.ts +9 -0
  80. package/dist/exporters/markdown.d.ts.map +1 -0
  81. package/dist/exporters/markdown.js +686 -0
  82. package/dist/exporters/markdown.js.map +1 -0
  83. package/dist/exporters/mermaid.d.ts +11 -0
  84. package/dist/exporters/mermaid.d.ts.map +1 -0
  85. package/dist/exporters/mermaid.js +161 -0
  86. package/dist/exporters/mermaid.js.map +1 -0
  87. package/dist/exporters/types.d.ts +22 -0
  88. package/dist/exporters/types.d.ts.map +1 -0
  89. package/dist/exporters/types.js +10 -0
  90. package/dist/exporters/types.js.map +1 -0
  91. package/dist/import/__tests__/import.test.d.ts +2 -0
  92. package/dist/import/__tests__/import.test.d.ts.map +1 -0
  93. package/dist/import/__tests__/import.test.js +781 -0
  94. package/dist/import/__tests__/import.test.js.map +1 -0
  95. package/dist/import/claude-code.d.ts +38 -0
  96. package/dist/import/claude-code.d.ts.map +1 -0
  97. package/dist/import/claude-code.js +897 -0
  98. package/dist/import/claude-code.js.map +1 -0
  99. package/dist/import/index.d.ts +18 -0
  100. package/dist/import/index.d.ts.map +1 -0
  101. package/dist/import/index.js +28 -0
  102. package/dist/import/index.js.map +1 -0
  103. package/dist/import/serializer.d.ts +11 -0
  104. package/dist/import/serializer.d.ts.map +1 -0
  105. package/dist/import/serializer.js +960 -0
  106. package/dist/import/serializer.js.map +1 -0
  107. package/dist/index.d.ts +26 -0
  108. package/dist/index.d.ts.map +1 -0
  109. package/dist/index.js +21 -0
  110. package/dist/index.js.map +1 -0
  111. package/dist/parser/__tests__/parser.test.d.ts +2 -0
  112. package/dist/parser/__tests__/parser.test.d.ts.map +1 -0
  113. package/dist/parser/__tests__/parser.test.js +6514 -0
  114. package/dist/parser/__tests__/parser.test.js.map +1 -0
  115. package/dist/parser/ast.d.ts +708 -0
  116. package/dist/parser/ast.d.ts.map +1 -0
  117. package/dist/parser/ast.js +12 -0
  118. package/dist/parser/ast.js.map +1 -0
  119. package/dist/parser/index.d.ts +253 -0
  120. package/dist/parser/index.d.ts.map +1 -0
  121. package/dist/parser/index.js +1985 -0
  122. package/dist/parser/index.js.map +1 -0
  123. package/dist/parser/lexer.d.ts +100 -0
  124. package/dist/parser/lexer.d.ts.map +1 -0
  125. package/dist/parser/lexer.js +304 -0
  126. package/dist/parser/lexer.js.map +1 -0
  127. package/dist/parser/validator.d.ts +42 -0
  128. package/dist/parser/validator.d.ts.map +1 -0
  129. package/dist/parser/validator.js +2568 -0
  130. package/dist/parser/validator.js.map +1 -0
  131. package/dist/scaffold/__tests__/scaffold.test.d.ts +7 -0
  132. package/dist/scaffold/__tests__/scaffold.test.d.ts.map +1 -0
  133. package/dist/scaffold/__tests__/scaffold.test.js +295 -0
  134. package/dist/scaffold/__tests__/scaffold.test.js.map +1 -0
  135. package/dist/scaffold/incremental.d.ts +43 -0
  136. package/dist/scaffold/incremental.d.ts.map +1 -0
  137. package/dist/scaffold/incremental.js +185 -0
  138. package/dist/scaffold/incremental.js.map +1 -0
  139. package/dist/scaffold/index.d.ts +13 -0
  140. package/dist/scaffold/index.d.ts.map +1 -0
  141. package/dist/scaffold/index.js +13 -0
  142. package/dist/scaffold/index.js.map +1 -0
  143. package/dist/scaffold/manifest.d.ts +30 -0
  144. package/dist/scaffold/manifest.d.ts.map +1 -0
  145. package/dist/scaffold/manifest.js +71 -0
  146. package/dist/scaffold/manifest.js.map +1 -0
  147. package/dist/scaffold/merge.d.ts +52 -0
  148. package/dist/scaffold/merge.d.ts.map +1 -0
  149. package/dist/scaffold/merge.js +134 -0
  150. package/dist/scaffold/merge.js.map +1 -0
  151. package/dist/scaffold/types.d.ts +51 -0
  152. package/dist/scaffold/types.d.ts.map +1 -0
  153. package/dist/scaffold/types.js +10 -0
  154. package/dist/scaffold/types.js.map +1 -0
  155. package/dist/sync/index.d.ts +34 -0
  156. package/dist/sync/index.d.ts.map +1 -0
  157. package/dist/sync/index.js +392 -0
  158. package/dist/sync/index.js.map +1 -0
  159. package/dist/visualizer/index.d.ts +17 -0
  160. package/dist/visualizer/index.d.ts.map +1 -0
  161. package/dist/visualizer/index.js +1976 -0
  162. package/dist/visualizer/index.js.map +1 -0
  163. package/examples/code-review.at +167 -0
  164. package/examples/data-processing.at +296 -0
  165. package/examples/openclaw-assistant.at +142 -0
  166. package/examples/scheduled-monitor.at +193 -0
  167. package/examples/simple-pipeline.at +163 -0
  168. package/package.json +77 -0
  169. package/skill/SKILL.md +403 -0
  170. package/skill/scripts/emit.ts +853 -0
  171. package/skill/scripts/visualize.ts +89 -0
  172. package/spec/grammar.md +1466 -0
  173. package/spec/reserved-keywords.md +147 -0
  174. package/spec/validation.md +535 -0
@@ -0,0 +1,2568 @@
1
+ /**
2
+ * AgenTopology AST validator.
3
+ *
4
+ * Implements the 29 validation rules from the AgenTopology specification
5
+ * (section 6). Takes a parsed {@link TopologyAST} and returns an array of
6
+ * {@link ValidationResult} entries describing errors and warnings.
7
+ *
8
+ * ```ts
9
+ * import { parse } from "./index";
10
+ * import { validate } from "./validator";
11
+ *
12
+ * const ast = parse(source);
13
+ * const issues = validate(ast);
14
+ * for (const issue of issues) {
15
+ * console.log(`[${issue.rule}] ${issue.level}: ${issue.message}`);
16
+ * }
17
+ * ```
18
+ *
19
+ * @module
20
+ */
21
+ // ---------------------------------------------------------------------------
22
+ // Reserved keywords (from spec section 7)
23
+ // ---------------------------------------------------------------------------
24
+ const RESERVED_KEYWORDS = new Set([
25
+ // Block keywords
26
+ "topology", "library", "import", "from", "use",
27
+ "agent", "action", "orchestrator", "meta", "roles", "memory", "flow",
28
+ "gates", "gate", "depth", "batch", "environments", "triggers", "command",
29
+ "event", "level", "hooks", "hook", "settings", "mcp-servers", "metering",
30
+ "tools", "tool", "scale", "skill",
31
+ // Field keywords
32
+ "model", "disallowed-tools", "reads", "writes", "outputs", "skip",
33
+ "retry", "isolation", "phase", "kind", "role", "version", "description",
34
+ "permissions", "prompt", "generates", "handles", "argument", "factors",
35
+ "behavior", "invocation", "omit", "when", "max", "parallel", "per",
36
+ "manual", "advisory", "blocking", "min", "batch-size", "batch-count",
37
+ "doc-count", "token-volume", "source-count", "fixed", "config",
38
+ "track", "tokens-in", "tokens-out", "cost", "wall-time", "agent-count",
39
+ "format", "pricing", "anthropic-current", "custom", "none", "json",
40
+ "jsonl", "csv", "pass", "fail", "plan-gap", "bounce-back", "halt",
41
+ "opus", "sonnet", "haiku", "inherit", "plan", "auto", "confirm",
42
+ "bypass", "worktree", "append-only", "background", "skills",
43
+ "user", "project", "local", "on", "matcher", "timeout",
44
+ // Hook event keywords
45
+ "PreToolUse", "PostToolUse", "PostToolUseFailure",
46
+ "SubagentStart", "SubagentStop", "Stop", "SessionStart", "SessionEnd",
47
+ "UserPromptSubmit", "InstructionsLoaded", "PermissionRequest",
48
+ "Notification", "TeammateIdle", "TaskCompleted", "ConfigChange",
49
+ "PreCompact", "WorktreeCreate", "WorktreeRemove",
50
+ // C18: Universal hook events missing from validator
51
+ "AgentStart", "AgentStop", "ToolUse", "Error",
52
+ // C18: Permission enum values missing from validator
53
+ "autonomous", "supervised", "interactive", "unrestricted",
54
+ "allow", "deny", "ask", "http", "stdio", "sse", "args", "env", "url",
55
+ "script", "lang", "bash", "python", "node", "on-fail", "after",
56
+ "before", "run", "checks", "load-when", "path", "mode", "files",
57
+ "routing", "protocol", "structure", "blueprints", "domains",
58
+ "references", "external-docs", "metrics", "workspace", "conflicts",
59
+ "detect", "resolve", "sequential-rebase", "source", "commands",
60
+ "external", "git", "decision", "inline", "report", "not", "ticket",
61
+ "true", "false", "pipeline", "supervisor", "blackboard",
62
+ "orchestrator-worker", "debate", "market-routing", "consensus",
63
+ "fan-out", "event-driven", "human-gate",
64
+ // Extension system keywords
65
+ "context", "extensions", "max-turns",
66
+ "disable-model-invocation", "user-invocable", "allowed-tools",
67
+ "domain", "fork",
68
+ // Provider keywords
69
+ "providers", "api-key", "base-url", "default",
70
+ // Schedule keywords
71
+ "schedule", "job", "cron", "every", "enabled",
72
+ // Interface keywords
73
+ "interfaces", "webhook", "channel", "auth", "port",
74
+ // Sandbox keywords
75
+ "sandbox", "docker", "network-only",
76
+ // Fallback chain keyword
77
+ "fallback-chain",
78
+ // Wave 1: Error handling and retry keywords
79
+ "backoff", "interval", "max-interval", "jitter",
80
+ "non-retryable", "exponential", "linear",
81
+ // Wave 1: Sampling parameter keywords
82
+ "temperature", "max-tokens", "top-p", "top-k", "stop", "seed",
83
+ // Wave 1: Defaults and thinking keywords
84
+ "defaults", "thinking", "thinking-budget", "off", "low", "medium", "high",
85
+ // Wave 1: Sensitive modifier
86
+ "sensitive",
87
+ // Wave 1: Logging keywords
88
+ "log-level", "debug", "info", "warn", "error",
89
+ // Wave 1: Output format keywords
90
+ "output-format", "json-schema", "text",
91
+ // Wave 2: Join semantics keywords
92
+ "join", "all", "any", "all-done", "none-failed",
93
+ // Wave 2: Edge attribute keywords
94
+ "tolerance", "race", "wait", "weight",
95
+ // Wave 2: Error handler keyword
96
+ "error-handler",
97
+ // Wave 3: Schema system keywords
98
+ "schemas", "schema", "input-schema", "output-schema",
99
+ "array", "of", "optional",
100
+ // Wave 3: Observability keywords
101
+ "observability", "exporter", "endpoint", "service",
102
+ "sample-rate", "capture", "prompts", "completions", "tool-args",
103
+ "tool-results", "spans", "agents",
104
+ // Wave 3: Observability exporter values
105
+ "otlp", "langsmith", "datadog", "stdout",
106
+ // Wave 3: Secret reference keywords
107
+ "secret", "vault", "op", "awssm", "ssm", "gcpsm", "azurekv",
108
+ // Wave 4: Composition keywords
109
+ "as", "with", "include", "fragment", "params", "interface",
110
+ "entry", "exit", "sha256",
111
+ // Wave 5: Advanced pattern keywords
112
+ "circuit-breaker", "threshold", "window", "cooldown",
113
+ "compensates", "human", "checkpoint", "durable",
114
+ // Wave 6: Checkpoint, replay, and artifact keywords
115
+ "backend", "connection", "strategy", "ttl", "replay",
116
+ "max-history", "branch", "every-node", "on-error", "explicit",
117
+ "memory", "sqlite", "postgres", "redis", "s3",
118
+ "artifacts", "artifact", "produces", "consumes",
119
+ "depends-on", "retention", "type",
120
+ // Wave 7: Group chat, reflection, and rate limiting keywords
121
+ "group", "members", "speaker-selection", "max-rounds", "termination",
122
+ "round-robin", "random", "reflection", "rate-limit",
123
+ ]);
124
+ // ---------------------------------------------------------------------------
125
+ // Helpers
126
+ // ---------------------------------------------------------------------------
127
+ function isAgent(n) {
128
+ return n.type === "agent";
129
+ }
130
+ function isGate(n) {
131
+ return n.type === "gate";
132
+ }
133
+ function isOrchestrator(n) {
134
+ return n.type === "orchestrator";
135
+ }
136
+ function isHuman(n) {
137
+ return n.type === "human";
138
+ }
139
+ function isGroup(n) {
140
+ return n.type === "group";
141
+ }
142
+ /** Build a set of all declared node ids. */
143
+ function allNodeIds(ast) {
144
+ return new Set(ast.nodes.map((n) => n.id));
145
+ }
146
+ /** Build a set of agent ids that are members of any group node. */
147
+ function groupMemberIds(ast) {
148
+ const members = new Set();
149
+ for (const node of ast.nodes) {
150
+ if (isGroup(node)) {
151
+ for (const m of node.members) {
152
+ members.add(m);
153
+ }
154
+ }
155
+ }
156
+ return members;
157
+ }
158
+ /** Look up the source line number for a node ID from the AST's _sourceMap. */
159
+ function lookupLine(ast, nodeId) {
160
+ return ast._sourceMap?.[nodeId];
161
+ }
162
+ /**
163
+ * Collect all outputs maps across the topology.
164
+ * Returns a map from `nodeId.outputKey` to the possible values.
165
+ */
166
+ function collectOutputs(ast) {
167
+ const map = new Map();
168
+ for (const node of ast.nodes) {
169
+ let outputs;
170
+ if (isOrchestrator(node) && node.outputs) {
171
+ outputs = node.outputs;
172
+ }
173
+ else if (isAgent(node) && node.outputs) {
174
+ outputs = node.outputs;
175
+ }
176
+ if (outputs) {
177
+ for (const [key, values] of Object.entries(outputs)) {
178
+ map.set(`${node.id}.${key}`, values);
179
+ }
180
+ }
181
+ }
182
+ return map;
183
+ }
184
+ /**
185
+ * Determine if an edge is a back-edge (target appears before source in
186
+ * a topological ordering derived from the flow). We approximate this by
187
+ * checking whether the target has an edge path leading to the source.
188
+ */
189
+ function findBackEdges(edges, nodeIds) {
190
+ // Step 1: Remove edges that already have [max N] — those are acknowledged loops.
191
+ // We compute the topological order on the remaining "forward" edges only.
192
+ const forwardEdges = edges.filter((e) => e.maxIterations === null);
193
+ const loopEdges = edges.filter((e) => e.maxIterations !== null);
194
+ // Build adjacency list from forward edges only
195
+ const adj = new Map();
196
+ for (const id of nodeIds) {
197
+ adj.set(id, []);
198
+ }
199
+ for (const e of forwardEdges) {
200
+ if (!adj.has(e.from))
201
+ adj.set(e.from, []);
202
+ adj.get(e.from).push(e.to);
203
+ }
204
+ // Compute topological order using Kahn's algorithm on forward edges.
205
+ const inDegree = new Map();
206
+ for (const id of nodeIds)
207
+ inDegree.set(id, 0);
208
+ for (const e of forwardEdges) {
209
+ if (nodeIds.has(e.to)) {
210
+ inDegree.set(e.to, (inDegree.get(e.to) ?? 0) + 1);
211
+ }
212
+ }
213
+ const queue = [];
214
+ for (const [id, deg] of inDegree) {
215
+ if (deg === 0)
216
+ queue.push(id);
217
+ }
218
+ const order = new Map();
219
+ let idx = 0;
220
+ while (queue.length > 0) {
221
+ const node = queue.shift();
222
+ order.set(node, idx++);
223
+ for (const next of adj.get(node) ?? []) {
224
+ if (!nodeIds.has(next))
225
+ continue;
226
+ const newDeg = (inDegree.get(next) ?? 1) - 1;
227
+ inDegree.set(next, newDeg);
228
+ if (newDeg === 0)
229
+ queue.push(next);
230
+ }
231
+ }
232
+ // Step 2: Among forward edges (no max N), find those that go backwards.
233
+ // These are the problematic back-edges that NEED a [max N] bound.
234
+ const backEdges = [];
235
+ for (const e of forwardEdges) {
236
+ const fromOrd = order.get(e.from);
237
+ const toOrd = order.get(e.to);
238
+ if (fromOrd === undefined || toOrd === undefined) {
239
+ // Nodes in unresolvable cycles
240
+ backEdges.push(e);
241
+ }
242
+ else if (toOrd <= fromOrd) {
243
+ backEdges.push(e);
244
+ }
245
+ }
246
+ return backEdges;
247
+ }
248
+ // ---------------------------------------------------------------------------
249
+ // Rule implementations
250
+ // ---------------------------------------------------------------------------
251
+ /** V1: All agent, action, and gate names must be globally unique. */
252
+ function v1UniqueNames(ast) {
253
+ const results = [];
254
+ const seen = new Map(); // id -> type
255
+ for (const node of ast.nodes) {
256
+ if (node.type === "orchestrator")
257
+ continue; // orchestrator is a singleton
258
+ const prev = seen.get(node.id);
259
+ if (prev) {
260
+ results.push({
261
+ rule: "V1",
262
+ level: "error",
263
+ message: `Duplicate name "${node.id}" — already declared as ${prev}`,
264
+ node: node.id,
265
+ line: lookupLine(ast, node.id),
266
+ });
267
+ }
268
+ else {
269
+ seen.set(node.id, node.type);
270
+ }
271
+ }
272
+ return results;
273
+ }
274
+ /** V2: No name may match a reserved keyword. */
275
+ function v2NoKeywordNames(ast) {
276
+ const results = [];
277
+ for (const node of ast.nodes) {
278
+ if (node.type === "orchestrator")
279
+ continue;
280
+ if (RESERVED_KEYWORDS.has(node.id)) {
281
+ results.push({
282
+ rule: "V2",
283
+ level: "error",
284
+ message: `Name "${node.id}" is a reserved keyword`,
285
+ node: node.id,
286
+ line: lookupLine(ast, node.id),
287
+ });
288
+ }
289
+ }
290
+ return results;
291
+ }
292
+ /** V3: Every node referenced in flow must be a declared agent, action, or gate. */
293
+ function v3FlowResolves(ast) {
294
+ const results = [];
295
+ const ids = allNodeIds(ast);
296
+ const referenced = new Set();
297
+ for (const edge of ast.edges) {
298
+ referenced.add(edge.from);
299
+ referenced.add(edge.to);
300
+ }
301
+ for (const ref of referenced) {
302
+ if (!ids.has(ref)) {
303
+ results.push({
304
+ rule: "V3",
305
+ level: "error",
306
+ message: `Flow references undeclared node "${ref}"`,
307
+ node: ref,
308
+ });
309
+ }
310
+ }
311
+ return results;
312
+ }
313
+ /** V4: Every agent must appear in flow unless it has `invocation: manual` or is a group member. */
314
+ function v4NoOrphans(ast) {
315
+ const results = [];
316
+ const flowNodes = new Set();
317
+ for (const edge of ast.edges) {
318
+ flowNodes.add(edge.from);
319
+ flowNodes.add(edge.to);
320
+ }
321
+ const gMembers = groupMemberIds(ast);
322
+ for (const node of ast.nodes) {
323
+ if (!isAgent(node))
324
+ continue;
325
+ if (node.invocation === "manual")
326
+ continue;
327
+ if (gMembers.has(node.id))
328
+ continue;
329
+ if (!flowNodes.has(node.id)) {
330
+ results.push({
331
+ rule: "V4",
332
+ level: "error",
333
+ message: `Agent "${node.id}" is not in flow and does not have invocation: manual`,
334
+ node: node.id,
335
+ line: lookupLine(ast, node.id),
336
+ });
337
+ }
338
+ }
339
+ return results;
340
+ }
341
+ /** V5: Every `[when x.y]` must reference a declared output. */
342
+ function v5OutputsExist(ast) {
343
+ const results = [];
344
+ const outputMap = collectOutputs(ast);
345
+ for (const edge of ast.edges) {
346
+ if (!edge.condition)
347
+ continue;
348
+ // Conditions look like "orchestrator.decision == plan-gap" or "classify.depth == 1"
349
+ // Extract the left-hand side reference (nodeId.outputKey)
350
+ const refMatch = edge.condition.match(/([a-zA-Z][a-zA-Z0-9_-]*\.[a-zA-Z][a-zA-Z0-9_-]*)/);
351
+ if (refMatch) {
352
+ const ref = refMatch[1];
353
+ if (!outputMap.has(ref)) {
354
+ results.push({
355
+ rule: "V5",
356
+ level: "error",
357
+ message: `Condition references undeclared output "${ref}"`,
358
+ node: edge.from,
359
+ });
360
+ }
361
+ }
362
+ }
363
+ return results;
364
+ }
365
+ /** V6: Every back-edge must have `max N`. */
366
+ function v6BoundedLoops(ast) {
367
+ const results = [];
368
+ const ids = allNodeIds(ast);
369
+ const backEdges = findBackEdges(ast.edges, ids);
370
+ for (const edge of backEdges) {
371
+ if (edge.maxIterations === null) {
372
+ results.push({
373
+ rule: "V6",
374
+ level: "error",
375
+ message: `Back-edge ${edge.from} -> ${edge.to} has no [max N] bound`,
376
+ node: edge.from,
377
+ });
378
+ }
379
+ }
380
+ return results;
381
+ }
382
+ /** V7: Every agent and orchestrator must have a model. */
383
+ function v7ModelRequired(ast) {
384
+ const results = [];
385
+ for (const node of ast.nodes) {
386
+ if (isAgent(node)) {
387
+ if (!node.model) {
388
+ results.push({
389
+ rule: "V7",
390
+ level: "error",
391
+ message: `Agent "${node.id}" has no model specified`,
392
+ node: node.id,
393
+ line: lookupLine(ast, node.id),
394
+ });
395
+ }
396
+ }
397
+ else if (isOrchestrator(node)) {
398
+ if (!node.model) {
399
+ results.push({
400
+ rule: "V7",
401
+ level: "error",
402
+ message: `Orchestrator has no model specified`,
403
+ node: node.id,
404
+ line: lookupLine(ast, node.id),
405
+ });
406
+ }
407
+ }
408
+ }
409
+ return results;
410
+ }
411
+ /**
412
+ * V8: Import references should resolve (warning only -- we cannot check the filesystem).
413
+ *
414
+ * NOTE: This is a placeholder. The import system is not yet implemented in the
415
+ * parser, so this rule always returns [] and is effectively skipped. It is
416
+ * retained for completeness so the rule numbering stays consistent with the
417
+ * spec. A future CLI extension can provide actual filesystem resolution.
418
+ */
419
+ function v8ImportsResolve(_ast) {
420
+ return [];
421
+ }
422
+ /** V9: Every action referenced in flow must appear in orchestrator.handles. */
423
+ function v9ActionsHandled(ast) {
424
+ const results = [];
425
+ // Find orchestrator
426
+ const orch = ast.nodes.find(isOrchestrator);
427
+ if (!orch)
428
+ return results;
429
+ const handlesSet = new Set(orch.handles);
430
+ // Collect action ids that appear in the flow
431
+ const flowNodes = new Set();
432
+ for (const edge of ast.edges) {
433
+ flowNodes.add(edge.from);
434
+ flowNodes.add(edge.to);
435
+ }
436
+ for (const node of ast.nodes) {
437
+ if (node.type !== "action")
438
+ continue;
439
+ if (flowNodes.has(node.id) && !handlesSet.has(node.id)) {
440
+ results.push({
441
+ rule: "V9",
442
+ level: "error",
443
+ message: `Action "${node.id}" is used in flow but not in orchestrator.handles`,
444
+ node: node.id,
445
+ line: lookupLine(ast, node.id),
446
+ });
447
+ }
448
+ }
449
+ return results;
450
+ }
451
+ /** V10: Prompt content should not be empty if a prompt block is declared. */
452
+ function v10PromptsExist(ast) {
453
+ const results = [];
454
+ for (const node of ast.nodes) {
455
+ if (isAgent(node) && node.prompt !== undefined && node.prompt.trim() === "") {
456
+ results.push({
457
+ rule: "V10",
458
+ level: "warning",
459
+ message: `Agent "${node.id}" has an empty prompt block`,
460
+ node: node.id,
461
+ line: lookupLine(ast, node.id),
462
+ });
463
+ }
464
+ }
465
+ return results;
466
+ }
467
+ /** V11: If agent A writes X and agent B reads X, a path A -> B must exist in flow. */
468
+ function v11ReadWriteConsistency(ast) {
469
+ const results = [];
470
+ // Build a reachability map using BFS from each node
471
+ const adj = new Map();
472
+ for (const node of ast.nodes) {
473
+ adj.set(node.id, new Set());
474
+ }
475
+ for (const edge of ast.edges) {
476
+ if (adj.has(edge.from)) {
477
+ adj.get(edge.from).add(edge.to);
478
+ }
479
+ }
480
+ function isReachable(from, to) {
481
+ if (from === to)
482
+ return true;
483
+ const visited = new Set();
484
+ const queue = [from];
485
+ while (queue.length > 0) {
486
+ const curr = queue.shift();
487
+ if (curr === to)
488
+ return true;
489
+ if (visited.has(curr))
490
+ continue;
491
+ visited.add(curr);
492
+ for (const next of adj.get(curr) ?? []) {
493
+ queue.push(next);
494
+ }
495
+ }
496
+ return false;
497
+ }
498
+ // Build group co-membership lookup: if A and B are in the same group,
499
+ // they communicate via group chat and don't need explicit flow paths.
500
+ const coGroupMembers = new Map();
501
+ for (const node of ast.nodes) {
502
+ if (isGroup(node)) {
503
+ for (const m of node.members) {
504
+ if (!coGroupMembers.has(m))
505
+ coGroupMembers.set(m, new Set());
506
+ for (const other of node.members) {
507
+ if (other !== m)
508
+ coGroupMembers.get(m).add(other);
509
+ }
510
+ }
511
+ }
512
+ }
513
+ // Collect writers: memory key -> agent ids
514
+ const writers = new Map();
515
+ for (const node of ast.nodes) {
516
+ if (isAgent(node) && node.writes) {
517
+ for (const key of node.writes) {
518
+ if (!writers.has(key))
519
+ writers.set(key, []);
520
+ writers.get(key).push(node.id);
521
+ }
522
+ }
523
+ }
524
+ // Check readers
525
+ for (const node of ast.nodes) {
526
+ if (!isAgent(node) || !node.reads)
527
+ continue;
528
+ for (const key of node.reads) {
529
+ const keyWriters = writers.get(key);
530
+ if (!keyWriters)
531
+ continue; // no writer = could be external, skip
532
+ const hasPath = keyWriters.some((writerId) => isReachable(writerId, node.id) ||
533
+ (coGroupMembers.get(node.id)?.has(writerId) ?? false));
534
+ if (!hasPath) {
535
+ results.push({
536
+ rule: "V11",
537
+ level: "error",
538
+ message: `Agent "${node.id}" reads "${key}" but no writing agent has a flow path to it`,
539
+ node: node.id,
540
+ line: lookupLine(ast, node.id),
541
+ });
542
+ }
543
+ }
544
+ }
545
+ return results;
546
+ }
547
+ /**
548
+ * V12: Edge attribute order must be [when, max, per].
549
+ *
550
+ * This rule is syntactic — at the AST level the attributes are already
551
+ * separated into distinct fields, so ordering violations cannot be detected
552
+ * post-parse. The parser's {@link parseFlow} function enforces this: its
553
+ * regex requires annotations to begin with `when` or `max` (not both in
554
+ * reversed order). If `[max N, when ...]` is written, `parseFlow` will
555
+ * still extract both values but the condition is silently mis-parsed.
556
+ *
557
+ * To catch mis-ordered annotations reliably, the parser emits a
558
+ * V12 error via `edgeAttributeErrors` when `max` appears before `when`
559
+ * in the same bracket annotation. Those errors are forwarded here.
560
+ */
561
+ function v12EdgeAttributeOrder(ast) {
562
+ // Errors are collected during parsing and attached to the AST.
563
+ // See parseFlow() in src/parser/index.ts.
564
+ return ast._edgeAttributeErrors ?? [];
565
+ }
566
+ /** V13: Gate `after` and `before` must reference declared nodes. */
567
+ function v13GatePlacement(ast) {
568
+ const results = [];
569
+ const ids = allNodeIds(ast);
570
+ for (const node of ast.nodes) {
571
+ if (!isGate(node))
572
+ continue;
573
+ if (node.after && !ids.has(node.after)) {
574
+ results.push({
575
+ rule: "V13",
576
+ level: "error",
577
+ message: `Gate "${node.id}" references undeclared node "${node.after}" in "after"`,
578
+ node: node.id,
579
+ line: lookupLine(ast, node.id),
580
+ });
581
+ }
582
+ if (node.before && !ids.has(node.before)) {
583
+ results.push({
584
+ rule: "V13",
585
+ level: "error",
586
+ message: `Gate "${node.id}" references undeclared node "${node.before}" in "before"`,
587
+ node: node.id,
588
+ line: lookupLine(ast, node.id),
589
+ });
590
+ }
591
+ }
592
+ return results;
593
+ }
594
+ /** V14: `tools` and `disallowed-tools` cannot both appear on the same agent. */
595
+ function v14ToolExclusivity(ast) {
596
+ const results = [];
597
+ for (const node of ast.nodes) {
598
+ if (!isAgent(node))
599
+ continue;
600
+ if (node.tools &&
601
+ node.tools.length > 0 &&
602
+ node.disallowedTools &&
603
+ node.disallowedTools.length > 0) {
604
+ results.push({
605
+ rule: "V14",
606
+ level: "error",
607
+ message: `Agent "${node.id}" has both "tools" and "disallowed-tools" — they are mutually exclusive`,
608
+ node: node.id,
609
+ line: lookupLine(ast, node.id),
610
+ });
611
+ }
612
+ }
613
+ return results;
614
+ }
615
+ /**
616
+ * V15: When a node has ONLY conditional outgoing edges, the conditions must
617
+ * cover all enum values of the referenced output that are reachable at that node.
618
+ */
619
+ function v15ExhaustiveConditions(ast) {
620
+ const results = [];
621
+ const outputMap = collectOutputs(ast);
622
+ // Group edges by source
623
+ const edgesBySource = new Map();
624
+ for (const edge of ast.edges) {
625
+ if (!edgesBySource.has(edge.from)) {
626
+ edgesBySource.set(edge.from, []);
627
+ }
628
+ edgesBySource.get(edge.from).push(edge);
629
+ }
630
+ for (const [source, outEdges] of edgesBySource) {
631
+ // Check if ALL outgoing edges are conditional
632
+ if (outEdges.length === 0)
633
+ continue;
634
+ const allConditional = outEdges.every((e) => e.condition !== null);
635
+ if (!allConditional)
636
+ continue;
637
+ // All edges are conditional. Group by output reference.
638
+ // Extract the output reference and the value being tested.
639
+ const coveredValues = new Map(); // outputRef -> covered values
640
+ for (const edge of outEdges) {
641
+ if (!edge.condition)
642
+ continue;
643
+ // Match patterns like "x.y == value" or "x.y == value"
644
+ const condMatch = edge.condition.match(/([a-zA-Z][a-zA-Z0-9_-]*\.[a-zA-Z][a-zA-Z0-9_-]*)\s*==\s*(\S+)/);
645
+ if (condMatch) {
646
+ const ref = condMatch[1];
647
+ const value = condMatch[2];
648
+ if (!coveredValues.has(ref))
649
+ coveredValues.set(ref, new Set());
650
+ coveredValues.get(ref).add(value);
651
+ }
652
+ }
653
+ // Check exhaustiveness for each referenced output
654
+ for (const [ref, covered] of coveredValues) {
655
+ const possibleValues = outputMap.get(ref);
656
+ if (!possibleValues)
657
+ continue; // can't check if output not declared
658
+ const missing = possibleValues.filter((v) => !covered.has(v));
659
+ if (missing.length > 0) {
660
+ results.push({
661
+ rule: "V15",
662
+ level: "error",
663
+ message: `Node "${source}" has only conditional edges on "${ref}" but does not cover: ${missing.join(", ")}`,
664
+ node: source,
665
+ });
666
+ }
667
+ }
668
+ }
669
+ return results;
670
+ }
671
+ /** V16: `api-key` must be a `${...}` env-var reference (no literal secrets). */
672
+ function v16ApiKeyEnvVar(ast) {
673
+ const results = [];
674
+ const envVarPattern = /^\$\{[A-Z][A-Z0-9_]*\}$/;
675
+ for (const provider of ast.providers) {
676
+ if (provider.apiKey && !envVarPattern.test(provider.apiKey)) {
677
+ results.push({
678
+ rule: "V16",
679
+ level: "error",
680
+ message: `Provider "${provider.name}" has a literal api-key value — must be a \${ENV_VAR} reference`,
681
+ node: provider.name,
682
+ });
683
+ }
684
+ }
685
+ return results;
686
+ }
687
+ /** V17: At most one provider may have `default: true`. */
688
+ function v17SingleDefault(ast) {
689
+ const results = [];
690
+ const defaults = ast.providers.filter((p) => p.default === true);
691
+ if (defaults.length > 1) {
692
+ results.push({
693
+ rule: "V17",
694
+ level: "error",
695
+ message: `Multiple providers marked as default: ${defaults.map((p) => p.name).join(", ")} — at most one allowed`,
696
+ });
697
+ }
698
+ return results;
699
+ }
700
+ /** V18: Every model referenced by an agent should exist in at least one provider's models list. */
701
+ function v18ModelInProvider(ast) {
702
+ const results = [];
703
+ if (ast.providers.length === 0)
704
+ return results;
705
+ const providerModels = new Set();
706
+ for (const provider of ast.providers) {
707
+ for (const model of provider.models) {
708
+ providerModels.add(model);
709
+ }
710
+ }
711
+ for (const node of ast.nodes) {
712
+ if (!isAgent(node))
713
+ continue;
714
+ if (node.model && !providerModels.has(node.model)) {
715
+ results.push({
716
+ rule: "V18",
717
+ level: "warning",
718
+ message: `Agent "${node.id}" uses model "${node.model}" which is not listed in any provider's models`,
719
+ node: node.id,
720
+ line: lookupLine(ast, node.id),
721
+ });
722
+ }
723
+ }
724
+ // Also check orchestrator
725
+ for (const node of ast.nodes) {
726
+ if (!isOrchestrator(node))
727
+ continue;
728
+ if (node.model && !providerModels.has(node.model)) {
729
+ results.push({
730
+ rule: "V18",
731
+ level: "warning",
732
+ message: `Orchestrator uses model "${node.model}" which is not listed in any provider's models`,
733
+ node: "orchestrator",
734
+ line: lookupLine(ast, "orchestrator"),
735
+ });
736
+ }
737
+ }
738
+ return results;
739
+ }
740
+ /** V19: Provider names must be unique. */
741
+ function v19UniqueProviders(ast) {
742
+ const results = [];
743
+ const seen = new Set();
744
+ for (const provider of ast.providers) {
745
+ if (seen.has(provider.name)) {
746
+ results.push({
747
+ rule: "V19",
748
+ level: "error",
749
+ message: `Duplicate provider name "${provider.name}"`,
750
+ node: provider.name,
751
+ });
752
+ }
753
+ else {
754
+ seen.add(provider.name);
755
+ }
756
+ }
757
+ return results;
758
+ }
759
+ /** V20: Every schedule job must reference a declared agent or action; cron and every are mutually exclusive. */
760
+ function v20ScheduleJobs(ast) {
761
+ const results = [];
762
+ const ids = allNodeIds(ast);
763
+ for (const job of ast.schedules) {
764
+ // cron and every are mutually exclusive
765
+ if (job.cron && job.every) {
766
+ results.push({
767
+ rule: "V20",
768
+ level: "error",
769
+ message: `Schedule job "${job.id}" has both "cron" and "every" — they are mutually exclusive`,
770
+ node: job.id,
771
+ });
772
+ }
773
+ // Must reference a declared agent or action
774
+ if (job.agent && !ids.has(job.agent)) {
775
+ results.push({
776
+ rule: "V20",
777
+ level: "error",
778
+ message: `Schedule job "${job.id}" references undeclared agent "${job.agent}"`,
779
+ node: job.id,
780
+ });
781
+ }
782
+ if (job.action && !ids.has(job.action)) {
783
+ results.push({
784
+ rule: "V20",
785
+ level: "error",
786
+ message: `Schedule job "${job.id}" references undeclared action "${job.action}"`,
787
+ node: job.id,
788
+ });
789
+ }
790
+ }
791
+ return results;
792
+ }
793
+ /** V21: Interface webhook/auth values containing literal secrets (not `${ENV_VAR}`) should error. */
794
+ function v21InterfaceSecrets(ast) {
795
+ const results = [];
796
+ const envVarPattern = /^\$\{[A-Z][A-Z0-9_]*\}$/;
797
+ const sensitiveKeys = new Set(["webhook", "auth", "token", "secret"]);
798
+ for (const iface of ast.interfaces) {
799
+ for (const [key, value] of Object.entries(iface.config)) {
800
+ if (!sensitiveKeys.has(key))
801
+ continue;
802
+ if (typeof value === "string" && !envVarPattern.test(value)) {
803
+ results.push({
804
+ rule: "V21",
805
+ level: "error",
806
+ message: `Interface "${iface.id}" has a literal "${key}" value — must be a \${ENV_VAR} reference`,
807
+ node: iface.id,
808
+ });
809
+ }
810
+ }
811
+ }
812
+ return results;
813
+ }
814
+ /** V22: Every model in a fallback-chain should exist in at least one provider's models list. */
815
+ function v22FallbackChainModels(ast) {
816
+ const results = [];
817
+ if (ast.providers.length === 0)
818
+ return results;
819
+ const providerModels = new Set();
820
+ for (const provider of ast.providers) {
821
+ for (const model of provider.models) {
822
+ providerModels.add(model);
823
+ }
824
+ }
825
+ // Check per-agent fallback chains
826
+ for (const node of ast.nodes) {
827
+ if (!isAgent(node))
828
+ continue;
829
+ if (!node.fallbackChain)
830
+ continue;
831
+ for (const model of node.fallbackChain) {
832
+ if (!providerModels.has(model)) {
833
+ results.push({
834
+ rule: "V22",
835
+ level: "warning",
836
+ message: `Agent "${node.id}" fallback-chain references model "${model}" which is not listed in any provider's models`,
837
+ node: node.id,
838
+ line: lookupLine(ast, node.id),
839
+ });
840
+ }
841
+ }
842
+ }
843
+ // Check settings-level fallback chain
844
+ const settingsFallback = ast.settings.fallbackChain;
845
+ if (Array.isArray(settingsFallback)) {
846
+ for (const model of settingsFallback) {
847
+ if (typeof model === "string" && !providerModels.has(model)) {
848
+ results.push({
849
+ rule: "V22",
850
+ level: "warning",
851
+ message: `Settings fallback-chain references model "${model}" which is not listed in any provider's models`,
852
+ });
853
+ }
854
+ }
855
+ }
856
+ return results;
857
+ }
858
+ /**
859
+ * V23: Duplicate top-level singleton sections.
860
+ *
861
+ * Sections like `meta`, `flow`, `memory`, etc. should appear at most once.
862
+ * When duplicates are found, only the first occurrence is used by the parser.
863
+ * This rule surfaces warnings collected during parsing.
864
+ */
865
+ function v23DuplicateSections(ast) {
866
+ return ast._duplicateSectionWarnings ?? [];
867
+ }
868
+ /**
869
+ * V24: Unknown sub-blocks in the `memory` section.
870
+ *
871
+ * Only known sub-blocks (domains, references, external-docs, metrics,
872
+ * workspace) are parsed. Any other named sub-block is flagged as a warning.
873
+ */
874
+ function v24UnknownMemorySubBlocks(ast) {
875
+ return ast._unknownMemorySubBlockWarnings ?? [];
876
+ }
877
+ /** V26: `action.kind` must be one of the allowed values. */
878
+ function v26ActionKindEnum(ast) {
879
+ const results = [];
880
+ const VALID_KINDS = new Set(["external", "git", "decision", "inline", "report"]);
881
+ for (const node of ast.nodes) {
882
+ if (node.type !== "action")
883
+ continue;
884
+ const action = node;
885
+ if (action.kind && !VALID_KINDS.has(action.kind)) {
886
+ results.push({
887
+ rule: "V26",
888
+ level: "error",
889
+ message: `Action "${action.id}" has invalid kind "${action.kind}" — must be one of: external, git, decision, inline, report`,
890
+ node: action.id,
891
+ line: lookupLine(ast, action.id),
892
+ });
893
+ }
894
+ }
895
+ return results;
896
+ }
897
+ /** V27: `agent.permissions` should be one of the known values. */
898
+ function v27AgentPermissionsEnum(ast) {
899
+ const results = [];
900
+ const VALID_PERMISSIONS = new Set([
901
+ "autonomous", "supervised", "interactive", "unrestricted",
902
+ "plan", "auto", "confirm", "bypass",
903
+ ]);
904
+ for (const node of ast.nodes) {
905
+ if (!isAgent(node))
906
+ continue;
907
+ if (node.permissions && !VALID_PERMISSIONS.has(node.permissions)) {
908
+ results.push({
909
+ rule: "V27",
910
+ level: "warning",
911
+ message: `Agent "${node.id}" has unrecognized permissions "${node.permissions}" — known values: autonomous, supervised, interactive, unrestricted, plan, auto, confirm, bypass`,
912
+ node: node.id,
913
+ line: lookupLine(ast, node.id),
914
+ });
915
+ }
916
+ }
917
+ return results;
918
+ }
919
+ /** V28: `metering.format` must be one of the allowed values. */
920
+ function v28MeteringFormatEnum(ast) {
921
+ const results = [];
922
+ const VALID_FORMATS = new Set(["json", "jsonl", "csv"]);
923
+ if (ast.metering && !VALID_FORMATS.has(ast.metering.format)) {
924
+ results.push({
925
+ rule: "V28",
926
+ level: "error",
927
+ message: `Metering format "${ast.metering.format}" is invalid — must be one of: json, jsonl, csv`,
928
+ });
929
+ }
930
+ return results;
931
+ }
932
+ /** V29: `metering.pricing` should be one of the known values. */
933
+ function v29MeteringPricingEnum(ast) {
934
+ const results = [];
935
+ const VALID_PRICING = new Set(["anthropic-current", "custom", "none"]);
936
+ if (ast.metering && !VALID_PRICING.has(ast.metering.pricing)) {
937
+ results.push({
938
+ rule: "V29",
939
+ level: "warning",
940
+ message: `Metering pricing "${ast.metering.pricing}" is unrecognized — known values: anthropic-current, custom, none`,
941
+ });
942
+ }
943
+ return results;
944
+ }
945
+ /** V25: `on-fail: bounce-back` is advisory on all CLI bindings. */
946
+ function v25BounceBackAdvisory(ast) {
947
+ const results = [];
948
+ for (const node of ast.nodes) {
949
+ if (node.type !== "gate")
950
+ continue;
951
+ const gate = node;
952
+ if (gate.onFail === "bounce-back") {
953
+ results.push({
954
+ rule: "V25",
955
+ level: "warning",
956
+ message: `Gate "${gate.id}" uses on-fail: bounce-back which is advisory on all CLI bindings — requires orchestrator cooperation or a framework binding for enforcement`,
957
+ node: gate.id,
958
+ });
959
+ }
960
+ }
961
+ return results;
962
+ }
963
+ /** V30: Validate `timeout` format is a valid duration string (matches /^\d+[smhd]$/). */
964
+ function v30TimeoutFormat(ast) {
965
+ const results = [];
966
+ const durationRe = /^\d+[smhd]$/;
967
+ for (const node of ast.nodes) {
968
+ if (isAgent(node) && node.timeout) {
969
+ if (!durationRe.test(node.timeout)) {
970
+ results.push({
971
+ rule: "V30",
972
+ level: "error",
973
+ message: `Agent "${node.id}" has invalid timeout "${node.timeout}" — must match format like "30s", "5m", "2h", "1d"`,
974
+ node: node.id,
975
+ line: lookupLine(ast, node.id),
976
+ });
977
+ }
978
+ }
979
+ if (node.type === "action") {
980
+ const action = node;
981
+ if (action.timeout && !durationRe.test(action.timeout)) {
982
+ results.push({
983
+ rule: "V30",
984
+ level: "error",
985
+ message: `Action "${action.id}" has invalid timeout "${action.timeout}" — must match format like "30s", "5m", "2h", "1d"`,
986
+ node: action.id,
987
+ line: lookupLine(ast, action.id),
988
+ });
989
+ }
990
+ }
991
+ if (isGate(node) && node.timeout) {
992
+ if (!durationRe.test(node.timeout)) {
993
+ results.push({
994
+ rule: "V30",
995
+ level: "error",
996
+ message: `Gate "${node.id}" has invalid timeout "${node.timeout}" — must match format like "30s", "5m", "2h", "1d"`,
997
+ node: node.id,
998
+ line: lookupLine(ast, node.id),
999
+ });
1000
+ }
1001
+ }
1002
+ if (isHuman(node) && node.timeout) {
1003
+ if (!durationRe.test(node.timeout)) {
1004
+ results.push({
1005
+ rule: "V30",
1006
+ level: "error",
1007
+ message: `Human node "${node.id}" has invalid timeout "${node.timeout}" — must match format like "30s", "5m", "2h", "1d"`,
1008
+ node: node.id,
1009
+ line: lookupLine(ast, node.id),
1010
+ });
1011
+ }
1012
+ }
1013
+ if (isGroup(node) && node.timeout) {
1014
+ if (!durationRe.test(node.timeout)) {
1015
+ results.push({
1016
+ rule: "V30",
1017
+ level: "error",
1018
+ message: `Group node "${node.id}" has invalid timeout "${node.timeout}" — must match format like "30s", "5m", "2h", "1d"`,
1019
+ node: node.id,
1020
+ line: lookupLine(ast, node.id),
1021
+ });
1022
+ }
1023
+ }
1024
+ }
1025
+ return results;
1026
+ }
1027
+ /** V59: Human node `on-timeout` must be one of: halt, skip, or start with "fallback ". */
1028
+ function v59HumanOnTimeout(ast) {
1029
+ const results = [];
1030
+ const VALID_ON_TIMEOUT = new Set(["halt", "skip"]);
1031
+ for (const node of ast.nodes) {
1032
+ if (isHuman(node) && node.onTimeout) {
1033
+ if (!VALID_ON_TIMEOUT.has(node.onTimeout) && !node.onTimeout.startsWith("fallback ")) {
1034
+ results.push({
1035
+ rule: "V59",
1036
+ level: "error",
1037
+ message: `Human node "${node.id}" has invalid on-timeout "${node.onTimeout}" — must be "halt", "skip", or "fallback <id>"`,
1038
+ node: node.id,
1039
+ line: lookupLine(ast, node.id),
1040
+ });
1041
+ }
1042
+ }
1043
+ }
1044
+ return results;
1045
+ }
1046
+ /** V31: Validate `on-fail` is one of: halt, retry, skip, continue, or starts with "fallback ". */
1047
+ function v31OnFailValue(ast) {
1048
+ const results = [];
1049
+ const VALID_ON_FAIL = new Set(["halt", "retry", "skip", "continue"]);
1050
+ function checkOnFail(nodeId, nodeType, onFail) {
1051
+ if (!VALID_ON_FAIL.has(onFail) && !onFail.startsWith("fallback ")) {
1052
+ results.push({
1053
+ rule: "V31",
1054
+ level: "error",
1055
+ message: `${nodeType} "${nodeId}" has invalid on-fail "${onFail}" — must be one of: halt, retry, skip, continue, or "fallback <agent-id>"`,
1056
+ node: nodeId,
1057
+ line: lookupLine(ast, nodeId),
1058
+ });
1059
+ }
1060
+ }
1061
+ for (const node of ast.nodes) {
1062
+ if (isAgent(node) && node.onFail) {
1063
+ checkOnFail(node.id, "Agent", node.onFail);
1064
+ }
1065
+ if (node.type === "action") {
1066
+ const action = node;
1067
+ if (action.onFail) {
1068
+ checkOnFail(action.id, "Action", action.onFail);
1069
+ }
1070
+ }
1071
+ }
1072
+ return results;
1073
+ }
1074
+ /** V32: If `on-fail: fallback <id>`, validate that the referenced agent exists. */
1075
+ function v32FallbackTargetExists(ast) {
1076
+ const results = [];
1077
+ const ids = allNodeIds(ast);
1078
+ function checkFallback(nodeId, nodeType, onFail) {
1079
+ if (onFail.startsWith("fallback ")) {
1080
+ const targetId = onFail.slice("fallback ".length).trim();
1081
+ if (!ids.has(targetId)) {
1082
+ results.push({
1083
+ rule: "V32",
1084
+ level: "error",
1085
+ message: `${nodeType} "${nodeId}" references undeclared fallback agent "${targetId}"`,
1086
+ node: nodeId,
1087
+ line: lookupLine(ast, nodeId),
1088
+ });
1089
+ }
1090
+ }
1091
+ }
1092
+ for (const node of ast.nodes) {
1093
+ if (isAgent(node) && node.onFail) {
1094
+ checkFallback(node.id, "Agent", node.onFail);
1095
+ }
1096
+ if (node.type === "action") {
1097
+ const action = node;
1098
+ if (action.onFail) {
1099
+ checkFallback(action.id, "Action", action.onFail);
1100
+ }
1101
+ }
1102
+ }
1103
+ return results;
1104
+ }
1105
+ /** V33: Validate retry block fields (backoff enum, interval format, non-retryable is list). */
1106
+ function v33RetryBlockFields(ast) {
1107
+ const results = [];
1108
+ const VALID_BACKOFF = new Set(["none", "linear", "exponential"]);
1109
+ const durationRe = /^\d+[smhd]$/;
1110
+ for (const node of ast.nodes) {
1111
+ if (!isAgent(node))
1112
+ continue;
1113
+ if (!node.retry || typeof node.retry === "number")
1114
+ continue;
1115
+ const retryConfig = node.retry;
1116
+ if (retryConfig.backoff && !VALID_BACKOFF.has(retryConfig.backoff)) {
1117
+ results.push({
1118
+ rule: "V33",
1119
+ level: "error",
1120
+ message: `Agent "${node.id}" retry block has invalid backoff "${retryConfig.backoff}" — must be one of: none, linear, exponential`,
1121
+ node: node.id,
1122
+ line: lookupLine(ast, node.id),
1123
+ });
1124
+ }
1125
+ if (retryConfig.interval && !durationRe.test(retryConfig.interval)) {
1126
+ results.push({
1127
+ rule: "V33",
1128
+ level: "error",
1129
+ message: `Agent "${node.id}" retry block has invalid interval "${retryConfig.interval}" — must match format like "1s", "5m"`,
1130
+ node: node.id,
1131
+ line: lookupLine(ast, node.id),
1132
+ });
1133
+ }
1134
+ if (retryConfig.maxInterval && !durationRe.test(retryConfig.maxInterval)) {
1135
+ results.push({
1136
+ rule: "V33",
1137
+ level: "error",
1138
+ message: `Agent "${node.id}" retry block has invalid max-interval "${retryConfig.maxInterval}" — must match format like "60s", "5m"`,
1139
+ node: node.id,
1140
+ line: lookupLine(ast, node.id),
1141
+ });
1142
+ }
1143
+ }
1144
+ return results;
1145
+ }
1146
+ /** V34: Validate `temperature` is between 0 and 2 (on agents and defaults). */
1147
+ function v34TemperatureRange(ast) {
1148
+ const results = [];
1149
+ for (const node of ast.nodes) {
1150
+ if (!isAgent(node))
1151
+ continue;
1152
+ if (node.temperature !== undefined && (node.temperature < 0 || node.temperature > 2)) {
1153
+ results.push({
1154
+ rule: "V34",
1155
+ level: "error",
1156
+ message: `Agent "${node.id}" has temperature ${node.temperature} — must be between 0 and 2`,
1157
+ node: node.id,
1158
+ line: lookupLine(ast, node.id),
1159
+ });
1160
+ }
1161
+ }
1162
+ if (ast.defaults?.temperature !== undefined && (ast.defaults.temperature < 0 || ast.defaults.temperature > 2)) {
1163
+ results.push({
1164
+ rule: "V34",
1165
+ level: "error",
1166
+ message: `Defaults block has temperature ${ast.defaults.temperature} — must be between 0 and 2`,
1167
+ });
1168
+ }
1169
+ return results;
1170
+ }
1171
+ /** V35: Validate `thinking` is one of: off, low, medium, high, max. */
1172
+ function v35ThinkingEnum(ast) {
1173
+ const results = [];
1174
+ const VALID_THINKING = new Set(["off", "low", "medium", "high", "max"]);
1175
+ for (const node of ast.nodes) {
1176
+ if (!isAgent(node))
1177
+ continue;
1178
+ if (node.thinking && !VALID_THINKING.has(node.thinking)) {
1179
+ results.push({
1180
+ rule: "V35",
1181
+ level: "error",
1182
+ message: `Agent "${node.id}" has invalid thinking "${node.thinking}" — must be one of: off, low, medium, high, max`,
1183
+ node: node.id,
1184
+ line: lookupLine(ast, node.id),
1185
+ });
1186
+ }
1187
+ }
1188
+ if (ast.defaults?.thinking && !VALID_THINKING.has(ast.defaults.thinking)) {
1189
+ results.push({
1190
+ rule: "V35",
1191
+ level: "error",
1192
+ message: `Defaults block has invalid thinking "${ast.defaults.thinking}" — must be one of: off, low, medium, high, max`,
1193
+ });
1194
+ }
1195
+ return results;
1196
+ }
1197
+ /** V36: Validate `output-format` is one of: text, json, json-schema. */
1198
+ function v36OutputFormatEnum(ast) {
1199
+ const results = [];
1200
+ const VALID_FORMATS = new Set(["text", "json", "json-schema"]);
1201
+ for (const node of ast.nodes) {
1202
+ if (!isAgent(node))
1203
+ continue;
1204
+ if (node.outputFormat && !VALID_FORMATS.has(node.outputFormat)) {
1205
+ results.push({
1206
+ rule: "V36",
1207
+ level: "error",
1208
+ message: `Agent "${node.id}" has invalid output-format "${node.outputFormat}" — must be one of: text, json, json-schema`,
1209
+ node: node.id,
1210
+ line: lookupLine(ast, node.id),
1211
+ });
1212
+ }
1213
+ }
1214
+ if (ast.defaults?.outputFormat && !VALID_FORMATS.has(ast.defaults.outputFormat)) {
1215
+ results.push({
1216
+ rule: "V36",
1217
+ level: "error",
1218
+ message: `Defaults block has invalid output-format "${ast.defaults.outputFormat}" — must be one of: text, json, json-schema`,
1219
+ });
1220
+ }
1221
+ return results;
1222
+ }
1223
+ /** V37: Validate `log-level` is one of: debug, info, warn, error. */
1224
+ function v37LogLevelEnum(ast) {
1225
+ const results = [];
1226
+ const VALID_LEVELS = new Set(["debug", "info", "warn", "error"]);
1227
+ for (const node of ast.nodes) {
1228
+ if (!isAgent(node))
1229
+ continue;
1230
+ if (node.logLevel && !VALID_LEVELS.has(node.logLevel)) {
1231
+ results.push({
1232
+ rule: "V37",
1233
+ level: "error",
1234
+ message: `Agent "${node.id}" has invalid log-level "${node.logLevel}" — must be one of: debug, info, warn, error`,
1235
+ node: node.id,
1236
+ line: lookupLine(ast, node.id),
1237
+ });
1238
+ }
1239
+ }
1240
+ if (ast.defaults?.logLevel && !VALID_LEVELS.has(ast.defaults.logLevel)) {
1241
+ results.push({
1242
+ rule: "V37",
1243
+ level: "error",
1244
+ message: `Defaults block has invalid log-level "${ast.defaults.logLevel}" — must be one of: debug, info, warn, error`,
1245
+ });
1246
+ }
1247
+ return results;
1248
+ }
1249
+ /** V38: Validate `max-tokens` is a positive integer. */
1250
+ function v38MaxTokensPositive(ast) {
1251
+ const results = [];
1252
+ for (const node of ast.nodes) {
1253
+ if (!isAgent(node))
1254
+ continue;
1255
+ if (node.maxTokens !== undefined && (!Number.isInteger(node.maxTokens) || node.maxTokens <= 0)) {
1256
+ results.push({
1257
+ rule: "V38",
1258
+ level: "error",
1259
+ message: `Agent "${node.id}" has invalid max-tokens ${node.maxTokens} — must be a positive integer`,
1260
+ node: node.id,
1261
+ line: lookupLine(ast, node.id),
1262
+ });
1263
+ }
1264
+ }
1265
+ if (ast.defaults?.maxTokens !== undefined && (!Number.isInteger(ast.defaults.maxTokens) || ast.defaults.maxTokens <= 0)) {
1266
+ results.push({
1267
+ rule: "V38",
1268
+ level: "error",
1269
+ message: `Defaults block has invalid max-tokens ${ast.defaults.maxTokens} — must be a positive integer`,
1270
+ });
1271
+ }
1272
+ return results;
1273
+ }
1274
+ /** Regex for N-of-M quorum join pattern. */
1275
+ const QUORUM_RE = /^\d+-of-\d+$/;
1276
+ /** Check whether a join value is valid (keyword or quorum pattern). */
1277
+ function isValidJoin(value) {
1278
+ const VALID_JOIN = new Set(["all", "any", "all-done", "none-failed"]);
1279
+ return VALID_JOIN.has(value) || QUORUM_RE.test(value);
1280
+ }
1281
+ /** V39: Validate `join` is one of: all, any, all-done, none-failed, or N-of-M. */
1282
+ function v39JoinEnum(ast) {
1283
+ const results = [];
1284
+ for (const node of ast.nodes) {
1285
+ if (isAgent(node) && node.join && !isValidJoin(node.join)) {
1286
+ results.push({
1287
+ rule: "V39",
1288
+ level: "error",
1289
+ message: `Agent "${node.id}" has invalid join "${node.join}" — must be one of: all, any, all-done, none-failed, or N-of-M (e.g. 2-of-3)`,
1290
+ node: node.id,
1291
+ line: lookupLine(ast, node.id),
1292
+ });
1293
+ }
1294
+ if (node.type === "action") {
1295
+ const action = node;
1296
+ if (action.join && !isValidJoin(action.join)) {
1297
+ results.push({
1298
+ rule: "V39",
1299
+ level: "error",
1300
+ message: `Action "${action.id}" has invalid join "${action.join}" — must be one of: all, any, all-done, none-failed, or N-of-M (e.g. 2-of-3)`,
1301
+ node: action.id,
1302
+ line: lookupLine(ast, action.id),
1303
+ });
1304
+ }
1305
+ }
1306
+ }
1307
+ return results;
1308
+ }
1309
+ /** V40: Error edge target must be a declared node. */
1310
+ function v40ErrorEdgeTarget(ast) {
1311
+ const results = [];
1312
+ const ids = allNodeIds(ast);
1313
+ for (const edge of ast.edges) {
1314
+ if (edge.isError) {
1315
+ if (!ids.has(edge.to)) {
1316
+ results.push({
1317
+ rule: "V40",
1318
+ level: "error",
1319
+ message: `Error edge target "${edge.to}" is not a declared node`,
1320
+ node: edge.to,
1321
+ });
1322
+ }
1323
+ }
1324
+ }
1325
+ return results;
1326
+ }
1327
+ /** V41: `[race]` is only valid on fan-out edges (node has multiple outgoing edges). */
1328
+ function v41RaceFanOut(ast) {
1329
+ const results = [];
1330
+ // Count outgoing edges per source node
1331
+ const outgoing = new Map();
1332
+ for (const edge of ast.edges) {
1333
+ outgoing.set(edge.from, (outgoing.get(edge.from) ?? 0) + 1);
1334
+ }
1335
+ for (const edge of ast.edges) {
1336
+ if (edge.race) {
1337
+ const count = outgoing.get(edge.from) ?? 0;
1338
+ if (count < 2) {
1339
+ results.push({
1340
+ rule: "V41",
1341
+ level: "error",
1342
+ message: `[race] on edge "${edge.from}" -> "${edge.to}" is only valid on fan-out edges (node must have multiple outgoing edges)`,
1343
+ node: edge.from,
1344
+ });
1345
+ }
1346
+ }
1347
+ }
1348
+ return results;
1349
+ }
1350
+ /** V42: `[tolerance]` format is valid (integer or percentage string matching /^\d+%?$/). */
1351
+ function v42ToleranceFormat(ast) {
1352
+ const results = [];
1353
+ const toleranceRe = /^\d+%?$/;
1354
+ for (const edge of ast.edges) {
1355
+ if (edge.tolerance !== undefined) {
1356
+ const tolStr = String(edge.tolerance);
1357
+ if (!toleranceRe.test(tolStr)) {
1358
+ results.push({
1359
+ rule: "V42",
1360
+ level: "error",
1361
+ message: `Edge "${edge.from}" -> "${edge.to}" has invalid tolerance "${tolStr}" — must be an integer or a percentage (e.g. "2" or "33%")`,
1362
+ node: edge.from,
1363
+ });
1364
+ }
1365
+ }
1366
+ }
1367
+ return results;
1368
+ }
1369
+ /** V43: `[wait]` format is a valid duration string (reuse timeout validation pattern). */
1370
+ function v43WaitFormat(ast) {
1371
+ const results = [];
1372
+ const durationRe = /^\d+[smhd]$/;
1373
+ for (const edge of ast.edges) {
1374
+ if (edge.wait) {
1375
+ if (!durationRe.test(edge.wait)) {
1376
+ results.push({
1377
+ rule: "V43",
1378
+ level: "error",
1379
+ message: `Edge "${edge.from}" -> "${edge.to}" has invalid wait duration "${edge.wait}" — must match format like "30s", "5m", "2h", "1d"`,
1380
+ node: edge.from,
1381
+ });
1382
+ }
1383
+ }
1384
+ }
1385
+ return results;
1386
+ }
1387
+ /** V44: Topology-level `error-handler` must reference a declared node. */
1388
+ function v44ErrorHandlerExists(ast) {
1389
+ const results = [];
1390
+ if (ast.topology.errorHandler) {
1391
+ const ids = allNodeIds(ast);
1392
+ if (!ids.has(ast.topology.errorHandler)) {
1393
+ results.push({
1394
+ rule: "V44",
1395
+ level: "error",
1396
+ message: `Topology error-handler "${ast.topology.errorHandler}" is not a declared node`,
1397
+ });
1398
+ }
1399
+ }
1400
+ return results;
1401
+ }
1402
+ /** V45: Topology-level `timeout` must be a valid duration string. */
1403
+ function v45TopologyTimeout(ast) {
1404
+ const results = [];
1405
+ const durationRe = /^\d+[smhd]$/;
1406
+ if (ast.topology.timeout && !durationRe.test(ast.topology.timeout)) {
1407
+ results.push({
1408
+ rule: "V45",
1409
+ level: "error",
1410
+ message: `Topology timeout "${ast.topology.timeout}" is invalid — must match format like "30s", "5m", "2h", "1d"`,
1411
+ });
1412
+ }
1413
+ return results;
1414
+ }
1415
+ /** V46: Schema type names must be valid (primitive, array of X, enum, or ref). */
1416
+ function v46SchemaTypeValid(ast) {
1417
+ const results = [];
1418
+ const VALID_PRIMITIVES = new Set(["string", "number", "integer", "boolean", "object"]);
1419
+ function validateSchemaType(t, context) {
1420
+ switch (t.kind) {
1421
+ case "primitive":
1422
+ if (!VALID_PRIMITIVES.has(t.value)) {
1423
+ results.push({
1424
+ rule: "V46",
1425
+ level: "error",
1426
+ message: `Invalid schema type "${t.value}" in ${context} — must be string, number, integer, boolean, or object`,
1427
+ });
1428
+ }
1429
+ break;
1430
+ case "array":
1431
+ validateSchemaType(t.itemType, context);
1432
+ break;
1433
+ case "enum":
1434
+ // Enum values are always valid strings
1435
+ break;
1436
+ case "ref":
1437
+ // Ref validity is checked by V47
1438
+ break;
1439
+ }
1440
+ }
1441
+ function validateFields(fields, context) {
1442
+ for (const field of fields) {
1443
+ validateSchemaType(field.type, `${context} field "${field.name}"`);
1444
+ }
1445
+ }
1446
+ // Check top-level schemas
1447
+ for (const schema of ast.schemas) {
1448
+ validateFields(schema.fields, `schema "${schema.id}"`);
1449
+ }
1450
+ // Check agent input/output schemas
1451
+ for (const node of ast.nodes) {
1452
+ if (node.type === "agent") {
1453
+ if (node.inputSchema) {
1454
+ validateFields(node.inputSchema, `agent "${node.id}" input-schema`);
1455
+ }
1456
+ if (node.outputSchema) {
1457
+ validateFields(node.outputSchema, `agent "${node.id}" output-schema`);
1458
+ }
1459
+ }
1460
+ }
1461
+ return results;
1462
+ }
1463
+ /** V47: Schema `ref` names must resolve to a declared schema in the top-level `schemas` block. */
1464
+ function v47SchemaRefResolves(ast) {
1465
+ const results = [];
1466
+ const declaredSchemas = new Set(ast.schemas.map((s) => s.id));
1467
+ function checkRefs(t, context) {
1468
+ switch (t.kind) {
1469
+ case "ref":
1470
+ if (!declaredSchemas.has(t.name)) {
1471
+ results.push({
1472
+ rule: "V47",
1473
+ level: "error",
1474
+ message: `Schema reference "${t.name}" in ${context} does not resolve to any declared schema`,
1475
+ });
1476
+ }
1477
+ break;
1478
+ case "array":
1479
+ checkRefs(t.itemType, context);
1480
+ break;
1481
+ // primitive and enum have no refs
1482
+ }
1483
+ }
1484
+ function checkFields(fields, context) {
1485
+ for (const field of fields) {
1486
+ checkRefs(field.type, `${context} field "${field.name}"`);
1487
+ }
1488
+ }
1489
+ // Check top-level schemas (can reference each other)
1490
+ for (const schema of ast.schemas) {
1491
+ checkFields(schema.fields, `schema "${schema.id}"`);
1492
+ }
1493
+ // Check agent input/output schemas
1494
+ for (const node of ast.nodes) {
1495
+ if (node.type === "agent") {
1496
+ if (node.inputSchema) {
1497
+ checkFields(node.inputSchema, `agent "${node.id}" input-schema`);
1498
+ }
1499
+ if (node.outputSchema) {
1500
+ checkFields(node.outputSchema, `agent "${node.id}" output-schema`);
1501
+ }
1502
+ }
1503
+ }
1504
+ return results;
1505
+ }
1506
+ // ---------------------------------------------------------------------------
1507
+ // V48 – Observability level enum
1508
+ // ---------------------------------------------------------------------------
1509
+ const VALID_OBSERVABILITY_LEVELS = new Set([
1510
+ "debug", "info", "warn", "error",
1511
+ ]);
1512
+ /** V48: Validate `observability.level` is one of: debug, info, warn, error. */
1513
+ function v48ObservabilityLevel(ast) {
1514
+ const results = [];
1515
+ if (ast.observability && !VALID_OBSERVABILITY_LEVELS.has(ast.observability.level)) {
1516
+ results.push({
1517
+ rule: "V48",
1518
+ level: "error",
1519
+ message: `Observability level "${ast.observability.level}" is invalid — must be one of: ${[...VALID_OBSERVABILITY_LEVELS].join(", ")}`,
1520
+ });
1521
+ }
1522
+ return results;
1523
+ }
1524
+ // ---------------------------------------------------------------------------
1525
+ // V49 – Observability exporter enum
1526
+ // ---------------------------------------------------------------------------
1527
+ const VALID_OBSERVABILITY_EXPORTERS = new Set([
1528
+ "otlp", "langsmith", "datadog", "stdout", "none",
1529
+ ]);
1530
+ /** V49: Validate `observability.exporter` is one of: otlp, langsmith, datadog, stdout, none. */
1531
+ function v49ObservabilityExporter(ast) {
1532
+ const results = [];
1533
+ if (ast.observability && !VALID_OBSERVABILITY_EXPORTERS.has(ast.observability.exporter)) {
1534
+ results.push({
1535
+ rule: "V49",
1536
+ level: "error",
1537
+ message: `Observability exporter "${ast.observability.exporter}" is invalid — must be one of: ${[...VALID_OBSERVABILITY_EXPORTERS].join(", ")}`,
1538
+ });
1539
+ }
1540
+ return results;
1541
+ }
1542
+ // ---------------------------------------------------------------------------
1543
+ // V50 – Observability sample-rate range
1544
+ // ---------------------------------------------------------------------------
1545
+ /** V50: Validate `observability.sample-rate` is between 0 and 1 (inclusive). */
1546
+ function v50ObservabilitySampleRate(ast) {
1547
+ const results = [];
1548
+ if (ast.observability) {
1549
+ const rate = ast.observability.sampleRate;
1550
+ if (isNaN(rate) || rate < 0 || rate > 1) {
1551
+ results.push({
1552
+ rule: "V50",
1553
+ level: "error",
1554
+ message: `Observability sample-rate ${rate} is invalid — must be between 0 and 1`,
1555
+ });
1556
+ }
1557
+ }
1558
+ return results;
1559
+ }
1560
+ // ---------------------------------------------------------------------------
1561
+ // V51 – Sensitive literal warning
1562
+ // ---------------------------------------------------------------------------
1563
+ const SENSITIVE_ENV_VAR_RE = /\$\{[^}]+\}/;
1564
+ /**
1565
+ * V51: When `sensitive` is used with a literal string (not a `${...}` env var
1566
+ * reference), emit a warning. Literal secrets should never appear in source.
1567
+ */
1568
+ function v51SensitiveLiteral(ast) {
1569
+ const results = [];
1570
+ for (const [key, val] of Object.entries(ast.env)) {
1571
+ if (typeof val === "object" && val !== null && val.sensitive && !val.secretRef) {
1572
+ // It's a sensitive value without a secret ref — check if it's a literal
1573
+ if (!SENSITIVE_ENV_VAR_RE.test(val.value)) {
1574
+ results.push({
1575
+ rule: "V51",
1576
+ level: "warning",
1577
+ message: `env "${key}": sensitive value should reference an environment variable, not a literal string`,
1578
+ });
1579
+ }
1580
+ }
1581
+ }
1582
+ return results;
1583
+ }
1584
+ // ---------------------------------------------------------------------------
1585
+ // V52 – Secret URI scheme validation
1586
+ // ---------------------------------------------------------------------------
1587
+ const VALID_SECRET_SCHEMES = new Set([
1588
+ "vault", "op", "awssm", "ssm", "gcpsm", "azurekv",
1589
+ ]);
1590
+ /**
1591
+ * V52: Validate that secret URI schemes are one of the supported providers.
1592
+ */
1593
+ function v52SecretUriScheme(ast) {
1594
+ const results = [];
1595
+ for (const [key, val] of Object.entries(ast.env)) {
1596
+ if (typeof val === "object" && val !== null && val.secretRef) {
1597
+ if (!VALID_SECRET_SCHEMES.has(val.secretRef.scheme)) {
1598
+ results.push({
1599
+ rule: "V52",
1600
+ level: "error",
1601
+ message: `env "${key}": unknown secret URI scheme "${val.secretRef.scheme}" — must be one of: ${[...VALID_SECRET_SCHEMES].join(", ")}`,
1602
+ });
1603
+ }
1604
+ }
1605
+ }
1606
+ return results;
1607
+ }
1608
+ // ---------------------------------------------------------------------------
1609
+ // V53 – Param type validation
1610
+ // ---------------------------------------------------------------------------
1611
+ /**
1612
+ * V53: Param type must be one of: `string`, `number`, `boolean`.
1613
+ */
1614
+ function v53ParamType(ast) {
1615
+ const results = [];
1616
+ const VALID_PARAM_TYPES = new Set(["string", "number", "boolean"]);
1617
+ for (const param of ast.params) {
1618
+ if (!VALID_PARAM_TYPES.has(param.type)) {
1619
+ results.push({
1620
+ rule: "V53",
1621
+ level: "error",
1622
+ message: `param "${param.name}": type "${param.type}" is invalid — must be one of: string, number, boolean`,
1623
+ });
1624
+ }
1625
+ }
1626
+ return results;
1627
+ }
1628
+ // ---------------------------------------------------------------------------
1629
+ // V54 – Interface entry/exit must reference declared nodes
1630
+ // ---------------------------------------------------------------------------
1631
+ /**
1632
+ * V54: Interface entry and exit must reference declared node ids.
1633
+ */
1634
+ function v54InterfaceEndpoints(ast) {
1635
+ const results = [];
1636
+ if (!ast.interfaceEndpoints)
1637
+ return results;
1638
+ const nodeIds = new Set(ast.nodes.map((n) => n.id));
1639
+ if (!nodeIds.has(ast.interfaceEndpoints.entry)) {
1640
+ results.push({
1641
+ rule: "V54",
1642
+ level: "error",
1643
+ message: `interface entry "${ast.interfaceEndpoints.entry}" does not reference a declared node`,
1644
+ });
1645
+ }
1646
+ if (!nodeIds.has(ast.interfaceEndpoints.exit)) {
1647
+ results.push({
1648
+ rule: "V54",
1649
+ level: "error",
1650
+ message: `interface exit "${ast.interfaceEndpoints.exit}" does not reference a declared node`,
1651
+ });
1652
+ }
1653
+ return results;
1654
+ }
1655
+ // ---------------------------------------------------------------------------
1656
+ // V55 – Import alias must be unique
1657
+ // ---------------------------------------------------------------------------
1658
+ /**
1659
+ * V55: No two imports may share the same alias.
1660
+ */
1661
+ function v55UniqueImportAlias(ast) {
1662
+ const results = [];
1663
+ const seen = new Set();
1664
+ for (const imp of ast.imports) {
1665
+ if (seen.has(imp.alias)) {
1666
+ results.push({
1667
+ rule: "V55",
1668
+ level: "error",
1669
+ message: `import alias "${imp.alias}" is used more than once — each import must have a unique alias`,
1670
+ });
1671
+ }
1672
+ seen.add(imp.alias);
1673
+ }
1674
+ return results;
1675
+ }
1676
+ // ---------------------------------------------------------------------------
1677
+ // V56 – Import source path validation
1678
+ // ---------------------------------------------------------------------------
1679
+ /**
1680
+ * V56: Import source must be a syntactically valid path (starts with `./`,
1681
+ * `../`, or is a registry address containing `/`).
1682
+ */
1683
+ function v56ImportSourcePath(ast) {
1684
+ const results = [];
1685
+ for (const imp of ast.imports) {
1686
+ const src = imp.source;
1687
+ const isRelative = src.startsWith("./") || src.startsWith("../");
1688
+ const isRegistry = src.includes("/") && !src.startsWith("/");
1689
+ if (!isRelative && !isRegistry) {
1690
+ results.push({
1691
+ rule: "V56",
1692
+ level: "error",
1693
+ message: `import source "${src}" is not a valid path — must start with "./" or "../" or be a registry address`,
1694
+ });
1695
+ }
1696
+ }
1697
+ return results;
1698
+ }
1699
+ // ---------------------------------------------------------------------------
1700
+ // V57 – Circuit breaker fields validation
1701
+ // ---------------------------------------------------------------------------
1702
+ /**
1703
+ * V57: Validate circuit-breaker fields — threshold must be a positive integer,
1704
+ * window and cooldown must be valid duration strings matching /^\d+[smhd]$/.
1705
+ */
1706
+ function v57CircuitBreakerFields(ast) {
1707
+ const results = [];
1708
+ const durationRe = /^\d+[smhd]$/;
1709
+ for (const node of ast.nodes) {
1710
+ if (!isAgent(node) || !node.circuitBreaker)
1711
+ continue;
1712
+ const cb = node.circuitBreaker;
1713
+ if (!Number.isInteger(cb.threshold) || cb.threshold < 1) {
1714
+ results.push({
1715
+ rule: "V57",
1716
+ level: "error",
1717
+ message: `Agent "${node.id}" circuit-breaker has invalid threshold "${cb.threshold}" — must be a positive integer`,
1718
+ node: node.id,
1719
+ line: lookupLine(ast, node.id),
1720
+ });
1721
+ }
1722
+ if (!durationRe.test(cb.window)) {
1723
+ results.push({
1724
+ rule: "V57",
1725
+ level: "error",
1726
+ message: `Agent "${node.id}" circuit-breaker has invalid window "${cb.window}" — must match format like "30s", "5m", "2h", "1d"`,
1727
+ node: node.id,
1728
+ line: lookupLine(ast, node.id),
1729
+ });
1730
+ }
1731
+ if (!durationRe.test(cb.cooldown)) {
1732
+ results.push({
1733
+ rule: "V57",
1734
+ level: "error",
1735
+ message: `Agent "${node.id}" circuit-breaker has invalid cooldown "${cb.cooldown}" — must match format like "30s", "5m", "2h", "1d"`,
1736
+ node: node.id,
1737
+ line: lookupLine(ast, node.id),
1738
+ });
1739
+ }
1740
+ }
1741
+ return results;
1742
+ }
1743
+ // ---------------------------------------------------------------------------
1744
+ // V58 – compensates must reference a declared agent
1745
+ // ---------------------------------------------------------------------------
1746
+ /**
1747
+ * V58: `compensates` must reference a declared agent node.
1748
+ */
1749
+ function v58CompensatesTarget(ast) {
1750
+ const results = [];
1751
+ const nodeIds = allNodeIds(ast);
1752
+ for (const node of ast.nodes) {
1753
+ if (node.type === "agent" && node.compensates) {
1754
+ if (!nodeIds.has(node.compensates)) {
1755
+ results.push({
1756
+ rule: "V58",
1757
+ level: "error",
1758
+ message: `agent "${node.id}": compensates target "${node.compensates}" is not a declared node`,
1759
+ node: node.id,
1760
+ });
1761
+ }
1762
+ }
1763
+ }
1764
+ return results;
1765
+ }
1766
+ // ---------------------------------------------------------------------------
1767
+ // V60 – Quorum join validation
1768
+ // ---------------------------------------------------------------------------
1769
+ /**
1770
+ * V60: When `join` uses quorum syntax `N-of-M`, validate:
1771
+ * - N >= 1
1772
+ * - M >= 2
1773
+ * - N <= M
1774
+ */
1775
+ function v60QuorumJoin(ast) {
1776
+ const results = [];
1777
+ function checkQuorum(nodeId, join) {
1778
+ if (!QUORUM_RE.test(join))
1779
+ return;
1780
+ const [nStr, mStr] = join.split("-of-");
1781
+ const n = parseInt(nStr, 10);
1782
+ const m = parseInt(mStr, 10);
1783
+ if (n < 1) {
1784
+ results.push({
1785
+ rule: "V60",
1786
+ level: "error",
1787
+ message: `Node "${nodeId}" has invalid quorum join "${join}" — N must be >= 1`,
1788
+ node: nodeId,
1789
+ line: lookupLine(ast, nodeId),
1790
+ });
1791
+ }
1792
+ if (m < 2) {
1793
+ results.push({
1794
+ rule: "V60",
1795
+ level: "error",
1796
+ message: `Node "${nodeId}" has invalid quorum join "${join}" — M must be >= 2`,
1797
+ node: nodeId,
1798
+ line: lookupLine(ast, nodeId),
1799
+ });
1800
+ }
1801
+ if (n > m) {
1802
+ results.push({
1803
+ rule: "V60",
1804
+ level: "error",
1805
+ message: `Node "${nodeId}" has invalid quorum join "${join}" — N must be <= M`,
1806
+ node: nodeId,
1807
+ line: lookupLine(ast, nodeId),
1808
+ });
1809
+ }
1810
+ }
1811
+ for (const node of ast.nodes) {
1812
+ if (isAgent(node) && node.join)
1813
+ checkQuorum(node.id, node.join);
1814
+ if (node.type === "action") {
1815
+ const action = node;
1816
+ if (action.join)
1817
+ checkQuorum(action.id, action.join);
1818
+ }
1819
+ }
1820
+ return results;
1821
+ }
1822
+ // ---------------------------------------------------------------------------
1823
+ // V61 – Weight edge attribute validation
1824
+ // ---------------------------------------------------------------------------
1825
+ /**
1826
+ * V61: Validate `[weight N]` edge attributes:
1827
+ * - Weight must be > 0 and <= 1.
1828
+ * - Warning if weights from the same source don't sum to ~1.0 (within 0.01).
1829
+ */
1830
+ function v61WeightRange(ast) {
1831
+ const results = [];
1832
+ // Check individual weight values
1833
+ for (const edge of ast.edges) {
1834
+ if (edge.weight !== undefined) {
1835
+ if (edge.weight <= 0) {
1836
+ results.push({
1837
+ rule: "V61",
1838
+ level: "error",
1839
+ message: `Edge "${edge.from}" -> "${edge.to}" has invalid weight ${edge.weight} — must be > 0`,
1840
+ node: edge.from,
1841
+ });
1842
+ }
1843
+ if (edge.weight > 1) {
1844
+ results.push({
1845
+ rule: "V61",
1846
+ level: "error",
1847
+ message: `Edge "${edge.from}" -> "${edge.to}" has invalid weight ${edge.weight} — must be <= 1`,
1848
+ node: edge.from,
1849
+ });
1850
+ }
1851
+ }
1852
+ }
1853
+ // Check weight sums per source node
1854
+ const weightsBySource = new Map();
1855
+ for (const edge of ast.edges) {
1856
+ if (edge.weight !== undefined) {
1857
+ if (!weightsBySource.has(edge.from))
1858
+ weightsBySource.set(edge.from, []);
1859
+ weightsBySource.get(edge.from).push(edge.weight);
1860
+ }
1861
+ }
1862
+ for (const [source, weights] of weightsBySource) {
1863
+ const sum = weights.reduce((a, b) => a + b, 0);
1864
+ if (Math.abs(sum - 1.0) > 0.01) {
1865
+ results.push({
1866
+ rule: "V61",
1867
+ level: "warning",
1868
+ message: `Edges from "${source}" have weights summing to ${sum.toFixed(4)} — expected ~1.0`,
1869
+ node: source,
1870
+ });
1871
+ }
1872
+ }
1873
+ return results;
1874
+ }
1875
+ // ---------------------------------------------------------------------------
1876
+ // V67 – Checkpoint backend validation
1877
+ // ---------------------------------------------------------------------------
1878
+ /** Valid checkpoint backends. */
1879
+ const VALID_CHECKPOINT_BACKENDS = new Set([
1880
+ "memory", "sqlite", "postgres", "redis", "s3", "custom",
1881
+ ]);
1882
+ /**
1883
+ * V67: checkpoint `backend` must be one of the known values.
1884
+ */
1885
+ function v67CheckpointBackend(ast) {
1886
+ const results = [];
1887
+ if (!ast.checkpoint)
1888
+ return results;
1889
+ if (!VALID_CHECKPOINT_BACKENDS.has(ast.checkpoint.backend)) {
1890
+ results.push({
1891
+ rule: "V67",
1892
+ level: "error",
1893
+ message: `checkpoint backend "${ast.checkpoint.backend}" is invalid — must be one of: ${[...VALID_CHECKPOINT_BACKENDS].join(", ")}`,
1894
+ });
1895
+ }
1896
+ return results;
1897
+ }
1898
+ // ---------------------------------------------------------------------------
1899
+ // V68 – Checkpoint strategy and TTL validation
1900
+ // ---------------------------------------------------------------------------
1901
+ /** Valid checkpoint strategies. */
1902
+ const VALID_CHECKPOINT_STRATEGIES = new Set([
1903
+ "every-node", "on-error", "explicit", "none",
1904
+ ]);
1905
+ /**
1906
+ * V68: checkpoint `strategy` must be one of the known values,
1907
+ * and `ttl` must be a valid duration string if present.
1908
+ */
1909
+ function v68CheckpointStrategy(ast) {
1910
+ const results = [];
1911
+ if (!ast.checkpoint)
1912
+ return results;
1913
+ if (!VALID_CHECKPOINT_STRATEGIES.has(ast.checkpoint.strategy)) {
1914
+ results.push({
1915
+ rule: "V68",
1916
+ level: "error",
1917
+ message: `checkpoint strategy "${ast.checkpoint.strategy}" is invalid — must be one of: ${[...VALID_CHECKPOINT_STRATEGIES].join(", ")}`,
1918
+ });
1919
+ }
1920
+ if (ast.checkpoint.ttl) {
1921
+ const durationRe = /^\d+[smhd]$/;
1922
+ if (!durationRe.test(ast.checkpoint.ttl)) {
1923
+ results.push({
1924
+ rule: "V68",
1925
+ level: "error",
1926
+ message: `checkpoint ttl "${ast.checkpoint.ttl}" is invalid — must match format like "30s", "5m", "2h", "7d"`,
1927
+ });
1928
+ }
1929
+ }
1930
+ return results;
1931
+ }
1932
+ // ---------------------------------------------------------------------------
1933
+ // V69 – Replay configuration validation
1934
+ // ---------------------------------------------------------------------------
1935
+ /**
1936
+ * V69: replay requires strategy "every-node"; max-history must be a positive
1937
+ * integer; warn if backend is "memory" with replay enabled.
1938
+ */
1939
+ function v69ReplayConfig(ast) {
1940
+ const results = [];
1941
+ if (!ast.checkpoint?.replay)
1942
+ return results;
1943
+ const replay = ast.checkpoint.replay;
1944
+ // replay requires strategy "every-node"
1945
+ if (ast.checkpoint.strategy !== "every-node") {
1946
+ results.push({
1947
+ rule: "V69",
1948
+ level: "error",
1949
+ message: `checkpoint replay requires strategy "every-node" but found "${ast.checkpoint.strategy}" — cannot replay without full checkpointing`,
1950
+ });
1951
+ }
1952
+ // max-history must be positive integer
1953
+ if (replay.maxHistory !== undefined) {
1954
+ if (!Number.isInteger(replay.maxHistory) || replay.maxHistory < 1) {
1955
+ results.push({
1956
+ rule: "V69",
1957
+ level: "error",
1958
+ message: `checkpoint replay max-history "${replay.maxHistory}" must be a positive integer`,
1959
+ });
1960
+ }
1961
+ }
1962
+ // warn if backend is "memory" with replay enabled
1963
+ if (replay.enabled && ast.checkpoint.backend === "memory") {
1964
+ results.push({
1965
+ rule: "V69",
1966
+ level: "warning",
1967
+ message: `checkpoint replay is enabled with backend "memory" — replay data will not survive restart`,
1968
+ });
1969
+ }
1970
+ return results;
1971
+ }
1972
+ // ---------------------------------------------------------------------------
1973
+ // V70 – Artifact IDs must be unique
1974
+ // ---------------------------------------------------------------------------
1975
+ /**
1976
+ * V70: All artifact IDs in the artifacts block must be unique.
1977
+ */
1978
+ function v70UniqueArtifactIds(ast) {
1979
+ const results = [];
1980
+ const seen = new Set();
1981
+ for (const artifact of ast.artifacts) {
1982
+ if (seen.has(artifact.id)) {
1983
+ results.push({
1984
+ rule: "V70",
1985
+ level: "error",
1986
+ message: `duplicate artifact id "${artifact.id}" — artifact ids must be unique`,
1987
+ });
1988
+ }
1989
+ seen.add(artifact.id);
1990
+ }
1991
+ return results;
1992
+ }
1993
+ // ---------------------------------------------------------------------------
1994
+ // V71 – Artifact references must resolve
1995
+ // ---------------------------------------------------------------------------
1996
+ /**
1997
+ * V71: depends-on, produces, and consumes must reference declared artifact IDs.
1998
+ */
1999
+ function v71ArtifactRefsResolve(ast) {
2000
+ const results = [];
2001
+ const artifactIds = new Set(ast.artifacts.map((a) => a.id));
2002
+ // Check depends-on within artifacts
2003
+ for (const artifact of ast.artifacts) {
2004
+ if (artifact.dependsOn) {
2005
+ for (const dep of artifact.dependsOn) {
2006
+ if (!artifactIds.has(dep)) {
2007
+ results.push({
2008
+ rule: "V71",
2009
+ level: "error",
2010
+ message: `artifact "${artifact.id}" depends-on undeclared artifact "${dep}"`,
2011
+ });
2012
+ }
2013
+ }
2014
+ }
2015
+ }
2016
+ // Check produces/consumes on agents
2017
+ for (const node of ast.nodes) {
2018
+ if (isAgent(node)) {
2019
+ if (node.produces) {
2020
+ for (const ref of node.produces) {
2021
+ if (!artifactIds.has(ref)) {
2022
+ results.push({
2023
+ rule: "V71",
2024
+ level: "error",
2025
+ message: `agent "${node.id}" produces undeclared artifact "${ref}"`,
2026
+ node: node.id,
2027
+ line: lookupLine(ast, node.id),
2028
+ });
2029
+ }
2030
+ }
2031
+ }
2032
+ if (node.consumes) {
2033
+ for (const ref of node.consumes) {
2034
+ if (!artifactIds.has(ref)) {
2035
+ results.push({
2036
+ rule: "V71",
2037
+ level: "error",
2038
+ message: `agent "${node.id}" consumes undeclared artifact "${ref}"`,
2039
+ node: node.id,
2040
+ line: lookupLine(ast, node.id),
2041
+ });
2042
+ }
2043
+ }
2044
+ }
2045
+ }
2046
+ }
2047
+ return results;
2048
+ }
2049
+ // ---------------------------------------------------------------------------
2050
+ // V72 – No circular artifact dependencies
2051
+ // ---------------------------------------------------------------------------
2052
+ /**
2053
+ * V72: Artifact dependency graph must be acyclic.
2054
+ */
2055
+ function v72ArtifactCycles(ast) {
2056
+ const results = [];
2057
+ if (ast.artifacts.length === 0)
2058
+ return results;
2059
+ // Build adjacency list
2060
+ const adj = new Map();
2061
+ for (const artifact of ast.artifacts) {
2062
+ adj.set(artifact.id, artifact.dependsOn ?? []);
2063
+ }
2064
+ // DFS cycle detection
2065
+ const WHITE = 0, GRAY = 1, BLACK = 2;
2066
+ const color = new Map();
2067
+ for (const id of adj.keys())
2068
+ color.set(id, WHITE);
2069
+ function dfs(node, path) {
2070
+ color.set(node, GRAY);
2071
+ path.push(node);
2072
+ for (const dep of adj.get(node) ?? []) {
2073
+ if (!adj.has(dep))
2074
+ continue; // undeclared — caught by V71
2075
+ if (color.get(dep) === GRAY) {
2076
+ const cycleStart = path.indexOf(dep);
2077
+ const cycle = path.slice(cycleStart).concat(dep);
2078
+ results.push({
2079
+ rule: "V72",
2080
+ level: "error",
2081
+ message: `circular artifact dependency detected: ${cycle.join(" -> ")}`,
2082
+ });
2083
+ return true;
2084
+ }
2085
+ if (color.get(dep) === WHITE) {
2086
+ if (dfs(dep, path))
2087
+ return true;
2088
+ }
2089
+ }
2090
+ path.pop();
2091
+ color.set(node, BLACK);
2092
+ return false;
2093
+ }
2094
+ for (const id of adj.keys()) {
2095
+ if (color.get(id) === WHITE) {
2096
+ dfs(id, []);
2097
+ }
2098
+ }
2099
+ return results;
2100
+ }
2101
+ // ---------------------------------------------------------------------------
2102
+ // V73 – Registry package name validation
2103
+ // ---------------------------------------------------------------------------
2104
+ /** Valid registry package name pattern: lowercase kebab-case with optional namespace. */
2105
+ const REGISTRY_PACKAGE_RE = /^[a-z0-9-]+(\/[a-z0-9-]+)*$/;
2106
+ /**
2107
+ * V73: Registry package names must match `[a-z0-9-]+(/[a-z0-9-]+)*`.
2108
+ */
2109
+ function v73RegistryPackageName(ast) {
2110
+ const results = [];
2111
+ for (const imp of ast.imports) {
2112
+ if (!imp.registry || !imp.registryPackage)
2113
+ continue;
2114
+ if (!REGISTRY_PACKAGE_RE.test(imp.registryPackage)) {
2115
+ results.push({
2116
+ rule: "V73",
2117
+ level: "error",
2118
+ message: `import "${imp.source}": registry package name "${imp.registryPackage}" is invalid — must be lowercase kebab-case (a-z, 0-9, hyphens) with optional namespace separator "/"`,
2119
+ });
2120
+ }
2121
+ }
2122
+ return results;
2123
+ }
2124
+ // ---------------------------------------------------------------------------
2125
+ // V74 – Registry version validation (semver or "latest")
2126
+ // ---------------------------------------------------------------------------
2127
+ /** Simple semver pattern. */
2128
+ const SEMVER_RE = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$/;
2129
+ /**
2130
+ * V74: Registry package version must be valid semver or "latest".
2131
+ */
2132
+ function v74RegistryVersion(ast) {
2133
+ const results = [];
2134
+ for (const imp of ast.imports) {
2135
+ if (!imp.registry || !imp.registryVersion)
2136
+ continue;
2137
+ if (imp.registryVersion !== "latest" && !SEMVER_RE.test(imp.registryVersion)) {
2138
+ results.push({
2139
+ rule: "V74",
2140
+ level: "error",
2141
+ message: `import "${imp.source}": version "${imp.registryVersion}" is invalid — must be valid semver (e.g. "1.2.0") or "latest"`,
2142
+ });
2143
+ }
2144
+ }
2145
+ return results;
2146
+ }
2147
+ // ---------------------------------------------------------------------------
2148
+ // V75 – SHA-256 hash validation
2149
+ // ---------------------------------------------------------------------------
2150
+ /** Valid SHA-256: exactly 64 hex characters. */
2151
+ const SHA256_RE = /^[0-9a-fA-F]{64}$/;
2152
+ /**
2153
+ * V75: If sha256 is present on an import, it must be a valid 64-char hex string.
2154
+ */
2155
+ function v75Sha256Format(ast) {
2156
+ const results = [];
2157
+ for (const imp of ast.imports) {
2158
+ if (!imp.sha256)
2159
+ continue;
2160
+ if (!SHA256_RE.test(imp.sha256)) {
2161
+ results.push({
2162
+ rule: "V75",
2163
+ level: "error",
2164
+ message: `import "${imp.source}": sha256 "${imp.sha256}" is invalid — must be exactly 64 hexadecimal characters`,
2165
+ });
2166
+ }
2167
+ }
2168
+ return results;
2169
+ }
2170
+ // ---------------------------------------------------------------------------
2171
+ // V76 – Unique variant ids within agent
2172
+ // ---------------------------------------------------------------------------
2173
+ /**
2174
+ * V76: Variant ids must be unique within each agent.
2175
+ */
2176
+ function v76UniqueVariantIds(ast) {
2177
+ const results = [];
2178
+ for (const node of ast.nodes) {
2179
+ if (!isAgent(node) || !node.variants)
2180
+ continue;
2181
+ const seen = new Set();
2182
+ for (const v of node.variants) {
2183
+ if (seen.has(v.id)) {
2184
+ results.push({
2185
+ rule: "V76",
2186
+ level: "error",
2187
+ message: `agent "${node.id}": duplicate variant id "${v.id}" — variant ids must be unique within an agent`,
2188
+ node: node.id,
2189
+ line: lookupLine(ast, node.id),
2190
+ });
2191
+ }
2192
+ seen.add(v.id);
2193
+ }
2194
+ }
2195
+ return results;
2196
+ }
2197
+ // ---------------------------------------------------------------------------
2198
+ // V77 – Variant weights must sum to ~1.0
2199
+ // ---------------------------------------------------------------------------
2200
+ /**
2201
+ * V77: Variant weights must sum to approximately 1.0 (within 0.01 tolerance).
2202
+ */
2203
+ function v77VariantWeightSum(ast) {
2204
+ const results = [];
2205
+ for (const node of ast.nodes) {
2206
+ if (!isAgent(node) || !node.variants || node.variants.length === 0)
2207
+ continue;
2208
+ const sum = node.variants.reduce((acc, v) => acc + v.weight, 0);
2209
+ if (Math.abs(sum - 1.0) > 0.01) {
2210
+ results.push({
2211
+ rule: "V77",
2212
+ level: "warning",
2213
+ message: `agent "${node.id}": variant weights sum to ${sum.toFixed(4)} — expected ~1.0`,
2214
+ node: node.id,
2215
+ line: lookupLine(ast, node.id),
2216
+ });
2217
+ }
2218
+ }
2219
+ return results;
2220
+ }
2221
+ // ---------------------------------------------------------------------------
2222
+ // V78 – Encrypted value SOPS envelope format
2223
+ // ---------------------------------------------------------------------------
2224
+ /** Valid SOPS methods. */
2225
+ const VALID_SOPS_METHODS = new Set([
2226
+ "AES256_GCM", "AES256_CBC", "RSA_OAEP",
2227
+ ]);
2228
+ /** SOPS envelope pattern: ENC[METHOD,data:BASE64] */
2229
+ const SOPS_ENVELOPE_RE = /^ENC\[([A-Z0-9_]+),data:[A-Za-z0-9+/=]+\.*\]$/;
2230
+ /**
2231
+ * V78: `encrypted` values must match SOPS envelope format ENC[METHOD,data:BASE64].
2232
+ * Valid methods: AES256_GCM, AES256_CBC, RSA_OAEP.
2233
+ */
2234
+ function v78EncryptedSopsFormat(ast) {
2235
+ const results = [];
2236
+ for (const [key, val] of Object.entries(ast.env)) {
2237
+ if (typeof val === "object" && val !== null && val.encrypted) {
2238
+ const m = val.value.match(SOPS_ENVELOPE_RE);
2239
+ if (!m) {
2240
+ results.push({
2241
+ rule: "V78",
2242
+ level: "warning",
2243
+ message: `env "${key}": encrypted value does not match SOPS envelope format ENC[METHOD,data:BASE64]`,
2244
+ });
2245
+ }
2246
+ else if (!VALID_SOPS_METHODS.has(m[1])) {
2247
+ results.push({
2248
+ rule: "V78",
2249
+ level: "warning",
2250
+ message: `env "${key}": unknown SOPS method "${m[1]}" — valid methods: ${[...VALID_SOPS_METHODS].join(", ")}`,
2251
+ });
2252
+ }
2253
+ }
2254
+ }
2255
+ return results;
2256
+ }
2257
+ // ---------------------------------------------------------------------------
2258
+ // V79 – Auth type enum
2259
+ // ---------------------------------------------------------------------------
2260
+ /** Valid auth types. */
2261
+ const VALID_AUTH_TYPES = new Set([
2262
+ "oidc", "oauth2", "api-key", "aws-iam", "gcp-sa", "azure-msi",
2263
+ ]);
2264
+ /**
2265
+ * V79: provider auth.type must be one of the known auth types.
2266
+ */
2267
+ function v79AuthType(ast) {
2268
+ const results = [];
2269
+ for (const provider of ast.providers) {
2270
+ if (!provider.auth)
2271
+ continue;
2272
+ if (!VALID_AUTH_TYPES.has(provider.auth.type)) {
2273
+ results.push({
2274
+ rule: "V79",
2275
+ level: "error",
2276
+ message: `provider "${provider.name}": auth type "${provider.auth.type}" is invalid — must be one of: ${[...VALID_AUTH_TYPES].join(", ")}`,
2277
+ });
2278
+ }
2279
+ }
2280
+ return results;
2281
+ }
2282
+ // ---------------------------------------------------------------------------
2283
+ // V80 – OIDC/OAuth2 require issuer
2284
+ // ---------------------------------------------------------------------------
2285
+ /**
2286
+ * V80: OIDC and OAuth2 auth types require an issuer field.
2287
+ */
2288
+ function v80AuthIssuerRequired(ast) {
2289
+ const results = [];
2290
+ const TYPES_REQUIRING_ISSUER = new Set(["oidc", "oauth2"]);
2291
+ for (const provider of ast.providers) {
2292
+ if (!provider.auth)
2293
+ continue;
2294
+ if (TYPES_REQUIRING_ISSUER.has(provider.auth.type) && !provider.auth.issuer) {
2295
+ results.push({
2296
+ rule: "V80",
2297
+ level: "error",
2298
+ message: `provider "${provider.name}": auth type "${provider.auth.type}" requires an issuer field`,
2299
+ });
2300
+ }
2301
+ }
2302
+ return results;
2303
+ }
2304
+ // ---------------------------------------------------------------------------
2305
+ // V62 – [reflection] requires [max N] and must be on back-edges
2306
+ // ---------------------------------------------------------------------------
2307
+ /**
2308
+ * V62: `[reflection]` is only valid on back-edges (edges that form cycles)
2309
+ * and MUST have `[max N]` to prevent infinite loops.
2310
+ */
2311
+ function v62ReflectionEdge(ast) {
2312
+ const results = [];
2313
+ // Build adjacency set for cycle detection
2314
+ const nodeIds = allNodeIds(ast);
2315
+ for (const edge of ast.edges) {
2316
+ if (!edge.reflection)
2317
+ continue;
2318
+ // reflection without max is an error
2319
+ if (edge.maxIterations === null || edge.maxIterations === undefined) {
2320
+ results.push({
2321
+ rule: "V62",
2322
+ level: "error",
2323
+ message: `Edge ${edge.from} -> ${edge.to}: [reflection] requires [max N] to prevent infinite loops`,
2324
+ });
2325
+ }
2326
+ // Check if this is a back-edge (forms a cycle).
2327
+ // A simple heuristic: check if there is a path from `to` to `from` in the other edges.
2328
+ const visited = new Set();
2329
+ const queue = [edge.to];
2330
+ let isCycle = false;
2331
+ while (queue.length > 0) {
2332
+ const current = queue.shift();
2333
+ if (current === edge.from) {
2334
+ // The target can reach the source via other edges — but we need to check
2335
+ // via edges OTHER than this one (or any edge, since having a path back means cycle).
2336
+ // Actually, for back-edge detection: if `to` can reach `from` through ANY forward edges,
2337
+ // then from->to is a back edge. But the simpler check: does `from` appear as a target
2338
+ // of edges originating from `to` (directly or transitively)?
2339
+ // For the reflection edge itself: from->to with [reflection] means to->from should exist
2340
+ // in the remaining edges for this to be a cycle.
2341
+ isCycle = true;
2342
+ break;
2343
+ }
2344
+ if (visited.has(current))
2345
+ continue;
2346
+ visited.add(current);
2347
+ for (const other of ast.edges) {
2348
+ if (other === edge)
2349
+ continue; // Skip the reflection edge itself
2350
+ if (other.from === current && !visited.has(other.to)) {
2351
+ queue.push(other.to);
2352
+ }
2353
+ }
2354
+ }
2355
+ if (!isCycle) {
2356
+ results.push({
2357
+ rule: "V62",
2358
+ level: "warning",
2359
+ message: `Edge ${edge.from} -> ${edge.to}: [reflection] is intended for back-edges that form cycles`,
2360
+ });
2361
+ }
2362
+ }
2363
+ return results;
2364
+ }
2365
+ // ---------------------------------------------------------------------------
2366
+ // V63 – Group node members must reference declared agent nodes
2367
+ // ---------------------------------------------------------------------------
2368
+ /**
2369
+ * V63: `members` in a group node must reference declared agent nodes.
2370
+ */
2371
+ function v63GroupMembers(ast) {
2372
+ const results = [];
2373
+ const agentIds = new Set(ast.nodes.filter(isAgent).map((n) => n.id));
2374
+ for (const node of ast.nodes) {
2375
+ if (isGroup(node)) {
2376
+ for (const member of node.members) {
2377
+ if (!agentIds.has(member)) {
2378
+ results.push({
2379
+ rule: "V63",
2380
+ level: "error",
2381
+ message: `group "${node.id}": member "${member}" is not a declared agent node`,
2382
+ node: node.id,
2383
+ line: lookupLine(ast, node.id),
2384
+ });
2385
+ }
2386
+ }
2387
+ }
2388
+ }
2389
+ return results;
2390
+ }
2391
+ // ---------------------------------------------------------------------------
2392
+ // V64 – Group node speaker-selection enum
2393
+ // ---------------------------------------------------------------------------
2394
+ /**
2395
+ * V64: `speaker-selection` must be one of: auto, round-robin, random, manual.
2396
+ */
2397
+ function v64SpeakerSelection(ast) {
2398
+ const results = [];
2399
+ const VALID = new Set(["auto", "round-robin", "random", "manual"]);
2400
+ for (const node of ast.nodes) {
2401
+ if (isGroup(node) && node.speakerSelection) {
2402
+ if (!VALID.has(node.speakerSelection)) {
2403
+ results.push({
2404
+ rule: "V64",
2405
+ level: "error",
2406
+ message: `group "${node.id}": speaker-selection "${node.speakerSelection}" is invalid — must be one of: auto, round-robin, random, manual`,
2407
+ node: node.id,
2408
+ line: lookupLine(ast, node.id),
2409
+ });
2410
+ }
2411
+ }
2412
+ }
2413
+ return results;
2414
+ }
2415
+ // ---------------------------------------------------------------------------
2416
+ // V65 – Group node max-rounds must be positive integer
2417
+ // ---------------------------------------------------------------------------
2418
+ /**
2419
+ * V65: `max-rounds` must be a positive integer.
2420
+ */
2421
+ function v65MaxRounds(ast) {
2422
+ const results = [];
2423
+ for (const node of ast.nodes) {
2424
+ if (isGroup(node) && node.maxRounds !== undefined) {
2425
+ if (!Number.isInteger(node.maxRounds) || node.maxRounds < 1) {
2426
+ results.push({
2427
+ rule: "V65",
2428
+ level: "error",
2429
+ message: `group "${node.id}": max-rounds must be a positive integer, got ${node.maxRounds}`,
2430
+ node: node.id,
2431
+ line: lookupLine(ast, node.id),
2432
+ });
2433
+ }
2434
+ }
2435
+ }
2436
+ return results;
2437
+ }
2438
+ // ---------------------------------------------------------------------------
2439
+ // V66 – Rate limit format
2440
+ // ---------------------------------------------------------------------------
2441
+ /**
2442
+ * V66: `rate-limit` must match N/unit where N >= 1 and unit is sec|min|hour|day.
2443
+ */
2444
+ function v66RateLimitFormat(ast) {
2445
+ const results = [];
2446
+ const RATE_LIMIT_RE = /^(\d+)\/(sec|min|hour|day)$/;
2447
+ for (const node of ast.nodes) {
2448
+ if (isAgent(node) && node.rateLimit) {
2449
+ const match = node.rateLimit.match(RATE_LIMIT_RE);
2450
+ if (!match) {
2451
+ results.push({
2452
+ rule: "V66",
2453
+ level: "error",
2454
+ message: `agent "${node.id}": rate-limit "${node.rateLimit}" is invalid — must match format N/unit where unit is sec, min, hour, or day`,
2455
+ node: node.id,
2456
+ line: lookupLine(ast, node.id),
2457
+ });
2458
+ }
2459
+ else {
2460
+ const n = parseInt(match[1], 10);
2461
+ if (n < 1) {
2462
+ results.push({
2463
+ rule: "V66",
2464
+ level: "error",
2465
+ message: `agent "${node.id}": rate-limit value must be >= 1, got ${n}`,
2466
+ node: node.id,
2467
+ line: lookupLine(ast, node.id),
2468
+ });
2469
+ }
2470
+ }
2471
+ }
2472
+ }
2473
+ return results;
2474
+ }
2475
+ // ---------------------------------------------------------------------------
2476
+ // Public API
2477
+ // ---------------------------------------------------------------------------
2478
+ /**
2479
+ * Validate a parsed AgenTopology AST against all specification rules.
2480
+ *
2481
+ * @param ast - The parsed topology AST.
2482
+ * @returns An array of validation results. An empty array means no issues found.
2483
+ */
2484
+ export function validate(ast) {
2485
+ return [
2486
+ ...v1UniqueNames(ast),
2487
+ ...v2NoKeywordNames(ast),
2488
+ ...v3FlowResolves(ast),
2489
+ ...v4NoOrphans(ast),
2490
+ ...v5OutputsExist(ast),
2491
+ ...v6BoundedLoops(ast),
2492
+ ...v7ModelRequired(ast),
2493
+ ...v8ImportsResolve(ast),
2494
+ ...v9ActionsHandled(ast),
2495
+ ...v10PromptsExist(ast),
2496
+ ...v11ReadWriteConsistency(ast),
2497
+ ...v12EdgeAttributeOrder(ast),
2498
+ ...v13GatePlacement(ast),
2499
+ ...v14ToolExclusivity(ast),
2500
+ ...v15ExhaustiveConditions(ast),
2501
+ ...v16ApiKeyEnvVar(ast),
2502
+ ...v17SingleDefault(ast),
2503
+ ...v18ModelInProvider(ast),
2504
+ ...v19UniqueProviders(ast),
2505
+ ...v20ScheduleJobs(ast),
2506
+ ...v21InterfaceSecrets(ast),
2507
+ ...v22FallbackChainModels(ast),
2508
+ ...v23DuplicateSections(ast),
2509
+ ...v24UnknownMemorySubBlocks(ast),
2510
+ ...v25BounceBackAdvisory(ast),
2511
+ ...v26ActionKindEnum(ast),
2512
+ ...v27AgentPermissionsEnum(ast),
2513
+ ...v28MeteringFormatEnum(ast),
2514
+ ...v29MeteringPricingEnum(ast),
2515
+ ...v30TimeoutFormat(ast),
2516
+ ...v31OnFailValue(ast),
2517
+ ...v32FallbackTargetExists(ast),
2518
+ ...v33RetryBlockFields(ast),
2519
+ ...v34TemperatureRange(ast),
2520
+ ...v35ThinkingEnum(ast),
2521
+ ...v36OutputFormatEnum(ast),
2522
+ ...v37LogLevelEnum(ast),
2523
+ ...v38MaxTokensPositive(ast),
2524
+ ...v39JoinEnum(ast),
2525
+ ...v40ErrorEdgeTarget(ast),
2526
+ ...v41RaceFanOut(ast),
2527
+ ...v42ToleranceFormat(ast),
2528
+ ...v43WaitFormat(ast),
2529
+ ...v44ErrorHandlerExists(ast),
2530
+ ...v45TopologyTimeout(ast),
2531
+ ...v46SchemaTypeValid(ast),
2532
+ ...v47SchemaRefResolves(ast),
2533
+ ...v48ObservabilityLevel(ast),
2534
+ ...v49ObservabilityExporter(ast),
2535
+ ...v50ObservabilitySampleRate(ast),
2536
+ ...v51SensitiveLiteral(ast),
2537
+ ...v52SecretUriScheme(ast),
2538
+ ...v53ParamType(ast),
2539
+ ...v54InterfaceEndpoints(ast),
2540
+ ...v55UniqueImportAlias(ast),
2541
+ ...v56ImportSourcePath(ast),
2542
+ ...v57CircuitBreakerFields(ast),
2543
+ ...v58CompensatesTarget(ast),
2544
+ ...v59HumanOnTimeout(ast),
2545
+ ...v60QuorumJoin(ast),
2546
+ ...v61WeightRange(ast),
2547
+ ...v67CheckpointBackend(ast),
2548
+ ...v68CheckpointStrategy(ast),
2549
+ ...v69ReplayConfig(ast),
2550
+ ...v70UniqueArtifactIds(ast),
2551
+ ...v71ArtifactRefsResolve(ast),
2552
+ ...v72ArtifactCycles(ast),
2553
+ ...v73RegistryPackageName(ast),
2554
+ ...v74RegistryVersion(ast),
2555
+ ...v75Sha256Format(ast),
2556
+ ...v76UniqueVariantIds(ast),
2557
+ ...v77VariantWeightSum(ast),
2558
+ ...v78EncryptedSopsFormat(ast),
2559
+ ...v79AuthType(ast),
2560
+ ...v80AuthIssuerRequired(ast),
2561
+ ...v62ReflectionEdge(ast),
2562
+ ...v63GroupMembers(ast),
2563
+ ...v64SpeakerSelection(ast),
2564
+ ...v65MaxRounds(ast),
2565
+ ...v66RateLimitFormat(ast),
2566
+ ];
2567
+ }
2568
+ //# sourceMappingURL=validator.js.map