@caddis/cli 0.4.44 → 0.4.46

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 (210) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +15 -3
  3. package/bundles/antigravity-plugin/scripts/claude-oss.sh +12 -1
  4. package/bundles/antigravity-plugin/skills/_registry.md +1 -0
  5. package/bundles/antigravity-plugin/skills/flow-studio/README.md +92 -0
  6. package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +102 -0
  7. package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +19 -0
  8. package/bundles/antigravity-plugin/skills/flow-studio/examples/agent-journey.json +103 -0
  9. package/bundles/antigravity-plugin/skills/flow-studio/examples/approval-flow.json +134 -0
  10. package/bundles/antigravity-plugin/skills/flow-studio/examples/ci-cd-pipeline.json +132 -0
  11. package/bundles/antigravity-plugin/skills/flow-studio/examples/hostile.json +1783 -0
  12. package/bundles/antigravity-plugin/skills/flow-studio/examples/incident-timeline.json +109 -0
  13. package/bundles/antigravity-plugin/skills/flow-studio/examples/lineage.json +1778 -0
  14. package/bundles/antigravity-plugin/skills/flow-studio/examples/order-desk.json +99 -0
  15. package/bundles/antigravity-plugin/skills/flow-studio/examples/service-map.json +133 -0
  16. package/bundles/antigravity-plugin/skills/flow-studio/examples/state-machine.json +106 -0
  17. package/bundles/antigravity-plugin/skills/flow-studio/examples/ticket-lifecycle.json +107 -0
  18. package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +142 -0
  19. package/bundles/antigravity-plugin/skills/flow-studio/package.json +11 -0
  20. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +82 -0
  21. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +67 -0
  22. package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +37 -0
  23. package/bundles/antigravity-plugin/skills/flow-studio/react/index.ts +3 -0
  24. package/bundles/antigravity-plugin/skills/flow-studio/react/native/FlowCanvas.tsx +58 -0
  25. package/bundles/antigravity-plugin/skills/flow-studio/react/native/Inspector.tsx +27 -0
  26. package/bundles/antigravity-plugin/skills/flow-studio/react/native/index.ts +6 -0
  27. package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.css +18 -0
  28. package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.test.tsx +161 -0
  29. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useFlowGeometry.ts +22 -0
  30. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useTrace.ts +29 -0
  31. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useViewport.ts +75 -0
  32. package/bundles/antigravity-plugin/skills/flow-studio/react/states.tsx +13 -0
  33. package/bundles/antigravity-plugin/skills/flow-studio/references/design-language.md +169 -0
  34. package/bundles/antigravity-plugin/skills/flow-studio/references/implement-in-app.md +213 -0
  35. package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +254 -0
  36. package/bundles/antigravity-plugin/skills/flow-studio/references/notes-and-spec.md +74 -0
  37. package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +415 -0
  38. package/bundles/antigravity-plugin/skills/flow-studio/references/ux-rules.md +91 -0
  39. package/bundles/antigravity-plugin/skills/flow-studio/references/verify.md +124 -0
  40. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +95 -0
  41. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +54 -0
  42. package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +680 -0
  43. package/bundles/antigravity-plugin/skills/flow-studio/scripts/default-notes.json +67 -0
  44. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
  45. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
  46. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
  47. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +54 -0
  48. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.mjs +18 -0
  49. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.test.mjs +15 -0
  50. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +135 -0
  51. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +179 -0
  52. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.mjs +23 -0
  53. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
  54. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/docs.test.mjs +128 -0
  55. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.mjs +24 -0
  56. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.test.mjs +29 -0
  57. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/library.test.mjs +26 -0
  58. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
  59. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
  60. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
  61. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
  62. package/bundles/antigravity-plugin/skills/flow-studio/scripts/react-proof.mjs +92 -0
  63. package/bundles/antigravity-plugin/skills/flow-studio/scripts/validate.mjs +42 -0
  64. package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.test.ts +78 -0
  65. package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.ts +132 -0
  66. package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +175 -0
  67. package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +239 -0
  68. package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +134 -0
  69. package/bundles/antigravity-plugin/skills/flow-studio/src/diff.test.ts +31 -0
  70. package/bundles/antigravity-plugin/skills/flow-studio/src/diff.ts +34 -0
  71. package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +193 -0
  72. package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +126 -0
  73. package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +253 -0
  74. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +144 -0
  75. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +95 -0
  76. package/bundles/antigravity-plugin/skills/flow-studio/src/lib.ts +20 -0
  77. package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.test.ts +57 -0
  78. package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.ts +29 -0
  79. package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +167 -0
  80. package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +250 -0
  81. package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +100 -0
  82. package/bundles/antigravity-plugin/skills/flow-studio/src/page.ts +21 -0
  83. package/bundles/antigravity-plugin/skills/flow-studio/src/playback.test.ts +104 -0
  84. package/bundles/antigravity-plugin/skills/flow-studio/src/playback.ts +106 -0
  85. package/bundles/antigravity-plugin/skills/flow-studio/src/playbackui.ts +193 -0
  86. package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +203 -0
  87. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +33 -0
  88. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +43 -0
  89. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +135 -0
  90. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +86 -0
  91. package/bundles/antigravity-plugin/skills/flow-studio/src/selection.test.ts +12 -0
  92. package/bundles/antigravity-plugin/skills/flow-studio/src/selection.ts +4 -0
  93. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +73 -0
  94. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +45 -0
  95. package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +190 -0
  96. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +40 -0
  97. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +40 -0
  98. package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.test.ts +55 -0
  99. package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.ts +45 -0
  100. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +2656 -0
  101. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +23 -0
  102. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +190 -0
  103. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.d.mts +1 -0
  104. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +2491 -0
  105. package/bundles/antigravity-plugin/skills/flow-studio/templates/shell-body.html +50 -0
  106. package/bundles/antigravity-plugin/skills/flow-studio/tsconfig.json +17 -0
  107. package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +92 -0
  108. package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +102 -0
  109. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +19 -0
  110. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/agent-journey.json +103 -0
  111. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/approval-flow.json +134 -0
  112. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ci-cd-pipeline.json +132 -0
  113. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/hostile.json +1783 -0
  114. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/incident-timeline.json +109 -0
  115. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/lineage.json +1778 -0
  116. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/order-desk.json +99 -0
  117. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/service-map.json +133 -0
  118. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/state-machine.json +106 -0
  119. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ticket-lifecycle.json +107 -0
  120. package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +142 -0
  121. package/bundles/codex/.codex/skills/frontend/flow-studio/package.json +11 -0
  122. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +82 -0
  123. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +67 -0
  124. package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +37 -0
  125. package/bundles/codex/.codex/skills/frontend/flow-studio/react/index.ts +3 -0
  126. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/FlowCanvas.tsx +58 -0
  127. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/Inspector.tsx +27 -0
  128. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/index.ts +6 -0
  129. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.css +18 -0
  130. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.test.tsx +161 -0
  131. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useFlowGeometry.ts +22 -0
  132. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useTrace.ts +29 -0
  133. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useViewport.ts +75 -0
  134. package/bundles/codex/.codex/skills/frontend/flow-studio/react/states.tsx +13 -0
  135. package/bundles/codex/.codex/skills/frontend/flow-studio/references/design-language.md +169 -0
  136. package/bundles/codex/.codex/skills/frontend/flow-studio/references/implement-in-app.md +213 -0
  137. package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +254 -0
  138. package/bundles/codex/.codex/skills/frontend/flow-studio/references/notes-and-spec.md +74 -0
  139. package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +415 -0
  140. package/bundles/codex/.codex/skills/frontend/flow-studio/references/ux-rules.md +91 -0
  141. package/bundles/codex/.codex/skills/frontend/flow-studio/references/verify.md +124 -0
  142. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +95 -0
  143. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +54 -0
  144. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +680 -0
  145. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/default-notes.json +67 -0
  146. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
  147. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
  148. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
  149. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +54 -0
  150. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.mjs +18 -0
  151. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.test.mjs +15 -0
  152. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +135 -0
  153. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +179 -0
  154. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.mjs +23 -0
  155. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
  156. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/docs.test.mjs +128 -0
  157. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.mjs +24 -0
  158. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.test.mjs +29 -0
  159. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/library.test.mjs +26 -0
  160. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
  161. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
  162. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
  163. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
  164. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/react-proof.mjs +92 -0
  165. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/validate.mjs +42 -0
  166. package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.test.ts +78 -0
  167. package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.ts +132 -0
  168. package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +175 -0
  169. package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +239 -0
  170. package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +134 -0
  171. package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.test.ts +31 -0
  172. package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.ts +34 -0
  173. package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +193 -0
  174. package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +126 -0
  175. package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +253 -0
  176. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +144 -0
  177. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +95 -0
  178. package/bundles/codex/.codex/skills/frontend/flow-studio/src/lib.ts +20 -0
  179. package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.test.ts +57 -0
  180. package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.ts +29 -0
  181. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +167 -0
  182. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +250 -0
  183. package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +100 -0
  184. package/bundles/codex/.codex/skills/frontend/flow-studio/src/page.ts +21 -0
  185. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.test.ts +104 -0
  186. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.ts +106 -0
  187. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playbackui.ts +193 -0
  188. package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +203 -0
  189. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +33 -0
  190. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +43 -0
  191. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +135 -0
  192. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +86 -0
  193. package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.test.ts +12 -0
  194. package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.ts +4 -0
  195. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +73 -0
  196. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +45 -0
  197. package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +190 -0
  198. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +40 -0
  199. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +40 -0
  200. package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.test.ts +55 -0
  201. package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.ts +45 -0
  202. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +2656 -0
  203. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +23 -0
  204. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +190 -0
  205. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.d.mts +1 -0
  206. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +2491 -0
  207. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/shell-body.html +50 -0
  208. package/bundles/codex/.codex/skills/frontend/flow-studio/tsconfig.json +17 -0
  209. package/bundles/manifest.json +2 -2
  210. package/package.json +1 -1
@@ -0,0 +1,128 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ const root = resolve(here, "../..");
9
+
10
+ test("docs: (1) every property in model.schema.json appears in references/model-schema.md", () => {
11
+ const schemaPath = join(root, "model.schema.json");
12
+ const docPath = join(root, "references", "model-schema.md");
13
+ assert.ok(existsSync(schemaPath), "model.schema.json exists");
14
+ assert.ok(existsSync(docPath), "references/model-schema.md exists");
15
+
16
+ const schema = JSON.parse(readFileSync(schemaPath, "utf8"));
17
+ const doc = readFileSync(docPath, "utf8");
18
+
19
+ function collectProperties(obj, props = new Set()) {
20
+ if (!obj || typeof obj !== "object") return props;
21
+ if (obj.properties && typeof obj.properties === "object") {
22
+ for (const p of Object.keys(obj.properties)) {
23
+ props.add(p);
24
+ collectProperties(obj.properties[p], props);
25
+ }
26
+ }
27
+ // tuple-style schemas put subschemas in an items array
28
+ if (Array.isArray(obj.items)) obj.items.forEach(x => collectProperties(x, props));
29
+ else if (obj.items) collectProperties(obj.items, props);
30
+ // definitions/$defs are name->schema maps: walk the schemas, never the names
31
+ if (obj.definitions) for (const def of Object.values(obj.definitions)) collectProperties(def, props);
32
+ if (obj.$defs) for (const def of Object.values(obj.$defs)) collectProperties(def, props);
33
+ if (Array.isArray(obj.allOf)) obj.allOf.forEach(x => collectProperties(x, props));
34
+ if (Array.isArray(obj.anyOf)) obj.anyOf.forEach(x => collectProperties(x, props));
35
+ if (Array.isArray(obj.oneOf)) obj.oneOf.forEach(x => collectProperties(x, props));
36
+ return props;
37
+ }
38
+
39
+ // a whole word, not a substring: `id` must not be satisfied by `identifier`
40
+ const wordIn = (text, word) =>
41
+ new RegExp(`(?<![A-Za-z0-9_$-])${word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9_$-])`).test(text);
42
+
43
+ const allProps = Array.from(collectProperties(schema));
44
+ const missing = allProps.filter(p => !wordIn(doc, p));
45
+ assert.deepEqual(missing, [], `Schema properties missing from model-schema.md: ${missing.join(", ")}`);
46
+ });
47
+
48
+ test("docs: (2) every example in examples/ is listed in examples/README.md", () => {
49
+ const examplesDir = join(root, "examples");
50
+ const readmePath = join(examplesDir, "README.md");
51
+ assert.ok(existsSync(readmePath), "examples/README.md exists");
52
+
53
+ const examples = readdirSync(examplesDir).filter(f => f.endsWith(".json"));
54
+ const readme = readFileSync(readmePath, "utf8");
55
+ const missing = examples.filter(e => !readme.includes(e));
56
+ assert.deepEqual(missing, [], `Examples missing from examples/README.md: ${missing.join(", ")}`);
57
+ });
58
+
59
+ test("docs: (3) every check group name used in scripts/check.mjs appears in references/verify.md", () => {
60
+ const checkScript = join(root, "scripts", "check.mjs");
61
+ const verifyDoc = join(root, "references", "verify.md");
62
+ assert.ok(existsSync(checkScript), "scripts/check.mjs exists");
63
+ assert.ok(existsSync(verifyDoc), "references/verify.md exists");
64
+
65
+ const checkCode = readFileSync(checkScript, "utf8");
66
+ const docText = readFileSync(verifyDoc, "utf8");
67
+
68
+ const groups = new Set();
69
+ for (const m of checkCode.matchAll(/\bcheck\(\s*["\x27]([^"\x27]+)["\x27]/g)) {
70
+ groups.add(m[1]);
71
+ }
72
+ const allGroups = Array.from(groups);
73
+ const missing = allGroups.filter(g => !docText.includes(g));
74
+ assert.deepEqual(missing, [], `Check groups missing from references/verify.md: ${missing.join(", ")}`);
75
+ });
76
+
77
+ test("docs: (4) SKILL.md frontmatter has name and description, name is flow-studio, under 220 lines", () => {
78
+ const skillPath = join(root, "SKILL.md");
79
+ assert.ok(existsSync(skillPath), "SKILL.md exists");
80
+
81
+ const content = readFileSync(skillPath, "utf8");
82
+ const lines = content.split(/\r?\n/);
83
+ if (lines.length && lines[lines.length - 1] === "") lines.pop(); // a trailing newline is not a line
84
+ assert.ok(lines.length < 220, `SKILL.md has ${lines.length} lines, must be under 220`);
85
+
86
+ const fmM = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
87
+ assert.ok(fmM, "SKILL.md has valid frontmatter block");
88
+ const fm = fmM[1];
89
+ assert.match(fm, /^name:\s*flow-studio\s*$/m, "frontmatter name is flow-studio");
90
+ assert.match(fm, /^description:\s*.+$/m, "frontmatter description is present");
91
+ });
92
+
93
+ test("docs: (5) every relative path in backticks starting with scripts/, src/, references/, examples/, templates/ exists", () => {
94
+ const refDir = join(root, "references");
95
+ const files = [
96
+ join(root, "SKILL.md"),
97
+ ...readdirSync(refDir).map(f => join(refDir, f))
98
+ ];
99
+ const prefixes = ["scripts/", "src/", "references/", "examples/", "templates/"];
100
+ const missing = [];
101
+
102
+ for (const file of files) {
103
+ const rawContent = readFileSync(file, "utf8");
104
+ // Strip fenced code blocks so we only check inline backtick code references
105
+ const stripped = rawContent.replace(/```[\s\S]*?```/g, "");
106
+ const matches = stripped.match(/`([^`\r\n]+)`/g) || [];
107
+
108
+ for (const m of matches) {
109
+ const token = m.slice(1, -1).trim();
110
+ // A span may hold a command (`scripts/check.mjs --json`) or a line reference
111
+ // (`scripts/check.mjs:23`), so check each whitespace-separated part, stripped of
112
+ // surrounding punctuation and a trailing :line suffix.
113
+ for (const part of token.split(/\s+/)) {
114
+ const candidate = part.replace(/^[("']+/, "").replace(/[.,;:)"']+$/, "").replace(/:\d+$/, "");
115
+ for (const prefix of prefixes) {
116
+ if (candidate.startsWith(prefix)) {
117
+ const resolved = resolve(root, candidate);
118
+ if (!existsSync(resolved)) {
119
+ missing.push({ file: file.replace(root, ""), path: candidate });
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ assert.deepEqual(missing, [], `Referenced paths do not exist: ${JSON.stringify(missing)}`);
128
+ });
@@ -0,0 +1,24 @@
1
+ // Safe embedding of a model into the engine template.
2
+ // JSON placed inside a <script> tag must not be able to close it or start markup, so the
3
+ // characters < > & and the two Unicode line separators are written as \u escapes. The result is
4
+ // still valid JSON and parses back to the same value.
5
+
6
+ export function escapeJson(value) {
7
+ return JSON.stringify(value)
8
+ .replace(/</g, "\\u003c")
9
+ .replace(/>/g, "\\u003e")
10
+ .replace(/&/g, "\\u0026")
11
+ .replace(/\u2028/g, "\\u2028")
12
+ .replace(/\u2029/g, "\\u2029");
13
+ }
14
+
15
+ const MODEL_SLOT = "/*__MODEL__*/{}";
16
+ const NOTES_SLOT = "/*__NOTES__*/[]";
17
+
18
+ export function embedModel(shell, model, notes = []) {
19
+ if (!shell.includes(MODEL_SLOT) || !shell.includes(NOTES_SLOT)) {
20
+ throw new Error("The shell is missing a model slot or a notes slot.");
21
+ }
22
+ // Replacer functions: a "$" in the data must never act as a replacement pattern.
23
+ return shell.replace(MODEL_SLOT, () => escapeJson(model)).replace(NOTES_SLOT, () => escapeJson(notes));
24
+ }
@@ -0,0 +1,29 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { escapeJson, embedModel } from "./embed.mjs";
4
+
5
+ const hostile = { t: "</script><script>window.pwned=1</script>", u: "<img src=x onerror=alert(1)>", q: `"'&`, ls: "a\u2028b\u2029c" };
6
+
7
+ test("escapeJson leaves no markup characters and round-trips", () => {
8
+ const s = escapeJson(hostile);
9
+ assert.ok(!/[<>&\u2028\u2029]/.test(s));
10
+ assert.deepEqual(JSON.parse(s), hostile);
11
+ });
12
+
13
+ test("embedModel fills both slots and adds no script tag", () => {
14
+ const shell = `<script id="m" type="application/json">/*__MODEL__*/{}</script><script id="n" type="application/json">/*__NOTES__*/[]</script>`;
15
+ const out = embedModel(shell, hostile, [{ anchor: "#x", title: "<b>", text: "</script>" }]);
16
+ assert.equal((out.match(/<\/script>/g) ?? []).length, 2);
17
+ assert.equal((out.match(/<script/g) ?? []).length, 2);
18
+ assert.ok(!out.includes("/*__MODEL__*/") && !out.includes("/*__NOTES__*/"));
19
+ });
20
+
21
+ test("a dollar sign in the model is not treated as a replacement pattern", () => {
22
+ const shell = `<script>/*__MODEL__*/{}</script><script>/*__NOTES__*/[]</script>`;
23
+ const out = embedModel(shell, { p: "$1 and $$ and $` and $'" }, []);
24
+ assert.ok(out.includes("$1 and $$ and $` and $'"));
25
+ });
26
+
27
+ test("embedModel refuses a shell without the slots", () => {
28
+ assert.throws(() => embedModel("<html></html>", {}, []), /slot/i);
29
+ });
@@ -0,0 +1,26 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { buildLibrary, buildEngine } from "../build-engine.mjs";
5
+
6
+ test("the library bundle imports without page auto-start", async () => {
7
+ const { js, css } = await buildLibrary();
8
+ assert.doesNotMatch(js, /addEventListener\(["']DOMContentLoaded["']/);
9
+ assert.doesNotMatch(js, /window\.flowStudioMount\s*=/);
10
+ assert.match(js, /SHELL_BODY_HTML/);
11
+ assert.equal(css, readFileSync(new URL("../../src/styles.css", import.meta.url), "utf8"));
12
+ const lib = await import("data:text/javascript," + encodeURIComponent(js));
13
+ assert.equal(typeof lib.mount, "function");
14
+ assert.equal(typeof lib.validateModel, "function");
15
+ assert.equal(typeof lib.zoomAt, "function");
16
+ assert.equal(typeof lib.elbow, "function");
17
+ assert.match(lib.SHELL_BODY_HTML, /id="viewport"/);
18
+ });
19
+
20
+ test("page build includes the shared shell body once", async () => {
21
+ const normalise = text => text.replace(/\r\n/g, "\n");
22
+ const body = readFileSync(new URL("../../templates/shell-body.html", import.meta.url), "utf8");
23
+ const page = await buildEngine();
24
+ assert.ok(normalise(page).includes(normalise(body)));
25
+ assert.equal(page.split('id="viewport"').length - 1, 1);
26
+ });
@@ -0,0 +1,12 @@
1
+ // On Windows, killing a child does not release its open files until it closes.
2
+ export async function stopPreview(preview, closed, graceMs = 5000) {
3
+ if (!preview) return;
4
+ if (preview.exitCode === null && preview.signalCode === null && !preview.kill())
5
+ throw new Error('Could not stop vite preview');
6
+ // A child that ignores the term signal must not block the teardown forever: force-kill it.
7
+ const finished = await Promise.race([closed.then(() => true), new Promise(resolve => setTimeout(resolve, graceMs, false))]);
8
+ if (!finished) {
9
+ preview.kill('SIGKILL');
10
+ await closed;
11
+ }
12
+ }
@@ -0,0 +1,45 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { EventEmitter } from 'node:events';
4
+ import { stopPreview } from './react-proof-cleanup.mjs';
5
+
6
+ test('stopPreview waits for process closure before temporary files can be removed', async () => {
7
+ const preview = new EventEmitter();
8
+ preview.exitCode = null;
9
+ preview.signalCode = null;
10
+ preview.kill = () => true;
11
+ const closed = new Promise(resolve => preview.once('close', resolve));
12
+ let stopped = false;
13
+ const stopping = stopPreview(preview, closed).then(() => { stopped = true; });
14
+ await Promise.resolve();
15
+ assert.equal(stopped, false);
16
+ preview.emit('close', 0);
17
+ await stopping;
18
+ assert.equal(stopped, true);
19
+ });
20
+
21
+ test('stopPreview waits for close after process exit', async () => {
22
+ const preview = new EventEmitter();
23
+ preview.exitCode = 0;
24
+ preview.signalCode = null;
25
+ preview.kill = () => { throw new Error('already exited'); };
26
+ const closed = new Promise(resolve => preview.once('close', resolve));
27
+ let stopped = false;
28
+ const stopping = stopPreview(preview, closed).then(() => { stopped = true; });
29
+ await Promise.resolve();
30
+ assert.equal(stopped, false);
31
+ preview.emit('close', 0);
32
+ await stopping;
33
+ assert.equal(stopped, true);
34
+ });
35
+
36
+ test('stopPreview force-kills a child that ignores the first kill', async () => {
37
+ const preview = new EventEmitter();
38
+ preview.exitCode = null;
39
+ preview.signalCode = null;
40
+ const kills = [];
41
+ preview.kill = signal => { kills.push(signal); if (signal === 'SIGKILL') preview.emit('close'); return true; };
42
+ const closed = new Promise(resolve => preview.once('close', resolve));
43
+ await stopPreview(preview, closed, 10);
44
+ assert.deepEqual(kills, [undefined, 'SIGKILL']);
45
+ });
@@ -0,0 +1,3 @@
1
+ export function proofIndexHtml() {
2
+ return '<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="icon" href="data:,"></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>';
3
+ }
@@ -0,0 +1,9 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { proofIndexHtml } from './react-proof-page.mjs';
4
+
5
+ test('proof page supplies an inline favicon so the browser makes no missing-file request', () => {
6
+ const html = proofIndexHtml();
7
+ assert.match(html, /<link rel="icon" href="data:,">/);
8
+ assert.match(html, /<script type="module" src="\/src\/main\.tsx"><\/script>/);
9
+ });
@@ -0,0 +1,92 @@
1
+ // Build and test both React routes in a disposable application.
2
+ // Run from the skill folder: node scripts/react-proof.mjs
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import { cpSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { createServer } from 'node:net';
9
+ import { stopPreview } from './lib/react-proof-cleanup.mjs';
10
+ import { proofIndexHtml } from './lib/react-proof-page.mjs';
11
+
12
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
13
+ const tmp = mkdtempSync(join(tmpdir(), 'flow-studio-react-proof-'));
14
+ let preview;
15
+ let previewClosed;
16
+
17
+ function run(command, args, cwd = tmp) {
18
+ const result = spawnSync(command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' && command.toLowerCase().endsWith('npm.cmd'), env: process.env, timeout: 600_000 });
19
+ if (result.error) throw result.error;
20
+ if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited ${result.status ?? result.signal}`);
21
+ }
22
+
23
+ async function freePort() {
24
+ return new Promise((resolvePort, reject) => {
25
+ const server = createServer();
26
+ server.once('error', reject);
27
+ server.listen(0, '127.0.0.1', () => {
28
+ const address = server.address();
29
+ server.close(() => resolvePort(address.port));
30
+ });
31
+ });
32
+ }
33
+
34
+ async function waitFor(url) {
35
+ for (let i = 0; i < 100; i++) {
36
+ if (preview.exitCode !== null) throw new Error(`vite preview exited ${preview.exitCode}`);
37
+ try { if ((await fetch(url)).ok) return; } catch { /* server still starting */ }
38
+ await new Promise(r => setTimeout(r, 100));
39
+ }
40
+ throw new Error(`vite preview did not start at ${url}`);
41
+ }
42
+
43
+ try {
44
+ mkdirSync(join(tmp, 'src'));
45
+ cpSync(join(root, 'react'), join(tmp, 'react'), { recursive: true });
46
+ // The React files use ../src imports. Keep the real source path in the throwaway app.
47
+ cpSync(join(root, 'src'), join(tmp, 'src'), { recursive: true });
48
+ mkdirSync(join(tmp, 'templates'));
49
+ for (const name of ['flow-studio.lib.mjs', 'flow-studio.lib.d.mts', 'flow-studio.css'])
50
+ cpSync(join(root, 'templates', name), join(tmp, 'templates', name));
51
+ mkdirSync(join(tmp, 'examples'));
52
+ cpSync(join(root, 'examples', 'order-desk.json'), join(tmp, 'examples', 'order-desk.json'));
53
+ writeFileSync(join(tmp, 'package.json'), JSON.stringify({
54
+ private: true, type: 'module', scripts: { test: 'vitest run', build: 'tsc --noEmit && vite build' },
55
+ dependencies: { react: '^18.3.1', 'react-dom': '^18.3.1' },
56
+ devDependencies: { '@testing-library/react': '^16.0.0', '@types/react': '^18.3.0', '@types/react-dom': '^18.3.0', jsdom: '^26.0.0', playwright: '^1.51.0', typescript: '^5.0.0', vite: '^6.0.0', vitest: '^3.0.0' },
57
+ }, null, 2));
58
+ writeFileSync(join(tmp, 'index.html'), proofIndexHtml());
59
+ writeFileSync(join(tmp, 'tsconfig.json'), JSON.stringify({ compilerOptions: {
60
+ target: 'ES2022', useDefineForClassFields: true, lib: ['ES2022', 'DOM', 'DOM.Iterable'], module: 'ESNext', skipLibCheck: true,
61
+ moduleResolution: 'Bundler', allowImportingTsExtensions: true, resolveJsonModule: true, isolatedModules: true,
62
+ noEmit: true, jsx: 'react-jsx', strict: true, types: ['vitest/globals'],
63
+ }, include: ['src', 'react', 'templates'], exclude: ['src/**/*.test.ts'] }, null, 2));
64
+ writeFileSync(join(tmp, 'src', 'App.tsx'), "import { FlowStudioView } from '../react/index.ts';\nimport model from '../examples/order-desk.json';\nimport '../templates/flow-studio.css';\nimport type { Model } from './model.ts';\nexport default function App() { return <FlowStudioView model={model as Model} />; }\n");
65
+ writeFileSync(join(tmp, 'src', 'main.tsx'), "import { StrictMode } from 'react';\nimport { createRoot } from 'react-dom/client';\nimport App from './App.tsx';\ncreateRoot(document.getElementById('root')!).render(<StrictMode><App /></StrictMode>);\n");
66
+ console.log(`Proof app: ${tmp}`);
67
+ // The browser checks launch a real browser. On Windows and when FLOW_STUDIO_CHANNEL is set,
68
+ // check.mjs uses a system browser (Edge or the named channel), so the Playwright browser
69
+ // download is skipped there. Everywhere else, let npm fetch Playwright's own browser, or the
70
+ // checks would fail on a clean machine that never downloaded one.
71
+ if (process.env.FLOW_STUDIO_CHANNEL || process.platform === 'win32') process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD ??= '1';
72
+ process.env.npm_config_cache = join(tmp, '.npm-cache');
73
+ run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['install', '--no-audit', '--no-fund']);
74
+ run(process.execPath, [join(tmp, 'node_modules', 'vitest', 'vitest.mjs'), 'run', 'react/FlowStudioView.test.tsx', 'react/native/native.test.tsx', '--environment', 'jsdom']);
75
+ run(process.execPath, [join(tmp, 'node_modules', 'typescript', 'bin', 'tsc'), '--noEmit']);
76
+ run(process.execPath, [join(tmp, 'node_modules', 'vite', 'bin', 'vite.js'), 'build']);
77
+ const port = await freePort();
78
+ const address = `http://127.0.0.1:${port}/`;
79
+ preview = spawn(process.execPath, [join(tmp, 'node_modules', 'vite', 'bin', 'vite.js'), 'preview', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { cwd: tmp, stdio: 'inherit' });
80
+ previewClosed = new Promise(resolve => preview.once('close', resolve));
81
+ await waitFor(address);
82
+ const checkArgs = [join(root, 'scripts', 'check.mjs'), address, '--out', join(tmp, 'check-out'), '--skip', 'hostile'];
83
+ if (process.env.FLOW_STUDIO_CHANNEL || process.platform === 'win32') checkArgs.push('--channel', process.env.FLOW_STUDIO_CHANNEL ?? 'msedge');
84
+ run(process.execPath, checkArgs, tmp);
85
+ console.log('React proof passed.');
86
+ } catch (error) {
87
+ console.error(`React proof failed: ${error.message}`);
88
+ process.exitCode = 1;
89
+ } finally {
90
+ await stopPreview(preview, previewClosed);
91
+ rmSync(tmp, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
92
+ }
@@ -0,0 +1,42 @@
1
+ // Validate a flow-studio model.
2
+ // node --experimental-strip-types scripts/validate.mjs <model.json>
3
+ // Exit 0: no errors (warnings are printed). Exit 1: at least one error. Exit 2: cannot read the file.
4
+
5
+ import { readFileSync } from "node:fs";
6
+ import { validateModel } from "../src/model.ts";
7
+ import { autoLayout } from "../src/autolayout.ts";
8
+ import { prepareModel } from "./lib/auto-entry-guard.mjs";
9
+
10
+ export function loadModel(path) {
11
+ let text;
12
+ try {
13
+ text = readFileSync(path, "utf8");
14
+ } catch (e) {
15
+ console.error(`cannot read ${path}: ${e.message}`);
16
+ process.exit(2);
17
+ }
18
+ try {
19
+ return JSON.parse(text);
20
+ } catch (e) {
21
+ console.error(`${path} is not valid JSON: ${e.message}`);
22
+ process.exit(2);
23
+ }
24
+ }
25
+
26
+ export function report(issues) {
27
+ for (const i of issues) console.log(`${i.level.toUpperCase().padEnd(5)} ${i.code.padEnd(16)} ${i.where}: ${i.message}`);
28
+ return issues.filter(i => i.level === "error").length;
29
+ }
30
+
31
+ if (process.argv[1]?.endsWith("validate.mjs")) {
32
+ const path = process.argv[2];
33
+ if (!path) {
34
+ console.error("usage: node --experimental-strip-types scripts/validate.mjs <model.json>");
35
+ process.exit(2);
36
+ }
37
+ const model = loadModel(path);
38
+ const ready = prepareModel(model, autoLayout);
39
+ const errors = report(validateModel(ready));
40
+ console.log(errors ? `${errors} error(s)` : "OK");
41
+ process.exit(errors ? 1 : 0);
42
+ }
@@ -0,0 +1,78 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { autoLayout } from "./autolayout.ts";
4
+ import { validateModel, type Model, type NodeDef } from "./model.ts";
5
+ import { computeGeometry } from "./layout.ts";
6
+
7
+ const node = (id: string, extra: Partial<NodeDef> = {}): NodeDef => ({ id, kind: "card", state: "ok", title: id, ...extra } as NodeDef);
8
+ const model = (ids: string[], pairs: [string, string][]): Model => ({
9
+ version: 1, meta: { title: "t", help: "", asOf: "", source: "" }, layout: "auto", columns: [], lanes: [],
10
+ states: { ok: { word: "OK", tone: "ok", border: "solid", edge: "flow", motion: "flow", bucket: "good" } },
11
+ nodes: ids.map(id => node(id)), links: pairs.map(([from, to], i) => ({ id: `e${i}`, from, to, state: "ok" })),
12
+ });
13
+ const errors = (m: Model) => validateModel(m).filter(i => i.level === "error");
14
+
15
+ test("chain gets longest path columns and generated stages", () => {
16
+ const out = autoLayout(model(["a", "b", "c"], [["a", "b"], ["b", "c"]]));
17
+ assert.deepEqual(out.nodes.map(n => n.col), [0, 1, 2]);
18
+ assert.deepEqual(out.columns.map(c => c.title), ["Stage 1", "Stage 2", "Stage 3"]);
19
+ assert.deepEqual(errors(out), []);
20
+ });
21
+ test("fork and join ranks the join after both branches", () => {
22
+ const out = autoLayout(model(["a", "b", "c", "d"], [["a", "b"], ["a", "c"], ["b", "d"], ["c", "d"]]));
23
+ assert.equal(out.nodes.find(n => n.id === "d")?.col, 2);
24
+ assert.equal(out.nodes.find(n => n.id === "b")?.col, 1);
25
+ assert.equal(out.nodes.find(n => n.id === "c")?.col, 1);
26
+ assert.deepEqual(errors(out), []);
27
+ });
28
+ test("a three cycle yields exactly one feedback edge and consistent ranks", () => {
29
+ const out = autoLayout(model(["a", "b", "c"], [["a", "b"], ["b", "c"], ["c", "a"]]));
30
+ assert.deepEqual(out.links.filter(l => l.kind === "loop-back").map(l => l.id), ["e2"]);
31
+ assert.deepEqual(out.nodes.map(n => n.col), [0, 1, 2]);
32
+ assert.deepEqual(errors(out), []);
33
+ });
34
+ test("disconnected parts get synthetic lanes in first appearance order", () => {
35
+ const out = autoLayout(model(["a", "b", "c", "d"], [["a", "b"], ["c", "d"]]));
36
+ assert.deepEqual(out.lanes.map(l => l.title), ["Group 1", "Group 2"]);
37
+ assert.equal(out.nodes[0]?.lane, out.nodes[1]?.lane);
38
+ assert.equal(out.nodes[2]?.lane, out.nodes[3]?.lane);
39
+ assert.notEqual(out.nodes[0]?.lane, out.nodes[2]?.lane);
40
+ const lanes = Object.values(computeGeometry(out).lanes).sort((a, b) => a.y - b.y);
41
+ assert.ok(lanes[0]!.y + lanes[0]!.h <= lanes[1]!.y);
42
+ });
43
+ test("author fixed column and row stay fixed and successors rank from them", () => {
44
+ const m = model(["a", "b", "c"], [["a", "b"], ["b", "c"]]);
45
+ m.nodes[1]!.col = 4; m.nodes[1]!.row = 7;
46
+ const out = autoLayout(m);
47
+ assert.equal(out.nodes[1]?.col, 4);
48
+ assert.equal(out.nodes[1]?.row, 7);
49
+ assert.equal(out.nodes[2]?.col, 5);
50
+ assert.equal(out.columns.length, 6);
51
+ assert.deepEqual(errors(out), []);
52
+ });
53
+ test("ordering lowers crossings on a reversed two-edge example", () => {
54
+ const m = model(["a", "b", "d", "c"], [["a", "c"], ["b", "d"]]);
55
+ m.nodes[0]!.col = 0; m.nodes[1]!.col = 0; m.nodes[2]!.col = 1; m.nodes[3]!.col = 1;
56
+ m.nodes.forEach(n => n.lane = "one"); m.lanes = [{ id: "one", title: "One" }];
57
+ const out = autoLayout(m), byId = new Map(out.nodes.map(n => [n.id, n]));
58
+ const crossing = (a: string, b: string, c: string, d: string): boolean =>
59
+ ((byId.get(a)!.row! - byId.get(c)!.row!) * (byId.get(b)!.row! - byId.get(d)!.row!)) < 0;
60
+ assert.equal(crossing("a", "c", "b", "d"), false);
61
+ assert.deepEqual(errors(out), []);
62
+ });
63
+ test("same input gives equal output without changing input", () => {
64
+ const m = model(["a", "b", "c"], [["a", "b"], ["b", "c"]]);
65
+ const before = JSON.stringify(m);
66
+ assert.deepEqual(autoLayout(m), autoLayout(m));
67
+ assert.equal(JSON.stringify(m), before);
68
+ });
69
+ test("seeded 200 node graph finishes under 300ms and validates", () => {
70
+ const ids = Array.from({ length: 200 }, (_, i) => `n${i}`);
71
+ let seed = 39;
72
+ const rnd = () => { seed = (1664525 * seed + 1013904223) >>> 0; return seed / 0x100000000; };
73
+ const pairs: [string, string][] = ids.slice(1).map((id, i) => [ids[i]!, id]);
74
+ for (let i = 0; i < 200; i++) pairs.push([ids[Math.floor(rnd() * ids.length)]!, ids[Math.floor(rnd() * ids.length)]!]);
75
+ const m = model(ids, pairs), start = performance.now(), out = autoLayout(m);
76
+ assert.ok(performance.now() - start < 300);
77
+ assert.deepEqual(errors(out), []);
78
+ });
@@ -0,0 +1,132 @@
1
+ // Deterministic placement for models without authored grid positions. No DOM and no input mutation.
2
+ import type { Model, NodeDef, LinkDef } from "./model.ts";
3
+
4
+ export function autoLayout(input: Model): Model {
5
+ const nodes = input.nodes.map(n => ({ ...n }));
6
+ const links = input.links.map(l => ({ ...l }));
7
+ const columns = input.columns.map(c => ({ ...c }));
8
+ const lanes = input.lanes.map(l => ({ ...l }));
9
+ const byId = new Map(nodes.map(n => [n.id, n]));
10
+ const index = new Map(nodes.map((n, i) => [n.id, i]));
11
+ const outgoing = new Map(nodes.map(n => [n.id, [] as LinkDef[]]));
12
+ const incoming = new Map(nodes.map(n => [n.id, [] as LinkDef[]]));
13
+ const neighbours = new Map(nodes.map(n => [n.id, [] as string[]]));
14
+ for (const link of links) {
15
+ if (!byId.has(link.from) || !byId.has(link.to)) continue;
16
+ outgoing.get(link.from)!.push(link);
17
+ incoming.get(link.to)!.push(link);
18
+ neighbours.get(link.from)!.push(link.to);
19
+ neighbours.get(link.to)!.push(link.from);
20
+ }
21
+
22
+ // A gray target is on the active DFS stack. Mark that edge as feedback.
23
+ const color = new Map<string, number>();
24
+ const post: string[] = [];
25
+ for (const root of nodes) {
26
+ if (color.has(root.id)) continue;
27
+ color.set(root.id, 1);
28
+ const stack: { id: string; next: number }[] = [{ id: root.id, next: 0 }];
29
+ while (stack.length) {
30
+ const frame = stack[stack.length - 1]!;
31
+ const edges = outgoing.get(frame.id)!;
32
+ if (frame.next >= edges.length) {
33
+ color.set(frame.id, 2); post.push(frame.id); stack.pop(); continue;
34
+ }
35
+ const link = edges[frame.next++]!;
36
+ if (link.kind === "loop-back") continue;
37
+ const state = color.get(link.to) ?? 0;
38
+ if (state === 1) link.kind = "loop-back";
39
+ else if (state === 0) { color.set(link.to, 1); stack.push({ id: link.to, next: 0 }); }
40
+ }
41
+ }
42
+
43
+ // Reverse finishing order is topological after feedback edges are removed.
44
+ for (const id of post.reverse()) {
45
+ const n = byId.get(id)!;
46
+ if (n.col !== undefined) continue;
47
+ let col = 0;
48
+ for (const edge of incoming.get(id)!) {
49
+ if (edge.kind === "loop-back") continue;
50
+ const parent = byId.get(edge.from)!;
51
+ col = Math.max(col, (parent.col ?? 0) + (parent.colSpan ?? 1));
52
+ }
53
+ n.col = col;
54
+ }
55
+
56
+ // Missing lane membership is grouped by weak connection, including feedback edges.
57
+ const seen = new Set<string>();
58
+ const usedLaneIds = new Set(lanes.map(l => l.id));
59
+ let group = 0;
60
+ for (const first of nodes) {
61
+ if (seen.has(first.id)) continue;
62
+ const queue = [first.id]; seen.add(first.id);
63
+ for (let q = 0; q < queue.length; q++) {
64
+ for (const id of neighbours.get(queue[q]!)!) if (!seen.has(id)) { seen.add(id); queue.push(id); }
65
+ }
66
+ const missing = queue.map(id => byId.get(id)!).filter(n => !n.lane);
67
+ if (!missing.length) continue;
68
+ group++;
69
+ let laneId = `group-${group}`;
70
+ while (usedLaneIds.has(laneId)) laneId += "-auto";
71
+ usedLaneIds.add(laneId);
72
+ lanes.push({ id: laneId, title: `Group ${group}` });
73
+ for (const n of missing) n.lane = laneId;
74
+ }
75
+
76
+ // Start in input order. Four alternating sweeps sort each lane and layer by
77
+ // neighbour barycentre. Stable input order resolves every tie.
78
+ const rank = new Map(nodes.map((n, i) => [n.id, i]));
79
+ const laneOrder = lanes.map(l => l.id);
80
+ const cells = new Map<string, NodeDef[]>();
81
+ for (const n of nodes) {
82
+ const key = `${n.lane}\0${n.col}`;
83
+ if (!cells.has(key)) cells.set(key, []);
84
+ cells.get(key)!.push(n);
85
+ }
86
+ const maxCol = Math.max(0, ...nodes.map(n => (n.col ?? 0) + (n.colSpan ?? 1) - 1));
87
+ for (let sweep = 0; sweep < 4; sweep++) {
88
+ const forward = sweep % 2 === 0;
89
+ const layers = Array.from({ length: maxCol + 1 }, (_, i) => forward ? i : maxCol - i);
90
+ for (const lane of laneOrder) for (const col of layers) {
91
+ const groupNodes = cells.get(`${lane}\0${col}`);
92
+ if (!groupNodes || groupNodes.length < 2) continue;
93
+ const score = (n: NodeDef): number => {
94
+ const edges = forward ? incoming.get(n.id)! : outgoing.get(n.id)!;
95
+ const ns = edges.filter(e => e.kind !== "loop-back").map(e => forward ? e.from : e.to);
96
+ return ns.length ? ns.reduce((sum, id) => sum + (rank.get(id) ?? 0), 0) / ns.length : (rank.get(n.id) ?? 0);
97
+ };
98
+ groupNodes.sort((a, b) => score(a) - score(b) || (index.get(a.id)! - index.get(b.id)!));
99
+ groupNodes.forEach((n, i) => rank.set(n.id, i));
100
+ }
101
+ }
102
+
103
+ // Place lanes in separate row ranges. A cell can share a row with another
104
+ // column, but nodes whose column spans intersect need a full row of space.
105
+ let nextBase = 0;
106
+ const placed: NodeDef[] = nodes.filter(n => n.row !== undefined);
107
+ for (const lane of laneOrder) {
108
+ const members = nodes.filter(n => n.lane === lane);
109
+ if (!members.length) continue;
110
+ const ordered = [...members].sort((a, b) => (a.col ?? 0) - (b.col ?? 0) || (rank.get(a.id) ?? 0) - (rank.get(b.id) ?? 0) || index.get(a.id)! - index.get(b.id)!);
111
+ const rowAtCol = new Map<number, number>();
112
+ for (const n of ordered) {
113
+ if (n.row !== undefined) continue;
114
+ let row = Math.max(nextBase, rowAtCol.get(n.col ?? 0) ?? nextBase);
115
+ while (placed.some(p => (n.col ?? 0) < (p.col ?? 0) + (p.colSpan ?? 1)
116
+ && (p.col ?? 0) < (n.col ?? 0) + (n.colSpan ?? 1) && Math.abs(row - (p.row ?? 0)) < 1)) row += 1;
117
+ n.row = row;
118
+ placed.push(n);
119
+ rowAtCol.set(n.col ?? 0, row + 1);
120
+ }
121
+ const last = Math.max(nextBase - 1, ...members.map(n => n.row ?? 0));
122
+ const loops = links.filter(l => l.kind === "loop-back" && byId.get(l.from)?.lane === lane && byId.get(l.to)?.lane === lane).length;
123
+ nextBase = last + 1.75 + loops * 0.35;
124
+ }
125
+
126
+ const needed = Math.max(0, ...nodes.map(n => (n.col ?? 0) + (n.colSpan ?? 1)));
127
+ while (columns.length < needed) {
128
+ const i = columns.length + 1;
129
+ columns.push({ id: `stage-${i}`, title: `Stage ${i}`, width: 190 });
130
+ }
131
+ return { ...input, nodes, links, columns, lanes };
132
+ }