(sourceDir, schemasDir)
| 270 | } |
| 271 | |
| 272 | function lintStoryboardIdempotency(sourceDir, schemasDir) { |
| 273 | const { refs: mutatingRefs, tools: mutatingTools } = loadMutatingSchemaRefs(schemasDir); |
| 274 | const violations = []; |
| 275 | const stableKeyViolations = []; |
| 276 | const duplicateGeneratedKeyViolations = []; |
| 277 | const missingSchemaRefs = []; |
| 278 | const generatedKeyUses = new Map(); |
| 279 | const duplicateGeneratedKeyAllowedFiles = new Set([ |
| 280 | 'universal/idempotency.yaml', |
| 281 | 'universal/webhook-emission.yaml', |
| 282 | ]); |
| 283 | |
| 284 | function isGeneratedIdempotencyKey(value, generatedContextNames) { |
| 285 | if (typeof value !== 'string') return false; |
| 286 | if (value.startsWith('$generate:uuid_v4')) return true; |
| 287 | const contextMatch = value.match(/^\$context\.([A-Za-z0-9_]+)$/); |
| 288 | return Boolean(contextMatch && generatedContextNames.has(contextMatch[1])); |
| 289 | } |
| 290 | |
| 291 | function rememberGeneratedUuidContextOutputs(step, generatedContextNames) { |
| 292 | for (const output of step?.context_outputs ?? []) { |
| 293 | if (output?.generate === 'uuid_v4' && typeof output.name === 'string') { |
| 294 | generatedContextNames.add(output.name); |
| 295 | } |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | function lintFile(p) { |
| 300 | const rel = path.relative(sourceDir, p); |
| 301 | // Not storyboards: test-kit fixtures and the schema doc itself. |
| 302 | if (rel.startsWith('test-kits/')) return; |
| 303 | if (rel.endsWith('storyboard-schema.yaml')) return; |
| 304 | |
| 305 | let doc; |
| 306 | try { doc = yaml.load(fs.readFileSync(p, 'utf8')); } |
| 307 | catch { return; } |
| 308 | if (!doc || typeof doc !== 'object' || !Array.isArray(doc.phases)) return; |
| 309 | const generatedContextNames = new Set(); |
| 310 | |
| 311 | for (const phase of doc.phases) { |
| 312 | if (!phase || !Array.isArray(phase.steps)) continue; |
| 313 | for (const step of phase.steps) { |
| 314 | if (!step || typeof step !== 'object' || !step.task) continue; |
| 315 | // Steps without schema_ref are HTTP probes, controller calls, or |
| 316 | // synthetic invocations (e.g., comply_test_controller's |
| 317 | // simulate_budget scenarios, universal/security.yaml's probe steps) |
| 318 | // that don't send a task request schema. They're out of scope for |
| 319 | // this lint — UNLESS the task name itself is a known mutating tool, |
| 320 | // in which case the missing schema_ref is itself a storyboard bug |
| 321 | // (the step would bypass the idempotency_key lint above). Positive |
| 322 | // check per red-team I-9 / security.mdx storyboard hygiene. |
| 323 | if (step.task && mutatingTools.has(step.task) && !step.schema_ref) { |
| 324 | missingSchemaRefs.push({ |
| 325 | file: rel, |
| 326 | step: step.id, |
| 327 | msg: `Step uses mutating tool "${step.task}" but has no schema_ref`, |
| 328 | }); |
| 329 | } |
no test coverage detected