@united-workforce/cli 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/README.md +45 -11
  2. package/dist/.build-fingerprint +1 -0
  3. package/dist/__tests__/adapter-json-roundtrip.test.js +17 -7
  4. package/dist/__tests__/adapter-json-roundtrip.test.js.map +1 -1
  5. package/dist/__tests__/agent-resolution-llm-free.test.d.ts +2 -0
  6. package/dist/__tests__/agent-resolution-llm-free.test.d.ts.map +1 -0
  7. package/dist/__tests__/agent-resolution-llm-free.test.js +30 -0
  8. package/dist/__tests__/agent-resolution-llm-free.test.js.map +1 -0
  9. package/dist/__tests__/build-step-entry.test.d.ts +2 -0
  10. package/dist/__tests__/build-step-entry.test.d.ts.map +1 -0
  11. package/dist/__tests__/build-step-entry.test.js +173 -0
  12. package/dist/__tests__/build-step-entry.test.js.map +1 -0
  13. package/dist/__tests__/clear-thread-failed-attempts.test.d.ts +2 -0
  14. package/dist/__tests__/clear-thread-failed-attempts.test.d.ts.map +1 -0
  15. package/dist/__tests__/clear-thread-failed-attempts.test.js +93 -0
  16. package/dist/__tests__/clear-thread-failed-attempts.test.js.map +1 -0
  17. package/dist/__tests__/concurrency.test.d.ts +2 -0
  18. package/dist/__tests__/concurrency.test.d.ts.map +1 -0
  19. package/dist/__tests__/concurrency.test.js +196 -0
  20. package/dist/__tests__/concurrency.test.js.map +1 -0
  21. package/dist/__tests__/config.test.js +26 -302
  22. package/dist/__tests__/config.test.js.map +1 -1
  23. package/dist/__tests__/current-role.test.js +7 -6
  24. package/dist/__tests__/current-role.test.js.map +1 -1
  25. package/dist/__tests__/e2e-mock-agent.test.js +43 -30
  26. package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
  27. package/dist/__tests__/format-text-default.test.d.ts +2 -0
  28. package/dist/__tests__/format-text-default.test.d.ts.map +1 -0
  29. package/dist/__tests__/format-text-default.test.js +43 -0
  30. package/dist/__tests__/format-text-default.test.js.map +1 -0
  31. package/dist/__tests__/format-text-registry.test.d.ts +2 -0
  32. package/dist/__tests__/format-text-registry.test.d.ts.map +1 -0
  33. package/dist/__tests__/format-text-registry.test.js +158 -0
  34. package/dist/__tests__/format-text-registry.test.js.map +1 -0
  35. package/dist/__tests__/issue-180-workflow-ref-removed.test.d.ts +2 -0
  36. package/dist/__tests__/issue-180-workflow-ref-removed.test.d.ts.map +1 -0
  37. package/dist/__tests__/issue-180-workflow-ref-removed.test.js +40 -0
  38. package/dist/__tests__/issue-180-workflow-ref-removed.test.js.map +1 -0
  39. package/dist/__tests__/log-text-renderer.test.d.ts +2 -0
  40. package/dist/__tests__/log-text-renderer.test.d.ts.map +1 -0
  41. package/dist/__tests__/log-text-renderer.test.js +265 -0
  42. package/dist/__tests__/log-text-renderer.test.js.map +1 -0
  43. package/dist/__tests__/moderator-evaluate.test.js +9 -50
  44. package/dist/__tests__/moderator-evaluate.test.js.map +1 -1
  45. package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts +2 -0
  46. package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts.map +1 -0
  47. package/dist/__tests__/output-mapper-thread-list-startedat.test.js +102 -0
  48. package/dist/__tests__/output-mapper-thread-list-startedat.test.js.map +1 -0
  49. package/dist/__tests__/output-mapper-workflow-add.test.d.ts +2 -0
  50. package/dist/__tests__/output-mapper-workflow-add.test.d.ts.map +1 -0
  51. package/dist/__tests__/output-mapper-workflow-add.test.js +22 -0
  52. package/dist/__tests__/output-mapper-workflow-add.test.js.map +1 -0
  53. package/dist/__tests__/pid-recycling.test.d.ts +2 -0
  54. package/dist/__tests__/pid-recycling.test.d.ts.map +1 -0
  55. package/dist/__tests__/pid-recycling.test.js +273 -0
  56. package/dist/__tests__/pid-recycling.test.js.map +1 -0
  57. package/dist/__tests__/prompt.test.js +365 -2
  58. package/dist/__tests__/prompt.test.js.map +1 -1
  59. package/dist/__tests__/resolve-head-hash.test.js +12 -4
  60. package/dist/__tests__/resolve-head-hash.test.js.map +1 -1
  61. package/dist/__tests__/setup-agent-discovery.test.js +21 -30
  62. package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
  63. package/dist/__tests__/setup-complexity.test.js +2 -168
  64. package/dist/__tests__/setup-complexity.test.js.map +1 -1
  65. package/dist/__tests__/setup-no-llm.test.d.ts +2 -0
  66. package/dist/__tests__/setup-no-llm.test.d.ts.map +1 -0
  67. package/dist/__tests__/setup-no-llm.test.js +52 -0
  68. package/dist/__tests__/setup-no-llm.test.js.map +1 -0
  69. package/dist/__tests__/solve-issue-tea-worktree.test.js +27 -28
  70. package/dist/__tests__/solve-issue-tea-worktree.test.js.map +1 -1
  71. package/dist/__tests__/step-ask.test.d.ts +2 -0
  72. package/dist/__tests__/step-ask.test.d.ts.map +1 -0
  73. package/dist/__tests__/step-ask.test.js +507 -0
  74. package/dist/__tests__/step-ask.test.js.map +1 -0
  75. package/dist/__tests__/step-show-json.test.js +1 -0
  76. package/dist/__tests__/step-show-json.test.js.map +1 -1
  77. package/dist/__tests__/step-timing.test.js +2 -0
  78. package/dist/__tests__/step-timing.test.js.map +1 -1
  79. package/dist/__tests__/store-global-cas.test.js +2 -2
  80. package/dist/__tests__/store-global-cas.test.js.map +1 -1
  81. package/dist/__tests__/store-unified-threads.test.js +28 -26
  82. package/dist/__tests__/store-unified-threads.test.js.map +1 -1
  83. package/dist/__tests__/thread-cancel-status.test.js +25 -19
  84. package/dist/__tests__/thread-cancel-status.test.js.map +1 -1
  85. package/dist/__tests__/thread-cancel-text-renderer.test.d.ts +2 -0
  86. package/dist/__tests__/thread-cancel-text-renderer.test.d.ts.map +1 -0
  87. package/dist/__tests__/thread-cancel-text-renderer.test.js +110 -0
  88. package/dist/__tests__/thread-cancel-text-renderer.test.js.map +1 -0
  89. package/dist/__tests__/thread-list-filters.test.js +354 -17
  90. package/dist/__tests__/thread-list-filters.test.js.map +1 -1
  91. package/dist/__tests__/thread-list-template-ms-date.test.d.ts +2 -0
  92. package/dist/__tests__/thread-list-template-ms-date.test.d.ts.map +1 -0
  93. package/dist/__tests__/thread-list-template-ms-date.test.js +102 -0
  94. package/dist/__tests__/thread-list-template-ms-date.test.js.map +1 -0
  95. package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts +2 -0
  96. package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts.map +1 -0
  97. package/dist/__tests__/thread-list-workflow-corrupt.test.js +157 -0
  98. package/dist/__tests__/thread-list-workflow-corrupt.test.js.map +1 -0
  99. package/dist/__tests__/thread-poke.test.d.ts +2 -0
  100. package/dist/__tests__/thread-poke.test.d.ts.map +1 -0
  101. package/dist/__tests__/thread-poke.test.js +422 -0
  102. package/dist/__tests__/thread-poke.test.js.map +1 -0
  103. package/dist/__tests__/thread-read-xml-tags.test.js +10 -9
  104. package/dist/__tests__/thread-read-xml-tags.test.js.map +1 -1
  105. package/dist/__tests__/thread-resume.test.js +21 -15
  106. package/dist/__tests__/thread-resume.test.js.map +1 -1
  107. package/dist/__tests__/thread-show-status.test.js +17 -28
  108. package/dist/__tests__/thread-show-status.test.js.map +1 -1
  109. package/dist/__tests__/thread-start-cwd-cli.test.js +15 -3
  110. package/dist/__tests__/thread-start-cwd-cli.test.js.map +1 -1
  111. package/dist/__tests__/thread-stop-text-renderer.test.d.ts +2 -0
  112. package/dist/__tests__/thread-stop-text-renderer.test.d.ts.map +1 -0
  113. package/dist/__tests__/thread-stop-text-renderer.test.js +148 -0
  114. package/dist/__tests__/thread-stop-text-renderer.test.js.map +1 -0
  115. package/dist/__tests__/thread-suspend-step.test.js +13 -16
  116. package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
  117. package/dist/__tests__/thread-suspended-display.test.js +10 -22
  118. package/dist/__tests__/thread-suspended-display.test.js.map +1 -1
  119. package/dist/__tests__/thread-test-helpers.d.ts +7 -0
  120. package/dist/__tests__/thread-test-helpers.d.ts.map +1 -1
  121. package/dist/__tests__/thread-test-helpers.js +13 -0
  122. package/dist/__tests__/thread-test-helpers.js.map +1 -1
  123. package/dist/__tests__/thread.test.js +15 -13
  124. package/dist/__tests__/thread.test.js.map +1 -1
  125. package/dist/__tests__/validate-semantic.test.js +105 -23
  126. package/dist/__tests__/validate-semantic.test.js.map +1 -1
  127. package/dist/__tests__/workflow-list-recursive.test.d.ts +2 -0
  128. package/dist/__tests__/workflow-list-recursive.test.d.ts.map +1 -0
  129. package/dist/__tests__/workflow-list-recursive.test.js +286 -0
  130. package/dist/__tests__/workflow-list-recursive.test.js.map +1 -0
  131. package/dist/__tests__/workflow-resolution.test.js +46 -28
  132. package/dist/__tests__/workflow-resolution.test.js.map +1 -1
  133. package/dist/__tests__/workflow-show-resolution.test.d.ts +2 -0
  134. package/dist/__tests__/workflow-show-resolution.test.d.ts.map +1 -0
  135. package/dist/__tests__/workflow-show-resolution.test.js +213 -0
  136. package/dist/__tests__/workflow-show-resolution.test.js.map +1 -0
  137. package/dist/__tests__/workflow-validate.test.d.ts +2 -0
  138. package/dist/__tests__/workflow-validate.test.d.ts.map +1 -0
  139. package/dist/__tests__/workflow-validate.test.js +707 -0
  140. package/dist/__tests__/workflow-validate.test.js.map +1 -0
  141. package/dist/__tests__/write-envelope.test.d.ts +2 -0
  142. package/dist/__tests__/write-envelope.test.d.ts.map +1 -0
  143. package/dist/__tests__/write-envelope.test.js +201 -0
  144. package/dist/__tests__/write-envelope.test.js.map +1 -0
  145. package/dist/background/background.d.ts +22 -1
  146. package/dist/background/background.d.ts.map +1 -1
  147. package/dist/background/background.js +83 -6
  148. package/dist/background/background.js.map +1 -1
  149. package/dist/background/index.d.ts +1 -1
  150. package/dist/background/index.d.ts.map +1 -1
  151. package/dist/background/index.js +1 -1
  152. package/dist/background/index.js.map +1 -1
  153. package/dist/background/types.d.ts +1 -0
  154. package/dist/background/types.d.ts.map +1 -1
  155. package/dist/cli.js +120 -62
  156. package/dist/cli.js.map +1 -1
  157. package/dist/commands/config.d.ts +3 -1
  158. package/dist/commands/config.d.ts.map +1 -1
  159. package/dist/commands/config.js +17 -31
  160. package/dist/commands/config.js.map +1 -1
  161. package/dist/commands/prompt.d.ts.map +1 -1
  162. package/dist/commands/prompt.js +57 -31
  163. package/dist/commands/prompt.js.map +1 -1
  164. package/dist/commands/setup.d.ts +12 -39
  165. package/dist/commands/setup.d.ts.map +1 -1
  166. package/dist/commands/setup.js +72 -303
  167. package/dist/commands/setup.js.map +1 -1
  168. package/dist/commands/step.d.ts +44 -1
  169. package/dist/commands/step.d.ts.map +1 -1
  170. package/dist/commands/step.js +255 -11
  171. package/dist/commands/step.js.map +1 -1
  172. package/dist/commands/thread.d.ts +16 -3
  173. package/dist/commands/thread.d.ts.map +1 -1
  174. package/dist/commands/thread.js +423 -142
  175. package/dist/commands/thread.js.map +1 -1
  176. package/dist/commands/workflow.d.ts +9 -1
  177. package/dist/commands/workflow.d.ts.map +1 -1
  178. package/dist/commands/workflow.js +126 -6
  179. package/dist/commands/workflow.js.map +1 -1
  180. package/dist/concurrency/concurrency.d.ts +34 -0
  181. package/dist/concurrency/concurrency.d.ts.map +1 -0
  182. package/dist/concurrency/concurrency.js +216 -0
  183. package/dist/concurrency/concurrency.js.map +1 -0
  184. package/dist/concurrency/index.d.ts +3 -0
  185. package/dist/concurrency/index.d.ts.map +1 -0
  186. package/dist/concurrency/index.js +2 -0
  187. package/dist/concurrency/index.js.map +1 -0
  188. package/dist/concurrency/types.d.ts +19 -0
  189. package/dist/concurrency/types.d.ts.map +1 -0
  190. package/dist/concurrency/types.js +2 -0
  191. package/dist/concurrency/types.js.map +1 -0
  192. package/dist/format.d.ts +69 -2
  193. package/dist/format.d.ts.map +1 -1
  194. package/dist/format.js +198 -1
  195. package/dist/format.js.map +1 -1
  196. package/dist/moderator/__tests__/evaluate.test.js +31 -17
  197. package/dist/moderator/__tests__/evaluate.test.js.map +1 -1
  198. package/dist/moderator/evaluate.d.ts.map +1 -1
  199. package/dist/moderator/evaluate.js +4 -16
  200. package/dist/moderator/evaluate.js.map +1 -1
  201. package/dist/moderator/index.d.ts +1 -2
  202. package/dist/moderator/index.d.ts.map +1 -1
  203. package/dist/moderator/index.js +0 -1
  204. package/dist/moderator/index.js.map +1 -1
  205. package/dist/moderator/types.d.ts +6 -10
  206. package/dist/moderator/types.d.ts.map +1 -1
  207. package/dist/moderator/types.js +1 -3
  208. package/dist/moderator/types.js.map +1 -1
  209. package/dist/output-mappers.d.ts +122 -0
  210. package/dist/output-mappers.d.ts.map +1 -0
  211. package/dist/output-mappers.js +134 -0
  212. package/dist/output-mappers.js.map +1 -0
  213. package/dist/schemas.d.ts +6 -1
  214. package/dist/schemas.d.ts.map +1 -1
  215. package/dist/schemas.js +34 -5
  216. package/dist/schemas.js.map +1 -1
  217. package/dist/store.d.ts +28 -9
  218. package/dist/store.d.ts.map +1 -1
  219. package/dist/store.js +75 -16
  220. package/dist/store.js.map +1 -1
  221. package/dist/text-renderers.d.ts +30 -0
  222. package/dist/text-renderers.d.ts.map +1 -0
  223. package/dist/text-renderers.js +251 -0
  224. package/dist/text-renderers.js.map +1 -0
  225. package/dist/validate-semantic.d.ts.map +1 -1
  226. package/dist/validate-semantic.js +95 -61
  227. package/dist/validate-semantic.js.map +1 -1
  228. package/dist/validate.d.ts +6 -0
  229. package/dist/validate.d.ts.map +1 -1
  230. package/dist/validate.js +24 -0
  231. package/dist/validate.js.map +1 -1
  232. package/examples/brainstorm.yaml +130 -0
  233. package/examples/debate.yaml +169 -0
  234. package/examples/socratic-questioning.yaml +112 -0
  235. package/package.json +9 -10
  236. package/src/__tests__/adapter-json-roundtrip.test.ts +16 -7
  237. package/src/__tests__/agent-resolution-llm-free.test.ts +39 -0
  238. package/src/__tests__/build-step-entry.test.ts +203 -0
  239. package/src/__tests__/clear-thread-failed-attempts.test.ts +122 -0
  240. package/src/__tests__/concurrency.test.ts +266 -0
  241. package/src/__tests__/config.test.ts +33 -321
  242. package/src/__tests__/current-role.test.ts +7 -6
  243. package/src/__tests__/e2e-mock-agent.test.ts +65 -30
  244. package/src/__tests__/fixtures/e2e-count.workflow.yaml +1 -0
  245. package/src/__tests__/fixtures/e2e-linear.workflow.yaml +1 -0
  246. package/src/__tests__/fixtures/{e2e-mustache.workflow.yaml → e2e-liquid.workflow.yaml} +3 -2
  247. package/src/__tests__/fixtures/e2e-loop.workflow.yaml +1 -0
  248. package/src/__tests__/fixtures/e2e-suspend.mock.yaml +2 -2
  249. package/src/__tests__/fixtures/e2e-suspend.workflow.yaml +6 -10
  250. package/src/__tests__/format-text-default.test.ts +49 -0
  251. package/src/__tests__/format-text-registry.test.ts +173 -0
  252. package/src/__tests__/issue-180-workflow-ref-removed.test.ts +43 -0
  253. package/src/__tests__/log-text-renderer.test.ts +294 -0
  254. package/src/__tests__/moderator-evaluate.test.ts +9 -52
  255. package/src/__tests__/output-mapper-thread-list-startedat.test.ts +124 -0
  256. package/src/__tests__/output-mapper-workflow-add.test.ts +24 -0
  257. package/src/__tests__/pid-recycling.test.ts +329 -0
  258. package/src/__tests__/prompt.test.ts +443 -2
  259. package/src/__tests__/resolve-head-hash.test.ts +11 -4
  260. package/src/__tests__/setup-agent-discovery.test.ts +26 -51
  261. package/src/__tests__/setup-complexity.test.ts +1 -203
  262. package/src/__tests__/setup-no-llm.test.ts +68 -0
  263. package/src/__tests__/solve-issue-tea-worktree.test.ts +27 -31
  264. package/src/__tests__/step-ask.test.ts +677 -0
  265. package/src/__tests__/step-show-json.test.ts +1 -0
  266. package/src/__tests__/step-timing.test.ts +2 -0
  267. package/src/__tests__/store-global-cas.test.ts +2 -2
  268. package/src/__tests__/store-unified-threads.test.ts +30 -27
  269. package/src/__tests__/thread-cancel-status.test.ts +27 -20
  270. package/src/__tests__/thread-cancel-text-renderer.test.ts +125 -0
  271. package/src/__tests__/thread-list-filters.test.ts +443 -17
  272. package/src/__tests__/thread-list-template-ms-date.test.ts +110 -0
  273. package/src/__tests__/thread-list-workflow-corrupt.test.ts +198 -0
  274. package/src/__tests__/thread-poke.test.ts +554 -0
  275. package/src/__tests__/thread-read-xml-tags.test.ts +9 -11
  276. package/src/__tests__/thread-resume.test.ts +20 -15
  277. package/src/__tests__/thread-show-status.test.ts +17 -29
  278. package/src/__tests__/thread-start-cwd-cli.test.ts +15 -3
  279. package/src/__tests__/thread-stop-text-renderer.test.ts +168 -0
  280. package/src/__tests__/thread-suspend-step.test.ts +13 -16
  281. package/src/__tests__/thread-suspended-display.test.ts +10 -22
  282. package/src/__tests__/thread-test-helpers.ts +15 -1
  283. package/src/__tests__/thread.test.ts +14 -14
  284. package/src/__tests__/validate-semantic.test.ts +118 -33
  285. package/src/__tests__/workflow-list-recursive.test.ts +370 -0
  286. package/src/__tests__/workflow-resolution.test.ts +48 -29
  287. package/src/__tests__/workflow-show-resolution.test.ts +286 -0
  288. package/src/__tests__/workflow-validate.test.ts +828 -0
  289. package/src/__tests__/write-envelope.test.ts +257 -0
  290. package/src/background/background.ts +88 -6
  291. package/src/background/index.ts +2 -0
  292. package/src/background/types.ts +1 -0
  293. package/src/cli.ts +184 -77
  294. package/src/commands/config.ts +16 -33
  295. package/src/commands/prompt.ts +57 -31
  296. package/src/commands/setup.ts +80 -358
  297. package/src/commands/step.ts +339 -12
  298. package/src/commands/thread.ts +511 -171
  299. package/src/commands/workflow.ts +155 -4
  300. package/src/concurrency/concurrency.ts +245 -0
  301. package/src/concurrency/index.ts +10 -0
  302. package/src/concurrency/types.ts +19 -0
  303. package/src/format.ts +282 -2
  304. package/src/moderator/__tests__/evaluate.test.ts +34 -17
  305. package/src/moderator/evaluate.ts +5 -17
  306. package/src/moderator/index.ts +1 -6
  307. package/src/moderator/types.ts +6 -14
  308. package/src/output-mappers.ts +254 -0
  309. package/src/schemas.ts +51 -5
  310. package/src/store.ts +86 -20
  311. package/src/text-renderers.ts +355 -0
  312. package/src/validate-semantic.ts +125 -73
  313. package/src/validate.ts +27 -0
  314. package/dist/__tests__/setup-validate.test.d.ts +0 -2
  315. package/dist/__tests__/setup-validate.test.d.ts.map +0 -1
  316. package/dist/__tests__/setup-validate.test.js +0 -108
  317. package/dist/__tests__/setup-validate.test.js.map +0 -1
  318. package/src/__tests__/setup-validate.test.ts +0 -148
  319. /package/src/__tests__/fixtures/{e2e-mustache.mock.yaml → e2e-liquid.mock.yaml} +0 -0
@@ -0,0 +1,707 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
7
+ import { stringify } from "yaml";
8
+ const TEST_DIR = dirname(fileURLToPath(import.meta.url));
9
+ const CLI_PATH = join(TEST_DIR, "..", "..", "dist", "cli.js");
10
+ function runValidate(filePath, extraArgs = [], envOverride) {
11
+ const args = [CLI_PATH, "workflow", "validate", filePath, ...extraArgs];
12
+ try {
13
+ const stdout = execFileSync(process.execPath, args, {
14
+ encoding: "utf8",
15
+ stdio: ["ignore", "pipe", "pipe"],
16
+ env: envOverride ?? process.env,
17
+ timeout: 15_000,
18
+ });
19
+ return { stdout, stderr: "", exitCode: 0 };
20
+ }
21
+ catch (e) {
22
+ const err = e;
23
+ return {
24
+ stdout: typeof err.stdout === "string" ? err.stdout : (err.stdout?.toString() ?? ""),
25
+ stderr: typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString() ?? ""),
26
+ exitCode: err.status ?? 1,
27
+ };
28
+ }
29
+ }
30
+ function runCli(args) {
31
+ try {
32
+ const stdout = execFileSync(process.execPath, [CLI_PATH, ...args], {
33
+ encoding: "utf8",
34
+ stdio: ["ignore", "pipe", "pipe"],
35
+ env: process.env,
36
+ timeout: 15_000,
37
+ });
38
+ return { stdout, stderr: "", exitCode: 0 };
39
+ }
40
+ catch (e) {
41
+ const err = e;
42
+ return {
43
+ stdout: typeof err.stdout === "string" ? err.stdout : (err.stdout?.toString() ?? ""),
44
+ stderr: typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString() ?? ""),
45
+ exitCode: err.status ?? 1,
46
+ };
47
+ }
48
+ }
49
+ /** Build a valid single-role workflow payload (writer→$END, status `done`). */
50
+ function makeMinimalPayload(name) {
51
+ return {
52
+ name,
53
+ description: `${name} workflow`,
54
+ roles: {
55
+ writer: {
56
+ description: "Writes content",
57
+ goal: "Write content",
58
+ capabilities: ["writing"],
59
+ procedure: "Write it",
60
+ output: "The content",
61
+ frontmatter: {
62
+ type: "object",
63
+ properties: {
64
+ $status: { const: "done" },
65
+ },
66
+ required: ["$status"],
67
+ },
68
+ },
69
+ },
70
+ graph: {
71
+ $START: {
72
+ new: { role: "writer", prompt: "Begin", location: null },
73
+ resume: { role: "writer", prompt: "Resume", location: null },
74
+ },
75
+ writer: { done: { role: "$END", prompt: "Done", location: null } },
76
+ },
77
+ };
78
+ }
79
+ /** Build a valid writer→reviewer workflow with Liquid var. */
80
+ function makeMultiRolePayload(name) {
81
+ return {
82
+ name,
83
+ description: `${name} workflow`,
84
+ roles: {
85
+ writer: {
86
+ description: "Writes content",
87
+ goal: "Write content",
88
+ capabilities: ["writing"],
89
+ procedure: "Write it",
90
+ output: "The content",
91
+ frontmatter: {
92
+ type: "object",
93
+ properties: {
94
+ $status: { const: "done" },
95
+ plan: { type: "string" },
96
+ },
97
+ required: ["$status", "plan"],
98
+ },
99
+ },
100
+ reviewer: {
101
+ description: "Reviews content",
102
+ goal: "Review content",
103
+ capabilities: ["reviewing"],
104
+ procedure: "Review it",
105
+ output: "The review",
106
+ frontmatter: {
107
+ type: "object",
108
+ properties: {
109
+ $status: { const: "approved" },
110
+ summary: { type: "string" },
111
+ },
112
+ required: ["$status", "summary"],
113
+ },
114
+ },
115
+ },
116
+ graph: {
117
+ $START: {
118
+ new: { role: "writer", prompt: "Begin writing", location: null },
119
+ resume: { role: "writer", prompt: "Continue", location: null },
120
+ },
121
+ writer: {
122
+ done: { role: "reviewer", prompt: "Review this: {{ plan }}", location: null },
123
+ },
124
+ reviewer: {
125
+ approved: { role: "$END", prompt: "Done: {{ summary }}", location: null },
126
+ },
127
+ },
128
+ };
129
+ }
130
+ /** Build a valid reviewer with oneOf multi-exit. */
131
+ function makeOneOfPayload(name) {
132
+ return {
133
+ name,
134
+ description: `${name} workflow`,
135
+ roles: {
136
+ writer: {
137
+ description: "Writes content",
138
+ goal: "Write",
139
+ capabilities: ["writing"],
140
+ procedure: "Write",
141
+ output: "Content",
142
+ frontmatter: {
143
+ type: "object",
144
+ properties: {
145
+ $status: { const: "done" },
146
+ plan: { type: "string" },
147
+ },
148
+ required: ["$status", "plan"],
149
+ },
150
+ },
151
+ reviewer: {
152
+ description: "Reviews",
153
+ goal: "Review",
154
+ capabilities: ["reviewing"],
155
+ procedure: "Review",
156
+ output: "Review",
157
+ frontmatter: {
158
+ type: "object",
159
+ oneOf: [
160
+ {
161
+ properties: {
162
+ $status: { const: "approved" },
163
+ summary: { type: "string" },
164
+ },
165
+ required: ["$status", "summary"],
166
+ },
167
+ {
168
+ properties: {
169
+ $status: { const: "rejected" },
170
+ reason: { type: "string" },
171
+ },
172
+ required: ["$status", "reason"],
173
+ },
174
+ ],
175
+ },
176
+ },
177
+ },
178
+ graph: {
179
+ $START: {
180
+ new: { role: "writer", prompt: "Begin", location: null },
181
+ resume: { role: "writer", prompt: "Resume", location: null },
182
+ },
183
+ writer: {
184
+ done: { role: "reviewer", prompt: "Review: {{ plan }}", location: null },
185
+ },
186
+ reviewer: {
187
+ approved: { role: "$END", prompt: "Done: {{ summary }}", location: null },
188
+ rejected: { role: "writer", prompt: "Fix: {{ reason }}", location: null },
189
+ },
190
+ },
191
+ };
192
+ }
193
+ let tmpDir;
194
+ beforeAll(() => {
195
+ // Confirm CLI is built before running tests
196
+ // (proman build should have produced dist/cli.js)
197
+ });
198
+ beforeEach(async () => {
199
+ tmpDir = await mkdtemp(join(tmpdir(), "wf-validate-test-"));
200
+ });
201
+ afterEach(async () => {
202
+ // chmod back in case a test modified a directory
203
+ try {
204
+ await chmod(tmpDir, 0o755);
205
+ }
206
+ catch {
207
+ // ignore
208
+ }
209
+ await rm(tmpDir, { recursive: true, force: true });
210
+ });
211
+ // ── Suite A: Success Path ────────────────────────────────────────────────────
212
+ //
213
+ // Note: Issue #308 makes validate an envelope-emitting command. Default
214
+ // `--format text` renders `✓ valid` (or `✗ invalid (N errors)`) to stdout.
215
+ // Tests below assert the new envelope contract.
216
+ describe("workflow validate — Suite A: Success Path", () => {
217
+ test("A.1 valid single-role workflow exits 0 with text envelope", async () => {
218
+ const file = join(tmpDir, "test-wf.yaml");
219
+ await writeFile(file, stringify(makeMinimalPayload("test-wf")));
220
+ const result = runValidate(file);
221
+ expect(result.exitCode).toBe(0);
222
+ expect(result.stdout.trim()).toBe("✓ valid");
223
+ expect(result.stderr).toBe("");
224
+ });
225
+ test("A.2 valid multi-role workflow with Liquid vars exits 0 with text envelope", async () => {
226
+ const file = join(tmpDir, "writer-flow.yaml");
227
+ await writeFile(file, stringify(makeMultiRolePayload("writer-flow")));
228
+ const result = runValidate(file);
229
+ expect(result.exitCode).toBe(0);
230
+ expect(result.stdout.trim()).toBe("✓ valid");
231
+ expect(result.stderr).toBe("");
232
+ });
233
+ test("A.3 valid oneOf multi-exit workflow exits 0 with text envelope", async () => {
234
+ const file = join(tmpDir, "review-flow.yaml");
235
+ await writeFile(file, stringify(makeOneOfPayload("review-flow")));
236
+ const result = runValidate(file);
237
+ expect(result.exitCode).toBe(0);
238
+ expect(result.stdout.trim()).toBe("✓ valid");
239
+ expect(result.stderr).toBe("");
240
+ });
241
+ test("A.4 !include tag resolution against YAML directory", async () => {
242
+ const subDir = join(tmpDir, "sub");
243
+ await mkdir(subDir, { recursive: true });
244
+ // The include payload is the workflow's roles section
245
+ const rolesYaml = stringify({
246
+ writer: {
247
+ description: "Writes",
248
+ goal: "Write",
249
+ capabilities: ["writing"],
250
+ procedure: "Write",
251
+ output: "Content",
252
+ frontmatter: {
253
+ type: "object",
254
+ properties: {
255
+ $status: { const: "done" },
256
+ },
257
+ required: ["$status"],
258
+ },
259
+ },
260
+ });
261
+ await writeFile(join(subDir, "roles.yaml"), rolesYaml);
262
+ const mainYaml = `name: main-wf
263
+ description: Main workflow
264
+ roles: !include sub/roles.yaml
265
+ graph:
266
+ $START:
267
+ new: { role: writer, prompt: Begin, location: null }
268
+ resume: { role: writer, prompt: Resume, location: null }
269
+ writer:
270
+ done: { role: $END, prompt: Done, location: null }
271
+ `;
272
+ const file = join(tmpDir, "main-wf.yaml");
273
+ await writeFile(file, mainYaml);
274
+ const result = runValidate(file);
275
+ expect(result.exitCode).toBe(0);
276
+ expect(result.stdout.trim()).toBe("✓ valid");
277
+ expect(result.stderr).toBe("");
278
+ });
279
+ test("A.5 --format raw-json emits bare valid envelope value", async () => {
280
+ const file = join(tmpDir, "test-wf.yaml");
281
+ await writeFile(file, stringify(makeMinimalPayload("test-wf")));
282
+ // --format is a global option on `program`, must come before the subcommand
283
+ const args = [CLI_PATH, "--format", "raw-json", "workflow", "validate", file];
284
+ let result;
285
+ try {
286
+ const stdout = execFileSync(process.execPath, args, {
287
+ encoding: "utf8",
288
+ stdio: ["ignore", "pipe", "pipe"],
289
+ env: process.env,
290
+ timeout: 15_000,
291
+ });
292
+ result = { stdout, stderr: "", exitCode: 0 };
293
+ }
294
+ catch (e) {
295
+ const err = e;
296
+ result = {
297
+ stdout: typeof err.stdout === "string" ? err.stdout : (err.stdout?.toString() ?? ""),
298
+ stderr: typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString() ?? ""),
299
+ exitCode: err.status ?? 1,
300
+ };
301
+ }
302
+ expect(result.exitCode).toBe(0);
303
+ expect(JSON.parse(result.stdout)).toEqual({ valid: true, errors: [] });
304
+ expect(result.stderr).toBe("");
305
+ });
306
+ });
307
+ // ── Suite B: File / IO Errors ────────────────────────────────────────────────
308
+ describe("workflow validate — Suite B: File / IO Errors", () => {
309
+ test("B.1 missing file exits 1 with file-not-found error", () => {
310
+ const file = join(tmpDir, "does-not-exist.yaml");
311
+ const result = runValidate(file);
312
+ expect(result.exitCode).toBe(1);
313
+ expect(result.stdout).toBe("");
314
+ expect(result.stderr).toContain("file not found:");
315
+ expect(result.stderr).toContain(file);
316
+ });
317
+ test("B.2 directory passed as file exits 1 with non-empty stderr", () => {
318
+ const result = runValidate(tmpDir);
319
+ expect(result.exitCode).toBe(1);
320
+ expect(result.stderr.length).toBeGreaterThan(0);
321
+ });
322
+ });
323
+ // ── Suite C: YAML / Shape Errors ─────────────────────────────────────────────
324
+ describe("workflow validate — Suite C: YAML / Shape Errors", () => {
325
+ test("C.1 malformed YAML exits 1 with 'invalid YAML' prefix", async () => {
326
+ const file = join(tmpDir, "broken.yaml");
327
+ await writeFile(file, ":::: not yaml ::::");
328
+ const result = runValidate(file);
329
+ expect(result.exitCode).toBe(1);
330
+ expect(result.stdout).toBe("");
331
+ expect(result.stderr).toContain("invalid YAML:");
332
+ });
333
+ test("C.2 valid YAML but wrong shape exits 1 with WorkflowPayload error", async () => {
334
+ const file = join(tmpDir, "wrong-shape.yaml");
335
+ await writeFile(file, stringify({ hello: "world" }));
336
+ const result = runValidate(file);
337
+ expect(result.exitCode).toBe(1);
338
+ expect(result.stderr).toContain("invalid workflow YAML: expected WorkflowPayload shape");
339
+ });
340
+ test("C.3 empty file exits 1", async () => {
341
+ const file = join(tmpDir, "empty.yaml");
342
+ await writeFile(file, "");
343
+ const result = runValidate(file);
344
+ expect(result.exitCode).toBe(1);
345
+ // either "invalid YAML:" or the WorkflowPayload-shape error is acceptable
346
+ const okMessage = result.stderr.includes("invalid YAML:") || result.stderr.includes("invalid workflow YAML:");
347
+ expect(okMessage).toBe(true);
348
+ });
349
+ });
350
+ // ── Suite D: Filename Consistency ────────────────────────────────────────────
351
+ describe("workflow validate — Suite D: Filename Consistency", () => {
352
+ test("D.1 name mismatch with filename exits 1", async () => {
353
+ const file = join(tmpDir, "foo-bar.yaml");
354
+ // write a payload whose name is baz-qux, file is foo-bar
355
+ await writeFile(file, stringify(makeMinimalPayload("baz-qux")));
356
+ const result = runValidate(file);
357
+ expect(result.exitCode).toBe(1);
358
+ // text envelope contains the error in stdout
359
+ expect(result.stdout).toContain("workflow name mismatch:");
360
+ expect(result.stdout).toContain("foo-bar");
361
+ expect(result.stdout).toContain("baz-qux");
362
+ });
363
+ test("D.2 index.yaml accepts directory name as workflow name", async () => {
364
+ const dir = join(tmpDir, "my-flow");
365
+ await mkdir(dir, { recursive: true });
366
+ const file = join(dir, "index.yaml");
367
+ await writeFile(file, stringify(makeMinimalPayload("my-flow")));
368
+ const result = runValidate(file);
369
+ expect(result.exitCode).toBe(0);
370
+ expect(result.stdout.trim()).toBe("✓ valid");
371
+ expect(result.stderr).toBe("");
372
+ });
373
+ });
374
+ // ── Suite E: Semantic Errors ─────────────────────────────────────────────────
375
+ //
376
+ // Issue #308: errors are now rendered to stdout via the validate-result
377
+ // envelope template (`✗ invalid (N errors)\n - <error>...`). Exit code is 1.
378
+ describe("workflow validate — Suite E: Semantic Errors", () => {
379
+ test("E.1 graph prompt references variable absent from frontmatter", async () => {
380
+ const payload = {
381
+ name: "comment-pr",
382
+ description: "Comment on PR",
383
+ roles: {
384
+ commenter: {
385
+ description: "Commenter",
386
+ goal: "Comment",
387
+ capabilities: ["commenting"],
388
+ procedure: "Comment",
389
+ output: "Comment",
390
+ // NB: no `prNumber` property declared
391
+ frontmatter: {
392
+ type: "object",
393
+ properties: {
394
+ $status: { const: "approved" },
395
+ },
396
+ required: ["$status"],
397
+ },
398
+ },
399
+ },
400
+ graph: {
401
+ $START: {
402
+ new: { role: "commenter", prompt: "Begin", location: null },
403
+ resume: { role: "commenter", prompt: "Resume", location: null },
404
+ },
405
+ commenter: {
406
+ approved: {
407
+ role: "$END",
408
+ prompt: "Comment on PR #{{ prNumber }}",
409
+ location: null,
410
+ },
411
+ },
412
+ },
413
+ };
414
+ const file = join(tmpDir, "comment-pr.yaml");
415
+ await writeFile(file, stringify(payload));
416
+ const result = runValidate(file);
417
+ expect(result.exitCode).toBe(1);
418
+ expect(result.stdout).toContain("✗ invalid");
419
+ expect(result.stdout).toContain('template variable "prNumber"');
420
+ expect(result.stdout).toContain("commenter");
421
+ });
422
+ test("E.2 multi-exit oneOf variant prompt references variable not in that variant", async () => {
423
+ const payload = {
424
+ name: "review-bad",
425
+ description: "Bad review",
426
+ roles: {
427
+ writer: {
428
+ description: "Writes",
429
+ goal: "Write",
430
+ capabilities: ["writing"],
431
+ procedure: "Write",
432
+ output: "Content",
433
+ frontmatter: {
434
+ type: "object",
435
+ properties: {
436
+ $status: { const: "done" },
437
+ plan: { type: "string" },
438
+ },
439
+ required: ["$status", "plan"],
440
+ },
441
+ },
442
+ reviewer: {
443
+ description: "Reviews",
444
+ goal: "Review",
445
+ capabilities: ["reviewing"],
446
+ procedure: "Review",
447
+ output: "Review",
448
+ frontmatter: {
449
+ type: "object",
450
+ oneOf: [
451
+ {
452
+ properties: {
453
+ $status: { const: "approved" },
454
+ summary: { type: "string" },
455
+ },
456
+ required: ["$status", "summary"],
457
+ },
458
+ {
459
+ properties: {
460
+ $status: { const: "rejected" },
461
+ reason: { type: "string" },
462
+ },
463
+ required: ["$status", "reason"],
464
+ },
465
+ ],
466
+ },
467
+ },
468
+ },
469
+ graph: {
470
+ $START: {
471
+ new: { role: "writer", prompt: "Begin", location: null },
472
+ resume: { role: "writer", prompt: "Resume", location: null },
473
+ },
474
+ writer: { done: { role: "reviewer", prompt: "Review: {{ plan }}", location: null } },
475
+ reviewer: {
476
+ // approved branch references {{ reason }} which only exists in rejected variant
477
+ approved: { role: "$END", prompt: "Approved because: {{ reason }}", location: null },
478
+ rejected: { role: "writer", prompt: "Fix: {{ reason }}", location: null },
479
+ },
480
+ },
481
+ };
482
+ const file = join(tmpDir, "review-bad.yaml");
483
+ await writeFile(file, stringify(payload));
484
+ const result = runValidate(file);
485
+ expect(result.exitCode).toBe(1);
486
+ expect(result.stdout).toContain('template variable "reason"');
487
+ expect(result.stdout).toContain('variant "approved"');
488
+ });
489
+ test("E.3 graph references unknown role", async () => {
490
+ const payload = makeMinimalPayload("orphan-graph");
491
+ const graph = payload.graph;
492
+ graph.bogus = { done: { role: "$END", prompt: "x", location: null } };
493
+ const file = join(tmpDir, "orphan-graph.yaml");
494
+ await writeFile(file, stringify(payload));
495
+ const result = runValidate(file);
496
+ expect(result.exitCode).toBe(1);
497
+ expect(result.stdout).toContain('unknown role "bogus"');
498
+ });
499
+ test("E.4 $START missing resume edge", async () => {
500
+ const payload = makeMinimalPayload("no-resume");
501
+ const graph = payload.graph;
502
+ graph.$START = {
503
+ new: { role: "writer", prompt: "Begin", location: null },
504
+ // no resume edge
505
+ };
506
+ const file = join(tmpDir, "no-resume.yaml");
507
+ await writeFile(file, stringify(payload));
508
+ const result = runValidate(file);
509
+ expect(result.exitCode).toBe(1);
510
+ expect(result.stdout).toContain('$START must have edges with statuses "new" and "resume"');
511
+ });
512
+ test("E.5 unreachable role exits 1", async () => {
513
+ const payload = makeMultiRolePayload("unreachable");
514
+ // add an extra role that is in roles + graph but no edge points to it
515
+ const roles = payload.roles;
516
+ roles.orphan = {
517
+ description: "Orphan",
518
+ goal: "nothing",
519
+ capabilities: [],
520
+ procedure: "none",
521
+ output: "none",
522
+ frontmatter: {
523
+ type: "object",
524
+ properties: { $status: { const: "done" } },
525
+ required: ["$status"],
526
+ },
527
+ };
528
+ const graph = payload.graph;
529
+ graph.orphan = { done: { role: "$END", prompt: "ok", location: null } };
530
+ const file = join(tmpDir, "unreachable.yaml");
531
+ await writeFile(file, stringify(payload));
532
+ const result = runValidate(file);
533
+ expect(result.exitCode).toBe(1);
534
+ expect(result.stdout).toContain("is not reachable from $START");
535
+ });
536
+ test("E.6 $SUSPEND used as edge target exits 1", async () => {
537
+ const payload = makeMinimalPayload("bad-suspend");
538
+ const graph = payload.graph;
539
+ graph.writer = { done: { role: "$SUSPEND", prompt: "x", location: null } };
540
+ const file = join(tmpDir, "bad-suspend.yaml");
541
+ await writeFile(file, stringify(payload));
542
+ const result = runValidate(file);
543
+ expect(result.exitCode).toBe(1);
544
+ expect(result.stdout).toContain("$SUSPEND");
545
+ });
546
+ test("E.7 multiple semantic errors are all reported", async () => {
547
+ const payload = makeMinimalPayload("multi-error");
548
+ // 1) unknown role: graph node referencing undefined role
549
+ const graph = payload.graph;
550
+ graph.bogus = { done: { role: "$END", prompt: "x", location: null } };
551
+ // 2) $START missing resume
552
+ graph.$START = {
553
+ new: { role: "writer", prompt: "Begin", location: null },
554
+ };
555
+ // 3) bad Liquid variable
556
+ graph.writer = {
557
+ done: { role: "$END", prompt: "Use {{ missing }}", location: null },
558
+ };
559
+ const file = join(tmpDir, "multi-error.yaml");
560
+ await writeFile(file, stringify(payload));
561
+ const result = runValidate(file);
562
+ expect(result.exitCode).toBe(1);
563
+ expect(result.stdout).toContain("✗ invalid");
564
+ expect(result.stdout).toContain('unknown role "bogus"');
565
+ expect(result.stdout).toContain("$START must have edges");
566
+ expect(result.stdout).toContain("missing");
567
+ // each error is bullet-prefixed with ` - `
568
+ expect(result.stdout).toContain(" - ");
569
+ });
570
+ });
571
+ // ── Suite F: Isolation From CAS / Store ──────────────────────────────────────
572
+ //
573
+ // Issue #308: validate now uses the unified envelope writer, which requires
574
+ // the CAS store (for `@uwf/output/validate-result` schema lookup and the
575
+ // `@ocas/template/text/<hash>` template). The store is initialized
576
+ // idempotently on startup. These tests assert that:
577
+ // - validate works without explicit OCAS_HOME
578
+ // - validate runs idempotently (second run modifies nothing on success)
579
+ // - validate does not modify the workflow registry on success
580
+ describe("workflow validate — Suite F: Isolation From CAS / Store", () => {
581
+ test("F.1 runs without OCAS_HOME set", async () => {
582
+ const file = join(tmpDir, "iso-wf.yaml");
583
+ await writeFile(file, stringify(makeMinimalPayload("iso-wf")));
584
+ // Strip OCAS_HOME / UWF_HOME from env, point HOME at empty tmp.
585
+ const isolatedHome = join(tmpDir, "isolated-home");
586
+ await mkdir(isolatedHome, { recursive: true });
587
+ const env = { ...process.env };
588
+ delete env.OCAS_HOME;
589
+ delete env.UWF_HOME;
590
+ env.HOME = isolatedHome;
591
+ const result = runValidate(file, [], env);
592
+ expect(result.exitCode).toBe(0);
593
+ expect(result.stdout.trim()).toBe("✓ valid");
594
+ expect(result.stderr).toBe("");
595
+ });
596
+ test("F.2 runs even when ocas store is empty/uninitialized (skip on win32)", {
597
+ skip: process.platform === "win32",
598
+ }, async () => {
599
+ const file = join(tmpDir, "ro-home-wf.yaml");
600
+ await writeFile(file, stringify(makeMinimalPayload("ro-home-wf")));
601
+ // Use a writable but empty OCAS_HOME — schema registration writes
602
+ // happen at startup but the validate command should still succeed.
603
+ const ocasHome = join(tmpDir, "fresh-ocas");
604
+ await mkdir(ocasHome, { recursive: true });
605
+ const env = { ...process.env };
606
+ delete env.UWF_HOME;
607
+ env.OCAS_HOME = ocasHome;
608
+ const result = runValidate(file, [], env);
609
+ expect(result.exitCode).toBe(0);
610
+ expect(result.stdout.trim()).toBe("✓ valid");
611
+ expect(result.stderr).toBe("");
612
+ });
613
+ test("F.3 does not modify workflow registry on success", async () => {
614
+ const file = join(tmpDir, "reg-wf.yaml");
615
+ await writeFile(file, stringify(makeMinimalPayload("reg-wf")));
616
+ const ocasHome = join(tmpDir, "ocas-home");
617
+ await mkdir(ocasHome, { recursive: true });
618
+ const env = { ...process.env, OCAS_HOME: ocasHome, UWF_HOME: ocasHome };
619
+ // First run primes the schema/template registrations
620
+ const first = runValidate(file, [], env);
621
+ expect(first.exitCode).toBe(0);
622
+ expect(first.stdout.trim()).toBe("✓ valid");
623
+ // Capture state after registrations are present
624
+ const beforeListing = await listingSnapshot(ocasHome);
625
+ // Second run must not modify the registry (no @uwf/registry/<name> binding)
626
+ const second = runValidate(file, [], env);
627
+ expect(second.exitCode).toBe(0);
628
+ expect(second.stdout.trim()).toBe("✓ valid");
629
+ expect(second.stderr).toBe("");
630
+ const afterListing = await listingSnapshot(ocasHome);
631
+ expect(afterListing).toEqual(beforeListing);
632
+ });
633
+ test("F.4 second run on the same workflow is idempotent (no further CAS writes)", async () => {
634
+ const file = join(tmpDir, "cas-iso-wf.yaml");
635
+ await writeFile(file, stringify(makeMinimalPayload("cas-iso-wf")));
636
+ const ocasHome = join(tmpDir, "ocas2");
637
+ await mkdir(ocasHome, { recursive: true });
638
+ const env = { ...process.env, OCAS_HOME: ocasHome, UWF_HOME: ocasHome };
639
+ // First run: schema/template registration is allowed to write to CAS
640
+ const first = runValidate(file, [], env);
641
+ expect(first.exitCode).toBe(0);
642
+ const beforeListing = await listingSnapshot(ocasHome);
643
+ // Second run: registrations are idempotent → no new writes
644
+ const second = runValidate(file, [], env);
645
+ expect(second.exitCode).toBe(0);
646
+ const afterListing = await listingSnapshot(ocasHome);
647
+ expect(afterListing).toEqual(beforeListing);
648
+ });
649
+ });
650
+ // ── Suite G: Argument Surface ────────────────────────────────────────────────
651
+ describe("workflow validate — Suite G: Argument Surface", () => {
652
+ test("G.1 missing <file> argument fails with non-zero exit", () => {
653
+ const result = runCli(["workflow", "validate"]);
654
+ expect(result.exitCode).not.toBe(0);
655
+ // commander phrasing varies; check for the broad `missing required argument` string.
656
+ expect(result.stderr.toLowerCase()).toContain("missing required argument");
657
+ });
658
+ test("G.2 'workflow --help' lists 'validate'", () => {
659
+ const result = runCli(["workflow", "--help"]);
660
+ expect(result.exitCode).toBe(0);
661
+ expect(result.stdout).toContain("validate");
662
+ });
663
+ test("G.3 'workflow validate --help' describes the command", () => {
664
+ const result = runCli(["workflow", "validate", "--help"]);
665
+ expect(result.exitCode).toBe(0);
666
+ expect(result.stdout).toContain("file");
667
+ expect(result.stdout.length).toBeGreaterThan(0);
668
+ });
669
+ });
670
+ // ── helpers for snapshot ─────────────────────────────────────────────────────
671
+ /**
672
+ * Recursively snapshot a directory's listing (paths + sizes).
673
+ * Used to assert no files are written during validate.
674
+ */
675
+ async function listingSnapshot(root) {
676
+ const out = {};
677
+ async function walk(dir) {
678
+ let entries = [];
679
+ try {
680
+ entries = await readdir(dir);
681
+ }
682
+ catch {
683
+ return;
684
+ }
685
+ for (const entry of entries) {
686
+ const full = join(dir, entry);
687
+ let st;
688
+ try {
689
+ st = await stat(full);
690
+ }
691
+ catch {
692
+ continue;
693
+ }
694
+ if (st.isDirectory()) {
695
+ await walk(full);
696
+ }
697
+ else {
698
+ out[full.slice(root.length)] = st.size;
699
+ }
700
+ }
701
+ }
702
+ await walk(root);
703
+ return out;
704
+ }
705
+ // Suppress unused warnings in tests that don't currently use these helpers
706
+ void readFile;
707
+ //# sourceMappingURL=workflow-validate.test.js.map