@canonical/pragma-cli 0.36.0 → 0.38.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 (312) hide show
  1. package/README.md +3 -2
  2. package/bundled-skills/adoption-a1-styles/SKILL.md +24 -0
  3. package/bundled-skills/adoption-a2-components/SKILL.md +22 -6
  4. package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +1397 -327
  5. package/bundled-skills/anatomy-author/SKILL.md +840 -137
  6. package/bundled-skills/design-auditor/SKILL.md +133 -9
  7. package/bundled-skills/implement-component/SKILL.md +192 -23
  8. package/bundled-skills/specify-component/SKILL.md +124 -12
  9. package/bundled-skills/specify-pattern/SKILL.md +47 -8
  10. package/dist/package.json +23 -20
  11. package/dist/pragma.conf.js +963 -40
  12. package/dist/pragma.conf.js.map +1 -1
  13. package/dist/src/capabilities/capabilities/catalog.js +14 -5
  14. package/dist/src/capabilities/capabilities/catalog.js.map +1 -1
  15. package/dist/src/capabilities/capabilities/hints.js +33 -5
  16. package/dist/src/capabilities/capabilities/hints.js.map +1 -1
  17. package/dist/src/capabilities/config/show.render.js +30 -2
  18. package/dist/src/capabilities/config/show.render.js.map +1 -1
  19. package/dist/src/capabilities/create/create.verb.js +2 -1
  20. package/dist/src/capabilities/create/create.verb.js.map +1 -1
  21. package/dist/src/capabilities/create/createSurface.generated.js +23 -1
  22. package/dist/src/capabilities/create/createSurface.generated.js.map +1 -1
  23. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js +11 -6
  24. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -1
  25. package/dist/src/capabilities/doctor/checks/harnessInventory.js +1 -1
  26. package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -1
  27. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js +38 -0
  28. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js.map +1 -0
  29. package/dist/src/capabilities/doctor/checks/targetHealth.js +104 -14
  30. package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
  31. package/dist/src/capabilities/graph/connect.render.js +101 -0
  32. package/dist/src/capabilities/graph/connect.render.js.map +1 -0
  33. package/dist/src/capabilities/graph/connect.types.js +21 -0
  34. package/dist/src/capabilities/graph/connect.types.js.map +1 -0
  35. package/dist/src/capabilities/graph/connect.verb.js +147 -0
  36. package/dist/src/capabilities/graph/connect.verb.js.map +1 -0
  37. package/dist/src/capabilities/graph/index.js +5 -3
  38. package/dist/src/capabilities/graph/index.js.map +1 -1
  39. package/dist/src/capabilities/graph/queryText.js +206 -0
  40. package/dist/src/capabilities/graph/queryText.js.map +1 -0
  41. package/dist/src/capabilities/graph/relationIndex.js +302 -0
  42. package/dist/src/capabilities/graph/relationIndex.js.map +1 -0
  43. package/dist/src/capabilities/graph/runConnect.js +456 -0
  44. package/dist/src/capabilities/graph/runConnect.js.map +1 -0
  45. package/dist/src/capabilities/graph/runQuery.js +21 -4
  46. package/dist/src/capabilities/graph/runQuery.js.map +1 -1
  47. package/dist/src/capabilities/setup/buildPlan.js +6 -2
  48. package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
  49. package/dist/src/capabilities/setup/operations/index.js +7 -2
  50. package/dist/src/capabilities/setup/operations/index.js.map +1 -1
  51. package/dist/src/capabilities/setup/operations/setupCompletions.js +6 -2
  52. package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -1
  53. package/dist/src/capabilities/setup/operations/setupConfig.js +4 -2
  54. package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -1
  55. package/dist/src/capabilities/setup/operations/setupGenerator.js +72 -14
  56. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  57. package/dist/src/capabilities/setup/operations/setupLsp.js +372 -62
  58. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  59. package/dist/src/capabilities/setup/operations/setupMcp.js +150 -5
  60. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  61. package/dist/src/capabilities/setup/operations/setupSkills.js +4 -2
  62. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  63. package/dist/src/capabilities/setup/operations/writability.js +114 -0
  64. package/dist/src/capabilities/setup/operations/writability.js.map +1 -0
  65. package/dist/src/capabilities/setup/plan.js.map +1 -1
  66. package/dist/src/capabilities/setup/setup.verb.js +80 -31
  67. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  68. package/dist/src/capabilities/setup/targets.js +124 -42
  69. package/dist/src/capabilities/setup/targets.js.map +1 -1
  70. package/dist/src/capabilities/shared/index.js +1 -1
  71. package/dist/src/capabilities/shared/index.js.map +1 -1
  72. package/dist/src/constants.js +7 -1
  73. package/dist/src/constants.js.map +1 -1
  74. package/dist/src/kernel/error/renderError.js +3 -2
  75. package/dist/src/kernel/error/renderError.js.map +1 -1
  76. package/dist/src/kernel/error/validOptions.js +41 -0
  77. package/dist/src/kernel/error/validOptions.js.map +1 -0
  78. package/dist/src/kernel/packs/compile.js +90 -6
  79. package/dist/src/kernel/packs/compile.js.map +1 -1
  80. package/dist/src/kernel/packs/cursor.js +135 -0
  81. package/dist/src/kernel/packs/cursor.js.map +1 -0
  82. package/dist/src/kernel/packs/graphql/buildLookupDocument.js +9 -2
  83. package/dist/src/kernel/packs/graphql/buildLookupDocument.js.map +1 -1
  84. package/dist/src/kernel/packs/paging.js +108 -0
  85. package/dist/src/kernel/packs/paging.js.map +1 -0
  86. package/dist/src/kernel/packs/renderPack.js +154 -5
  87. package/dist/src/kernel/packs/renderPack.js.map +1 -1
  88. package/dist/src/kernel/packs/resolveEntity.js +118 -19
  89. package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
  90. package/dist/src/kernel/packs/runBodies.js +156 -18
  91. package/dist/src/kernel/packs/runBodies.js.map +1 -1
  92. package/dist/src/kernel/packs/schema.js +160 -12
  93. package/dist/src/kernel/packs/schema.js.map +1 -1
  94. package/dist/src/kernel/packs/sparql/authorQuery.js +227 -0
  95. package/dist/src/kernel/packs/sparql/authorQuery.js.map +1 -0
  96. package/dist/src/kernel/packs/sparql/buildListQuery.js +251 -0
  97. package/dist/src/kernel/packs/sparql/buildListQuery.js.map +1 -0
  98. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +125 -22
  99. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  100. package/dist/src/kernel/packs/sparql/filterValues.js +145 -0
  101. package/dist/src/kernel/packs/sparql/filterValues.js.map +1 -0
  102. package/dist/src/kernel/packs/sparql/index.js +24 -14
  103. package/dist/src/kernel/packs/sparql/index.js.map +1 -1
  104. package/dist/src/kernel/packs/sparql/runSelect.js +10 -4
  105. package/dist/src/kernel/packs/sparql/runSelect.js.map +1 -1
  106. package/dist/src/kernel/packs/sparql/searchTerm.js +25 -0
  107. package/dist/src/kernel/packs/sparql/searchTerm.js.map +1 -0
  108. package/dist/src/kernel/packs/storyRules.js +151 -0
  109. package/dist/src/kernel/packs/storyRules.js.map +1 -0
  110. package/dist/src/kernel/packs/tierScope.js +310 -0
  111. package/dist/src/kernel/packs/tierScope.js.map +1 -0
  112. package/dist/src/kernel/packs/types.js +80 -0
  113. package/dist/src/kernel/packs/types.js.map +1 -1
  114. package/dist/src/kernel/project/cli/buildProgram.js +10 -1
  115. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
  116. package/dist/src/kernel/project/cli/dispatch.js +104 -3
  117. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  118. package/dist/src/kernel/project/cli/rootHelp.js +8 -5
  119. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  120. package/dist/src/kernel/project/cli/suggestNames.js +31 -7
  121. package/dist/src/kernel/project/cli/suggestNames.js.map +1 -1
  122. package/dist/src/kernel/project/mcp/registerVerb.js +5 -1
  123. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
  124. package/dist/src/kernel/render/renderers.js +34 -2
  125. package/dist/src/kernel/render/renderers.js.map +1 -1
  126. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js +2 -3
  127. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -1
  128. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +1 -1
  129. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -1
  130. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js +4 -0
  131. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js.map +1 -0
  132. package/dist/src/kernel/runtime/graphpack/embedded.js +13 -6
  133. package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -1
  134. package/dist/src/kernel/runtime/graphpack/read.js +17 -10
  135. package/dist/src/kernel/runtime/graphpack/read.js.map +1 -1
  136. package/dist/src/kernel/runtime/loadSession.js +3 -1
  137. package/dist/src/kernel/runtime/loadSession.js.map +1 -1
  138. package/dist/src/kernel/runtime/probe.js +1 -1
  139. package/dist/src/kernel/runtime/probe.js.map +1 -1
  140. package/dist/src/kernel/runtime/readEntity.js +19 -4
  141. package/dist/src/kernel/runtime/readEntity.js.map +1 -1
  142. package/dist/src/kernel/runtime/refs/parseRef.js +17 -1
  143. package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -1
  144. package/dist/src/kernel/runtime/refs/resolve.js +38 -5
  145. package/dist/src/kernel/runtime/refs/resolve.js.map +1 -1
  146. package/dist/src/kernel/spec/emitReference.js +1 -1
  147. package/dist/src/kernel/spec/emitReference.js.map +1 -1
  148. package/package.json +23 -20
  149. package/pragma.conf.ts +983 -40
  150. package/src/capabilities/block.parity.test.ts +207 -27
  151. package/src/capabilities/block.shipped.exec.test.ts +156 -14
  152. package/src/capabilities/block.tierRank.exec.test.ts +46 -9
  153. package/src/capabilities/capabilities/catalog.ts +14 -5
  154. package/src/capabilities/capabilities/hints.ts +40 -5
  155. package/src/capabilities/config/collectConfigShow.test.ts +1 -0
  156. package/src/capabilities/config/set.test.ts +24 -25
  157. package/src/capabilities/config/show.render.test.ts +9 -4
  158. package/src/capabilities/config/show.render.ts +30 -2
  159. package/src/capabilities/create/byteEquality.test.ts +28 -1
  160. package/src/capabilities/create/create.test.ts +1 -0
  161. package/src/capabilities/create/create.verb.ts +2 -1
  162. package/src/capabilities/create/createGrammar.test.ts +4 -1
  163. package/src/capabilities/create/createSurface.generated.ts +23 -1
  164. package/src/capabilities/create/crossCli.subprocess.test.ts +10 -3
  165. package/src/capabilities/distribution.parity.test.ts +12 -11
  166. package/src/capabilities/distribution.test.ts +77 -7
  167. package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
  168. package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
  169. package/src/capabilities/doctor/checks/harnessInventory.test.ts +13 -6
  170. package/src/capabilities/doctor/checks/harnessInventory.ts +8 -4
  171. package/src/capabilities/doctor/checks/isSupportedNodeVersion.test.ts +111 -0
  172. package/src/capabilities/doctor/checks/isSupportedNodeVersion.ts +34 -0
  173. package/src/capabilities/doctor/checks/mcpCommand.test.ts +7 -1
  174. package/src/capabilities/doctor/checks/targetHealth.ts +114 -13
  175. package/src/capabilities/doctor/doctor.test.ts +327 -36
  176. package/src/capabilities/emptyRecovery.test.ts +84 -28
  177. package/src/capabilities/graph/connect.render.ts +141 -0
  178. package/src/capabilities/graph/connect.shipped.exec.test.ts +370 -0
  179. package/src/capabilities/graph/connect.test.ts +361 -0
  180. package/src/capabilities/graph/connect.types.ts +136 -0
  181. package/src/capabilities/graph/connect.verb.ts +165 -0
  182. package/src/capabilities/graph/index.ts +5 -3
  183. package/src/capabilities/graph/query.shipped.exec.test.ts +66 -0
  184. package/src/capabilities/graph/query.test.ts +17 -0
  185. package/src/capabilities/graph/queryText.test.ts +121 -0
  186. package/src/capabilities/graph/queryText.ts +240 -0
  187. package/src/capabilities/graph/relationIndex.ts +372 -0
  188. package/src/capabilities/graph/runConnect.ts +577 -0
  189. package/src/capabilities/graph/runQuery.ts +21 -6
  190. package/src/capabilities/lazy.test.ts +32 -2
  191. package/src/capabilities/listBudget.shipped.exec.test.ts +239 -0
  192. package/src/capabilities/listLookup.shipped.exec.test.ts +387 -0
  193. package/src/capabilities/listQuery.shipped.exec.test.ts +400 -0
  194. package/src/capabilities/resources/resources.test.ts +38 -2
  195. package/src/capabilities/setup/buildPlan.ts +7 -1
  196. package/src/capabilities/setup/operations/index.ts +14 -0
  197. package/src/capabilities/setup/operations/setupCompletions.ts +9 -2
  198. package/src/capabilities/setup/operations/setupConfig.ts +7 -2
  199. package/src/capabilities/setup/operations/setupGenerator.ts +93 -15
  200. package/src/capabilities/setup/operations/setupLsp.ts +544 -85
  201. package/src/capabilities/setup/operations/setupMcp.ts +212 -4
  202. package/src/capabilities/setup/operations/setupSkills.ts +7 -2
  203. package/src/capabilities/setup/operations/writability.test.ts +151 -0
  204. package/src/capabilities/setup/operations/writability.ts +135 -0
  205. package/src/capabilities/setup/plan.ts +11 -0
  206. package/src/capabilities/setup/setup.test.ts +992 -16
  207. package/src/capabilities/setup/setup.verb.ts +84 -36
  208. package/src/capabilities/setup/targets.ts +152 -48
  209. package/src/capabilities/setup/types.ts +41 -5
  210. package/src/capabilities/shared/index.ts +1 -0
  211. package/src/capabilities/sources/sources.test.ts +55 -0
  212. package/src/capabilities/surface.test.ts +59 -11
  213. package/src/capabilities/tier.parity.test.ts +2 -2
  214. package/src/capabilities/token.parity.test.ts +784 -0
  215. package/src/capabilities/tokenGraph.cli.test.ts +279 -0
  216. package/src/capabilities/tokenGraph.shipped.exec.test.ts +653 -0
  217. package/src/capabilities/variable.parity.test.ts +448 -0
  218. package/src/constants.ts +8 -0
  219. package/src/kernel/completion/shellDrive.test.ts +32 -30
  220. package/src/kernel/config/defaults.test.ts +21 -2
  221. package/src/kernel/config/readConfig.test.ts +1 -1
  222. package/src/kernel/error/renderError.ts +3 -2
  223. package/src/kernel/error/validOptions.test.ts +57 -0
  224. package/src/kernel/error/validOptions.ts +45 -0
  225. package/src/kernel/packs/collect.test.ts +2 -0
  226. package/src/kernel/packs/compile.test.ts +188 -15
  227. package/src/kernel/packs/compile.ts +109 -14
  228. package/src/kernel/packs/cursor.ts +135 -0
  229. package/src/kernel/packs/filteredEmpty.test.ts +213 -0
  230. package/src/kernel/packs/graphql/buildLookupDocument.ts +16 -9
  231. package/src/kernel/packs/graphqlSource.test.ts +152 -0
  232. package/src/kernel/packs/listNotice.test.ts +156 -0
  233. package/src/kernel/packs/paging.ts +110 -0
  234. package/src/kernel/packs/renderPack.ts +186 -13
  235. package/src/kernel/packs/resolveEntity.test.ts +62 -3
  236. package/src/kernel/packs/resolveEntity.ts +186 -26
  237. package/src/kernel/packs/runBodies.ts +196 -26
  238. package/src/kernel/packs/schema.ts +180 -13
  239. package/src/kernel/packs/sparql/authorQuery.test.ts +179 -0
  240. package/src/kernel/packs/sparql/authorQuery.ts +257 -0
  241. package/src/kernel/packs/sparql/buildListQuery.test.ts +344 -0
  242. package/src/kernel/packs/sparql/buildListQuery.ts +344 -0
  243. package/src/kernel/packs/sparql/buildLookupQuery.test.ts +138 -0
  244. package/src/kernel/packs/sparql/buildLookupQuery.ts +140 -20
  245. package/src/kernel/packs/sparql/filterValues.test.ts +86 -0
  246. package/src/kernel/packs/sparql/filterValues.ts +182 -0
  247. package/src/kernel/packs/sparql/index.ts +30 -14
  248. package/src/kernel/packs/sparql/runSelect.test.ts +63 -0
  249. package/src/kernel/packs/sparql/runSelect.ts +42 -2
  250. package/src/kernel/packs/sparql/searchTerm.ts +29 -0
  251. package/src/kernel/packs/storyRules.ts +180 -0
  252. package/src/kernel/packs/tierScope.test.ts +487 -0
  253. package/src/kernel/packs/tierScope.ts +409 -0
  254. package/src/kernel/packs/toolDescription.test.ts +13 -10
  255. package/src/kernel/packs/types.ts +311 -9
  256. package/src/kernel/project/cli/buildProgram.ts +17 -1
  257. package/src/kernel/project/cli/dispatch.test.ts +87 -0
  258. package/src/kernel/project/cli/dispatch.ts +114 -4
  259. package/src/kernel/project/cli/rootHelp.ts +8 -5
  260. package/src/kernel/project/cli/suggest.test.ts +35 -0
  261. package/src/kernel/project/cli/suggestNames.ts +34 -7
  262. package/src/kernel/project/mcp/registerVerb.ts +5 -1
  263. package/src/kernel/render/contracts.ts +2 -0
  264. package/src/kernel/render/prefixes.test.ts +2 -0
  265. package/src/kernel/render/renderers.test.ts +43 -0
  266. package/src/kernel/render/renderers.ts +34 -1
  267. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +2 -3
  268. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
  269. package/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.ts +3 -0
  270. package/src/kernel/runtime/graphpack/embedded.ts +14 -6
  271. package/src/kernel/runtime/graphpack/graphpack.test.ts +31 -5
  272. package/src/kernel/runtime/graphpack/read.ts +17 -13
  273. package/src/kernel/runtime/loadSession.ts +3 -1
  274. package/src/kernel/runtime/probe.ts +1 -1
  275. package/src/kernel/runtime/readEntity.ts +19 -4
  276. package/src/kernel/runtime/refs/parseRef.test.ts +49 -1
  277. package/src/kernel/runtime/refs/parseRef.ts +25 -1
  278. package/src/kernel/runtime/refs/resolve.ts +39 -5
  279. package/src/kernel/runtime/types.ts +30 -0
  280. package/src/kernel/spec/emitReference.ts +1 -1
  281. package/src/kernel/spec/surfaceConformance.test.ts +2 -2
  282. package/src/kernel/spec/types.ts +17 -0
  283. package/src/testing/PARITY_GAPS.ts +4 -4
  284. package/src/testing/behavioral/agentSession.mcp.test.ts +43 -39
  285. package/src/testing/behavioral/completion.test.ts +7 -6
  286. package/src/testing/behavioral/disclosure.test.ts +39 -37
  287. package/src/testing/behavioral/errorMatrix.mcp.test.ts +82 -64
  288. package/src/testing/behavioral/errorMatrix.storeless.test.ts +11 -10
  289. package/src/testing/behavioral/journeys.cli.test.ts +71 -32
  290. package/src/testing/behavioral/journeys.defaultPack.test.ts +6 -4
  291. package/src/testing/behavioral/journeys.firstInstall.test.ts +70 -5
  292. package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +4 -3
  293. package/src/testing/behavioral/journeys.packageStories.test.ts +2 -1
  294. package/src/testing/behavioral/mcpSurface.test.ts +28 -25
  295. package/src/testing/behavioral/parity.test.ts +25 -23
  296. package/src/testing/eval/__snapshots__/eval.test.ts.snap +20 -0
  297. package/src/testing/eval/cases/effectful.ts +7 -1
  298. package/src/testing/eval/cases/readNouns.ts +100 -11
  299. package/src/testing/fixtures/blockGraph.ts +89 -5
  300. package/src/testing/fixtures/connectGraph.ts +159 -0
  301. package/src/testing/fixtures/graph/canonical.ts +128 -32
  302. package/src/testing/helpers/fsProbe.ts +88 -0
  303. package/src/testing/helpers/runCli.test.ts +44 -0
  304. package/src/testing/helpers/runCli.ts +27 -3
  305. package/src/testing/perf/budgets.test.ts +3 -1
  306. package/src/testing/perf/budgets.ts +138 -54
  307. package/dist/src/kernel/packs/sparql/applyFilters.js +0 -167
  308. package/dist/src/kernel/packs/sparql/applyFilters.js.map +0 -1
  309. package/dist/src/kernel/packs/sparql/applySearch.js +0 -25
  310. package/dist/src/kernel/packs/sparql/applySearch.js.map +0 -1
  311. package/src/kernel/packs/sparql/applyFilters.ts +0 -203
  312. package/src/kernel/packs/sparql/applySearch.ts +0 -33
@@ -0,0 +1,159 @@
1
+ /**
2
+ * A fixture graph authored to exercise `graph connect`'s traversal POLICY and
3
+ * every one of its five negative answers.
4
+ *
5
+ * This is where exact numbers belong. The shipped pack is about to change —
6
+ * new channel symbols and hundreds of binding records will move every hop count
7
+ * and path set in it — so a test that pinned "these two are exactly 2 hops
8
+ * apart" against real data would fail on a correct change and teach a
9
+ * maintainer to update the fixture rather than to think. Here the data is
10
+ * authored, so the numbers ARE the specification: this graph exists to make one
11
+ * statement per shape, and if a count moves, the behaviour moved.
12
+ *
13
+ * The roster sizes are GENERATED from the kernel's own threshold rather than
14
+ * written out, for the reason `blockGraph.ts` gives for the same move: the count
15
+ * is the whole point of the case, and a hand-copied number invites someone to
16
+ * tidy one line away and quietly turn a roster back into a relation. They are
17
+ * also derived from the constant rather than a copy of it, so a change to the
18
+ * threshold moves this fixture with it.
19
+ *
20
+ * What each region is for:
21
+ *
22
+ * - **the chain** `ds:a1 … ds:a8`, one predicate, one edge per node: hop
23
+ * counting, the default limit, the `--hops` ceiling, and the "no path within
24
+ * the limit" answer. Deliberately UNTYPED — a class with eight members is a
25
+ * relation under the fan-in rule, so typing them would connect every pair of
26
+ * them through their class in two hops and there would be no unconnected pair
27
+ * left to test.
28
+ * - **the reversed pair** `ds:r1 … ds:r3`: a path that has to be walked against
29
+ * the direction of one of its edges.
30
+ * - **the fan of three routes** `ds:m1 … ds:m2`: several shortest paths, both
31
+ * ranking tiebreaks, and the path cap.
32
+ * - **`ds:shares` twice over**: ONE predicate carrying a roster group and a
33
+ * relation group, which is what a rule keyed on a predicate NAME could never
34
+ * get right.
35
+ * - **`ds:fansTo`**: the other direction of the same rule — one subject fanning
36
+ * out past the threshold.
37
+ * - **`ds:isolated`**: in the graph, but with nothing walkable on it at all.
38
+ * - **the blank-node bridge**: a path THROUGH a record, which is the traversal
39
+ * a per-layer SPARQL query cannot do because a blank node cannot be re-bound
40
+ * by name.
41
+ */
42
+
43
+ import { ROSTER_THRESHOLD } from "../../kernel/runtime/readEntity.js";
44
+
45
+ /** The prefixes the fixture store is built and queried with. */
46
+ export const CONNECT_PREFIXES: Readonly<Record<string, string>> = {
47
+ ds: "https://ds.canonical.com/",
48
+ owl: "http://www.w3.org/2002/07/owl#",
49
+ rdfs: "http://www.w3.org/2000/01/rdf-schema#",
50
+ xsd: "http://www.w3.org/2001/XMLSchema#",
51
+ };
52
+
53
+ /** How long the hop-counting chain is — long enough to outrun the ceiling. */
54
+ export const CHAIN_LENGTH = 8;
55
+
56
+ /**
57
+ * How many members a group needs to be a roster: one clear of the threshold,
58
+ * plus one so that removing a line does not silently reclassify the group.
59
+ */
60
+ export const ROSTER_SIZE = ROSTER_THRESHOLD + 2;
61
+
62
+ /**
63
+ * The true size of the `(ds:shares, ds:hub)` group: the generated members plus
64
+ * `ds:isolated`, which shares the hub precisely so that it has nothing walkable
65
+ * of its own. Stated rather than left to be recounted, because it is the number
66
+ * the answer reports.
67
+ */
68
+ export const HUB_ROSTER_SIZE = ROSTER_SIZE + 1;
69
+
70
+ /** `ds:a1 -> ds:a2 -> … -> ds:a8`, one admitted edge per link. */
71
+ const CHAIN = Array.from(
72
+ { length: CHAIN_LENGTH - 1 },
73
+ (_, index) => `ds:a${index + 1} ds:linksTo ds:a${index + 2} .`,
74
+ ).join("\n");
75
+
76
+ /**
77
+ * `ROSTER_SIZE` subjects all asserting `ds:shares ds:hub` — so the group
78
+ * `(ds:shares, ds:hub)` fans in past the threshold and every one of those edges
79
+ * is refused. Each member also holds ONE private relation, so it is still a
80
+ * node the walk can start from: an endpoint with no relation at all is a
81
+ * different answer, and `ds:isolated` is where that one is tested.
82
+ */
83
+ const ROSTER = Array.from(
84
+ { length: ROSTER_SIZE },
85
+ (_, index) =>
86
+ `ds:member${index} a ds:Probe ; ds:shares ds:hub ; ds:owns ds:private${index} .`,
87
+ ).join("\n");
88
+
89
+ /**
90
+ * One subject asserting `ds:fansTo` at `ROSTER_SIZE` objects — the OTHER
91
+ * direction of the same rule. Its objects share a roster; none of the edges is
92
+ * walkable. Each object holds one private relation, for the same reason the
93
+ * roster members do.
94
+ */
95
+ const FAN = `ds:fanHub ${Array.from(
96
+ { length: ROSTER_SIZE },
97
+ (_, index) => `ds:fansTo ds:leaf${index}`,
98
+ ).join(" ; ")} .
99
+ ${Array.from(
100
+ { length: ROSTER_SIZE },
101
+ (_, index) => `ds:leaf${index} ds:owns ds:leafPrivate${index} .`,
102
+ ).join("\n")}`;
103
+
104
+ /** The fixture ontology + individuals as Turtle. */
105
+ export const CONNECT_TTL = `
106
+ @prefix ds: <https://ds.canonical.com/> .
107
+ @prefix owl: <http://www.w3.org/2002/07/owl#> .
108
+ @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
109
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
110
+
111
+ # ---- Ontology (TBox) ----
112
+ ds:Probe a owl:Class ; rdfs:label "Probe" .
113
+ ds:Record a owl:Class ; rdfs:label "Record" .
114
+
115
+ ds:linksTo a owl:ObjectProperty ; rdfs:label "linksTo" .
116
+ ds:pointsAt a owl:ObjectProperty ; rdfs:label "pointsAt" .
117
+ ds:alpha a owl:ObjectProperty ; rdfs:label "alpha" .
118
+ ds:beta a owl:ObjectProperty ; rdfs:label "beta" .
119
+ ds:gamma a owl:ObjectProperty ; rdfs:label "gamma" .
120
+ ds:shares a owl:ObjectProperty ; rdfs:label "shares" .
121
+ ds:fansTo a owl:ObjectProperty ; rdfs:label "fansTo" .
122
+ ds:owns a owl:ObjectProperty ; rdfs:label "owns" .
123
+ ds:holds a owl:ObjectProperty ; rdfs:label "holds" .
124
+ ds:refers a owl:ObjectProperty ; rdfs:label "refers" .
125
+
126
+ # ---- The chain: hop counting, the limit, and the ceiling ----
127
+ ${CHAIN}
128
+
129
+ # ---- The reversed pair: r1 <-pointsAt- r2 -pointsAt-> r3 ----
130
+ ds:r2 ds:pointsAt ds:r1 , ds:r3 .
131
+
132
+ # ---- Three shortest routes from m1 to m2, for ranking and the path cap ----
133
+ # via1 stays inside ds:alpha; via3 stays inside ds:gamma; via2 mixes the two.
134
+ # So the ranking's second key (fewest distinct predicates) puts via1 and via3
135
+ # ahead of via2, and its third key (lexicographic) settles alpha before gamma.
136
+ ds:m1 ds:alpha ds:via1 , ds:via2 .
137
+ ds:via1 ds:alpha ds:m2 .
138
+ ds:via2 ds:beta ds:m2 .
139
+ ds:m1 ds:gamma ds:via3 .
140
+ ds:via3 ds:gamma ds:m2 .
141
+
142
+ # ---- ds:shares, as a ROSTER: ${ROSTER_SIZE} subjects on one object ----
143
+ ${ROSTER}
144
+
145
+ # ---- ds:shares, as a RELATION: two subjects on one object ----
146
+ # The SAME predicate as the roster above. Nothing about the name distinguishes
147
+ # these edges; only the fan-in does.
148
+ ds:relA a ds:Probe ; ds:shares ds:small .
149
+ ds:relB a ds:Probe ; ds:shares ds:small .
150
+
151
+ # ---- ds:fansTo: one subject past the threshold, the other direction ----
152
+ ${FAN}
153
+
154
+ # ---- In the graph, but nothing walkable on it ----
155
+ ds:isolated a ds:Probe ; ds:shares ds:hub .
156
+
157
+ # ---- A path THROUGH a blank-node record ----
158
+ ds:bridgeStart ds:holds [ a ds:Record ; ds:refers ds:bridgeEnd ] .
159
+ `;
@@ -29,7 +29,7 @@
29
29
  *
30
30
  * Two of those anchors exist to pin an ABSENCE, and are load-bearing for the
31
31
  * "declared, not inferred" halves of the read grammar:
32
- * - `ds:token.legacy.borderRadius` — a `ds:Token` with no `ds:tokenId`, which
32
+ * - `dt:legacy.borderRadius` — a `dt:TokenSymbol` with no `rdfs:label`, which
33
33
  * `token list` therefore never publishes and `token lookup`/`token sample`
34
34
  * must never reach (`PackLookup.nameFallback` is declared by `standard`
35
35
  * alone).
@@ -42,6 +42,10 @@ import { BLOCK_PREFIXES, BLOCK_TTL } from "../blockGraph.js";
42
42
 
43
43
  /** Extra `ds:` individuals: a tier-chain + a beta-only block, tokens, tiers. */
44
44
  const DS_EXTRA_TTL = `
45
+ @prefix dt: <https://dt.canonical.com/> .
46
+ @prefix w3c-tokens: <https://dt.canonical.com/w3c-tokens/> .
47
+ @prefix dt-web: <https://dt.canonical.com/platform/web/> .
48
+ @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
45
49
  # ---- Tiers beyond the global BLOCK_TTL already declares ----
46
50
  ds:apps a ds:Tier ; ds:name "apps" .
47
51
  ds:apps_lxd a ds:Tier ; ds:name "apps/lxd" .
@@ -66,37 +70,125 @@ ds:betaWidget a ds:Component ;
66
70
  ds:summary "An experimental widget gated to the prerelease channel." .
67
71
 
68
72
  # ---- Tokens ----
69
- ds:Token a owl:Class .
70
- ds:TokenType a owl:Class .
71
- ds:tokenId a owl:DatatypeProperty ; rdfs:domain ds:Token ; rdfs:range xsd:string .
72
- ds:tokenType a owl:ObjectProperty ; rdfs:domain ds:Token ; rdfs:range ds:TokenType .
73
- ds:valueLight a owl:DatatypeProperty ; rdfs:domain ds:Token ; rdfs:range xsd:string .
74
- ds:valueDark a owl:DatatypeProperty ; rdfs:domain ds:Token ; rdfs:range xsd:string .
75
-
76
- ds:type.color a ds:TokenType ; rdfs:label "color" .
77
- ds:type.spacing a ds:TokenType ; rdfs:label "spacing" .
78
-
79
- ds:token.color.primary a ds:Token ;
80
- ds:tokenId "color.primary" ;
81
- ds:tokenType ds:type.color ;
82
- ds:valueLight "#0066CC" ;
83
- ds:valueDark "#4D94FF" .
84
- ds:token.spacing.medium a ds:Token ;
85
- ds:tokenId "spacing.medium" ;
86
- ds:tokenType ds:type.spacing ;
87
- ds:valueLight "16px" ;
88
- ds:valueDark "16px" .
89
-
90
- # A ds:Token carrying NO ds:tokenId — the entity "token list" does not publish,
91
- # because its query REQUIRES the id. It is here to pin what a lookup may NOT
92
- # reach: "token lookup" addresses by ds:tokenId, and a story whose list requires
93
- # its "by" property must not become addressable (or sampleable) under a name
94
- # derived from its IRI. See PackLookup.nameFallback, declared by the "standard"
95
- # story alone — the one story whose list DOES publish such a name.
96
- ds:token.legacy.borderRadius a ds:Token ;
97
- ds:tokenType ds:type.spacing ;
98
- ds:valueLight "4px" ;
99
- ds:valueDark "4px" .
73
+ # The token STRATA, not the retired ds:Token class: the story addresses
74
+ # dt:TokenSymbol, keyed on the rdfs:label the token-ontology pack publishes,
75
+ # with type and description hanging on the DEFINITIONS behind each symbol
76
+ # rather than on the symbol itself. The story reaches the one definition that
77
+ # speaks for the symbol through the symbol's BASE resolved value — the head of
78
+ # its resolution chain — so every labelled symbol here carries one, which is
79
+ # the shipped graph's own shape.
80
+ dt:TokenSymbol a owl:Class .
81
+ w3c-tokens:Token a owl:Class .
82
+ w3c-tokens:TokenType a owl:Class .
83
+ dt:symbol a owl:ObjectProperty ; rdfs:range dt:TokenSymbol .
84
+ dt:tokenType a owl:ObjectProperty ; rdfs:range w3c-tokens:TokenType .
85
+ dt:channelOf a owl:ObjectProperty ; rdfs:range dt:TokenSymbol .
86
+ dt:covers a owl:ObjectProperty ; rdfs:range dt:TokenSymbol .
87
+ dt:forSymbol a owl:ObjectProperty ; rdfs:range dt:TokenSymbol .
88
+ dt:resolvesTo a owl:DatatypeProperty ; rdfs:range xsd:string .
89
+ dt:resolutionChain a owl:ObjectProperty .
90
+ dt:derivedFrom a owl:ObjectProperty ; rdfs:range dt:TokenSymbol .
91
+ dt:coordinate a owl:ObjectProperty .
92
+ w3c-tokens:description a owl:DatatypeProperty ; rdfs:range xsd:string .
93
+ w3c-tokens:inFile a owl:ObjectProperty .
94
+ w3c-tokens:path a owl:DatatypeProperty ; rdfs:range xsd:string .
95
+
96
+ w3c-tokens:color a w3c-tokens:TokenType ; rdfs:label "color" .
97
+ w3c-tokens:dimension a w3c-tokens:TokenType ; rdfs:label "dimension" .
98
+
99
+ dt:color.primary a dt:TokenSymbol ; rdfs:label "color.primary" .
100
+ dt:spacing.medium a dt:TokenSymbol ; rdfs:label "spacing.medium" .
101
+
102
+ <https://dt.canonical.com/file/light.json#color.primary> a w3c-tokens:Token ;
103
+ dt:symbol dt:color.primary ;
104
+ dt:tokenType w3c-tokens:color ;
105
+ w3c-tokens:inFile <https://dt.canonical.com/file/light.json> ;
106
+ w3c-tokens:description "The primary brand colour." .
107
+ <https://dt.canonical.com/file/light.json#spacing.medium> a w3c-tokens:Token ;
108
+ dt:symbol dt:spacing.medium ;
109
+ dt:tokenType w3c-tokens:dimension ;
110
+ w3c-tokens:inFile <https://dt.canonical.com/file/light.json> ;
111
+ w3c-tokens:description "A medium spacing step." .
112
+ <https://dt.canonical.com/file/light.json> w3c-tokens:path "light.json" .
113
+
114
+ [] a dt:ResolvedValue ; dt:forSymbol dt:color.primary ;
115
+ dt:resolvesTo "#0066CC" ;
116
+ dt:resolutionChain ( <https://dt.canonical.com/file/light.json#color.primary> ) .
117
+ [] a dt:ResolvedValue ; dt:forSymbol dt:spacing.medium ;
118
+ dt:resolvesTo "1rem" ;
119
+ dt:resolutionChain ( <https://dt.canonical.com/file/light.json#spacing.medium> ) .
120
+
121
+ # ---- Platform variables ----
122
+ # The second stratum-shaped noun needs a population here for the same reason the
123
+ # symbols do: the cross-noun sweeps derive their cases from the declared stories,
124
+ # so a noun with no rows in this graph makes every filter's roster empty and
125
+ # every refusal unable to name what it would accept.
126
+ dt:Variable a owl:Class .
127
+ dt:Declaration a owl:Class .
128
+ dt:Condition a owl:Class .
129
+ dt:Coordinate a owl:Class .
130
+ dt:Tier a owl:Class .
131
+ dt:Visibility a owl:Class .
132
+ dt:ofSymbol a owl:ObjectProperty ; rdfs:range dt:TokenSymbol .
133
+ dt:tier a owl:ObjectProperty ; rdfs:range dt:Tier .
134
+ dt:visibility a owl:ObjectProperty ; rdfs:range dt:Visibility .
135
+ dt:declaredAt a owl:ObjectProperty ; rdfs:range dt:Declaration .
136
+ dt:under a owl:ObjectProperty ; rdfs:range dt:Condition .
137
+ dt:emits a owl:DatatypeProperty ; rdfs:range xsd:string .
138
+ dt:at a owl:DatatypeProperty ; rdfs:range xsd:string .
139
+ dt:alsoAt a owl:ObjectProperty ; rdfs:range dt:Coordinate .
140
+ dt:derives a owl:ObjectProperty .
141
+ dt:references a owl:ObjectProperty .
142
+ dt:selectsCoordinate a owl:ObjectProperty ; rdfs:range dt:Coordinate .
143
+ dt-web:selector a owl:DatatypeProperty ; rdfs:range xsd:string .
144
+ dt-web:inAtRule a owl:ObjectProperty .
145
+
146
+ dt:tier.semantic a dt:Tier .
147
+ dt:tier.primitive a dt:Tier .
148
+ dt:visibility.public a dt:Visibility .
149
+ dt:visibility.internal a dt:Visibility .
150
+ dt:coordinate.mode.dark a dt:Coordinate .
151
+
152
+ <https://dt.canonical.com/s4/web/cond/root> a dt:Condition ;
153
+ dt-web:selector ":root" ;
154
+ dt-web:inAtRule ( "@layer ds.tokens" ) .
155
+
156
+ # Published WITHOUT its leading dashes, which is the only form typable as a
157
+ # positional argument.
158
+ <https://dt.canonical.com/s4/web/--color-primary> a dt:Variable ;
159
+ rdfs:label "color-primary" ;
160
+ dt:ofSymbol dt:color.primary ;
161
+ dt:tier dt:tier.semantic ;
162
+ dt:visibility dt:visibility.public ;
163
+ dt:declaredAt [
164
+ a dt:Declaration ;
165
+ dt:under <https://dt.canonical.com/s4/web/cond/root> ;
166
+ dt:emits "#0066CC" ;
167
+ dt:at "tokens.css:4" ;
168
+ dt:alsoAt dt:coordinate.mode.dark
169
+ ] .
170
+ # Stands for NO symbol — the population the token noun cannot reach.
171
+ <https://dt.canonical.com/s4/web/--legacy-radius> a dt:Variable ;
172
+ rdfs:label "legacy-radius" ;
173
+ dt:tier dt:tier.primitive ;
174
+ dt:visibility dt:visibility.internal ;
175
+ dt:declaredAt [
176
+ a dt:Declaration ;
177
+ dt:under <https://dt.canonical.com/s4/web/cond/root> ;
178
+ dt:emits "var(--color-primary)" ;
179
+ dt:at "tokens.css:9" ;
180
+ dt:references ( <https://dt.canonical.com/s4/web/--color-primary> )
181
+ ] .
182
+
183
+ # A dt:TokenSymbol carrying NO rdfs:label — the entity "token list" does not
184
+ # publish, because its query REQUIRES the literal. It is here to pin what a
185
+ # lookup may NOT reach: "token lookup" addresses by rdfs:label, and a story
186
+ # whose list requires its "by" property must not become addressable (or
187
+ # sampleable) under a name derived from its IRI. See PackLookup.nameFallback,
188
+ # declared by the "standard" story alone — the one story whose list DOES
189
+ # publish such a name. The token noun deliberately declares none, because the
190
+ # kernel's derivation would publish these dots as slashes.
191
+ dt:legacy.borderRadius a dt:TokenSymbol .
100
192
  `;
101
193
 
102
194
  /**
@@ -336,6 +428,10 @@ ds:implementation.svelte-ds-global.button a ds:ImplementationObject ;
336
428
  /** The prefixes the canonical store is built and queried with. */
337
429
  export const CANONICAL_PREFIXES: Readonly<Record<string, string>> = {
338
430
  ...BLOCK_PREFIXES,
431
+ dt: "https://dt.canonical.com/",
432
+ "w3c-tokens": "https://dt.canonical.com/w3c-tokens/",
433
+ "dt-web": "https://dt.canonical.com/platform/web/",
434
+ rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
339
435
  cs: "http://pragma.canonical.com/codestandards#",
340
436
  skos: "http://www.w3.org/2004/02/skos/core#",
341
437
  };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Filesystem fixtures for `probeWritable`'s seam — the two machines no CI host
3
+ * is.
4
+ *
5
+ * `probeWritable` decides whether a row REPORTS a named skip or attempts a
6
+ * write that fails with a raw fs message, and its two blocking answers cannot
7
+ * be arranged for real here: no CI host has a `/nix/store`, and a test process
8
+ * running as root can write to a `0o500` directory whatever its mode bits say.
9
+ * So the filesystem is three injected functions.
10
+ *
11
+ * Two factories, because the suites want different things. {@link storeProbe}
12
+ * keeps the REAL filesystem and rewrites one subtree's realpath into the
13
+ * store, so an end-to-end case can build a genuine project and still be a Nix
14
+ * machine for the one path that matters. {@link fakeFsProbe} invents the
15
+ * filesystem outright and RECORDS every call, so a unit case can assert the
16
+ * walk itself and not only its answer.
17
+ */
18
+
19
+ import { accessSync, existsSync, realpathSync } from "node:fs";
20
+ import type { FsProbe } from "../../capabilities/setup/operations/writability.js";
21
+
22
+ /**
23
+ * A probe over the real filesystem whose `realpathSync` maps everything under
24
+ * `root` into `/nix/store/<hash>-…`.
25
+ *
26
+ * @param root - The subtree that resolves into the store.
27
+ * @param hash - The store path's name, so two suites can tell theirs apart.
28
+ * @returns The probe.
29
+ * @note Impure — `existsSync`/`accessSync` are the real ones.
30
+ */
31
+ export const storeProbe = (root: string, hash = "1a2b3c-vscode"): FsProbe => ({
32
+ existsSync,
33
+ accessSync,
34
+ realpathSync: (path) =>
35
+ path.startsWith(root)
36
+ ? `/nix/store/${hash}${path.slice(root.length)}`
37
+ : realpathSync(path),
38
+ });
39
+
40
+ /** What a {@link fakeFsProbe} filesystem consists of. */
41
+ export interface FakeFsSpec {
42
+ /** The paths that exist. */
43
+ readonly present?: readonly string[];
44
+ /** Each path's realpath, for the ones that resolve elsewhere. */
45
+ readonly realpath?: Readonly<Record<string, string>>;
46
+ /** The RESOLVED paths that refuse `W_OK`. */
47
+ readonly denied?: readonly string[];
48
+ /** Whether `realpathSync` throws (a race, or a symlink loop). */
49
+ readonly realpathThrows?: boolean;
50
+ }
51
+
52
+ /** A {@link fakeFsProbe}, plus the calls it saw. */
53
+ export type RecordingFsProbe = FsProbe & {
54
+ /** Every path `existsSync` was asked about, in order — the walk. */
55
+ readonly seen: string[];
56
+ /** Every path `accessSync` was asked about, in order. */
57
+ readonly accessed: string[];
58
+ };
59
+
60
+ /**
61
+ * An invented filesystem that records what it was asked.
62
+ *
63
+ * @param spec - The filesystem to present.
64
+ * @returns The probe, carrying `seen` and `accessed`.
65
+ * @note Pure — it touches no real path; the arrays are its own state.
66
+ */
67
+ export const fakeFsProbe = (spec: FakeFsSpec): RecordingFsProbe => {
68
+ const seen: string[] = [];
69
+ const accessed: string[] = [];
70
+ return {
71
+ seen,
72
+ accessed,
73
+ existsSync: (path) => {
74
+ seen.push(path);
75
+ return (spec.present ?? []).includes(path);
76
+ },
77
+ realpathSync: (path) => {
78
+ if (spec.realpathThrows === true) throw new Error("ELOOP");
79
+ return spec.realpath?.[path] ?? path;
80
+ },
81
+ accessSync: (path) => {
82
+ accessed.push(path);
83
+ if ((spec.denied ?? []).includes(path)) {
84
+ throw new Error("EACCES: permission denied");
85
+ }
86
+ },
87
+ };
88
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Pins how `runCli` reports a child that does not finish.
3
+ *
4
+ * `spawnSync` reports a timeout the same way it reports a failure to spawn —
5
+ * through `error` — and a caller that does not look will see only
6
+ * `status: null`, which reads downstream as an ordinary non-zero exit or, worse,
7
+ * as a content mismatch. These tests hold the boundary where that distinction
8
+ * is made, because the cost of losing it is paid in diagnosis rather than in a
9
+ * red test.
10
+ */
11
+
12
+ import { describe, expect, it } from "vitest";
13
+ import { runCli } from "./runCli.js";
14
+
15
+ describe("runCli — a child that does not finish", () => {
16
+ it("reports a timeout as a timeout, with the duration", async () => {
17
+ // `--help` on a 1ms budget cannot complete; any real CLI start-up exceeds it.
18
+ await expect(async () =>
19
+ runCli(["--help"], { timeoutMs: 1 }),
20
+ ).rejects.toThrow(/timed out after 1ms/);
21
+ });
22
+
23
+ it("names the signal that killed it, so a hang is distinguishable", async () => {
24
+ await expect(async () =>
25
+ runCli(["--help"], { timeoutMs: 1 }),
26
+ ).rejects.toThrow(/killed by SIG/);
27
+ });
28
+
29
+ it("does not describe a timeout as a failure to spawn", async () => {
30
+ // The previous message said "failed to spawn", which sent a reader looking
31
+ // for a missing binary or a bad path instead of a slow or hung command.
32
+ await expect(async () =>
33
+ runCli(["--help"], { timeoutMs: 1 }),
34
+ ).rejects.not.toThrow(/failed to spawn/);
35
+ });
36
+
37
+ it("attaches whatever the child managed to write", async () => {
38
+ // Empty streams are still reported explicitly: "(empty)" is evidence, an
39
+ // absent section is an omission the reader has to interpret.
40
+ await expect(async () =>
41
+ runCli(["--help"], { timeoutMs: 1 }),
42
+ ).rejects.toThrow(/stdout:[\s\S]*stderr:/);
43
+ });
44
+ });
@@ -46,10 +46,17 @@ export interface RunCliOptions {
46
46
  * test the shipped-entry boundary itself.
47
47
  */
48
48
  readonly mode?: "shipped" | "source";
49
- /** Spawn timeout in milliseconds (default 20000). */
49
+ /** Spawn timeout in milliseconds (default {@link DEFAULT_TIMEOUT_MS}). */
50
50
  readonly timeoutMs?: number;
51
51
  }
52
52
 
53
+ /**
54
+ * How long a spawned CLI may run before it is killed. Twenty seconds is far
55
+ * past any assertion these tests make; a run that reaches it has hung, not
56
+ * been slow.
57
+ */
58
+ const DEFAULT_TIMEOUT_MS = 20_000;
59
+
53
60
  /** The captured outcome of a `runCli` invocation. */
54
61
  export interface RunCliResult {
55
62
  readonly stdout: string;
@@ -114,12 +121,29 @@ export function runCli(
114
121
  cwd: options.cwd,
115
122
  env: { ...env, ...options.env },
116
123
  encoding: "utf-8",
117
- timeout: options.timeoutMs ?? 20_000,
124
+ timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
118
125
  });
119
126
 
120
127
  if (result.error) {
128
+ // A timeout is not a spawn failure, and saying so costs real diagnosis
129
+ // time: `spawnSync` reports both through `error`, but a timed-out child
130
+ // has already produced output worth seeing, and the duration is the whole
131
+ // finding. Separated here so the message names what happened, and carries
132
+ // the evidence rather than making the reader go and get it.
133
+ const timedOutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
134
+ const invocation = `${command} ${spawnArgs.join(" ")}`;
135
+ const captured = [
136
+ result.stdout ? `stdout:\n${result.stdout}` : "stdout: (empty)",
137
+ result.stderr ? `stderr:\n${result.stderr}` : "stderr: (empty)",
138
+ ].join("\n");
139
+
140
+ if ((result.error as NodeJS.ErrnoException).code === "ETIMEDOUT") {
141
+ throw new Error(
142
+ `runCli: ${invocation} timed out after ${timedOutMs}ms (killed by ${result.signal ?? "signal"}).\n${captured}`,
143
+ );
144
+ }
121
145
  throw new Error(
122
- `runCli: failed to spawn ${command} ${spawnArgs.join(" ")} — ${result.error.message}`,
146
+ `runCli: failed to spawn ${invocation} — ${result.error.message}.\n${captured}`,
123
147
  );
124
148
  }
125
149
 
@@ -77,7 +77,9 @@ describe("perf budgets (PROTECTED)", () => {
77
77
  // so it flaked red under whole-suite CPU contention on slower-than-reference
78
78
  // hardware even though the median sat comfortably under budget (see
79
79
  // BUDGETS.md). p95 is kept as a SOFT signal against gross regressions, with
80
- // headroom rather than at the bare ceiling. The 100 ms ceiling is unchanged.
80
+ // headroom rather than at the bare ceiling. That statistic change left the
81
+ // ceiling of the day (100 ms) untouched; BUDGET_COMPLETE_MS has been
82
+ // re-derived twice since, most recently to 240 on 2026-09-10.
81
83
  expect(result.trimmedMeanMs).toBeLessThanOrEqual(BUDGET_COMPLETE_MS);
82
84
  expect(result.p95Ms).toBeLessThanOrEqual(BUDGET_COMPLETE_MS * 1.5);
83
85
  });