skillscript-runtime 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/ARCHITECTURE.md +70 -0
  2. package/LICENSE +21 -0
  3. package/README.md +346 -0
  4. package/dist/audit.d.ts +33 -0
  5. package/dist/audit.d.ts.map +1 -0
  6. package/dist/audit.js +76 -0
  7. package/dist/audit.js.map +1 -0
  8. package/dist/bootstrap.d.ts +69 -0
  9. package/dist/bootstrap.d.ts.map +1 -0
  10. package/dist/bootstrap.js +117 -0
  11. package/dist/bootstrap.js.map +1 -0
  12. package/dist/cli.d.ts +3 -0
  13. package/dist/cli.d.ts.map +1 -0
  14. package/dist/cli.js +805 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/compile.d.ts +88 -0
  17. package/dist/compile.d.ts.map +1 -0
  18. package/dist/compile.js +544 -0
  19. package/dist/compile.js.map +1 -0
  20. package/dist/connectors/agent-noop.d.ts +23 -0
  21. package/dist/connectors/agent-noop.d.ts.map +1 -0
  22. package/dist/connectors/agent-noop.js +43 -0
  23. package/dist/connectors/agent-noop.js.map +1 -0
  24. package/dist/connectors/agent.d.ts +54 -0
  25. package/dist/connectors/agent.d.ts.map +1 -0
  26. package/dist/connectors/agent.js +21 -0
  27. package/dist/connectors/agent.js.map +1 -0
  28. package/dist/connectors/index.d.ts +13 -0
  29. package/dist/connectors/index.d.ts.map +1 -0
  30. package/dist/connectors/index.js +17 -0
  31. package/dist/connectors/index.js.map +1 -0
  32. package/dist/connectors/local-model.d.ts +41 -0
  33. package/dist/connectors/local-model.d.ts.map +1 -0
  34. package/dist/connectors/local-model.js +106 -0
  35. package/dist/connectors/local-model.js.map +1 -0
  36. package/dist/connectors/mcp.d.ts +22 -0
  37. package/dist/connectors/mcp.d.ts.map +1 -0
  38. package/dist/connectors/mcp.js +31 -0
  39. package/dist/connectors/mcp.js.map +1 -0
  40. package/dist/connectors/memory-store.d.ts +53 -0
  41. package/dist/connectors/memory-store.d.ts.map +1 -0
  42. package/dist/connectors/memory-store.js +169 -0
  43. package/dist/connectors/memory-store.js.map +1 -0
  44. package/dist/connectors/registry.d.ts +74 -0
  45. package/dist/connectors/registry.d.ts.map +1 -0
  46. package/dist/connectors/registry.js +127 -0
  47. package/dist/connectors/registry.js.map +1 -0
  48. package/dist/connectors/skill-store.d.ts +38 -0
  49. package/dist/connectors/skill-store.d.ts.map +1 -0
  50. package/dist/connectors/skill-store.js +314 -0
  51. package/dist/connectors/skill-store.js.map +1 -0
  52. package/dist/connectors/types.d.ts +188 -0
  53. package/dist/connectors/types.d.ts.map +1 -0
  54. package/dist/connectors/types.js +35 -0
  55. package/dist/connectors/types.js.map +1 -0
  56. package/dist/dashboard/server.d.ts +40 -0
  57. package/dist/dashboard/server.d.ts.map +1 -0
  58. package/dist/dashboard/server.js +122 -0
  59. package/dist/dashboard/server.js.map +1 -0
  60. package/dist/dashboard/spa/app.js +375 -0
  61. package/dist/dashboard/spa/index.html +26 -0
  62. package/dist/dashboard/spa/styles.css +99 -0
  63. package/dist/errors.d.ts +111 -0
  64. package/dist/errors.d.ts.map +1 -0
  65. package/dist/errors.js +187 -0
  66. package/dist/errors.js.map +1 -0
  67. package/dist/filters.d.ts +17 -0
  68. package/dist/filters.d.ts.map +1 -0
  69. package/dist/filters.js +40 -0
  70. package/dist/filters.js.map +1 -0
  71. package/dist/index.d.ts +41 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +33 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/lint.d.ts +97 -0
  76. package/dist/lint.d.ts.map +1 -0
  77. package/dist/lint.js +990 -0
  78. package/dist/lint.js.map +1 -0
  79. package/dist/mcp-server.d.ts +93 -0
  80. package/dist/mcp-server.d.ts.map +1 -0
  81. package/dist/mcp-server.js +505 -0
  82. package/dist/mcp-server.js.map +1 -0
  83. package/dist/metrics.d.ts +51 -0
  84. package/dist/metrics.d.ts.map +1 -0
  85. package/dist/metrics.js +107 -0
  86. package/dist/metrics.js.map +1 -0
  87. package/dist/parser.d.ts +160 -0
  88. package/dist/parser.d.ts.map +1 -0
  89. package/dist/parser.js +991 -0
  90. package/dist/parser.js.map +1 -0
  91. package/dist/provenance.d.ts +43 -0
  92. package/dist/provenance.d.ts.map +1 -0
  93. package/dist/provenance.js +58 -0
  94. package/dist/provenance.js.map +1 -0
  95. package/dist/runtime.d.ts +145 -0
  96. package/dist/runtime.d.ts.map +1 -0
  97. package/dist/runtime.js +1071 -0
  98. package/dist/runtime.js.map +1 -0
  99. package/dist/scheduler.d.ts +121 -0
  100. package/dist/scheduler.d.ts.map +1 -0
  101. package/dist/scheduler.js +271 -0
  102. package/dist/scheduler.js.map +1 -0
  103. package/dist/skill-manager.d.ts +121 -0
  104. package/dist/skill-manager.d.ts.map +1 -0
  105. package/dist/skill-manager.js +251 -0
  106. package/dist/skill-manager.js.map +1 -0
  107. package/dist/testing/conformance.d.ts +57 -0
  108. package/dist/testing/conformance.d.ts.map +1 -0
  109. package/dist/testing/conformance.js +365 -0
  110. package/dist/testing/conformance.js.map +1 -0
  111. package/dist/testing/index.d.ts +3 -0
  112. package/dist/testing/index.d.ts.map +1 -0
  113. package/dist/testing/index.js +5 -0
  114. package/dist/testing/index.js.map +1 -0
  115. package/dist/trace.d.ts +141 -0
  116. package/dist/trace.d.ts.map +1 -0
  117. package/dist/trace.js +226 -0
  118. package/dist/trace.js.map +1 -0
  119. package/examples/README.md +56 -0
  120. package/examples/classify-support-ticket.skill.md +30 -0
  121. package/examples/cut-release-tag.skill.md +40 -0
  122. package/examples/doc-qa-with-citations.skill.md +12 -0
  123. package/examples/feedback-sentiment-scan.skill.md +29 -0
  124. package/examples/hello.skill.md +9 -0
  125. package/examples/hello.skill.provenance.json +10 -0
  126. package/examples/morning-brief.skill.md +24 -0
  127. package/examples/programmatic-trace-demo.mjs +89 -0
  128. package/examples/service-health-watch.skill.md +18 -0
  129. package/package.json +100 -0
  130. package/scaffold/config.toml +35 -0
  131. package/scaffold/connectors.json +19 -0
  132. package/scaffold/examples/hello.skill.md +9 -0
package/dist/lint.js ADDED
@@ -0,0 +1,990 @@
1
+ import { parse } from "./parser.js";
2
+ import { KNOWN_FILTERS } from "./filters.js";
3
+ // ─── lint() entry point ────────────────────────────────────────────────────
4
+ export async function lint(source, options) {
5
+ const parsed = parse(source);
6
+ const ctx = {
7
+ parsed,
8
+ capabilityClasses: options?.classes ?? collectClassesFromRegistry(options?.registry),
9
+ skillStore: options?.skillStore,
10
+ hasSkillStore: options?.skillStore !== undefined,
11
+ callSite: options?.callSite ?? "api",
12
+ };
13
+ const findings = [];
14
+ for (const rule of RULES) {
15
+ const result = await rule.check(ctx);
16
+ for (const f of result) {
17
+ findings.push({
18
+ ...f,
19
+ remediation: f.remediation ?? rule.remediation,
20
+ });
21
+ }
22
+ }
23
+ // Stable sort: by severity (error > warning > info), then rule id, then block.
24
+ const sevWeight = { error: 0, warning: 1, info: 2 };
25
+ findings.sort((a, b) => sevWeight[a.severity] - sevWeight[b.severity] ||
26
+ a.rule.localeCompare(b.rule) ||
27
+ (a.block ?? "").localeCompare(b.block ?? ""));
28
+ return {
29
+ findings,
30
+ errorCount: findings.filter((f) => f.severity === "error").length,
31
+ warningCount: findings.filter((f) => f.severity === "warning").length,
32
+ infoCount: findings.filter((f) => f.severity === "info").length,
33
+ };
34
+ }
35
+ /** Synchronous variant for callers that don't need SkillStore-dependent rules. */
36
+ export function lintSync(source, options) {
37
+ const parsed = parse(source);
38
+ const ctx = {
39
+ parsed,
40
+ capabilityClasses: options?.classes ?? collectClassesFromRegistry(options?.registry),
41
+ skillStore: options?.skillStore,
42
+ hasSkillStore: options?.skillStore !== undefined,
43
+ callSite: options?.callSite ?? "api",
44
+ };
45
+ const findings = [];
46
+ for (const rule of RULES) {
47
+ const result = rule.check(ctx);
48
+ if (result instanceof Promise) {
49
+ throw new Error(`Rule '${rule.id}' is async; use lint() instead of lintSync().`);
50
+ }
51
+ for (const f of result) {
52
+ findings.push({ ...f, remediation: f.remediation ?? rule.remediation });
53
+ }
54
+ }
55
+ const sevWeight = { error: 0, warning: 1, info: 2 };
56
+ findings.sort((a, b) => sevWeight[a.severity] - sevWeight[b.severity] ||
57
+ a.rule.localeCompare(b.rule) ||
58
+ (a.block ?? "").localeCompare(b.block ?? ""));
59
+ return {
60
+ findings,
61
+ errorCount: findings.filter((f) => f.severity === "error").length,
62
+ warningCount: findings.filter((f) => f.severity === "warning").length,
63
+ infoCount: findings.filter((f) => f.severity === "info").length,
64
+ };
65
+ }
66
+ /** Human-readable formatter over the structured LintResult. JSON is the canonical form; this is for `--human` CLI output. */
67
+ export function formatLintResult(result) {
68
+ if (result.findings.length === 0)
69
+ return "OK: no findings.";
70
+ const lines = [];
71
+ for (const f of result.findings) {
72
+ const block = f.block ? ` (in ${f.block})` : "";
73
+ lines.push(`[${f.severity}] ${f.rule}${block}: ${f.message}`);
74
+ if (f.remediation)
75
+ lines.push(` → ${f.remediation}`);
76
+ }
77
+ lines.push(``);
78
+ lines.push(`${result.errorCount} error(s), ${result.warningCount} warning(s), ${result.infoCount} info.`);
79
+ return lines.join("\n");
80
+ }
81
+ // ─── Rule registry ─────────────────────────────────────────────────────────
82
+ const PARSE_ERROR = {
83
+ id: "parse-error",
84
+ severity: "error",
85
+ description: "Any syntax error collected by the parser.",
86
+ remediation: "Fix the grammar error per the message. Check op syntax, header form, indent levels.",
87
+ check: (ctx) => ctx.parsed.parseErrors.map((msg) => ({
88
+ rule: "parse-error",
89
+ severity: "error",
90
+ message: msg,
91
+ })),
92
+ };
93
+ const NO_TARGETS = {
94
+ id: "no-targets",
95
+ severity: "error",
96
+ description: "Skill defines zero targets.",
97
+ remediation: "Declare at least one target. A target is a name + `:` + indented op lines.",
98
+ check: (ctx) => {
99
+ if (ctx.parsed.targets.size === 0 && ctx.parsed.parseErrors.length === 0) {
100
+ return [{
101
+ rule: "no-targets",
102
+ severity: "error",
103
+ message: "Skill defines no targets. A skill needs at least one target with ops.",
104
+ }];
105
+ }
106
+ return [];
107
+ },
108
+ };
109
+ const NO_ENTRY_TARGET = {
110
+ id: "no-entry-target",
111
+ severity: "error",
112
+ description: "Targets exist but no entry resolved. Currently unreachable since the parser's fallback picks the last target — kept in the registry so authoring tools can introspect the rule list; a parser change that tracks `entryTargetExplicit` would activate this.",
113
+ remediation: "Add `default: <target-name>` at the bottom of the skill.",
114
+ check: (ctx) => {
115
+ if (ctx.parsed.targets.size > 0 && ctx.parsed.entryTarget === null) {
116
+ return [{
117
+ rule: "no-entry-target",
118
+ severity: "error",
119
+ message: "Skill has no entry target. Declare one with `default: <target-name>`.",
120
+ }];
121
+ }
122
+ return [];
123
+ },
124
+ };
125
+ const ORPHAN_TARGET = {
126
+ id: "orphan-target",
127
+ severity: "warning",
128
+ description: "A target isn't reachable from the entry via the `needs:` DAG.",
129
+ remediation: "Declare a dependency (Make-style: `b: a` makes b depend on a), change `default:`, or fold the steps into the entry target.",
130
+ check: (ctx) => {
131
+ const findings = [];
132
+ if (ctx.parsed.entryTarget === null || !ctx.parsed.targets.has(ctx.parsed.entryTarget))
133
+ return findings;
134
+ const reached = new Set();
135
+ function walk(name) {
136
+ if (reached.has(name))
137
+ return;
138
+ reached.add(name);
139
+ const t = ctx.parsed.targets.get(name);
140
+ if (!t)
141
+ return;
142
+ for (const dep of t.deps)
143
+ walk(dep);
144
+ }
145
+ walk(ctx.parsed.entryTarget);
146
+ for (const name of ctx.parsed.targets.keys()) {
147
+ if (!reached.has(name)) {
148
+ findings.push({
149
+ rule: "orphan-target",
150
+ severity: "warning",
151
+ message: `Target '${name}' is not reachable from entry target '${ctx.parsed.entryTarget}'.`,
152
+ block: name,
153
+ });
154
+ }
155
+ }
156
+ return findings;
157
+ },
158
+ };
159
+ const UNKNOWN_CAPABILITY = {
160
+ id: "unknown-capability",
161
+ severity: "error",
162
+ description: "A `# Requires:` capability clause names a feature flag no registered connector class provides.",
163
+ remediation: "Either remove the requirement, configure a connector class that provides the flag, or fix the typo in the flag name.",
164
+ check: (ctx) => {
165
+ if (ctx.parsed.requiredCapabilities.length === 0 || ctx.capabilityClasses === null)
166
+ return [];
167
+ const provided = buildFeatureSet(ctx.capabilityClasses);
168
+ const findings = [];
169
+ for (const cap of ctx.parsed.requiredCapabilities) {
170
+ if (!provided.has(cap)) {
171
+ findings.push({
172
+ rule: "unknown-capability",
173
+ severity: "error",
174
+ message: `Skill requires capability '${cap}', but no registered connector class provides it. ` +
175
+ `Available: ${provided.size === 0 ? "(none)" : Array.from(provided).sort().join(", ")}.`,
176
+ });
177
+ }
178
+ }
179
+ return findings;
180
+ },
181
+ };
182
+ /**
183
+ * Tier-1 ambient refs per language reference §3 — runtime injects these
184
+ * automatically; authors don't declare them. The lint considers them
185
+ * pre-declared.
186
+ */
187
+ const AMBIENT_VARS = [
188
+ "NOW",
189
+ "USER",
190
+ "SESSION_CONTEXT",
191
+ "TRIGGER_TYPE",
192
+ "TRIGGER_PAYLOAD",
193
+ "ERROR_CONTEXT",
194
+ ];
195
+ const UNDECLARED_VAR = {
196
+ id: "undeclared-var",
197
+ severity: "error",
198
+ description: "An op body references `$(NAME)` for a variable that's not declared in `# Vars:`/`# Requires:`, not output-bound by any op anywhere in the skill, not a foreach iterator in scope, and not a tier-1 ambient ref (NOW/USER/SESSION_CONTEXT/TRIGGER_TYPE/TRIGGER_PAYLOAD/ERROR_CONTEXT).",
199
+ remediation: "Add the variable to `# Vars:` or `# Requires:`, or check the spelling against the declared variable list.",
200
+ check: (ctx) => {
201
+ const declared = new Set(AMBIENT_VARS);
202
+ for (const v of ctx.parsed.vars)
203
+ declared.add(v.name);
204
+ for (const r of ctx.parsed.requires)
205
+ declared.add(r.target);
206
+ // Collect output-bound vars across the whole skill — once bound by any
207
+ // target's $set / -> outputVar / foreach iterator, the var is available
208
+ // for substitution downstream. The runtime walks targets in topo-sort;
209
+ // by the time a downstream target executes, earlier targets' bindings
210
+ // have populated `vars`.
211
+ for (const target of ctx.parsed.targets.values()) {
212
+ const collect = (op) => {
213
+ if (op.setName !== undefined)
214
+ declared.add(op.setName);
215
+ if (op.outputVar !== undefined)
216
+ declared.add(op.outputVar);
217
+ if (op.foreachIter !== undefined)
218
+ declared.add(op.foreachIter);
219
+ };
220
+ walkOps(target.ops, collect);
221
+ // Bindings inside `else:` error-handler blocks also become available
222
+ // downstream — the runtime executes the else: chain when the main
223
+ // body throws and propagates any $set bindings into the vars Map.
224
+ if (target.elseBlock !== undefined)
225
+ walkOps(target.elseBlock, collect);
226
+ }
227
+ const findings = [];
228
+ for (const [targetName, target] of ctx.parsed.targets) {
229
+ const reported = new Set(); // dedupe per target
230
+ for (const op of target.ops) {
231
+ // `@ unsafe` ops use bash `$(...)` syntax — handled by
232
+ // unsafe-shell-ambiguous-subst, which offers the dual rewrite
233
+ // (`$$(NAME)` for bash, `$(KNOWN_VAR)` for skillscript). Skip here
234
+ // to avoid double-reporting.
235
+ if (op.kind === "@" && op.policy === "unsafe")
236
+ continue;
237
+ for (const ref of extractVarRefs(op)) {
238
+ // Heuristic: dotted refs (targetname.output, MEMORY.field) pass
239
+ // as ambient — runtime substitution handles dotted lookups.
240
+ if (ref.includes("."))
241
+ continue;
242
+ if (declared.has(ref))
243
+ continue;
244
+ if (reported.has(ref))
245
+ continue;
246
+ reported.add(ref);
247
+ findings.push({
248
+ rule: "undeclared-var",
249
+ severity: "error",
250
+ message: `Reference to undeclared variable '$(${ref})' in op of target '${targetName}'.`,
251
+ block: targetName,
252
+ extras: { var_name: ref },
253
+ });
254
+ }
255
+ }
256
+ }
257
+ return findings;
258
+ },
259
+ };
260
+ const UNKNOWN_FILTER = {
261
+ id: "unknown-filter",
262
+ severity: "error",
263
+ description: "A `$(VAR|filter)` reference uses a filter not in the registered set.",
264
+ remediation: `Use a known filter: ${KNOWN_FILTERS.join(", ")}. Or remove the filter to substitute the raw value.`,
265
+ check: (ctx) => {
266
+ const knownSet = new Set(KNOWN_FILTERS);
267
+ const findings = [];
268
+ for (const [targetName, target] of ctx.parsed.targets) {
269
+ const reported = new Set(); // dedupe per target
270
+ for (const op of target.ops) {
271
+ for (const { name, filter } of extractVarRefsWithFilter(op)) {
272
+ if (!filter || knownSet.has(filter))
273
+ continue;
274
+ const key = `${name}|${filter}`;
275
+ if (reported.has(key))
276
+ continue;
277
+ reported.add(key);
278
+ findings.push({
279
+ rule: "unknown-filter",
280
+ severity: "error",
281
+ message: `Reference '$(${name}|${filter})' in target '${targetName}' uses unknown filter '${filter}'.`,
282
+ block: targetName,
283
+ extras: { var_name: name, filter },
284
+ });
285
+ }
286
+ }
287
+ }
288
+ return findings;
289
+ },
290
+ };
291
+ const MALFORMED_OP_GRAMMAR = {
292
+ id: "malformed-op-grammar",
293
+ severity: "error",
294
+ description: "An op line failed parser grammar validation. Surfaces parse errors that originate from op-specific shape.",
295
+ remediation: "Check the op's syntax against the language reference. Common cases: `>` and `~` need `key=value ... -> VAR`; `& skill arg=value -> VAR` for skill invocations.",
296
+ check: (ctx) => ctx.parsed.parseErrors
297
+ .filter((msg) => /Malformed `[~>&$@!?]/.test(msg))
298
+ .map((msg) => ({
299
+ rule: "malformed-op-grammar",
300
+ severity: "error",
301
+ message: msg,
302
+ })),
303
+ };
304
+ const INVALID_CONDITIONAL_SYNTAX = {
305
+ id: "invalid-conditional-syntax",
306
+ severity: "error",
307
+ description: "An `if:` / `elif:` condition uses syntax outside the v1 narrow grammar (truthy / `==` / `!=` / `in` / `not in`).",
308
+ remediation: "Restructure the condition to use a supported shape. v1 explicitly excludes AND/OR, numeric comparison, and defined-checks.",
309
+ check: (ctx) => ctx.parsed.parseErrors
310
+ .filter((msg) => /Unsupported condition/.test(msg))
311
+ .map((msg) => ({
312
+ rule: "invalid-conditional-syntax",
313
+ severity: "error",
314
+ message: msg,
315
+ })),
316
+ };
317
+ const SINGLE_EQUALS = {
318
+ id: "single-equals",
319
+ severity: "error",
320
+ description: "An `if:` / `elif:` condition uses single `=` for equality. Skillscript condition equality is `==` (two-character).",
321
+ remediation: "Replace `=` with `==`. The diagnostic includes the rewritten line.",
322
+ check: (ctx) => ctx.parsed.parseErrors
323
+ .filter((msg) => /`=` is not valid in a condition; use `==`/.test(msg))
324
+ .map((msg) => ({
325
+ rule: "single-equals",
326
+ severity: "error",
327
+ message: msg,
328
+ })),
329
+ };
330
+ const RESERVED_KEYWORD = {
331
+ id: "reserved-keyword",
332
+ severity: "error",
333
+ description: "An identifier (skill name, variable name, target name, or foreach iterator) uses a reserved keyword. Reserved words: `default`, `needs`, `if`, `elif`, `else`, `foreach`, `in`, `not`, `unsafe` (current) and `while`, `for`, `match`, `try`, `catch`, `return` (future-reserved).",
334
+ remediation: "Rename to a non-reserved identifier. The diagnostic includes a suggested rename.",
335
+ check: (ctx) => ctx.parsed.parseErrors
336
+ .filter((msg) => / is a reserved keyword/.test(msg))
337
+ .map((msg) => ({
338
+ rule: "reserved-keyword",
339
+ severity: "error",
340
+ message: msg,
341
+ })),
342
+ };
343
+ const INDENTATION = {
344
+ id: "indentation",
345
+ severity: "error",
346
+ description: "Indentation must be spaces-only with consistent depth within a block. Tabs and mid-block indent changes are parse errors.",
347
+ remediation: "Replace tabs with spaces (conventional indent is 4 spaces). Within a block, every non-sub-block line must use the same indent depth.",
348
+ check: (ctx) => ctx.parsed.parseErrors
349
+ .filter((msg) => /Tab characters in indentation|Mid-block indent change/.test(msg))
350
+ .map((msg) => ({
351
+ rule: "indentation",
352
+ severity: "error",
353
+ message: msg,
354
+ })),
355
+ };
356
+ const UNKNOWN_SKILL_REFERENCE = {
357
+ id: "unknown-skill-reference",
358
+ severity: "error",
359
+ description: "An `&` op references a skill that's not present in the configured SkillStore.",
360
+ remediation: "Check the skill name spelling, or store the missing skill before referencing it. If the reference is intentional and the skill will be added later, defer compile until it exists.",
361
+ check: async (ctx) => {
362
+ if (ctx.skillStore === undefined)
363
+ return [];
364
+ const findings = [];
365
+ const seen = new Set();
366
+ for (const [targetName, target] of ctx.parsed.targets) {
367
+ for (const refName of collectAmpRefsFromOps(target.ops)) {
368
+ if (seen.has(refName))
369
+ continue;
370
+ seen.add(refName);
371
+ try {
372
+ await ctx.skillStore.metadata(refName);
373
+ }
374
+ catch {
375
+ findings.push({
376
+ rule: "unknown-skill-reference",
377
+ severity: "error",
378
+ message: `Skill '${targetName}' references skill '${refName}' via \`&\`, but the SkillStore has no skill by that name.`,
379
+ block: targetName,
380
+ extras: { referenced_skill: refName },
381
+ });
382
+ }
383
+ }
384
+ }
385
+ return findings;
386
+ },
387
+ };
388
+ const DISABLED_SKILL_REFERENCE = {
389
+ id: "disabled-skill-reference",
390
+ severity: "error",
391
+ description: "An `&` op references a skill whose `# Status:` is `disabled`.",
392
+ remediation: "Re-enable the target skill via `update_status`, or remove the reference. Disabled skills are intentionally not compose-able to surface deprecation paths.",
393
+ check: async (ctx) => {
394
+ if (ctx.skillStore === undefined)
395
+ return [];
396
+ const findings = [];
397
+ const checked = new Set();
398
+ for (const [targetName, target] of ctx.parsed.targets) {
399
+ for (const refName of collectAmpRefsFromOps(target.ops)) {
400
+ if (checked.has(refName))
401
+ continue;
402
+ checked.add(refName);
403
+ try {
404
+ const meta = await ctx.skillStore.metadata(refName);
405
+ if (meta.status === "Disabled") {
406
+ findings.push({
407
+ rule: "disabled-skill-reference",
408
+ severity: "error",
409
+ message: `Skill '${targetName}' references '${refName}' which is disabled.`,
410
+ block: targetName,
411
+ extras: { referenced_skill: refName, target_status: meta.status },
412
+ });
413
+ }
414
+ }
415
+ catch {
416
+ /* unknown-skill-reference handles missing-skill case */
417
+ }
418
+ }
419
+ }
420
+ return findings;
421
+ },
422
+ };
423
+ /** Patterns that strongly suggest a credential in plaintext. Conservative — false positives are noisy, false negatives are dangerous, so we err on the side of catching obvious cases. */
424
+ const CREDENTIAL_ARG_PATTERN = /\b(apikey|api_key|token|secret|password|passwd|pwd|auth_token|access_token|bearer)\s*=/i;
425
+ const CREDENTIAL_IN_ARGS = {
426
+ id: "credential-in-args",
427
+ severity: "error",
428
+ description: "A `$` op carries arg values that match credential-like patterns. Credentials don't belong in skill source.",
429
+ remediation: "Move credentials to per-connector config (env vars, mounted secrets). Skill args should reference operator-managed values, not embed them.",
430
+ check: (ctx) => {
431
+ const findings = [];
432
+ for (const [targetName, target] of ctx.parsed.targets) {
433
+ walkOps(target.ops, (op) => {
434
+ if (op.kind !== "$")
435
+ return;
436
+ if (CREDENTIAL_ARG_PATTERN.test(op.body)) {
437
+ findings.push({
438
+ rule: "credential-in-args",
439
+ severity: "error",
440
+ message: `\`$\` op in target '${targetName}' appears to carry credential-like arg ('${op.body.slice(0, 40)}...').`,
441
+ block: targetName,
442
+ });
443
+ }
444
+ });
445
+ }
446
+ return findings;
447
+ },
448
+ };
449
+ const STATUS_DISABLED = {
450
+ id: "status-disabled",
451
+ severity: "error",
452
+ description: "The skill being compiled is `# Status: Disabled`. Disabled skills don't compile.",
453
+ remediation: "Transition the skill to `approved` or `draft` via `update_status` before compiling, or revisit whether the skill should be disabled.",
454
+ check: (ctx) => {
455
+ if (ctx.parsed.status !== "Disabled")
456
+ return [];
457
+ return [{
458
+ rule: "status-disabled",
459
+ severity: "error",
460
+ message: `Skill '${ctx.parsed.name ?? "(unnamed)"}' is \`# Status: Disabled\` and cannot be compiled.`,
461
+ }];
462
+ },
463
+ };
464
+ const CIRCULAR_DEPENDENCY = {
465
+ id: "circular-dependency",
466
+ severity: "error",
467
+ description: "The target dependency DAG has a cycle, OR a `&` skill-reference chain has one.",
468
+ remediation: "Break the cycle by restructuring the dependency graph or extracting shared logic into a separate skill.",
469
+ check: (ctx) => {
470
+ const findings = [];
471
+ if (ctx.parsed.entryTarget === null)
472
+ return findings;
473
+ // Target-level cycle detection (compile.ts's toposort throws on this
474
+ // at runtime; we replicate the walk for lint-time detection so
475
+ // diagnostics surface before the throw).
476
+ const visiting = new Set();
477
+ const visited = new Set();
478
+ function visit(name, path) {
479
+ if (visiting.has(name)) {
480
+ const cycleStart = path.indexOf(name);
481
+ const cycle = cycleStart >= 0 ? [...path.slice(cycleStart), name] : [name];
482
+ findings.push({
483
+ rule: "circular-dependency",
484
+ severity: "error",
485
+ message: `Dependency cycle in targets: ${cycle.join(" → ")}.`,
486
+ extras: { cycle },
487
+ });
488
+ return true;
489
+ }
490
+ if (visited.has(name))
491
+ return false;
492
+ visiting.add(name);
493
+ const target = ctx.parsed.targets.get(name);
494
+ if (target) {
495
+ for (const dep of target.deps) {
496
+ if (visit(dep, [...path, name])) {
497
+ visiting.delete(name);
498
+ return true;
499
+ }
500
+ }
501
+ }
502
+ visiting.delete(name);
503
+ visited.add(name);
504
+ return false;
505
+ }
506
+ visit(ctx.parsed.entryTarget, []);
507
+ return findings;
508
+ },
509
+ };
510
+ const MISSING_DEPENDENCY = {
511
+ id: "missing-dependency",
512
+ severity: "error",
513
+ description: "A `needs:` clause references a target that's not declared in this skill.",
514
+ remediation: "Add the target definition, or remove the reference. Targets are declared as `<name>: [deps]` at the top level of a skill.",
515
+ check: (ctx) => {
516
+ const findings = [];
517
+ for (const [name, target] of ctx.parsed.targets) {
518
+ for (const dep of target.deps) {
519
+ if (!ctx.parsed.targets.has(dep)) {
520
+ findings.push({
521
+ rule: "missing-dependency",
522
+ severity: "error",
523
+ message: `Target '${name}' depends on '${dep}', which isn't declared in this skill.`,
524
+ block: name,
525
+ extras: { missing_dep: dep },
526
+ });
527
+ }
528
+ }
529
+ }
530
+ return findings;
531
+ },
532
+ };
533
+ const MISSING_SKILLSTORE_FOR_DATA_REF = {
534
+ id: "missing-skillstore-for-data-ref",
535
+ severity: "error",
536
+ description: "Skill body uses `&` to reference another skill, but no SkillStore was provided to compile/lint. Data-skill inlining is silently skipped — the `&` op survives into the runtime, which rejects it.",
537
+ remediation: "Pass a SkillStore via `compile()` / `lint()` options, or via the CLI environment. Without it, references can't resolve.",
538
+ check: (ctx) => {
539
+ if (ctx.hasSkillStore)
540
+ return [];
541
+ const findings = [];
542
+ for (const [targetName, target] of ctx.parsed.targets) {
543
+ for (const op of target.ops) {
544
+ if (op.kind === "&") {
545
+ findings.push({
546
+ rule: "missing-skillstore-for-data-ref",
547
+ severity: "error",
548
+ message: `Skill references skill '${op.ampParams?.skillName ?? "(unknown)"}' via \`&\`, but lint was invoked without a SkillStore (call site: ${ctx.callSite}). Data-skill inlining will silently skip; the \`&\` op will survive into the runtime and error.`,
549
+ block: targetName,
550
+ extras: { call_site: ctx.callSite },
551
+ });
552
+ // One finding per skill is sufficient; the operator fixes it once.
553
+ return findings;
554
+ }
555
+ }
556
+ }
557
+ return findings;
558
+ },
559
+ };
560
+ // ─── Tier-2 rules (warning) ─────────────────────────────────────────────────
561
+ const DEPRECATED_QUESTION = {
562
+ id: "deprecated-question",
563
+ severity: "warning",
564
+ description: "Skill uses bare `?` (deprecated). The implicit-context reasoning form makes behavior depend on context not visible in the skill source. Compile-error in v1.x.",
565
+ remediation: "Rewrite as `~ prompt=\"<explicit reasoning task>\" -> VAR`. Use the explicit prompt to capture what the implicit `?` was doing (\"decide whether to escalate\", \"classify this input\", etc.).",
566
+ check: (ctx) => {
567
+ const findings = [];
568
+ for (const [targetName, target] of ctx.parsed.targets) {
569
+ walkOps(target.ops, (op) => {
570
+ if (op.kind === "?") {
571
+ const varName = op.outputVar ?? "VAR";
572
+ findings.push({
573
+ rule: "deprecated-question",
574
+ severity: "warning",
575
+ message: `\`?\` op in target '${targetName}' is deprecated (compile-error in v1.x). rewrite as: \`~ prompt="<explicit reasoning task>" -> ${varName}\``,
576
+ block: targetName,
577
+ });
578
+ }
579
+ });
580
+ }
581
+ return findings;
582
+ },
583
+ };
584
+ const UNSAFE_SHELL_AMBIGUOUS_SUBST = {
585
+ id: "unsafe-shell-ambiguous-subst",
586
+ severity: "warning",
587
+ description: "An `@ unsafe` op body contains `$(NAME)` where NAME isn't a declared skillscript variable. Collides with bash's `$(command)` command-substitution syntax.",
588
+ remediation: "Use `$$(...)` to send the `$(...)` literally to bash (command-substitution), or `$(KNOWN_VAR)` to reference a declared skillscript variable.",
589
+ check: (ctx) => {
590
+ const declared = new Set();
591
+ for (const v of ctx.parsed.vars)
592
+ declared.add(v.name);
593
+ for (const r of ctx.parsed.requires)
594
+ declared.add(r.target);
595
+ for (const target of ctx.parsed.targets.values()) {
596
+ const collect = (op) => {
597
+ if (op.setName !== undefined)
598
+ declared.add(op.setName);
599
+ if (op.outputVar !== undefined)
600
+ declared.add(op.outputVar);
601
+ if (op.foreachIter !== undefined)
602
+ declared.add(op.foreachIter);
603
+ };
604
+ walkOps(target.ops, collect);
605
+ // Bindings inside `else:` error-handler blocks also become available
606
+ // downstream — the runtime executes the else: chain when the main
607
+ // body throws and propagates any $set bindings into the vars Map.
608
+ if (target.elseBlock !== undefined)
609
+ walkOps(target.elseBlock, collect);
610
+ }
611
+ const findings = [];
612
+ // Permissive — matches any `$(...)` in @ unsafe body that's not `$$(...)`.
613
+ // Skillscript vars are strict identifiers; bash command-subs can contain
614
+ // anything. The rule wants to fire on both.
615
+ const REF_RE = /(?<!\$)\$\(([^)]+)\)/g;
616
+ for (const [targetName, target] of ctx.parsed.targets) {
617
+ const reported = new Set();
618
+ walkOps(target.ops, (op) => {
619
+ if (op.kind !== "@" || op.policy !== "unsafe")
620
+ return;
621
+ const re = new RegExp(REF_RE.source, "g");
622
+ let m;
623
+ while ((m = re.exec(op.body)) !== null) {
624
+ const inner = m[1];
625
+ // A declared skillscript variable is safe. Strict-identifier match
626
+ // — anything else (spaces, special chars, etc.) is implicitly bash.
627
+ const trimmed = inner.trim();
628
+ if (/^[A-Za-z_]\w*$/.test(trimmed) && declared.has(trimmed))
629
+ continue;
630
+ if (reported.has(inner))
631
+ continue;
632
+ reported.add(inner);
633
+ findings.push({
634
+ rule: "unsafe-shell-ambiguous-subst",
635
+ severity: "warning",
636
+ message: `\`$(${inner})\` in \`@ unsafe\` body of target '${targetName}' is ambiguous — either send literally to bash via \`$$(${inner})\`, or use a declared skillscript variable.`,
637
+ block: targetName,
638
+ extras: { ref: inner },
639
+ });
640
+ }
641
+ });
642
+ }
643
+ return findings;
644
+ },
645
+ };
646
+ const UNSAFE_SHELL_OP = {
647
+ id: "unsafe-shell-op",
648
+ severity: "warning",
649
+ description: "Skill uses `@ unsafe` (opt-in full-shell exec). Requires human review every time.",
650
+ remediation: "Confirm the operator deployment has `runtime.enable_unsafe_shell = true` and the shell content is reviewed. Prefer the default `@ <binary> <args>` form (structured-spawn sandbox) when the work can decompose to single binaries.",
651
+ check: (ctx) => {
652
+ const findings = [];
653
+ for (const [targetName, target] of ctx.parsed.targets) {
654
+ walkOps(target.ops, (op) => {
655
+ if (op.kind === "@" && op.policy === "unsafe") {
656
+ findings.push({
657
+ rule: "unsafe-shell-op",
658
+ severity: "warning",
659
+ message: `\`@ unsafe\` shell op in target '${targetName}': '${op.body.slice(0, 60)}${op.body.length > 60 ? "..." : ""}'`,
660
+ block: targetName,
661
+ });
662
+ }
663
+ });
664
+ }
665
+ return findings;
666
+ },
667
+ };
668
+ /** Tool-name patterns that strongly suggest mutating operations. Conservative — false positives are tolerable for warnings; false negatives are dangerous. */
669
+ const MUTATING_TOOL_PATTERN = /^(?:write_|update_|delete_|remove_|set_|create_|insert_|put_|patch_|destroy_).*/;
670
+ const UNCONFIRMED_MUTATION = {
671
+ id: "unconfirmed-mutation",
672
+ severity: "warning",
673
+ description: "A `$` op invokes a tool whose name suggests mutation (write/update/delete/...) without a preceding `??` confirmation step.",
674
+ remediation: "Add a `??` confirmation op before the mutation, or restructure to make the mutation explicit in the skill's name/output.",
675
+ check: (ctx) => {
676
+ const findings = [];
677
+ for (const [targetName, target] of ctx.parsed.targets) {
678
+ let sawConfirm = false;
679
+ for (const op of target.ops) {
680
+ if (op.kind === "??")
681
+ sawConfirm = true;
682
+ if (op.kind === "$" && !sawConfirm) {
683
+ const toolName = op.body.split(/\s+/)[0] ?? "";
684
+ if (MUTATING_TOOL_PATTERN.test(toolName)) {
685
+ findings.push({
686
+ rule: "unconfirmed-mutation",
687
+ severity: "warning",
688
+ message: `\`$\` op in target '${targetName}' invokes '${toolName}' (mutating shape) without a preceding \`??\` confirmation.`,
689
+ block: targetName,
690
+ });
691
+ }
692
+ }
693
+ }
694
+ }
695
+ return findings;
696
+ },
697
+ };
698
+ const MODEL_CONTENTION = {
699
+ id: "model-contention",
700
+ severity: "warning",
701
+ description: "Skill body has a `$` op dispatching async batch work on a model + a downstream `~ model=X` synchronous call to the same model. The runtime serializes per-model; the sync call queues behind the batch.",
702
+ remediation: "Use distinct models for async vs sync work: e.g., `gemma2` for batch + `qwen` for the interactive verdict. See ERD §3 model selection convention.",
703
+ check: (ctx) => {
704
+ const findings = [];
705
+ // Heuristic: collect ~ op model names per target. Flag if any $ op
706
+ // in the same target dispatches a batch-classification-shaped tool
707
+ // (name contains "olsen", "scan", "batch", "classify"). Conservative.
708
+ for (const [targetName, target] of ctx.parsed.targets) {
709
+ const syncModels = new Set();
710
+ walkOps(target.ops, (op) => {
711
+ if (op.kind === "~" && op.localModelParams?.model)
712
+ syncModels.add(op.localModelParams.model);
713
+ });
714
+ if (syncModels.size === 0)
715
+ return findings;
716
+ walkOps(target.ops, (op) => {
717
+ if (op.kind !== "$")
718
+ return;
719
+ const toolName = op.body.split(/\s+/)[0] ?? "";
720
+ if (/scan|batch|classify|atomize/i.test(toolName)) {
721
+ findings.push({
722
+ rule: "model-contention",
723
+ severity: "warning",
724
+ message: `Target '${targetName}' dispatches batch work via '${toolName}' AND uses sync \`~ model=...\` — possible model contention on the same backend.`,
725
+ block: targetName,
726
+ });
727
+ }
728
+ });
729
+ }
730
+ return findings;
731
+ },
732
+ };
733
+ const DRAFT_WITH_TRIGGER = {
734
+ id: "draft-with-trigger",
735
+ severity: "warning",
736
+ description: "Skill has `# Status: Draft` but declares triggers. Draft skills shouldn't be fire-able autonomously.",
737
+ remediation: "Promote to `approved` once tested, or remove the trigger declarations until the skill is ready.",
738
+ check: (ctx) => {
739
+ if (ctx.parsed.status !== "Draft" || ctx.parsed.triggers.length === 0)
740
+ return [];
741
+ return [{
742
+ rule: "draft-with-trigger",
743
+ severity: "warning",
744
+ message: `Skill is \`# Status: Draft\` but declares ${ctx.parsed.triggers.length} trigger(s). Draft skills won't fire — promote or drop the triggers.`,
745
+ }];
746
+ },
747
+ };
748
+ const REFERENCE_TO_DISABLED_SKILL = {
749
+ id: "reference-to-disabled-skill",
750
+ severity: "warning",
751
+ description: "An `&` op references a skill whose `# Status:` is `disabled`. Tier-2 warning to surface deprecation paths without breaking existing references.",
752
+ remediation: "Plan a migration off the disabled skill. Existing references resolve; new authoring should pick a non-disabled target.",
753
+ check: async (ctx) => {
754
+ if (ctx.skillStore === undefined)
755
+ return [];
756
+ const findings = [];
757
+ const checked = new Set();
758
+ for (const [targetName, target] of ctx.parsed.targets) {
759
+ for (const refName of collectAmpRefsFromOps(target.ops)) {
760
+ if (checked.has(refName))
761
+ continue;
762
+ checked.add(refName);
763
+ try {
764
+ const meta = await ctx.skillStore.metadata(refName);
765
+ if (meta.status === "Disabled") {
766
+ findings.push({
767
+ rule: "reference-to-disabled-skill",
768
+ severity: "warning",
769
+ message: `Target '${targetName}' references '${refName}' which is disabled.`,
770
+ block: targetName,
771
+ extras: { referenced_skill: refName },
772
+ });
773
+ }
774
+ }
775
+ catch {
776
+ /* unknown-skill-reference handles missing case */
777
+ }
778
+ }
779
+ }
780
+ return findings;
781
+ },
782
+ };
783
+ // ─── Tier-3 rules (info) ────────────────────────────────────────────────────
784
+ const NO_DEFAULT_TARGET = {
785
+ id: "no-default-target",
786
+ severity: "info",
787
+ description: "Multi-target skill resolves entry via fallback (last target) instead of an explicit `default:` declaration. Authors lose intent visibility.",
788
+ remediation: "Add `default: <target-name>` to make the entry point explicit.",
789
+ check: (ctx) => {
790
+ if (ctx.parsed.targets.size <= 1)
791
+ return [];
792
+ // The parser sets entryTarget to the last declared target when no `default:`
793
+ // line was present. Re-derive that condition from the source.
794
+ // Simpler: ParsedSkill doesn't distinguish explicit vs fallback. The
795
+ // parser's behavior is `entryTarget === null` only when no targets at
796
+ // all; with targets it picks the last. So we can't distinguish at
797
+ // this layer without a parser change. For v1.0-dev, skip the check
798
+ // (parser change deferred to v1.x).
799
+ return [];
800
+ },
801
+ };
802
+ const DUPLICATE_SKILL_NAME = {
803
+ id: "duplicate-skill-name",
804
+ severity: "info",
805
+ description: "Another skill in the SkillStore has the same name as this one. Risk of authoring confusion.",
806
+ remediation: "Rename one of the skills. Unique names per substrate; conflicts surface as ambiguous-name errors at load time.",
807
+ check: async (ctx) => {
808
+ if (ctx.skillStore === undefined || ctx.parsed.name === null)
809
+ return [];
810
+ const matches = await ctx.skillStore.query();
811
+ const dupes = matches.filter((m) => m.name === ctx.parsed.name);
812
+ if (dupes.length <= 1)
813
+ return [];
814
+ return [{
815
+ rule: "duplicate-skill-name",
816
+ severity: "info",
817
+ message: `${dupes.length} skills in the SkillStore share the name '${ctx.parsed.name}'.`,
818
+ }];
819
+ },
820
+ };
821
+ const PLUGIN_COLLISION = {
822
+ id: "plugin-collision",
823
+ severity: "info",
824
+ description: "The same plugin name resolves in both filesystem and npm — operator should confirm which wins per the resolution-order config.",
825
+ remediation: "Set `plugins.resolution_order` in config.toml to commit to a precedence order, or remove the duplicate.",
826
+ check: () => {
827
+ // Plugin loader doesn't exist yet (T7). Rule shape is here so the
828
+ // registry shape stays complete; check returns empty until T7 wires
829
+ // plugin discovery.
830
+ return [];
831
+ },
832
+ };
833
+ const RULES = [
834
+ // Tier-1 (error)
835
+ PARSE_ERROR,
836
+ NO_TARGETS,
837
+ NO_ENTRY_TARGET,
838
+ ORPHAN_TARGET,
839
+ UNKNOWN_CAPABILITY,
840
+ UNDECLARED_VAR,
841
+ UNKNOWN_FILTER,
842
+ MALFORMED_OP_GRAMMAR,
843
+ INVALID_CONDITIONAL_SYNTAX,
844
+ SINGLE_EQUALS,
845
+ INDENTATION,
846
+ RESERVED_KEYWORD,
847
+ UNKNOWN_SKILL_REFERENCE,
848
+ DISABLED_SKILL_REFERENCE,
849
+ CREDENTIAL_IN_ARGS,
850
+ STATUS_DISABLED,
851
+ CIRCULAR_DEPENDENCY,
852
+ MISSING_DEPENDENCY,
853
+ MISSING_SKILLSTORE_FOR_DATA_REF,
854
+ // Tier-2 (warning)
855
+ DEPRECATED_QUESTION,
856
+ UNSAFE_SHELL_AMBIGUOUS_SUBST,
857
+ UNSAFE_SHELL_OP,
858
+ UNCONFIRMED_MUTATION,
859
+ MODEL_CONTENTION,
860
+ DRAFT_WITH_TRIGGER,
861
+ REFERENCE_TO_DISABLED_SKILL,
862
+ // Tier-3 (info)
863
+ NO_DEFAULT_TARGET,
864
+ DUPLICATE_SKILL_NAME,
865
+ PLUGIN_COLLISION,
866
+ ];
867
+ /** Read-only view of the rule registry — for tooling that introspects v1 rules. */
868
+ export function listRules() {
869
+ return RULES.map(({ id, severity, description, remediation }) => ({ id, severity, description, remediation }));
870
+ }
871
+ // ─── AST walking helpers ───────────────────────────────────────────────────
872
+ function walkOps(ops, visit) {
873
+ for (const op of ops) {
874
+ visit(op);
875
+ if (op.foreachBody !== undefined)
876
+ walkOps(op.foreachBody, visit);
877
+ if (op.ifBranches !== undefined) {
878
+ for (const b of op.ifBranches)
879
+ walkOps(b.body, visit);
880
+ }
881
+ if (op.ifElseBody !== undefined)
882
+ walkOps(op.ifElseBody, visit);
883
+ }
884
+ }
885
+ function collectAmpRefsFromOps(ops) {
886
+ const out = new Set();
887
+ walkOps(ops, (op) => {
888
+ if (op.kind === "&" && op.ampParams !== undefined)
889
+ out.add(op.ampParams.skillName);
890
+ });
891
+ return out;
892
+ }
893
+ function extractVarRefs(op) {
894
+ const text = collectOpText(op);
895
+ const re = /\$\(([^|)\s]+)(?:\s*\|\s*[A-Za-z_]\w*)?\)/g;
896
+ const refs = [];
897
+ let m;
898
+ while ((m = re.exec(text)) !== null)
899
+ refs.push(m[1]);
900
+ return refs;
901
+ }
902
+ function extractVarRefsWithFilter(op) {
903
+ const text = collectOpText(op);
904
+ const re = /\$\(([^|)\s]+)(?:\s*\|\s*([A-Za-z_]\w*))?\)/g;
905
+ const out = [];
906
+ let m;
907
+ while ((m = re.exec(text)) !== null) {
908
+ const entry = { name: m[1] };
909
+ if (m[2] !== undefined)
910
+ entry.filter = m[2];
911
+ out.push(entry);
912
+ }
913
+ return out;
914
+ }
915
+ function collectOpText(op) {
916
+ let text = op.body;
917
+ if (op.retrievalParams !== undefined) {
918
+ text += " " + op.retrievalParams.query + " " + Object.values(op.retrievalParams.extra).join(" ");
919
+ }
920
+ if (op.localModelParams !== undefined)
921
+ text += " " + op.localModelParams.prompt;
922
+ if (op.setValue !== undefined)
923
+ text += " " + op.setValue;
924
+ if (op.foreachList !== undefined)
925
+ text += " " + op.foreachList;
926
+ return text;
927
+ }
928
+ /** Walk surrounding `foreach` scopes to see if `varName` is an iterator currently in scope at `op`. Conservative: walks the parent ops tree. */
929
+ function isLoopIterInScope(allOps, target, varName) {
930
+ function check(ops) {
931
+ for (const op of ops) {
932
+ if (op === target)
933
+ return false;
934
+ if (op.kind === "foreach" && op.foreachIter === varName) {
935
+ if (op.foreachBody !== undefined && containsOp(op.foreachBody, target))
936
+ return true;
937
+ }
938
+ if (op.foreachBody !== undefined && check(op.foreachBody))
939
+ return true;
940
+ if (op.ifBranches !== undefined) {
941
+ for (const b of op.ifBranches)
942
+ if (check(b.body))
943
+ return true;
944
+ }
945
+ if (op.ifElseBody !== undefined && check(op.ifElseBody))
946
+ return true;
947
+ }
948
+ return false;
949
+ }
950
+ return check(allOps);
951
+ }
952
+ function containsOp(ops, target) {
953
+ for (const op of ops) {
954
+ if (op === target)
955
+ return true;
956
+ if (op.foreachBody !== undefined && containsOp(op.foreachBody, target))
957
+ return true;
958
+ if (op.ifBranches !== undefined) {
959
+ for (const b of op.ifBranches)
960
+ if (containsOp(b.body, target))
961
+ return true;
962
+ }
963
+ if (op.ifElseBody !== undefined && containsOp(op.ifElseBody, target))
964
+ return true;
965
+ }
966
+ return false;
967
+ }
968
+ // ─── Capability helpers (shared with the unknown-capability rule) ──────────
969
+ function collectClassesFromRegistry(registry) {
970
+ if (registry === undefined)
971
+ return null;
972
+ return [
973
+ ...registry.listSkillStoreClasses(),
974
+ ...registry.listMemoryStoreClasses(),
975
+ ...registry.listLocalModelClasses(),
976
+ ...registry.listMcpConnectorClasses(),
977
+ ];
978
+ }
979
+ function buildFeatureSet(classes) {
980
+ const provided = new Set();
981
+ for (const Ctor of classes) {
982
+ const caps = Ctor.staticCapabilities();
983
+ for (const [flag, value] of Object.entries(caps.features)) {
984
+ if (value === true)
985
+ provided.add(`${caps.connector_type}.${flag}`);
986
+ }
987
+ }
988
+ return provided;
989
+ }
990
+ //# sourceMappingURL=lint.js.map