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.
- package/LICENSE +191 -0
- package/README.md +450 -0
- package/dist/analyzer/__tests__/analyzer.test.d.ts +2 -0
- package/dist/analyzer/__tests__/analyzer.test.d.ts.map +1 -0
- package/dist/analyzer/__tests__/analyzer.test.js +258 -0
- package/dist/analyzer/__tests__/analyzer.test.js.map +1 -0
- package/dist/analyzer/index.d.ts +44 -0
- package/dist/analyzer/index.d.ts.map +1 -0
- package/dist/analyzer/index.js +308 -0
- package/dist/analyzer/index.js.map +1 -0
- package/dist/bindings/__tests__/bindings.test.d.ts +2 -0
- package/dist/bindings/__tests__/bindings.test.d.ts.map +1 -0
- package/dist/bindings/__tests__/bindings.test.js +2089 -0
- package/dist/bindings/__tests__/bindings.test.js.map +1 -0
- package/dist/bindings/claude-code.d.ts +12 -0
- package/dist/bindings/claude-code.d.ts.map +1 -0
- package/dist/bindings/claude-code.js +2063 -0
- package/dist/bindings/claude-code.js.map +1 -0
- package/dist/bindings/codex.d.ts +18 -0
- package/dist/bindings/codex.d.ts.map +1 -0
- package/dist/bindings/codex.js +1633 -0
- package/dist/bindings/codex.js.map +1 -0
- package/dist/bindings/copilot-cli.d.ts +20 -0
- package/dist/bindings/copilot-cli.d.ts.map +1 -0
- package/dist/bindings/copilot-cli.js +1515 -0
- package/dist/bindings/copilot-cli.js.map +1 -0
- package/dist/bindings/cursor.d.ts +17 -0
- package/dist/bindings/cursor.d.ts.map +1 -0
- package/dist/bindings/cursor.js +1715 -0
- package/dist/bindings/cursor.js.map +1 -0
- package/dist/bindings/gemini-cli.d.ts +18 -0
- package/dist/bindings/gemini-cli.d.ts.map +1 -0
- package/dist/bindings/gemini-cli.js +1327 -0
- package/dist/bindings/gemini-cli.js.map +1 -0
- package/dist/bindings/index.d.ts +20 -0
- package/dist/bindings/index.d.ts.map +1 -0
- package/dist/bindings/index.js +33 -0
- package/dist/bindings/index.js.map +1 -0
- package/dist/bindings/kiro.d.ts +17 -0
- package/dist/bindings/kiro.d.ts.map +1 -0
- package/dist/bindings/kiro.js +1624 -0
- package/dist/bindings/kiro.js.map +1 -0
- package/dist/bindings/openclaw.d.ts +18 -0
- package/dist/bindings/openclaw.d.ts.map +1 -0
- package/dist/bindings/openclaw.js +1578 -0
- package/dist/bindings/openclaw.js.map +1 -0
- package/dist/bindings/types.d.ts +33 -0
- package/dist/bindings/types.d.ts.map +1 -0
- package/dist/bindings/types.js +23 -0
- package/dist/bindings/types.js.map +1 -0
- package/dist/cli/index.d.ts +19 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +679 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/docs/__tests__/docs.test.d.ts +2 -0
- package/dist/docs/__tests__/docs.test.d.ts.map +1 -0
- package/dist/docs/__tests__/docs.test.js +438 -0
- package/dist/docs/__tests__/docs.test.js.map +1 -0
- package/dist/docs/content.d.ts +16 -0
- package/dist/docs/content.d.ts.map +1 -0
- package/dist/docs/content.js +3273 -0
- package/dist/docs/content.js.map +1 -0
- package/dist/docs/index.d.ts +17 -0
- package/dist/docs/index.d.ts.map +1 -0
- package/dist/docs/index.js +117 -0
- package/dist/docs/index.js.map +1 -0
- package/dist/exporters/__tests__/exporters.test.d.ts +2 -0
- package/dist/exporters/__tests__/exporters.test.d.ts.map +1 -0
- package/dist/exporters/__tests__/exporters.test.js +731 -0
- package/dist/exporters/__tests__/exporters.test.js.map +1 -0
- package/dist/exporters/index.d.ts +13 -0
- package/dist/exporters/index.d.ts.map +1 -0
- package/dist/exporters/index.js +18 -0
- package/dist/exporters/index.js.map +1 -0
- package/dist/exporters/json.d.ts +8 -0
- package/dist/exporters/json.d.ts.map +1 -0
- package/dist/exporters/json.js +25 -0
- package/dist/exporters/json.js.map +1 -0
- package/dist/exporters/markdown.d.ts +9 -0
- package/dist/exporters/markdown.d.ts.map +1 -0
- package/dist/exporters/markdown.js +686 -0
- package/dist/exporters/markdown.js.map +1 -0
- package/dist/exporters/mermaid.d.ts +11 -0
- package/dist/exporters/mermaid.d.ts.map +1 -0
- package/dist/exporters/mermaid.js +161 -0
- package/dist/exporters/mermaid.js.map +1 -0
- package/dist/exporters/types.d.ts +22 -0
- package/dist/exporters/types.d.ts.map +1 -0
- package/dist/exporters/types.js +10 -0
- package/dist/exporters/types.js.map +1 -0
- package/dist/import/__tests__/import.test.d.ts +2 -0
- package/dist/import/__tests__/import.test.d.ts.map +1 -0
- package/dist/import/__tests__/import.test.js +781 -0
- package/dist/import/__tests__/import.test.js.map +1 -0
- package/dist/import/claude-code.d.ts +38 -0
- package/dist/import/claude-code.d.ts.map +1 -0
- package/dist/import/claude-code.js +897 -0
- package/dist/import/claude-code.js.map +1 -0
- package/dist/import/index.d.ts +18 -0
- package/dist/import/index.d.ts.map +1 -0
- package/dist/import/index.js +28 -0
- package/dist/import/index.js.map +1 -0
- package/dist/import/serializer.d.ts +11 -0
- package/dist/import/serializer.d.ts.map +1 -0
- package/dist/import/serializer.js +960 -0
- package/dist/import/serializer.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/parser/__tests__/parser.test.d.ts +2 -0
- package/dist/parser/__tests__/parser.test.d.ts.map +1 -0
- package/dist/parser/__tests__/parser.test.js +6514 -0
- package/dist/parser/__tests__/parser.test.js.map +1 -0
- package/dist/parser/ast.d.ts +708 -0
- package/dist/parser/ast.d.ts.map +1 -0
- package/dist/parser/ast.js +12 -0
- package/dist/parser/ast.js.map +1 -0
- package/dist/parser/index.d.ts +253 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +1985 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/lexer.d.ts +100 -0
- package/dist/parser/lexer.d.ts.map +1 -0
- package/dist/parser/lexer.js +304 -0
- package/dist/parser/lexer.js.map +1 -0
- package/dist/parser/validator.d.ts +42 -0
- package/dist/parser/validator.d.ts.map +1 -0
- package/dist/parser/validator.js +2568 -0
- package/dist/parser/validator.js.map +1 -0
- package/dist/scaffold/__tests__/scaffold.test.d.ts +7 -0
- package/dist/scaffold/__tests__/scaffold.test.d.ts.map +1 -0
- package/dist/scaffold/__tests__/scaffold.test.js +295 -0
- package/dist/scaffold/__tests__/scaffold.test.js.map +1 -0
- package/dist/scaffold/incremental.d.ts +43 -0
- package/dist/scaffold/incremental.d.ts.map +1 -0
- package/dist/scaffold/incremental.js +185 -0
- package/dist/scaffold/incremental.js.map +1 -0
- package/dist/scaffold/index.d.ts +13 -0
- package/dist/scaffold/index.d.ts.map +1 -0
- package/dist/scaffold/index.js +13 -0
- package/dist/scaffold/index.js.map +1 -0
- package/dist/scaffold/manifest.d.ts +30 -0
- package/dist/scaffold/manifest.d.ts.map +1 -0
- package/dist/scaffold/manifest.js +71 -0
- package/dist/scaffold/manifest.js.map +1 -0
- package/dist/scaffold/merge.d.ts +52 -0
- package/dist/scaffold/merge.d.ts.map +1 -0
- package/dist/scaffold/merge.js +134 -0
- package/dist/scaffold/merge.js.map +1 -0
- package/dist/scaffold/types.d.ts +51 -0
- package/dist/scaffold/types.d.ts.map +1 -0
- package/dist/scaffold/types.js +10 -0
- package/dist/scaffold/types.js.map +1 -0
- package/dist/sync/index.d.ts +34 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +392 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/visualizer/index.d.ts +17 -0
- package/dist/visualizer/index.d.ts.map +1 -0
- package/dist/visualizer/index.js +1976 -0
- package/dist/visualizer/index.js.map +1 -0
- package/examples/code-review.at +167 -0
- package/examples/data-processing.at +296 -0
- package/examples/openclaw-assistant.at +142 -0
- package/examples/scheduled-monitor.at +193 -0
- package/examples/simple-pipeline.at +163 -0
- package/package.json +77 -0
- package/skill/SKILL.md +403 -0
- package/skill/scripts/emit.ts +853 -0
- package/skill/scripts/visualize.ts +89 -0
- package/spec/grammar.md +1466 -0
- package/spec/reserved-keywords.md +147 -0
- 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
|