(p)
| 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 | } |
| 330 | const schemaRef = step.schema_ref; |
| 331 | if (!schemaRef || !mutatingRefs.has(schemaRef)) { |
| 332 | rememberGeneratedUuidContextOutputs(step, generatedContextNames); |
| 333 | continue; |
| 334 | } |
| 335 | // The missing-key negative vector explicitly suppresses both runner |
| 336 | // auto-injection and this authored-sample lint. |
| 337 | if (step.omit_idempotency_key === true) { |
| 338 | rememberGeneratedUuidContextOutputs(step, generatedContextNames); |
| 339 | continue; |
| 340 | } |
| 341 | const hasKey = |
| 342 | step.sample_request && |
| 343 | typeof step.sample_request === 'object' && |
| 344 | step.sample_request.idempotency_key !== undefined; |
| 345 | if (hasKey) { |
| 346 | const key = step.sample_request.idempotency_key; |
| 347 | if (!isGeneratedIdempotencyKey(key, generatedContextNames)) { |
| 348 | stableKeyViolations.push({ |
| 349 | file: rel, |
| 350 | phase: phase.id, |
| 351 | step: step.id, |
| 352 | task: step.task, |
| 353 | key, |
| 354 | }); |
| 355 | } else if (!duplicateGeneratedKeyAllowedFiles.has(rel)) { |
| 356 | const existing = generatedKeyUses.get(key); |
no test coverage detected