@starci/skills 1.1.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 (321) hide show
  1. package/INDEX.md +74 -0
  2. package/INDEX.vi.md +75 -0
  3. package/README.md +44 -0
  4. package/README.vi.md +43 -0
  5. package/SKILL.md +135 -0
  6. package/SKILL.vi.md +128 -0
  7. package/alias/INDEX.md +104 -0
  8. package/alias/INDEX.vi.md +104 -0
  9. package/alias/alias.json +297 -0
  10. package/bin/starci-skills.mjs +219 -0
  11. package/knowledge/INDEX.md +22 -0
  12. package/knowledge/INDEX.vi.md +22 -0
  13. package/knowledge/grammars/starci/DNA.md +146 -0
  14. package/knowledge/grammars/starci/DNA.vi.md +146 -0
  15. package/knowledge/grammars/starci/INDEX.md +25 -0
  16. package/knowledge/grammars/starci/INDEX.vi.md +25 -0
  17. package/knowledge/grammars/starci/family.md +50 -0
  18. package/knowledge/grammars/starci/family.vi.md +50 -0
  19. package/knowledge/grammars/starci/idioms.md +120 -0
  20. package/knowledge/grammars/starci/idioms.vi.md +121 -0
  21. package/knowledge/grammars/starci/playbook.md +36 -0
  22. package/knowledge/grammars/starci/playbook.vi.md +35 -0
  23. package/knowledge/patterns/be/INDEX.md +29 -0
  24. package/knowledge/patterns/be/INDEX.vi.md +29 -0
  25. package/knowledge/patterns/be/comment.md +80 -0
  26. package/knowledge/patterns/be/comment.vi.md +80 -0
  27. package/knowledge/patterns/be/error.md +86 -0
  28. package/knowledge/patterns/be/error.vi.md +86 -0
  29. package/knowledge/patterns/be/folder.md +87 -0
  30. package/knowledge/patterns/be/folder.vi.md +86 -0
  31. package/knowledge/patterns/be/function.md +80 -0
  32. package/knowledge/patterns/be/function.vi.md +80 -0
  33. package/knowledge/patterns/be/imports.md +79 -0
  34. package/knowledge/patterns/be/imports.vi.md +79 -0
  35. package/knowledge/patterns/be/naming.md +87 -0
  36. package/knowledge/patterns/be/naming.vi.md +87 -0
  37. package/knowledge/patterns/be/test.md +79 -0
  38. package/knowledge/patterns/be/test.vi.md +79 -0
  39. package/knowledge/patterns/be/typing.md +73 -0
  40. package/knowledge/patterns/be/typing.vi.md +73 -0
  41. package/knowledge/patterns/fe/INDEX.md +29 -0
  42. package/knowledge/patterns/fe/INDEX.vi.md +29 -0
  43. package/knowledge/patterns/fe/comment.md +70 -0
  44. package/knowledge/patterns/fe/comment.vi.md +70 -0
  45. package/knowledge/patterns/fe/error.md +61 -0
  46. package/knowledge/patterns/fe/error.vi.md +61 -0
  47. package/knowledge/patterns/fe/folder.md +98 -0
  48. package/knowledge/patterns/fe/folder.vi.md +95 -0
  49. package/knowledge/patterns/fe/function.md +70 -0
  50. package/knowledge/patterns/fe/function.vi.md +70 -0
  51. package/knowledge/patterns/fe/imports.md +87 -0
  52. package/knowledge/patterns/fe/imports.vi.md +87 -0
  53. package/knowledge/patterns/fe/naming.md +77 -0
  54. package/knowledge/patterns/fe/naming.vi.md +77 -0
  55. package/knowledge/patterns/fe/test.md +73 -0
  56. package/knowledge/patterns/fe/test.vi.md +73 -0
  57. package/knowledge/patterns/fe/typing.md +67 -0
  58. package/knowledge/patterns/fe/typing.vi.md +67 -0
  59. package/knowledge/ui/INDEX.md +101 -0
  60. package/knowledge/ui/INDEX.vi.md +101 -0
  61. package/knowledge/ui/composition/INDEX.md +60 -0
  62. package/knowledge/ui/composition/INDEX.vi.md +63 -0
  63. package/knowledge/ui/composition/accent.md +73 -0
  64. package/knowledge/ui/composition/accent.vi.md +74 -0
  65. package/knowledge/ui/composition/action.md +55 -0
  66. package/knowledge/ui/composition/action.vi.md +56 -0
  67. package/knowledge/ui/composition/coverage.md +37 -0
  68. package/knowledge/ui/composition/coverage.vi.md +37 -0
  69. package/knowledge/ui/composition/cta.md +79 -0
  70. package/knowledge/ui/composition/cta.vi.md +79 -0
  71. package/knowledge/ui/composition/feedback.md +55 -0
  72. package/knowledge/ui/composition/feedback.vi.md +56 -0
  73. package/knowledge/ui/composition/hierarchy.md +83 -0
  74. package/knowledge/ui/composition/hierarchy.vi.md +82 -0
  75. package/knowledge/ui/composition/layout.md +91 -0
  76. package/knowledge/ui/composition/layout.vi.md +93 -0
  77. package/knowledge/ui/composition/responsive.md +67 -0
  78. package/knowledge/ui/composition/responsive.vi.md +67 -0
  79. package/knowledge/ui/composition/state.md +105 -0
  80. package/knowledge/ui/composition/state.vi.md +106 -0
  81. package/knowledge/ui/presentation/INDEX.md +94 -0
  82. package/knowledge/ui/presentation/INDEX.vi.md +69 -0
  83. package/knowledge/ui/presentation/boundary.md +170 -0
  84. package/knowledge/ui/presentation/boundary.vi.md +169 -0
  85. package/knowledge/ui/presentation/font.md +155 -0
  86. package/knowledge/ui/presentation/font.vi.md +156 -0
  87. package/knowledge/ui/presentation/gap.md +181 -0
  88. package/knowledge/ui/presentation/gap.vi.md +181 -0
  89. package/knowledge/ui/presentation/margin.md +168 -0
  90. package/knowledge/ui/presentation/margin.vi.md +171 -0
  91. package/knowledge/ui/presentation/measure.md +174 -0
  92. package/knowledge/ui/presentation/measure.vi.md +178 -0
  93. package/knowledge/ui/presentation/overflow.md +142 -0
  94. package/knowledge/ui/presentation/overflow.vi.md +142 -0
  95. package/knowledge/ui/presentation/padding.md +293 -0
  96. package/knowledge/ui/presentation/padding.vi.md +292 -0
  97. package/knowledge/ui/presentation/surface.md +166 -0
  98. package/knowledge/ui/presentation/surface.vi.md +166 -0
  99. package/knowledge/ui/presentation/text-flow.md +123 -0
  100. package/knowledge/ui/presentation/text-flow.vi.md +123 -0
  101. package/knowledge/ui/presentation/tone.md +114 -0
  102. package/knowledge/ui/presentation/tone.vi.md +113 -0
  103. package/knowledge/ui/proof/INDEX.md +55 -0
  104. package/knowledge/ui/proof/INDEX.vi.md +55 -0
  105. package/knowledge/ui/proof/accessibility.md +67 -0
  106. package/knowledge/ui/proof/accessibility.vi.md +68 -0
  107. package/knowledge/ui/proof/contrast.md +58 -0
  108. package/knowledge/ui/proof/contrast.vi.md +57 -0
  109. package/knowledge/ui/proof/focus.md +76 -0
  110. package/knowledge/ui/proof/focus.vi.md +76 -0
  111. package/knowledge/ui/proof/motion.md +63 -0
  112. package/knowledge/ui/proof/motion.vi.md +64 -0
  113. package/knowledge/ui/proof/render-truth.md +63 -0
  114. package/knowledge/ui/proof/render-truth.vi.md +63 -0
  115. package/operators/INDEX.md +199 -0
  116. package/operators/INDEX.vi.md +199 -0
  117. package/operators/architecture-decide/errors.json +115 -0
  118. package/operators/architecture-decide/operator.json +20 -0
  119. package/operators/architecture-decide/operator.md +133 -0
  120. package/operators/architecture-decide/operator.vi.md +130 -0
  121. package/operators/architecture-decide/self-test.mjs +221 -0
  122. package/operators/architecture-decide/validate.mjs +112 -0
  123. package/operators/backend-source-apply/errors.json +66 -0
  124. package/operators/backend-source-apply/operator.json +20 -0
  125. package/operators/backend-source-apply/operator.md +189 -0
  126. package/operators/backend-source-apply/operator.vi.md +185 -0
  127. package/operators/backend-source-apply/self-test.mjs +257 -0
  128. package/operators/backend-source-apply/validate.mjs +240 -0
  129. package/operators/business-decide/errors.json +78 -0
  130. package/operators/business-decide/operator.json +20 -0
  131. package/operators/business-decide/operator.md +176 -0
  132. package/operators/business-decide/operator.vi.md +174 -0
  133. package/operators/business-decide/self-test.mjs +225 -0
  134. package/operators/business-decide/validate.mjs +277 -0
  135. package/operators/content-generate/errors.json +106 -0
  136. package/operators/content-generate/operator.json +21 -0
  137. package/operators/content-generate/operator.md +155 -0
  138. package/operators/content-generate/operator.vi.md +155 -0
  139. package/operators/content-generate/self-test.mjs +288 -0
  140. package/operators/content-generate/validate.mjs +196 -0
  141. package/operators/errors.json +178 -0
  142. package/operators/frontend-direction-decide/errors.json +135 -0
  143. package/operators/frontend-direction-decide/operator.json +21 -0
  144. package/operators/frontend-direction-decide/operator.md +167 -0
  145. package/operators/frontend-direction-decide/operator.vi.md +167 -0
  146. package/operators/frontend-direction-decide/self-test.mjs +226 -0
  147. package/operators/frontend-direction-decide/validate.mjs +146 -0
  148. package/operators/frontend-presentation-resolve/errors.json +42 -0
  149. package/operators/frontend-presentation-resolve/operator.json +19 -0
  150. package/operators/frontend-presentation-resolve/operator.md +142 -0
  151. package/operators/frontend-presentation-resolve/operator.vi.md +140 -0
  152. package/operators/frontend-presentation-resolve/self-test.mjs +163 -0
  153. package/operators/frontend-presentation-resolve/validate.mjs +143 -0
  154. package/operators/frontend-source-apply/errors.json +30 -0
  155. package/operators/frontend-source-apply/operator.json +20 -0
  156. package/operators/frontend-source-apply/operator.md +127 -0
  157. package/operators/frontend-source-apply/operator.vi.md +126 -0
  158. package/operators/frontend-source-apply/self-test.mjs +214 -0
  159. package/operators/frontend-source-apply/validate.mjs +133 -0
  160. package/operators/frontend-surface-audit/errors.json +5 -0
  161. package/operators/frontend-surface-audit/operator.json +22 -0
  162. package/operators/frontend-surface-audit/operator.md +121 -0
  163. package/operators/frontend-surface-audit/operator.vi.md +121 -0
  164. package/operators/frontend-surface-audit/self-test.mjs +185 -0
  165. package/operators/frontend-surface-audit/validate.mjs +127 -0
  166. package/operators/git-publish/errors.json +54 -0
  167. package/operators/git-publish/operator.json +20 -0
  168. package/operators/git-publish/operator.md +180 -0
  169. package/operators/git-publish/operator.vi.md +178 -0
  170. package/operators/git-publish/self-test.mjs +177 -0
  171. package/operators/git-publish/validate.mjs +106 -0
  172. package/operators/platform-operate/errors.json +90 -0
  173. package/operators/platform-operate/operator.json +22 -0
  174. package/operators/platform-operate/operator.md +162 -0
  175. package/operators/platform-operate/operator.vi.md +160 -0
  176. package/operators/platform-operate/self-test.mjs +202 -0
  177. package/operators/platform-operate/validate.mjs +193 -0
  178. package/operators/quality-verify/errors.json +54 -0
  179. package/operators/quality-verify/operator.json +20 -0
  180. package/operators/quality-verify/operator.md +185 -0
  181. package/operators/quality-verify/operator.vi.md +177 -0
  182. package/operators/quality-verify/self-test.mjs +210 -0
  183. package/operators/quality-verify/validate.mjs +197 -0
  184. package/operators/release-deploy/errors.json +158 -0
  185. package/operators/release-deploy/operator.json +23 -0
  186. package/operators/release-deploy/operator.md +183 -0
  187. package/operators/release-deploy/operator.vi.md +181 -0
  188. package/operators/release-deploy/self-test.mjs +235 -0
  189. package/operators/release-deploy/validate.mjs +129 -0
  190. package/operators/uat-verify/errors.json +78 -0
  191. package/operators/uat-verify/operator.json +25 -0
  192. package/operators/uat-verify/operator.md +162 -0
  193. package/operators/uat-verify/operator.vi.md +161 -0
  194. package/operators/uat-verify/self-test.mjs +270 -0
  195. package/operators/uat-verify/validate.mjs +202 -0
  196. package/operators/workspace-bind/errors.json +90 -0
  197. package/operators/workspace-bind/operator.json +21 -0
  198. package/operators/workspace-bind/operator.md +148 -0
  199. package/operators/workspace-bind/operator.vi.md +148 -0
  200. package/operators/workspace-bind/self-test.mjs +205 -0
  201. package/operators/workspace-bind/validate.mjs +152 -0
  202. package/package.json +50 -0
  203. package/readiness/initialization/workspaces/commit-policy.json +91 -0
  204. package/readiness/initialization/workspaces/config.schema.json +35 -0
  205. package/readiness/initialization/workspaces/device-state.schema.json +54 -0
  206. package/readiness/initialization/workspaces/local-route.schema.json +206 -0
  207. package/readiness/initialization/workspaces/portable-route.schema.json +200 -0
  208. package/resources/INDEX.md +96 -0
  209. package/resources/INDEX.vi.md +99 -0
  210. package/resources/agents/profiles/claude.json +131 -0
  211. package/resources/agents/profiles/openai.json +131 -0
  212. package/resources/orchestrator.json +71 -0
  213. package/resources/tools.json +85 -0
  214. package/routing.json +272 -0
  215. package/scripts/alias-registry.mjs +31 -0
  216. package/scripts/device-state.mjs +497 -0
  217. package/scripts/device-state.spec.mjs +18 -0
  218. package/scripts/errors-registry.mjs +68 -0
  219. package/scripts/generate-alias-doc.mjs +66 -0
  220. package/scripts/generate-grammar-dna.mjs +387 -0
  221. package/scripts/generate-operators-index.mjs +95 -0
  222. package/scripts/generate-presentation-owned.mjs +681 -0
  223. package/scripts/install-cli.spec.mjs +74 -0
  224. package/scripts/json-schema.mjs +94 -0
  225. package/scripts/operator-md.mjs +96 -0
  226. package/scripts/run-operator-self-tests.mjs +36 -0
  227. package/scripts/validate-alias.mjs +165 -0
  228. package/scripts/validate-defaults.mjs +72 -0
  229. package/scripts/validate-knowledge-citations.mjs +90 -0
  230. package/scripts/validate-operator.mjs +125 -0
  231. package/scripts/validate-request.mjs +80 -0
  232. package/scripts/validate-resources.mjs +117 -0
  233. package/scripts/validate-response.mjs +141 -0
  234. package/scripts/validate-routing.mjs +91 -0
  235. package/scripts/validate-step.mjs +50 -0
  236. package/scripts/validate-templates.mjs +226 -0
  237. package/scripts/validate-templates.spec.mjs +144 -0
  238. package/scripts/validate-workflows.mjs +106 -0
  239. package/scripts/workspace-portable.mjs +389 -0
  240. package/scripts/workspace-portable.spec.mjs +246 -0
  241. package/templates/README.md +47 -0
  242. package/templates/README.vi.md +45 -0
  243. package/templates/changes.example.md +27 -0
  244. package/templates/grammars.template.md +34 -0
  245. package/templates/kinds/architecture-decision.contract.json +14 -0
  246. package/templates/kinds/architecture-decision.skeleton.md +52 -0
  247. package/templates/kinds/backend-source-application.contract.json +10 -0
  248. package/templates/kinds/backend-source-application.skeleton.md +34 -0
  249. package/templates/kinds/business-promise-authority.contract.json +13 -0
  250. package/templates/kinds/business-promise-authority.skeleton.md +67 -0
  251. package/templates/kinds/capture.schema.json +33 -0
  252. package/templates/kinds/changes.contract.json +10 -0
  253. package/templates/kinds/changes.skeleton.md +26 -0
  254. package/templates/kinds/checks.schema.json +44 -0
  255. package/templates/kinds/claims.schema.json +165 -0
  256. package/templates/kinds/conformance.schema.json +15 -0
  257. package/templates/kinds/content-brief.contract.json +12 -0
  258. package/templates/kinds/content-brief.skeleton.md +45 -0
  259. package/templates/kinds/content-generation-receipt.contract.json +11 -0
  260. package/templates/kinds/content-generation-receipt.skeleton.md +40 -0
  261. package/templates/kinds/content-review.contract.json +11 -0
  262. package/templates/kinds/content-review.skeleton.md +45 -0
  263. package/templates/kinds/contract.schema.json +28 -0
  264. package/templates/kinds/coverage-matrix.schema.json +86 -0
  265. package/templates/kinds/coverage.schema.json +62 -0
  266. package/templates/kinds/current-state.schema.json +40 -0
  267. package/templates/kinds/delta.schema.json +95 -0
  268. package/templates/kinds/e2e.schema.json +63 -0
  269. package/templates/kinds/frontend-direction-decision.contract.json +93 -0
  270. package/templates/kinds/frontend-direction-decision.skeleton.md +68 -0
  271. package/templates/kinds/frontend-presentation-resolution.contract.json +11 -0
  272. package/templates/kinds/frontend-presentation-resolution.skeleton.md +32 -0
  273. package/templates/kinds/frontend-source-application.contract.json +10 -0
  274. package/templates/kinds/frontend-source-application.skeleton.md +30 -0
  275. package/templates/kinds/frontend-surface-audit.contract.json +52 -0
  276. package/templates/kinds/frontend-surface-audit.skeleton.md +32 -0
  277. package/templates/kinds/gate-result.schema.json +64 -0
  278. package/templates/kinds/git-publication.contract.json +82 -0
  279. package/templates/kinds/git-publication.skeleton.md +61 -0
  280. package/templates/kinds/independent-critique.contract.json +9 -0
  281. package/templates/kinds/independent-critique.skeleton.md +28 -0
  282. package/templates/kinds/inventory.schema.json +35 -0
  283. package/templates/kinds/model.schema.json +99 -0
  284. package/templates/kinds/mutations.schema.json +65 -0
  285. package/templates/kinds/platform-operation-receipt.contract.json +76 -0
  286. package/templates/kinds/platform-operation-receipt.skeleton.md +54 -0
  287. package/templates/kinds/probes.schema.json +130 -0
  288. package/templates/kinds/proof.schema.json +17 -0
  289. package/templates/kinds/quality-verification.contract.json +87 -0
  290. package/templates/kinds/quality-verification.skeleton.md +58 -0
  291. package/templates/kinds/release-deployment.contract.json +85 -0
  292. package/templates/kinds/release-deployment.skeleton.md +67 -0
  293. package/templates/kinds/route.schema.json +293 -0
  294. package/templates/kinds/stack-model.schema.json +90 -0
  295. package/templates/kinds/uat-capture.schema.json +35 -0
  296. package/templates/kinds/uat-flow-verification.contract.json +11 -0
  297. package/templates/kinds/uat-flow-verification.skeleton.md +47 -0
  298. package/templates/kinds/uat-snapshot.schema.json +96 -0
  299. package/templates/kinds/uat-verdicts.schema.json +41 -0
  300. package/templates/kinds/ui-coverage.schema.json +77 -0
  301. package/templates/kinds/verdicts.schema.json +39 -0
  302. package/templates/kinds/workspace-route-binding.contract.json +12 -0
  303. package/templates/kinds/workspace-route-binding.skeleton.md +60 -0
  304. package/templates/kinds/writes.schema.json +37 -0
  305. package/templates/operator.template.md +73 -0
  306. package/templates/patterns.template.md +31 -0
  307. package/templates/step/request.schema.json +42 -0
  308. package/templates/step/response.schema.json +144 -0
  309. package/templates/ui-composition.template.md +36 -0
  310. package/templates/ui-presentation.template.md +57 -0
  311. package/templates/ui-proof.template.md +34 -0
  312. package/workflows/README.md +37 -0
  313. package/workflows/README.vi.md +37 -0
  314. package/workflows/backend-feature.json +59 -0
  315. package/workflows/content-unit.json +19 -0
  316. package/workflows/frontend-new-surface.json +81 -0
  317. package/workflows/frontend-reconstruct.json +67 -0
  318. package/workflows/frontend-refine.json +67 -0
  319. package/workflows/frontend-with-uat.json +78 -0
  320. package/workflows/full-feature.json +104 -0
  321. package/workflows/release.json +29 -0
@@ -0,0 +1,226 @@
1
+ // Every operator document and every knowledge topic conforms to the template of its kind.
2
+ //
3
+ // A template is `templates/<kind>.template.md`: a human skeleton plus one fenced ```json block
4
+ // tagged `template-contract`. The contract says which files it applies to, what the title must
5
+ // match, which `##` sections must appear in which order (with `free` zones where a document may add
6
+ // its own law sections), which table header a section or a rule must carry, what the closing
7
+ // section is, and that a `.vi.md` mirror must exist and follow the Vietnamese form of the same
8
+ // contract. The contract is the authority; the skeleton is how a human reads it.
9
+ import { readdir, readFile, stat } from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import process from 'node:process';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const CONTRACT_FENCE = /```json template-contract\r?\n([\s\S]*?)\r?\n```/;
15
+
16
+ export async function loadTemplates(root) {
17
+ const dir = path.join(root, 'templates');
18
+ const templates = [];
19
+ for (const name of (await readdir(dir)).filter((f) => f.endsWith('.template.md')).sort()) {
20
+ const text = await readFile(path.join(dir, name), 'utf8');
21
+ const m = CONTRACT_FENCE.exec(text);
22
+ if (!m) throw new Error(`templates/${name}: no template-contract block`);
23
+ const contract = JSON.parse(m[1]);
24
+ if (contract.kind !== name.replace(/\.template\.md$/, '')) {
25
+ throw new Error(`templates/${name}: contract.kind must equal the file name`);
26
+ }
27
+ templates.push(contract);
28
+ }
29
+ return templates;
30
+ }
31
+
32
+ // Kind contracts describe markdown files that only exist inside a session: templates/kinds/<kind>.contract.json
33
+ // is the authority (checked against contract.schema.json), <kind>.skeleton.md is the copyable skeleton and
34
+ // must itself pass the contract. They claim nothing in the tree; validate-response loads them by kind.
35
+ export async function loadKindTemplates(root) {
36
+ const dir = path.join(root, 'templates', 'kinds');
37
+ const { validateAgainst } = await import('./json-schema.mjs');
38
+ const schema = JSON.parse(await readFile(path.join(dir, 'contract.schema.json'), 'utf8'));
39
+ const kinds = new Map();
40
+ for (const name of (await readdir(dir)).filter((f) => f.endsWith('.contract.json')).sort()) {
41
+ const contract = JSON.parse(await readFile(path.join(dir, name), 'utf8'));
42
+ const rel = `templates/kinds/${name}`;
43
+ const errors = validateAgainst(schema, contract, rel);
44
+ if (errors.length) throw new Error(errors.join('\n'));
45
+ if (contract.kind !== name.replace(/\.contract\.json$/, '')) throw new Error(`${rel}: contract.kind must equal the file name`);
46
+ for (const s of contract.sections) {
47
+ if (!s.table && (s.rows || s.cell || s.minRows !== undefined || s.exactRows !== undefined)) throw new Error(`${rel}: section ${s.heading} has row rules but no table`);
48
+ if (s.table && s.cell) {
49
+ const cols = s.table.split('|').slice(1, -1).map((c) => c.trim());
50
+ for (const col of Object.keys(s.cell)) if (!cols.includes(col)) throw new Error(`${rel}: section ${s.heading} names cell column ${col}, which its table lacks`);
51
+ }
52
+ }
53
+ kinds.set(contract.kind, { ...contract, skeleton: `templates/kinds/${contract.kind}.skeleton.md` });
54
+ }
55
+ return kinds;
56
+ }
57
+
58
+ async function walk(dir) {
59
+ const out = [];
60
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
61
+ const full = path.join(dir, entry.name);
62
+ if (entry.isDirectory()) {
63
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
64
+ out.push(...(await walk(full)));
65
+ } else out.push(full);
66
+ }
67
+ return out;
68
+ }
69
+
70
+ // Glob support is deliberately small: `*` within one segment, `**` across segments.
71
+ function globToRegExp(glob) {
72
+ const escaped = glob
73
+ .split('/')
74
+ .map((seg) => (seg === '**' ? '(?:[^/]+/)*' : seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')))
75
+ .join('/')
76
+ .replace(/\(\?:\[\^\/\]\+\/\)\*\//g, '(?:[^/]+/)*');
77
+ return new RegExp(`^${escaped}$`);
78
+ }
79
+
80
+ function headings(lines) {
81
+ const out = [];
82
+ lines.forEach((line, i) => {
83
+ if (/^## /.test(line)) out.push({ text: line.trimEnd(), line: i + 1 });
84
+ });
85
+ return out;
86
+ }
87
+
88
+ function tableHeadersBetween(lines, from, to) {
89
+ const out = [];
90
+ for (let i = from; i < to - 1; i += 1) {
91
+ if (lines[i].startsWith('|') && /^\|\s*-{3,}/.test(lines[i + 1])) out.push({ text: lines[i].trim().replace(/\s+/g, ' '), line: i + 1 });
92
+ }
93
+ return out;
94
+ }
95
+
96
+ // Data rows of the table whose header sits at `headerLine` (1-based): one array of trimmed cells per row.
97
+ function tableRowsAt(lines, headerLine) {
98
+ const rows = [];
99
+ for (let i = headerLine + 1; i < lines.length && lines[i].startsWith('|'); i += 1) rows.push(lines[i].split('|').slice(1, -1).map((c) => c.trim()));
100
+ return rows;
101
+ }
102
+ const unquote = (s) => s.replace(/^`|`$/g, '');
103
+
104
+ // A contract value is either one string (a single-language kind template) or { en, vi }.
105
+ export function checkDocument(rel, text, contract, lang) {
106
+ const L = (v) => (typeof v === 'string' ? v : v?.[lang] ?? v?.en ?? v?.heading);
107
+ const errors = [];
108
+ const lines = text.split(/\r?\n/);
109
+ const title = lines[0] ?? '';
110
+ const titleRe = new RegExp(L(contract.title), 'u');
111
+ if (!titleRe.test(title)) errors.push(`${rel}:1: title must match ${L(contract.title)}`);
112
+
113
+ const all = headings(lines);
114
+ const ruleRe = contract.rules ? new RegExp(contract.rules.heading, 'u') : null;
115
+ const closingRe = contract.rules?.closing ? new RegExp(L(contract.rules.closing), 'u') : null;
116
+ const frame = all.filter((h) => !(ruleRe && ruleRe.test(h.text)) && !(closingRe && closingRe.test(h.text)));
117
+
118
+ // Walk the ordered section contract against the frame headings.
119
+ let cursor = 0;
120
+ let freeZone = false;
121
+ for (const section of contract.sections ?? []) {
122
+ if (section.free) { freeZone = true; continue; }
123
+ const re = new RegExp(L(section), 'u');
124
+ let found = -1;
125
+ for (let i = cursor; i < frame.length; i += 1) if (re.test(frame[i].text)) { found = i; break; }
126
+ if (found === -1) { errors.push(`${rel}: missing section ${L(section)}`); continue; }
127
+ if (found > cursor && !freeZone) {
128
+ for (let i = cursor; i < found; i += 1) errors.push(`${rel}:${frame[i].line}: unexpected section "${frame[i].text}" before ${L(section)}`);
129
+ }
130
+ if (section.table) {
131
+ const start = frame[found].line;
132
+ const next = all.find((h) => h.line > start);
133
+ const tables = tableHeadersBetween(lines, start, next ? next.line - 1 : lines.length);
134
+ const header = L(section.table);
135
+ if (tables.length === 0 || tables[0].text !== header) {
136
+ errors.push(`${rel}:${start}: section must open with the table ${header}`);
137
+ } else {
138
+ // Row-level shape: how many rows, which first-column values must appear, which cells must match.
139
+ const cols = header.split('|').slice(1, -1).map((c) => c.trim());
140
+ const rows = tableRowsAt(lines, tables[0].line);
141
+ const at = `${rel}:${tables[0].line}`;
142
+ if (section.minRows !== undefined && rows.length < section.minRows) errors.push(`${at}: table needs at least ${section.minRows} rows, found ${rows.length}`);
143
+ if (section.exactRows !== undefined && rows.length !== section.exactRows) errors.push(`${at}: table needs exactly ${section.exactRows} rows, found ${rows.length}`);
144
+ const firsts = new Set(rows.map((r) => unquote(r[0] ?? '')));
145
+ for (const need of section.rows ?? []) if (!firsts.has(need)) errors.push(`${at}: table lacks a row for ${need}`);
146
+ for (const [col, pattern] of Object.entries(section.cell ?? {})) {
147
+ const idx = cols.indexOf(col);
148
+ if (idx === -1) continue; // the column belongs to the other language's header
149
+ const cellRe = new RegExp(pattern, 'u');
150
+ rows.forEach((r, i) => { if (!cellRe.test(r[idx] ?? '')) errors.push(`${at}: row ${i + 1} cell ${col} "${r[idx] ?? ''}" does not match ${pattern}`); });
151
+ }
152
+ }
153
+ }
154
+ cursor = found + 1;
155
+ freeZone = false;
156
+ }
157
+ if (!freeZone && cursor < frame.length && contract.sections !== undefined) {
158
+ for (let i = cursor; i < frame.length; i += 1) errors.push(`${rel}:${frame[i].line}: unexpected trailing section "${frame[i].text}"`);
159
+ }
160
+
161
+ // Rules: each heading carries exactly one table of the published shape.
162
+ if (contract.rules) {
163
+ const rules = all.filter((h) => ruleRe.test(h.text));
164
+ if (contract.rules.required && rules.length === 0) errors.push(`${rel}: publishes no rule heading`);
165
+ rules.forEach((rule) => {
166
+ const next = all.find((h) => h.line > rule.line);
167
+ const tables = tableHeadersBetween(lines, rule.line, next ? next.line - 1 : lines.length);
168
+ if (tables.length !== 1) errors.push(`${rel}:${rule.line}: rule must carry exactly one table, found ${tables.length}`);
169
+ else if (tables[0].text !== L(contract.rules.table)) errors.push(`${rel}:${tables[0].line}: rule table must be ${L(contract.rules.table)}`);
170
+ });
171
+ if (closingRe) {
172
+ const last = all[all.length - 1];
173
+ if (!last || !closingRe.test(last.text)) errors.push(`${rel}: last section must be ${L(contract.rules.closing)}`);
174
+ }
175
+ }
176
+ return errors;
177
+ }
178
+
179
+ export async function validateTree(root) {
180
+ const templates = await loadTemplates(root);
181
+ const files = (await walk(root)).map((f) => path.relative(root, f).split(path.sep).join('/'));
182
+ const errors = [];
183
+ let checked = 0;
184
+ const claimed = new Map();
185
+ for (const contract of templates) {
186
+ const matchers = contract.applies.map(globToRegExp);
187
+ // INDEX files are reading indexes, not documents of a kind; they are outside every template.
188
+ const targets = files.filter((f) => f.endsWith('.md') && !f.endsWith('.vi.md') && !/(^|\/)INDEX\.md$/.test(f) && matchers.some((re) => re.test(f)));
189
+ for (const rel of targets) {
190
+ if (claimed.has(rel)) errors.push(`${rel}: claimed by templates ${claimed.get(rel)} and ${contract.kind}`);
191
+ claimed.set(rel, contract.kind);
192
+ const text = await readFile(path.join(root, rel), 'utf8');
193
+ errors.push(...checkDocument(rel, text, contract, 'en'));
194
+ checked += 1;
195
+ const viRel = rel.replace(/\.md$/, '.vi.md');
196
+ if (!files.includes(viRel)) { errors.push(`${rel}: no Vietnamese mirror ${viRel}`); continue; }
197
+ const viText = await readFile(path.join(root, viRel), 'utf8');
198
+ errors.push(...checkDocument(viRel, viText, contract, 'vi'));
199
+ checked += 1;
200
+ }
201
+ }
202
+ // A kind template may name one enforced example in the tree, so the shape a session file must take
203
+ // is visible to a reader before any session exists.
204
+ // Every kind's skeleton, and its example when it names one, must pass the kind's own contract.
205
+ let kinds = new Map();
206
+ try { kinds = await loadKindTemplates(root); } catch (e) { if (e.code !== 'ENOENT') throw e; }
207
+ for (const contract of kinds.values()) {
208
+ for (const rel of [contract.skeleton, contract.example].filter(Boolean)) {
209
+ if (!files.includes(rel)) { errors.push(`${rel}: named by kind ${contract.kind} but missing`); continue; }
210
+ errors.push(...checkDocument(rel, await readFile(path.join(root, rel), 'utf8'), contract, 'en'));
211
+ checked += 1;
212
+ }
213
+ }
214
+ return { errors, checked, templates: templates.length + kinds.size };
215
+ }
216
+
217
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
218
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
219
+ const { errors, checked, templates } = await validateTree(root);
220
+ if (errors.length > 0) {
221
+ process.stderr.write(`${errors.join('\n')}\n`);
222
+ process.exitCode = 1;
223
+ } else {
224
+ process.stdout.write(`templates enforced: ${templates} templates, ${checked} documents\n`);
225
+ }
226
+ }
@@ -0,0 +1,144 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+ import { validateTree, checkDocument } from './validate-templates.mjs';
7
+
8
+ // A synthetic tree: one template of each shape the validator understands, and documents that
9
+ // satisfy or break it in exactly one way each.
10
+ const contractOf = (kind, contract) => `# ${kind}\n\n\`\`\`json template-contract\n${JSON.stringify({ kind, ...contract }, null, 2)}\n\`\`\`\n`;
11
+
12
+ const opContract = {
13
+ applies: ['ops/*/execute.md'],
14
+ title: { en: '^# Execute `[a-z.]+`$', vi: '^# Thực thi `[a-z.]+`$' },
15
+ sections: [
16
+ { en: '^## Single job$', vi: '^## Một việc duy nhất$' },
17
+ { free: true },
18
+ { en: '^## Sequence$', vi: '^## Trình tự$', table: { en: '| # | Step | Reads | Writes | Stops with |', vi: '| # | Bước | Đọc | Ghi | Dừng với |' } },
19
+ { en: '^## Mandatory attacks$', vi: '^## Các đòn tấn công bắt buộc$' },
20
+ ],
21
+ rules: null,
22
+ };
23
+ const ruleContract = {
24
+ applies: ['kb/**/*.md'],
25
+ title: { en: '^# .+ proof$', vi: '^# .+ proof$' },
26
+ sections: [],
27
+ rules: {
28
+ heading: '^## [A-Z]+-\\d+ — .+$',
29
+ table: { en: '| Case | When | Observe |', vi: '| Case | Dùng khi | Quan sát |' },
30
+ closing: { en: '^## What this file does not decide$', vi: '^## File này không quyết định$' },
31
+ required: true,
32
+ },
33
+ };
34
+
35
+ const goodExecute = (lang) => (lang === 'en'
36
+ ? '# Execute `x.y`\n\n## Single job\n\ntext\n\n## A law\n\ntext\n\n## Sequence\n\n| # | Step | Reads | Writes | Stops with |\n| --- | --- | --- | --- | --- |\n| 1 | a | b | c | d |\n\n## Mandatory attacks\n\n- one\n'
37
+ : '# Thực thi `x.y`\n\n## Một việc duy nhất\n\ntext\n\n## Một luật\n\ntext\n\n## Trình tự\n\n| # | Bước | Đọc | Ghi | Dừng với |\n| --- | --- | --- | --- | --- |\n| 1 | a | b | c | d |\n\n## Các đòn tấn công bắt buộc\n\n- one\n');
38
+ const goodProof = (lang) => (lang === 'en'
39
+ ? '# Focus proof\n\nintro\n\n## FOCUS-1 — Visible focus\n\nline\n\n| Case | When | Observe |\n| --- | --- | --- |\n| Case 1 | a | b |\n\n## What this file does not decide\n\nlinks\n'
40
+ : '# Focus proof\n\nintro\n\n## FOCUS-1 — Focus nhìn thấy\n\nline\n\n| Case | Dùng khi | Quan sát |\n| --- | --- | --- |\n| Case 1 | a | b |\n\n## File này không quyết định\n\nlinks\n');
41
+
42
+ function tree(mutate = () => {}) {
43
+ const root = mkdtempSync(join(tmpdir(), 'templates-'));
44
+ mkdirSync(join(root, 'templates'));
45
+ mkdirSync(join(root, 'ops', 'one'), { recursive: true });
46
+ mkdirSync(join(root, 'kb', 'proof'), { recursive: true });
47
+ writeFileSync(join(root, 'templates', 'execute.template.md'), contractOf('execute', opContract));
48
+ writeFileSync(join(root, 'templates', 'proof.template.md'), contractOf('proof', ruleContract));
49
+ const files = {
50
+ 'ops/one/execute.md': goodExecute('en'),
51
+ 'ops/one/execute.vi.md': goodExecute('vi'),
52
+ 'kb/proof/focus.md': goodProof('en'),
53
+ 'kb/proof/focus.vi.md': goodProof('vi'),
54
+ 'kb/proof/INDEX.md': '# index\n\nnot a document of a kind\n',
55
+ };
56
+ mutate(files);
57
+ for (const [rel, text] of Object.entries(files)) {
58
+ if (text === null) continue;
59
+ writeFileSync(join(root, rel), text);
60
+ }
61
+ return root;
62
+ }
63
+
64
+ async function run(mutate) {
65
+ const root = tree(mutate);
66
+ try {
67
+ return await validateTree(root);
68
+ } finally {
69
+ rmSync(root, { recursive: true, force: true });
70
+ }
71
+ }
72
+
73
+ test('conforming documents pass, INDEX files are not claimed, and both languages are counted', async () => {
74
+ const result = await run();
75
+ assert.deepEqual(result.errors, []);
76
+ assert.equal(result.checked, 4);
77
+ assert.equal(result.templates, 2);
78
+ });
79
+
80
+ test('a missing required section is named', async () => {
81
+ const result = await run((f) => { f['ops/one/execute.md'] = f['ops/one/execute.md'].replace('## Mandatory attacks\n\n- one\n', ''); });
82
+ assert.ok(result.errors.some((e) => e.includes('ops/one/execute.md') && e.includes('missing section ^## Mandatory attacks$')));
83
+ });
84
+
85
+ test('a section outside a free zone is rejected with its line', async () => {
86
+ const result = await run((f) => { f['ops/one/execute.md'] = f['ops/one/execute.md'].replace('# Execute `x.y`\n\n', '# Execute `x.y`\n\n## Preface\n\ntext\n\n'); });
87
+ assert.ok(result.errors.some((e) => /ops\/one\/execute\.md:3: unexpected section "## Preface"/.test(e)));
88
+ });
89
+
90
+ test('a section table with the wrong header is rejected', async () => {
91
+ const result = await run((f) => { f['ops/one/execute.md'] = f['ops/one/execute.md'].replace('| # | Step | Reads | Writes | Stops with |', '| Step | Notes |').replace('| --- | --- | --- | --- | --- |', '| --- | --- |'); });
92
+ assert.ok(result.errors.some((e) => e.includes('section must open with the table | # | Step | Reads | Writes | Stops with |')));
93
+ });
94
+
95
+ test('a rule with the wrong table, two tables, or no table is rejected', async () => {
96
+ const wrong = await run((f) => { f['kb/proof/focus.md'] = f['kb/proof/focus.md'].replace('| Case | When | Observe |', '| Case | When | Decide |'); });
97
+ assert.ok(wrong.errors.some((e) => e.includes('rule table must be | Case | When | Observe |')));
98
+ const two = await run((f) => { f['kb/proof/focus.md'] = f['kb/proof/focus.md'].replace('| Case 1 | a | b |\n', '| Case 1 | a | b |\n\n| Case | When | Observe |\n| --- | --- | --- |\n| Case 2 | c | d |\n'); });
99
+ assert.ok(two.errors.some((e) => e.includes('exactly one table, found 2')));
100
+ const none = await run((f) => { f['kb/proof/focus.md'] = f['kb/proof/focus.md'].replace('| Case | When | Observe |\n| --- | --- | --- |\n| Case 1 | a | b |\n', ''); });
101
+ assert.ok(none.errors.some((e) => e.includes('exactly one table, found 0')));
102
+ });
103
+
104
+ test('the closing section must come last and a rule file must publish a rule', async () => {
105
+ const notLast = await run((f) => { f['kb/proof/focus.md'] = `${f['kb/proof/focus.md']}\n## Afterword\n\ntext\n`; });
106
+ assert.ok(notLast.errors.some((e) => e.includes('last section must be ^## What this file does not decide$')));
107
+ const noRule = await run((f) => { f['kb/proof/focus.md'] = '# Focus proof\n\nintro\n\n## What this file does not decide\n\nlinks\n'; });
108
+ assert.ok(noRule.errors.some((e) => e.includes('publishes no rule heading')));
109
+ });
110
+
111
+ test('a title outside the pattern is rejected in either language', async () => {
112
+ const result = await run((f) => { f['ops/one/execute.vi.md'] = f['ops/one/execute.vi.md'].replace('# Thực thi `x.y`', '# Execute `x.y`'); });
113
+ assert.ok(result.errors.some((e) => e.includes('ops/one/execute.vi.md:1: title must match')));
114
+ });
115
+
116
+ test('a missing Vietnamese mirror is rejected and the English document is still checked', async () => {
117
+ const result = await run((f) => { f['kb/proof/focus.vi.md'] = null; });
118
+ assert.ok(result.errors.some((e) => e.includes('no Vietnamese mirror kb/proof/focus.vi.md')));
119
+ assert.equal(result.checked, 3);
120
+ });
121
+
122
+ test('row-level shape: minRows, exactRows, rows and cell are enforced, and a kind contract may be single-language', () => {
123
+ const contract = {
124
+ kind: 'critique', applies: [],
125
+ title: '^# critique$',
126
+ sections: [{ en: '^## Attacks$', table: '| Path | Verdict |', exactRows: 2, rows: ['a', 'b'], cell: { Verdict: '^(holds|fails)$' } }],
127
+ rules: null,
128
+ };
129
+ const good = '# critique\n\n## Attacks\n\n| Path | Verdict |\n| --- | --- |\n| `a` | holds |\n| b | fails |\n';
130
+ assert.deepEqual(checkDocument('c.md', good, contract, 'en'), []);
131
+ const tooFew = checkDocument('c.md', good.replace('| b | fails |\n', ''), contract, 'en');
132
+ assert.ok(tooFew.some((e) => e.includes('exactly 2 rows, found 1')) && tooFew.some((e) => e.includes('lacks a row for b')));
133
+ const badCell = checkDocument('c.md', good.replace('| b | fails |', '| b | maybe |'), contract, 'en');
134
+ assert.ok(badCell.some((e) => e.includes('cell Verdict "maybe" does not match')));
135
+ const min = checkDocument('c.md', good, { ...contract, sections: [{ en: '^## Attacks$', table: '| Path | Verdict |', minRows: 3 }] }, 'en');
136
+ assert.ok(min.some((e) => e.includes('at least 3 rows, found 2')));
137
+ });
138
+
139
+ test('a template whose kind does not match its file name is refused', async () => {
140
+ const root = tree();
141
+ writeFileSync(join(root, 'templates', 'other.template.md'), contractOf('mismatch', ruleContract));
142
+ await assert.rejects(() => validateTree(root), /contract\.kind must equal the file name/);
143
+ rmSync(root, { recursive: true, force: true });
144
+ });
@@ -0,0 +1,106 @@
1
+ // A workflow is a pre-composed chain of operators: an ordered list of steps, each step a list of
2
+ // branches that run in parallel. workflows/*.json are examples the entry may reuse when a request
3
+ // matches their `when`; otherwise the entry composes its own chain under the same rules this script
4
+ // enforces: every operator exists; every requirement preset names a declared field; every required
5
+ // Input of a branch is produced by an earlier step; branches of one step share no write alias; a loop
6
+ // goes back to an earlier step and carries a round cap; the chain ends where it says it ends.
7
+ import { readdir, readFile } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import process from 'node:process';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { loadOperatorPackages, cellAliases, kindOf, isYes } from './operator-md.mjs';
12
+ import { loadAliasRegistry, baseOf } from './alias-registry.mjs';
13
+
14
+ const unquote = (s) => String(s ?? '').trim().replace(/^`|`$/g, '');
15
+
16
+ export async function validateWorkflows(root) {
17
+ const errors = [];
18
+ const packages = (await loadOperatorPackages(root)).filter((p) => p.shape === 'v9');
19
+ const aliases = (await loadAliasRegistry(root)).aliases;
20
+ const ops = new Map(packages.map((p) => {
21
+ const op = p.en;
22
+ const writes = new Set();
23
+ for (const s of op.tables.steps?.rows ?? []) for (const a of cellAliases(s.writes)) writes.add(baseOf(aliases, a) ?? a);
24
+ // The checkout roles the operator's required Context binds (@workspaces/fe, @workspaces/be, ...).
25
+ const roles = new Set();
26
+ for (const r of op.tables.context?.rows ?? []) { const a = cellAliases(r.alias)[0]; const m = a && /^@workspaces\/(fe|be)\b/.exec(a); if (m && isYes(r.required)) roles.add(m[1]); }
27
+ return [p.manifest.id, {
28
+ fields: new Set((op.tables.requirements?.rows ?? []).map((r) => unquote(r.field))),
29
+ required: (op.tables.inputs?.rows ?? []).filter((r) => isYes(r.required)).map((r) => kindOf(r.kind)),
30
+ outputs: new Set((op.tables.outputs?.rows ?? []).map((r) => kindOf(r.kind))),
31
+ next: new Set((op.tables.next?.rows ?? []).map((r) => unquote(r.operator))),
32
+ roles,
33
+ writes,
34
+ }];
35
+ }));
36
+ const dir = path.join(root, 'workflows');
37
+ const files = (await readdir(dir)).filter((f) => f.endsWith('.json')).sort();
38
+ const ids = new Set();
39
+ for (const file of files) {
40
+ const rel = `workflows/${file}`;
41
+ let wf; try { wf = JSON.parse(await readFile(path.join(dir, file), 'utf8')); } catch (e) { errors.push(`${rel}: ${e.message}`); continue; }
42
+ if (wf.schemaVersion !== 9) errors.push(`${rel}: schemaVersion must be 9`);
43
+ if (wf.id !== file.replace(/\.json$/, '')) errors.push(`${rel}: id must equal the file name`);
44
+ if (ids.has(wf.id)) errors.push(`${rel}: duplicate id`); ids.add(wf.id);
45
+ if (!wf.when?.en || !wf.when?.vi) errors.push(`${rel}: when.en and when.vi are required`);
46
+ if (!Array.isArray(wf.chain) || wf.chain.length === 0) { errors.push(`${rel}: chain must be a non-empty array of steps`); continue; }
47
+ const produced = new Set();
48
+ const positions = new Map(); // operator -> first step index
49
+ const boundRoles = new Set(); // workspace.bind roles bound by earlier steps
50
+ let previousOps = null;
51
+ wf.chain.forEach((step, n) => {
52
+ // Adjacency: every operator of this step must be a Next of some operator of the previous step,
53
+ // or the same operator re-entered (a resume or a second mode of the same job).
54
+ if (Array.isArray(step) && previousOps) {
55
+ for (const b of step) {
56
+ const allowed = previousOps.some((prev) => prev === b.operator || (ops.get(prev)?.next ?? new Set()).has(b.operator));
57
+ if (!allowed) errors.push(`${rel}: step ${n + 1} runs ${b.operator}, which no Next table of step ${n} (${previousOps.join(', ')}) permits`);
58
+ }
59
+ }
60
+ if (!Array.isArray(step) || step.length === 0) { errors.push(`${rel}: step ${n + 1} must be a non-empty array of branches`); return; }
61
+ if (step.length > 3) errors.push(`${rel}: step ${n + 1} has ${step.length} branches; at most 3 run in parallel`);
62
+ const stepProduces = new Set();
63
+ const seenWrites = new Map();
64
+ step.forEach((b, m) => {
65
+ const at = `${rel}: step ${n + 1} branch ${m + 1}`;
66
+ const op = ops.get(b.operator);
67
+ if (!op) { errors.push(`${at}: unknown operator ${b.operator}`); return; }
68
+ if (!positions.has(b.operator)) positions.set(b.operator, n);
69
+ for (const key of Object.keys(b.requirements ?? {})) if (!op.fields.has(key)) errors.push(`${at}: requirement ${key} is not a field of ${b.operator}`);
70
+ for (const kind of op.required) if (!produced.has(kind)) errors.push(`${at}: ${b.operator} requires input ${kind}, which no earlier step produces`);
71
+ // A required @workspaces/<role> context needs a workspace.bind of that role in an earlier step.
72
+ if (b.operator !== 'workspace.bind') for (const role of op.roles) if (!boundRoles.has(role)) errors.push(`${at}: ${b.operator} requires @workspaces/${role}, which no earlier workspace.bind (role ${role}) bound`);
73
+ if (b.fanout !== undefined && b.fanout !== 'matrix') errors.push(`${at}: fanout must be "matrix"`);
74
+ if (b.maxParallel !== undefined && !(Number.isInteger(b.maxParallel) && b.maxParallel >= 1 && b.maxParallel <= 3)) errors.push(`${at}: maxParallel must be 1..3`);
75
+ for (const w of op.writes) {
76
+ if (seenWrites.has(w)) errors.push(`${at}: ${b.operator} and ${seenWrites.get(w)} both write ${w} in the same step; branches of one step must not share a write alias`);
77
+ seenWrites.set(w, b.operator);
78
+ }
79
+ for (const k of op.outputs) stepProduces.add(k);
80
+ });
81
+ for (const k of stepProduces) produced.add(k);
82
+ for (const b of step) if (b.operator === 'workspace.bind' && b.requirements?.role) boundRoles.add(b.requirements.role);
83
+ previousOps = step.map((b) => b.operator);
84
+ });
85
+ for (const loop of wf.loops ?? []) {
86
+ const from = positions.get(loop.from); const to = positions.get(loop.to);
87
+ if (from === undefined || to === undefined) errors.push(`${rel}: loop names an operator outside the chain (${loop.from} → ${loop.to})`);
88
+ else if (to >= from) errors.push(`${rel}: loop ${loop.from} → ${loop.to} must go back to an earlier step`);
89
+ if (!(Number.isInteger(loop.maxRounds) && loop.maxRounds >= 1)) errors.push(`${rel}: loop ${loop.from} → ${loop.to} needs maxRounds ≥ 1`);
90
+ if (!loop.when) errors.push(`${rel}: loop ${loop.from} → ${loop.to} needs a when`);
91
+ }
92
+ const last = wf.chain[wf.chain.length - 1];
93
+ const lastOps = Array.isArray(last) ? last.map((b) => b.operator) : [];
94
+ if (wf.ends !== 'user' && !lastOps.includes(wf.ends)) errors.push(`${rel}: ends must be "user" or an operator of the last step (${lastOps.join(', ')})`);
95
+ }
96
+ // The README lists every example.
97
+ const readme = await readFile(path.join(dir, 'README.md'), 'utf8');
98
+ for (const id of ids) if (!readme.includes(`\`${id}\``)) errors.push(`workflows/README.md: does not list ${id}`);
99
+ return { errors, count: files.length };
100
+ }
101
+
102
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
103
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
104
+ const { errors, count } = await validateWorkflows(root);
105
+ if (errors.length) { process.stderr.write(`${errors.join('\n')}\n`); process.exitCode = 1; } else process.stdout.write(`workflows closed: ${count} examples\n`);
106
+ }