@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,361 @@
1
+ /**
2
+ * `graph connect` over a fixture graph authored for it.
3
+ *
4
+ * The five negative answers and the traversal policy are tested HERE, against
5
+ * data this repo owns, because that is the only place exact numbers mean
6
+ * anything: the shipped pack's hop counts and path sets will move the moment new
7
+ * symbols and binding records land, so pinning them there would fail on a
8
+ * correct change. What the shipped pack is tested for is invariants — see
9
+ * `connect.shipped.exec.test.ts`.
10
+ *
11
+ * Each of the five causes is reached, and each is asserted to be
12
+ * DISTINGUISHABLE from the other four: two refusals with different codes, and
13
+ * three answers with different discriminators. That is the whole point of
14
+ * `connect`'s output contract — a caller who cannot tell "I have never heard of
15
+ * this name" from "these two are unrelated" has been told nothing.
16
+ */
17
+
18
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
19
+ import { verbKey } from "../../kernel/packs/uniqueness.js";
20
+ import { ROSTER_THRESHOLD } from "../../kernel/runtime/readEntity.js";
21
+ import type { PragmaRuntime } from "../../kernel/runtime/types.js";
22
+ import type { VerbSpec } from "../../kernel/spec/types.js";
23
+ import {
24
+ CHAIN_LENGTH,
25
+ CONNECT_PREFIXES,
26
+ CONNECT_TTL,
27
+ HUB_ROSTER_SIZE,
28
+ ROSTER_SIZE,
29
+ } from "../../testing/fixtures/connectGraph.js";
30
+ import { buildFixtureRuntime } from "../../testing/helpers/packRuntime.js";
31
+ import type { ConnectResult } from "./connect.types.js";
32
+ import { graphModule } from "./index.js";
33
+
34
+ const DS = "https://ds.canonical.com/";
35
+ const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
36
+
37
+ const connect = graphModule.verbs.find(
38
+ (v) => verbKey(v.path) === "graph connect",
39
+ ) as VerbSpec;
40
+
41
+ let rt: PragmaRuntime;
42
+ beforeAll(async () => {
43
+ ({ rt } = await buildFixtureRuntime({
44
+ ttl: CONNECT_TTL,
45
+ prefixes: CONNECT_PREFIXES,
46
+ }));
47
+ });
48
+ afterAll(async () => {
49
+ (await rt.store.get()).store.dispose();
50
+ });
51
+
52
+ /** Ask one connect question through the verb, as a projector would. */
53
+ async function ask(
54
+ a: string,
55
+ b: string,
56
+ options: { hops?: number; paths?: number } = {},
57
+ ): Promise<ConnectResult> {
58
+ return (await connect.run({ a, b, ...options }, rt)) as ConnectResult;
59
+ }
60
+
61
+ /** The error a question raises, or `undefined` when it answered. */
62
+ async function refusal(
63
+ a: string,
64
+ b: string,
65
+ options: { hops?: number; paths?: number } = {},
66
+ ): Promise<{ code?: string; message: string; suggestions?: string[] }> {
67
+ try {
68
+ await ask(a, b, options);
69
+ } catch (error) {
70
+ return error as { code?: string; message: string; suggestions?: string[] };
71
+ }
72
+ throw new Error(`connect ${a} ${b} answered where it should have refused`);
73
+ }
74
+
75
+ describe("graph connect — the traversal policy", () => {
76
+ it("walks a chain and reports the hop count and the limit it used", async () => {
77
+ const result = await ask("ds:a1", "ds:a5");
78
+ expect(result.connected).toBe(true);
79
+ expect(result.hops).toBe(4);
80
+ expect(result.pathCount).toBe(1);
81
+ expect(result.paths[0]?.steps).toHaveLength(4);
82
+ expect(result.hopLimit).toBe(4);
83
+ expect(result.rosterThreshold).toBe(ROSTER_THRESHOLD);
84
+ });
85
+
86
+ it("walks each step in the direction the triple is asserted", async () => {
87
+ // ds:r2 points at BOTH ends, so the only route runs against the first
88
+ // edge's direction. A path that dropped `direction` would claim
89
+ // `ds:r1 ds:pointsAt ds:r2`, which the graph does not assert.
90
+ const result = await ask("ds:r1", "ds:r3");
91
+ expect(result.connected).toBe(true);
92
+ expect(result.paths[0]?.steps.map((step) => step.direction)).toEqual([
93
+ "reverse",
94
+ "forward",
95
+ ]);
96
+ expect(result.paths[0]?.steps.map((step) => step.to.value)).toEqual([
97
+ `${DS}r2`,
98
+ `${DS}r3`,
99
+ ]);
100
+ });
101
+
102
+ it("walks THROUGH a blank-node record", async () => {
103
+ // The traversal a per-layer SPARQL query cannot do: a blank node's label is
104
+ // not a legal IRI, so a frontier query either fails or drops it silently.
105
+ const result = await ask("ds:bridgeStart", "ds:bridgeEnd");
106
+ expect(result.connected).toBe(true);
107
+ expect(result.hops).toBe(2);
108
+ const middle = result.paths[0]?.steps[0]?.to;
109
+ expect(middle?.addressable).toBe(false);
110
+ expect(result.paths[0]?.steps[1]?.from.value).toBe(middle?.value);
111
+ });
112
+
113
+ it("returns ONLY shortest paths, all of one length, ranked and distinct", async () => {
114
+ const result = await ask("ds:m1", "ds:m2");
115
+ expect(result.hops).toBe(2);
116
+ expect(result.pathCount).toBe(3);
117
+ for (const path of result.paths) expect(path.steps).toHaveLength(2);
118
+ // Ranked by fewest distinct predicates, then lexicographically: the two
119
+ // single-family routes come first, alpha before gamma, and the route that
120
+ // mixes two predicates comes last.
121
+ expect(result.paths.map((path) => path.steps[0]?.to.value)).toEqual([
122
+ `${DS}via1`,
123
+ `${DS}via3`,
124
+ `${DS}via2`,
125
+ ]);
126
+ expect(result.truncated).toBeUndefined();
127
+ });
128
+
129
+ it("caps the paths shown but never the count, and says it truncated", async () => {
130
+ const capped = await ask("ds:m1", "ds:m2", { paths: 1 });
131
+ expect(capped.paths).toHaveLength(1);
132
+ expect(capped.pathCount).toBe(3);
133
+ expect(capped.truncated).toBe(true);
134
+ // A cap above the true count is not a truncation.
135
+ const generous = await ask("ds:m1", "ds:m2", { paths: 99 });
136
+ expect(generous.paths).toHaveLength(3);
137
+ expect(generous.truncated).toBeUndefined();
138
+ });
139
+
140
+ it("honours --hops, up to its ceiling, and refuses above it", async () => {
141
+ // The chain is longer than the ceiling, so it can be walked to the ceiling
142
+ // and still have a pair the ceiling cannot reach.
143
+ expect((await ask("ds:a1", "ds:a6")).connected).toBe(false);
144
+ const raised = await ask("ds:a1", "ds:a6", { hops: 5 });
145
+ expect(raised.connected).toBe(true);
146
+ expect(raised.hops).toBe(5);
147
+ expect(raised.hopLimit).toBe(5);
148
+ const ceiling = await ask("ds:a1", "ds:a7", { hops: 6 });
149
+ expect(ceiling.connected).toBe(true);
150
+ expect(ceiling.hops).toBe(6);
151
+
152
+ // Above the ceiling it REFUSES rather than clamping: a clamped limit is a
153
+ // limit the answer would report without the caller having set it.
154
+ const tooFar = await refusal("ds:a1", "ds:a7", { hops: 7 });
155
+ expect(tooFar.code).toBe("INVALID_INPUT");
156
+ expect(tooFar.message).toContain("7");
157
+ const notWhole = await refusal("ds:a1", "ds:a7", { hops: 2.5 });
158
+ expect(notWhole.code).toBe("INVALID_INPUT");
159
+ });
160
+
161
+ it("classifies each edge, not each predicate name", async () => {
162
+ // ONE predicate, two group sizes. `ds:shares` fans in past the threshold on
163
+ // ds:hub and stays under it on ds:small, so the same predicate is a roster
164
+ // at one node and a real relation at another. No rule keyed on a predicate
165
+ // NAME can produce both of these answers.
166
+ const relation = await ask("ds:relA", "ds:relB");
167
+ expect(relation.connected).toBe(true);
168
+ expect(relation.hops).toBe(2);
169
+ expect(relation.paths[0]?.steps[0]?.predicate.value).toBe(`${DS}shares`);
170
+
171
+ const roster = await ask("ds:member0", "ds:member1");
172
+ expect(roster.connected).toBe(false);
173
+ expect(
174
+ roster.sharedRosters.map((group) => group.predicate.value),
175
+ ).toContain(`${DS}shares`);
176
+ });
177
+
178
+ it("applies the rule in BOTH directions of a group", async () => {
179
+ // ds:fanHub fans OUT past the threshold, so its edges are refused for the
180
+ // (subject, predicate) half of the rule — the half a rule that only counted
181
+ // inbound fan-in would miss entirely.
182
+ const result = await ask("ds:leaf0", "ds:leaf1");
183
+ expect(result.connected).toBe(false);
184
+ expect(result.sharedRosters).toEqual([
185
+ {
186
+ side: "objects",
187
+ predicate: expect.objectContaining({ value: `${DS}fansTo` }),
188
+ node: expect.objectContaining({ value: `${DS}fanHub` }),
189
+ members: ROSTER_SIZE,
190
+ },
191
+ ]);
192
+ });
193
+
194
+ it("answers the same path set whichever way round it is asked", async () => {
195
+ const forwards = await ask("ds:m1", "ds:m2");
196
+ const backwards = await ask("ds:m2", "ds:m1");
197
+ expect(backwards.hops).toBe(forwards.hops);
198
+ expect(backwards.pathCount).toBe(forwards.pathCount);
199
+ // The same paths, walked the other way: reverse the step order, swap each
200
+ // step's ends, and flip its direction.
201
+ const mirrored = backwards.paths.map((path) =>
202
+ [...path.steps].reverse().map((step) => ({
203
+ from: step.to.value,
204
+ to: step.from.value,
205
+ predicate: step.predicate.value,
206
+ direction: step.direction === "forward" ? "reverse" : "forward",
207
+ })),
208
+ );
209
+ const original = forwards.paths.map((path) =>
210
+ path.steps.map((step) => ({
211
+ from: step.from.value,
212
+ to: step.to.value,
213
+ predicate: step.predicate.value,
214
+ direction: step.direction,
215
+ })),
216
+ );
217
+ expect(mirrored.map((p) => JSON.stringify(p)).sort()).toEqual(
218
+ original.map((p) => JSON.stringify(p)).sort(),
219
+ );
220
+ });
221
+ });
222
+
223
+ describe("graph connect — the five negative answers, each distinguishable", () => {
224
+ it("(a) refuses an absent name, naming WHICH argument, with suggestions", async () => {
225
+ const first = await refusal("ds:membre0", "ds:a1");
226
+ expect(first.code).toBe("ENTITY_NOT_FOUND");
227
+ expect(first.message).toContain("<a>");
228
+ expect(first.message).toContain("ds:membre0");
229
+ // A name-similarity suggestion, from the same index the argument's own
230
+ // shell completion draws on.
231
+ expect(first.suggestions).toContain("ds:member0");
232
+ // And never a connectivity VERDICT: a typo must not be reported as a fact
233
+ // about how two things relate. (Saying that nothing can be said is the
234
+ // opposite of a claim, so the check is for the verdicts themselves.)
235
+ expect(first.message).not.toMatch(
236
+ /not connected|unrelated|no path|no relation/i,
237
+ );
238
+
239
+ // The SECOND argument is named as the second argument.
240
+ const second = await refusal("ds:a1", "ds:nosuchthing");
241
+ expect(second.code).toBe("ENTITY_NOT_FOUND");
242
+ expect(second.message).toContain("<b>");
243
+ });
244
+
245
+ it("(b) answers that an endpoint carries no relation, and says what it is", async () => {
246
+ const result = await ask("ds:isolated", "ds:a1");
247
+ expect(result.connected).toBe(false);
248
+ expect(result.unconnectedBecause).toBe("endpoint-carries-no-relation");
249
+ expect(result.a.relatable).toBe(false);
250
+ expect(result.b.relatable).toBe(true);
251
+ // "Then what IS it?" — the groups that classify it, which is all there is
252
+ // to say about a node with no relations.
253
+ const classifiers = (result.a.classifiedBy ?? []).map(
254
+ (group) => group.predicate.value,
255
+ );
256
+ expect(classifiers).toContain(RDF_TYPE);
257
+ expect(classifiers).toContain(`${DS}shares`);
258
+ });
259
+
260
+ it("(c) answers that there is no path within the limit", async () => {
261
+ const result = await ask("ds:a1", `ds:a${CHAIN_LENGTH}`);
262
+ expect(result.connected).toBe(false);
263
+ expect(result.unconnectedBecause).toBe("no-path-within-hop-limit");
264
+ expect(result.hops).toBeNull();
265
+ expect(result.paths).toEqual([]);
266
+ // Nothing in common at all — which is what separates this from (d).
267
+ expect(result.sharedRosters).toEqual([]);
268
+ // Both endpoints ARE relatable, which is what separates it from (b).
269
+ expect(result.a.relatable).toBe(true);
270
+ expect(result.b.relatable).toBe(true);
271
+ });
272
+
273
+ it("(d) answers that the two share only membership, and names it", async () => {
274
+ const result = await ask("ds:member0", "ds:member1");
275
+ expect(result.connected).toBe(false);
276
+ expect(result.unconnectedBecause).toBe("shares-only-rosters");
277
+ expect(result.a.relatable).toBe(true);
278
+ expect(result.b.relatable).toBe(true);
279
+ const hub = result.sharedRosters.find(
280
+ (group) => group.node.value === `${DS}hub`,
281
+ );
282
+ expect(hub).toMatchObject({
283
+ side: "subjects",
284
+ members: HUB_ROSTER_SIZE,
285
+ });
286
+ });
287
+
288
+ it("(e) REFUSES when no loaded pack binds an endpoint's namespace", async () => {
289
+ // Staleness, not a fact about the model — and the dangerous one, because a
290
+ // missing pack makes the honest "no path" and the dishonest one identical.
291
+ // So it refuses with the store's own recovery instead of answering.
292
+ const result = await refusal("https://unloaded.example/thing", "ds:a1");
293
+ expect(result.code).toBe("STORE_UNAVAILABLE");
294
+ expect(result.message).toContain("https://unloaded.example/thing");
295
+ expect(result.message).not.toMatch(
296
+ /not connected|unrelated|no path|no relation/i,
297
+ );
298
+ // The recovery the kernel already mints for a store that cannot answer.
299
+ expect(
300
+ (result as { recovery?: { cli?: string; mcp?: { tool: string } } })
301
+ .recovery?.cli,
302
+ ).toContain("sources update");
303
+ expect(
304
+ (result as { recovery?: { mcp?: { tool: string } } }).recovery?.mcp?.tool,
305
+ ).toBe("sources_update");
306
+ });
307
+
308
+ it("keeps all five apart — five causes, five distinct discriminators", async () => {
309
+ const absent = await refusal("ds:nosuchthing", "ds:a1");
310
+ const unbound = await refusal("https://unloaded.example/thing", "ds:a1");
311
+ const answers = await Promise.all([
312
+ ask("ds:isolated", "ds:a1"),
313
+ ask("ds:a1", `ds:a${CHAIN_LENGTH}`),
314
+ ask("ds:member0", "ds:member1"),
315
+ ]);
316
+ // The two refusals carry different codes...
317
+ expect(new Set([absent.code, unbound.code]).size).toBe(2);
318
+ // ...and the three answers carry different reasons, none of them empty.
319
+ const reasons = answers.map((answer) => answer.unconnectedBecause);
320
+ expect(new Set(reasons).size).toBe(3);
321
+ for (const answer of answers) {
322
+ expect(answer.connected).toBe(false);
323
+ expect(answer.unconnectedBecause).toBeDefined();
324
+ }
325
+ });
326
+ });
327
+
328
+ describe("graph connect — what every answer states about itself", () => {
329
+ it("reports the packs the answering store was actually built from", async () => {
330
+ const session = await rt.store.get();
331
+ const result = await ask("ds:a1", "ds:a2");
332
+ expect(result.packs).toEqual(
333
+ session.manifest.sourceRef
334
+ .split(",")
335
+ .map((ref) => ref.trim())
336
+ .filter((ref) => ref.length > 0),
337
+ );
338
+ });
339
+
340
+ it("states its policy on a NEGATIVE answer too", async () => {
341
+ // The cheapest answer is the one most in need of context: without the
342
+ // limit, the threshold and the packs, "not connected" is unfalsifiable.
343
+ const result = await ask("ds:a1", `ds:a${CHAIN_LENGTH}`, { hops: 3 });
344
+ expect(result.hopLimit).toBe(3);
345
+ expect(result.rosterThreshold).toBe(ROSTER_THRESHOLD);
346
+ expect(result.packs.length).toBeGreaterThan(0);
347
+ });
348
+
349
+ it("refuses two spellings of the same entity rather than answering 0 hops", async () => {
350
+ const result = await refusal("ds:a1", `${DS}a1`);
351
+ expect(result.code).toBe("INVALID_INPUT");
352
+ });
353
+
354
+ it("refuses an unknown prefix as invalid input, naming the loaded ones", async () => {
355
+ const result = await refusal("nope:a1", "ds:a1");
356
+ expect(result.code).toBe("INVALID_INPUT");
357
+ expect((result as { validOptions?: string[] }).validOptions).toContain(
358
+ "ds",
359
+ );
360
+ });
361
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The shape of a `graph connect` answer — the ONE contract its run body writes
3
+ * and its three formatters read.
4
+ *
5
+ * Type-only, and deliberately its own module: the formatters hang off the verb
6
+ * spec, so they are on the static import graph the lazy-dispatch probe walks
7
+ * (`capabilities/lazy.test.ts`), while the walk itself must stay behind a
8
+ * dynamic import. A shared type module lets both sides name the same answer
9
+ * without dragging the traversal onto the storeless fast path.
10
+ *
11
+ * Every answer carries the POLICY it was produced under — the hop limit, the
12
+ * roster threshold, and the packs searched. That is Constitution VI applied to
13
+ * the answer rather than only to the source: the commonest answer this verb
14
+ * gives is "these two are not connected", and that sentence means nothing
15
+ * without knowing how far it looked, what it was willing to walk, and what it
16
+ * was looking in. A missing pack is what makes an honest "no path" and a
17
+ * dishonest one byte-identical, so `packs` is present whether the answer is
18
+ * positive or negative.
19
+ */
20
+
21
+ /** A node or predicate as the answer names it: full IRI, short form, human name. */
22
+ export interface ConnectTerm {
23
+ /** The full IRI, or a blank node's session-local label. */
24
+ readonly value: string;
25
+ /** Prefixed form, when a loaded namespace matches. */
26
+ readonly prefixed?: string;
27
+ /** Human name from the pack index, when it says more than the local name. */
28
+ readonly title?: string;
29
+ /**
30
+ * Marks a term no `pragma:{+uri}` read can resolve. Only blank nodes carry
31
+ * it: their labels re-mint on every load, so the record is real but its
32
+ * handle is not addressable. Blank nodes ARE walked — 6,942 of them carry the
33
+ * token graph's declarations and resolved values — so a path may name one.
34
+ */
35
+ readonly addressable?: false;
36
+ }
37
+
38
+ /**
39
+ * One step of a path: the triple that was traversed, and which way.
40
+ *
41
+ * `direction` is not decoration. `forward` says the triple `(from, predicate,
42
+ * to)` is asserted; `reverse` says `(to, predicate, from)` is. Concatenating a
43
+ * path's steps walks from one endpoint to the other only if each step's
44
+ * direction is respected, and an answer that dropped it would claim edges the
45
+ * graph does not assert.
46
+ */
47
+ export interface ConnectStep {
48
+ readonly from: ConnectTerm;
49
+ readonly predicate: ConnectTerm;
50
+ readonly direction: "forward" | "reverse";
51
+ readonly to: ConnectTerm;
52
+ }
53
+
54
+ /** One path: an ordered walk of steps from `a` to `b`. */
55
+ export interface ConnectPath {
56
+ readonly steps: readonly ConnectStep[];
57
+ }
58
+
59
+ /**
60
+ * A group both endpoints belong to that is a ROSTER rather than a relation —
61
+ * membership, which is what the noun's own list verb answers properly.
62
+ *
63
+ * Two shapes, because a group has two directions: `subjects` means both
64
+ * endpoints are subjects of `predicate` pointing at `node` (both are
65
+ * `ds:Component`s, both in the `ds:global` tier); `objects` means both are
66
+ * objects of `node` asserting `predicate` (both covered by one modifier
67
+ * family's 359 `dt:covers` edges). Reporting these is what separates "unrelated
68
+ * in the model, though they do share membership" from "unrelated, full stop".
69
+ */
70
+ export interface SharedRoster {
71
+ /** Which side of the group the two endpoints sit on. */
72
+ readonly side: "subjects" | "objects";
73
+ readonly predicate: ConnectTerm;
74
+ /** The group's other end: the shared object, or the shared subject. */
75
+ readonly node: ConnectTerm;
76
+ /** The TRUE size of the group — why it is a roster and not a relation. */
77
+ readonly members: number;
78
+ }
79
+
80
+ /** One endpoint of the question, as the store resolved it. */
81
+ export interface ConnectEndpoint {
82
+ /** The value the caller supplied, verbatim. */
83
+ readonly input: string;
84
+ readonly term: ConnectTerm;
85
+ /**
86
+ * False when this endpoint carries no walkable relation edge in either
87
+ * direction — it is in the graph, but only classified. Nothing can be
88
+ * connected to it, which is a different answer from "nothing is".
89
+ */
90
+ readonly relatable: boolean;
91
+ /**
92
+ * The roster groups that classify an endpoint, present only when
93
+ * {@link relatable} is false — the answer to "then what IS it?", which is
94
+ * the only useful thing to say about a node with no relations.
95
+ */
96
+ readonly classifiedBy?: readonly SharedRoster[];
97
+ }
98
+
99
+ /**
100
+ * Why an unconnected pair is unconnected. Three of `CN.07`'s five cases; the
101
+ * other two are refusals rather than answers (an absent name throws
102
+ * `ENTITY_NOT_FOUND` naming which argument, an unbound namespace throws
103
+ * `STORE_UNAVAILABLE`), because neither may make a connectivity claim at all.
104
+ */
105
+ export type ConnectRefusal =
106
+ /** An endpoint carries no relation edge — see {@link ConnectEndpoint.relatable}. */
107
+ | "endpoint-carries-no-relation"
108
+ /** The walk exhausted, but the two share membership — {@link ConnectResult.sharedRosters}. */
109
+ | "shares-only-rosters"
110
+ /** The walk exhausted with nothing in common at all. */
111
+ | "no-path-within-hop-limit";
112
+
113
+ /** The answer to one `graph connect` question. */
114
+ export interface ConnectResult {
115
+ readonly a: ConnectEndpoint;
116
+ readonly b: ConnectEndpoint;
117
+ readonly connected: boolean;
118
+ /** Which negative answer this is; absent when {@link connected}. */
119
+ readonly unconnectedBecause?: ConnectRefusal;
120
+ /** The shortest-path length, or null when there is no path. */
121
+ readonly hops: number | null;
122
+ /** How many distinct shortest paths there are — the truth behind `paths`. */
123
+ readonly pathCount: number;
124
+ /** The shortest paths, ranked and capped by `--paths`. */
125
+ readonly paths: readonly ConnectPath[];
126
+ /** Set when `paths` is shorter than {@link pathCount}. */
127
+ readonly truncated?: true;
128
+ /** Roster groups both endpoints belong to (see {@link SharedRoster}). */
129
+ readonly sharedRosters: readonly SharedRoster[];
130
+ /** The hop limit actually applied to this walk. */
131
+ readonly hopLimit: number;
132
+ /** The fan-in above which an edge was refused as a roster. */
133
+ readonly rosterThreshold: number;
134
+ /** The packs the answering store was built from, as it records them. */
135
+ readonly packs: readonly string[];
136
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `graph connect <a> <b>` — what connects two entities, and what "unlinked"
3
+ * means when nothing does.
4
+ *
5
+ * The third verb on the `graph` noun, beside `inspect` (one entity's
6
+ * neighbourhood) and `query` (the raw SPARQL escape hatch). It belongs here for
7
+ * the same reason those two do: it reads ACROSS the whole store rather than
8
+ * within one population, so it hangs off the noun for a store-wide read and
9
+ * adds no noun of its own.
10
+ *
11
+ * It is hand-written rather than a declared pack story because the pack grammar
12
+ * cannot express it. A list story's parameters are exactly its declared filters
13
+ * plus its declared search, every filter is projected as an OPTIONAL NAMED
14
+ * flag, and the only positional-and-required parameter the compiler ever mints
15
+ * is a lookup's single name — so there is nowhere in the grammar to put two
16
+ * required entity arguments. And a filter is a row predicate over an author's
17
+ * query, so a declared form would have to enumerate the walk space and let a
18
+ * filter pick one pair out of it: at four hops that space is 71 million walks,
19
+ * against the largest declared story's 1,698 rows.
20
+ *
21
+ * Both arguments accept exactly what `graph inspect` accepts — a prefixed name
22
+ * or an absolute IRI — and complete from the pack index through the same
23
+ * `names`/`index` source a compiled lookup's `<name>` uses, so the two
24
+ * positionals behave like every other entity argument in the CLI.
25
+ *
26
+ * Store-backed and read-only, like both its siblings. The traversal lives in a
27
+ * dynamically-imported run body: the relation index it needs is built by one
28
+ * scan of the store, and a process that never asks a connect question must
29
+ * never pay for it — which is the same guarantee `needsStore` and the lazy
30
+ * store already give every other read.
31
+ */
32
+
33
+ import { BIN_NAME } from "../../constants.js";
34
+ import { PragmaError } from "../../kernel/error/index.js";
35
+ import { asVerb } from "../../kernel/spec/asVerb.js";
36
+ import type { VerbSpec } from "../../kernel/spec/index.js";
37
+ import { connectFormatters } from "./connect.render.js";
38
+ import type { ConnectResult } from "./connect.types.js";
39
+
40
+ /**
41
+ * The hop limit in force unless `--hops` says otherwise.
42
+ *
43
+ * Four, from the measured connectivity ladder on the shipped pack: it is the
44
+ * smallest limit that spans the longest genuine bridge in this store — the walk
45
+ * from a platform variable through its declaration and its references to the
46
+ * symbol behind it — and the largest that keeps the answer set honest. At three
47
+ * that walk is refused; at six the connected fraction jumps and the paths stop
48
+ * being something a person can hold in mind.
49
+ */
50
+ export const DEFAULT_HOPS = 4;
51
+
52
+ /** The ceiling `--hops` may be raised to; above it the verb refuses. */
53
+ export const MAX_HOPS = 6;
54
+
55
+ /** How many shortest paths are shown unless `--paths` says otherwise. */
56
+ export const DEFAULT_PATHS = 10;
57
+
58
+ /**
59
+ * Hold a numeric param to a whole number within its stated range.
60
+ *
61
+ * REFUSES rather than clamps. A clamped `--hops 40` would answer a question
62
+ * nobody asked and report a limit the caller did not set, which is precisely
63
+ * the hidden policy every answer's policy block exists to rule out.
64
+ *
65
+ * The kernel already coerces a `number` param and rejects what is not a number
66
+ * at all (`project/cli/dispatch.ts#coerceParam`, and `z.number()` over MCP), so
67
+ * what is left to check is RANGE and wholeness — `--hops 2.5` is a number, and
68
+ * it is not a hop count.
69
+ *
70
+ * @param value - The coerced param value.
71
+ * @param name - The flag's name, for the diagnostic.
72
+ * @param fallback - The value when the flag was not given.
73
+ * @param min - The smallest accepted value.
74
+ * @param max - The largest accepted value, if there is one.
75
+ * @returns The validated integer.
76
+ * @throws PragmaError INVALID_INPUT when the value is not an integer in range.
77
+ */
78
+ function bounded(
79
+ value: unknown,
80
+ name: string,
81
+ fallback: number,
82
+ min: number,
83
+ max?: number,
84
+ ): number {
85
+ if (value === undefined || value === null) return fallback;
86
+ const parsed = typeof value === "number" ? value : Number(value);
87
+ const ceiling = max === undefined ? "" : ` and at most ${max}`;
88
+ if (
89
+ !Number.isInteger(parsed) ||
90
+ parsed < min ||
91
+ (max !== undefined && parsed > max)
92
+ ) {
93
+ throw PragmaError.invalidInput(name, String(value), {
94
+ recovery: {
95
+ message: `--${name} must be a whole number of at least ${min}${ceiling}.`,
96
+ },
97
+ });
98
+ }
99
+ return parsed;
100
+ }
101
+
102
+ const connectVerb: VerbSpec<Record<string, unknown>, ConnectResult> = {
103
+ path: ["graph", "connect"],
104
+ summary: "Show the shortest relation paths between two entities.",
105
+ doc: "Finds every shortest path of RELATION edges between two entities, and says which kind of nothing it found when there is no path. An edge counts as a relation only where it does not fan out into a roster — membership of a class, a tier or a family is answered by that noun's list verb, not by a path — so most pairs are correctly reported as unconnected. Address each endpoint by prefixed name (ds:global.component.button) or absolute IRI. Every answer states the hop limit, the fan-in threshold, and the packs searched.",
106
+ params: [
107
+ {
108
+ kind: "string",
109
+ name: "a",
110
+ doc: "The first endpoint — a prefixed name or absolute IRI.",
111
+ positional: true,
112
+ required: true,
113
+ complete: { kind: "names", source: { from: "index", type: "" } },
114
+ },
115
+ {
116
+ kind: "string",
117
+ name: "b",
118
+ doc: "The second endpoint — a prefixed name or absolute IRI.",
119
+ positional: true,
120
+ required: true,
121
+ complete: { kind: "names", source: { from: "index", type: "" } },
122
+ },
123
+ {
124
+ kind: "number",
125
+ name: "hops",
126
+ doc: `Largest path length to return (default ${DEFAULT_HOPS}, ceiling ${MAX_HOPS}).`,
127
+ default: DEFAULT_HOPS,
128
+ },
129
+ {
130
+ kind: "number",
131
+ name: "paths",
132
+ doc: `Most shortest paths to show (default ${DEFAULT_PATHS}); the answer always reports the true total.`,
133
+ default: DEFAULT_PATHS,
134
+ },
135
+ ],
136
+ output: { formatters: connectFormatters },
137
+ examples: [
138
+ {
139
+ cmd: `${BIN_NAME} graph connect ds:global.component.button dt:color.text`,
140
+ note: "a block and a token symbol",
141
+ },
142
+ {
143
+ cmd: `${BIN_NAME} graph connect ds:global.component.button ds:apps.pattern.datatable --format json`,
144
+ note: "an honest unlinked answer, with the policy it was found under",
145
+ },
146
+ ],
147
+ capability: {
148
+ needsStore: true,
149
+ mutates: false,
150
+ mcp: {
151
+ expose: true,
152
+ annotations: { readOnlyHint: true, openWorldHint: false },
153
+ },
154
+ },
155
+ run: (params: Record<string, unknown>, rt) =>
156
+ import("./runConnect.js").then((m) =>
157
+ m.runConnect(rt, String(params.a), String(params.b), {
158
+ hops: bounded(params.hops, "hops", DEFAULT_HOPS, 1, MAX_HOPS),
159
+ paths: bounded(params.paths, "paths", DEFAULT_PATHS, 1),
160
+ }),
161
+ ),
162
+ };
163
+
164
+ /** The `graph connect` verb. */
165
+ export const graphConnectVerb = asVerb(connectVerb);
@@ -1,10 +1,12 @@
1
1
  /**
2
- * The `graph` capability module — `inspect` (single-entity read) plus `query`
3
- * (the raw SPARQL escape hatch, PR6). The covenant blesses both.
2
+ * The `graph` capability module — `inspect` (single-entity read), `query` (the
3
+ * raw SPARQL escape hatch, PR6) and `connect` (relation paths between two
4
+ * entities). The covenant blesses all three.
4
5
  */
5
6
 
6
7
  import type { CapabilityModule } from "../../kernel/spec/index.js";
7
8
  import { resourceProvider } from "../resources/index.js";
9
+ import { graphConnectVerb } from "./connect.verb.js";
8
10
  import { graphInspectVerb } from "./inspect.verb.js";
9
11
  import { graphQueryVerb } from "./query.verb.js";
10
12
 
@@ -16,6 +18,6 @@ import { graphQueryVerb } from "./query.verb.js";
16
18
  */
17
19
  export const graphModule: CapabilityModule = {
18
20
  name: "graph",
19
- verbs: [graphInspectVerb, graphQueryVerb],
21
+ verbs: [graphConnectVerb, graphInspectVerb, graphQueryVerb],
20
22
  mcpResources: resourceProvider,
21
23
  };