@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,135 @@
1
+ /**
2
+ * The cursor a caller spends to ask for the page after this one.
3
+ *
4
+ * WHY AN OFFSET. The candidate alternative is a keyset cursor encoding the last
5
+ * row's ordering key, which does not drift when the store changes underneath a
6
+ * caller. It cannot be general, and `block list` is the counterexample: it
7
+ * orders by `?tierRank`, a variable its SELECT does not project, so the
8
+ * ordering key is not in the rows at all — and a pagination mechanism that
9
+ * works only for stories whose ordering keys happen to be columns is not a
10
+ * pagination mechanism. An offset needs nothing from the rows.
11
+ *
12
+ * The drift an offset admits is bounded by what the store is: a read-only,
13
+ * content-addressed pack that changes only when `sources update` rebuilds it.
14
+ * Between two pages of one browse there is no writer.
15
+ *
16
+ * What an offset cannot survive is the caller changing the QUESTION between
17
+ * pages — different filters, a different search term, another story — which is
18
+ * why the cursor carries a fingerprint of the read it was issued for and
19
+ * refuses to be spent on another. That is the failure worth catching, and the
20
+ * one a bare integer would have answered wrongly and in silence.
21
+ *
22
+ * The encoding is opaque on purpose: a caller who cannot read it cannot come to
23
+ * depend on it, so the day a story needs a keyset cursor the cursor's contents
24
+ * change and its contract does not.
25
+ *
26
+ * The fingerprint does not cover the OFFSET, and cannot: a cursor is a token
27
+ * this build issued, not a signed one, so its `o` is editable by anyone who
28
+ * base64-decodes it. That is why the offset is BOUNDED here as well as parsed
29
+ * here — an `o` of 2^53 is a legal JSON integer that the store answers with a
30
+ * raw parse error, so {@link ./paging.MAX_LIST_WINDOW} is checked before the
31
+ * number can reach a query.
32
+ */
33
+
34
+ import { createHash } from "node:crypto";
35
+ import { PragmaError } from "../error/index.js";
36
+ import { MAX_LIST_WINDOW } from "./paging.js";
37
+
38
+ /** The cursor encoding's own version, so a future keyset form is tellable apart. */
39
+ const CURSOR_VERSION = 1;
40
+
41
+ /**
42
+ * The identity of the read a cursor belongs to — the author query and the
43
+ * arguments that narrow it, hashed short.
44
+ *
45
+ * Each part is LENGTH-PREFIXED rather than merely joined. A separator is only
46
+ * unambiguous if it cannot occur inside a part, and these parts are SPARQL
47
+ * text and JSON — there is no such character. Two different argument lists
48
+ * hashing the same would let a cursor be spent on a read it was not issued
49
+ * for, which is the single thing this function exists to prevent.
50
+ *
51
+ * @param parts - Everything that decides which rows the read returns.
52
+ * @returns A short, stable fingerprint.
53
+ */
54
+ export function pageFingerprint(parts: readonly string[]): string {
55
+ const hash = createHash("sha256");
56
+ for (const part of parts) hash.update(`${part.length}:${part}`);
57
+ return hash.digest("base64url").slice(0, 12);
58
+ }
59
+
60
+ /**
61
+ * Encode the cursor that asks for the rows after this page.
62
+ *
63
+ * @param offset - The row index the next page starts at.
64
+ * @param fingerprint - The read this cursor may be spent on.
65
+ * @returns An opaque, URL-safe token.
66
+ */
67
+ export function encodeCursor(offset: number, fingerprint: string): string {
68
+ const payload = JSON.stringify({
69
+ v: CURSOR_VERSION,
70
+ o: offset,
71
+ q: fingerprint,
72
+ });
73
+ return Buffer.from(payload, "utf8").toString("base64url");
74
+ }
75
+
76
+ /**
77
+ * Read an `--after`/`after` cursor back into an offset.
78
+ *
79
+ * @param provided - The raw argument, if any.
80
+ * @param fingerprint - The read the cursor must have been issued for.
81
+ * @returns The row index to start at (0 when no cursor was given).
82
+ * @throws PragmaError INVALID_INPUT when the token is not a cursor this build
83
+ * issued, or was issued for a different read — a mismatch answered with rows
84
+ * would be a page of the wrong question, with nothing to show it.
85
+ */
86
+ export function readCursor(provided: unknown, fingerprint: string): number {
87
+ if (provided === undefined) return 0;
88
+ const decoded = decodeCursor(provided);
89
+ if (!decoded) {
90
+ throw PragmaError.invalidInput("after", String(provided), {
91
+ recovery: {
92
+ message:
93
+ "--after takes the cursor from a previous page of this same read; run the read without it to start from the first page.",
94
+ },
95
+ });
96
+ }
97
+ if (decoded.q !== fingerprint) {
98
+ throw PragmaError.invalidInput("after", String(provided), {
99
+ recovery: {
100
+ message:
101
+ "This cursor was issued for a different read — the filters or the search term changed. Run the new read without --after to start from its first page.",
102
+ },
103
+ });
104
+ }
105
+ return decoded.o;
106
+ }
107
+
108
+ /**
109
+ * The cursor payload, or `undefined` for anything that is not one.
110
+ *
111
+ * The offset is checked against {@link MAX_LIST_WINDOW} here rather than at the
112
+ * store: an unbounded `o` is emitted straight into the generated query's
113
+ * `OFFSET`, where a number the engine cannot express comes back as a parse
114
+ * error dressed as an internal one. Refused here, a hand-edited offset is the
115
+ * same calm INVALID_INPUT as every other malformed cursor.
116
+ */
117
+ function decodeCursor(
118
+ provided: unknown,
119
+ ): { readonly o: number; readonly q: string } | undefined {
120
+ if (typeof provided !== "string" || provided === "") return undefined;
121
+ let parsed: unknown;
122
+ try {
123
+ parsed = JSON.parse(Buffer.from(provided, "base64url").toString("utf8"));
124
+ } catch {
125
+ return undefined;
126
+ }
127
+ if (typeof parsed !== "object" || parsed === null) return undefined;
128
+ const payload = parsed as Record<string, unknown>;
129
+ if (payload.v !== CURSOR_VERSION) return undefined;
130
+ if (!Number.isInteger(payload.o)) return undefined;
131
+ const offset = payload.o as number;
132
+ if (offset < 0 || offset > MAX_LIST_WINDOW) return undefined;
133
+ if (typeof payload.q !== "string") return undefined;
134
+ return { o: offset, q: payload.q };
135
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * A zero-row answer names the FILTER and still explains the POPULATION.
3
+ *
4
+ * Two facts can empty a list and the rows cannot tell them apart: nothing was
5
+ * there, or a filter missed what was. So the page says both — the kernel's
6
+ * sentence names the filters in force (`token list --search zzzznotreal` used
7
+ * to answer "No token symbols in the store … run `pragma sources update`",
8
+ * telling a reader with 745 present symbols to rebuild for nothing), and the
9
+ * story's own `emptyRecovery` follows it, exactly as it does when nothing
10
+ * narrowed the read.
11
+ *
12
+ * Which puts one requirement on the stories rather than on the kernel: a
13
+ * recovery is worded to hold in BOTH cases, or the story declares none. The
14
+ * fixture below is worded that way on purpose.
15
+ */
16
+
17
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
18
+ import { buildFixtureRuntime } from "../../testing/helpers/packRuntime.js";
19
+ import { executeVerb } from "../project/cli/dispatch.js";
20
+ import type { GlobalFlags, PragmaRuntime } from "../runtime/types.js";
21
+ import type { VerbSpec } from "../spec/types.js";
22
+ import { compilePack } from "./compile.js";
23
+ import type { PackDefinition } from "./types.js";
24
+ import { distributionSource } from "./types.js";
25
+ import { verbKey } from "./uniqueness.js";
26
+
27
+ const PREFIXES = {
28
+ ex: "https://example.org/widgets#",
29
+ owl: "http://www.w3.org/2002/07/owl#",
30
+ rdfs: "http://www.w3.org/2000/01/rdf-schema#",
31
+ xsd: "http://www.w3.org/2001/XMLSchema#",
32
+ };
33
+
34
+ const TTL = `
35
+ @prefix ex: <https://example.org/widgets#> .
36
+ @prefix owl: <http://www.w3.org/2002/07/owl#> .
37
+ @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
38
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
39
+
40
+ ex:Widget a owl:Class .
41
+ ex:Gadget a owl:Class .
42
+ ex:name a owl:DatatypeProperty ; rdfs:domain ex:Widget ; rdfs:range xsd:string .
43
+ ex:kind a owl:DatatypeProperty ; rdfs:domain ex:Widget ; rdfs:range xsd:string .
44
+
45
+ ex:button a ex:Widget ; ex:name "Button" ; ex:kind "input" .
46
+ ex:label a ex:Widget ; ex:name "Label" ; ex:kind "display" .
47
+ ex:slider a ex:Widget ; ex:name "Slider" ; ex:kind "input" .
48
+ `;
49
+
50
+ /**
51
+ * The recovery a story authors — where the rows come from, said so it holds
52
+ * over a populated table a filter missed as well as over an empty one.
53
+ *
54
+ * "No widgets in the store." would not: it is a claim about the population, and
55
+ * the kernel prints this under a filtered miss too, where three widgets are
56
+ * sitting in the store.
57
+ */
58
+ const EMPTY_RECOVERY = {
59
+ message:
60
+ "Widgets are recorded by the catalogue build, and a store built before it ran records none.",
61
+ cli: "sources update",
62
+ } as const;
63
+
64
+ const listShape = (uriClass: string) => ({
65
+ query: [
66
+ "SELECT ?uri ?name ?kind WHERE {",
67
+ ` ?uri a ${uriClass} ; ex:name ?name .`,
68
+ " OPTIONAL { ?uri ex:kind ?kind }",
69
+ "} ORDER BY ?name",
70
+ ].join("\n"),
71
+ columns: [
72
+ { field: "uri", label: "IRI" },
73
+ { field: "name", label: "Name" },
74
+ { field: "kind", label: "Kind" },
75
+ ],
76
+ filters: [{ param: "kind", variable: "kind", values: ["input", "display"] }],
77
+ search: { variables: ["name"] },
78
+ emptyRecovery: EMPTY_RECOVERY,
79
+ });
80
+
81
+ /** Three rows — a POPULATED table, so a filter that misses really is the news. */
82
+ const WIDGET_PACK: PackDefinition = {
83
+ noun: "widget",
84
+ description: "List widgets.",
85
+ list: listShape("ex:Widget"),
86
+ };
87
+
88
+ /** A declared class with no individuals — the genuinely empty population. */
89
+ const GADGET_PACK: PackDefinition = {
90
+ noun: "gadget",
91
+ description: "List gadgets.",
92
+ list: { ...listShape("ex:Gadget"), emptyRecovery: EMPTY_RECOVERY },
93
+ };
94
+
95
+ const listVerb = (pack: PackDefinition): VerbSpec =>
96
+ compilePack(pack, distributionSource("test:list"), PREFIXES).find(
97
+ (v) => verbKey(v.path) === `${pack.noun} list`,
98
+ ) as VerbSpec;
99
+
100
+ const REAL = { dryRun: false, undo: false, yes: false };
101
+
102
+ const PLAIN: GlobalFlags = {
103
+ llm: false,
104
+ autoLlm: false,
105
+ format: "plain",
106
+ verbose: false,
107
+ };
108
+ const LLM: GlobalFlags = { ...PLAIN, llm: true, format: "llm" };
109
+ const JSON_FLAGS: GlobalFlags = { ...PLAIN, format: "json" };
110
+
111
+ let rt: PragmaRuntime;
112
+ /** The same runtime, reading in another format. */
113
+ const as = (flags: GlobalFlags): PragmaRuntime => ({
114
+ ...rt,
115
+ globalFlags: flags,
116
+ });
117
+
118
+ beforeAll(async () => {
119
+ ({ rt } = await buildFixtureRuntime({ ttl: TTL, prefixes: PREFIXES }));
120
+ });
121
+ afterAll(async () => {
122
+ (await rt.store.get()).store.dispose();
123
+ });
124
+
125
+ describe("a zero-row answer names the filter and keeps the recovery", () => {
126
+ it("a search that matched nothing names the search, and still explains", async () => {
127
+ const outcome = await executeVerb(
128
+ listVerb(WIDGET_PACK),
129
+ { search: "zzzznotreal" },
130
+ REAL,
131
+ as(PLAIN),
132
+ );
133
+ expect(outcome.exitCode).toBe(0);
134
+ const said = `${outcome.stdout ?? ""}${outcome.stderr ?? ""}`;
135
+ expect(said).toContain("No widget matches `--search zzzznotreal`.");
136
+ // The filter is the news; the story's account of where widgets come from is
137
+ // the other half, and a reader who mistyped nothing needs it.
138
+ expect(said).toContain("recorded by the catalogue build");
139
+ expect(said).toContain("pragma sources update");
140
+ });
141
+
142
+ it("every filter in force is named", async () => {
143
+ const outcome = await executeVerb(
144
+ listVerb(WIDGET_PACK),
145
+ { kind: "display", search: "zzzznotreal" },
146
+ REAL,
147
+ as(PLAIN),
148
+ );
149
+ const said = `${outcome.stdout ?? ""}${outcome.stderr ?? ""}`;
150
+ expect(said).toContain(
151
+ "No widget matches `--kind display` and `--search zzzznotreal`.",
152
+ );
153
+ });
154
+
155
+ it("the llm body says the same thing", async () => {
156
+ const outcome = await executeVerb(
157
+ listVerb(WIDGET_PACK),
158
+ { search: "zzzznotreal" },
159
+ REAL,
160
+ as(LLM),
161
+ );
162
+ expect(outcome.stdout).toContain(
163
+ "No widget matches `--search zzzznotreal`.",
164
+ );
165
+ expect(outcome.stdout).toContain("recorded by the catalogue build");
166
+ });
167
+
168
+ it("json says the same thing on meta.notice", async () => {
169
+ const outcome = await executeVerb(
170
+ listVerb(WIDGET_PACK),
171
+ { search: "zzzznotreal" },
172
+ REAL,
173
+ as(JSON_FLAGS),
174
+ );
175
+ const envelope = JSON.parse(outcome.stdout as string);
176
+ expect(envelope.data).toEqual([]);
177
+ expect(envelope.meta.notice).toContain(
178
+ "No widget matches `--search zzzznotreal`.",
179
+ );
180
+ expect(envelope.meta.notice).toContain("recorded by the catalogue build");
181
+ });
182
+
183
+ it("an EMPTY POPULATION under a filter keeps the story's own recovery", async () => {
184
+ // The regression: `token consumers --symbol color.text` narrows a table
185
+ // that records nothing at all, and answering only "no match for that
186
+ // symbol" read as a typo while withholding the one account the verb has.
187
+ // No probe tells the two apart — the sentence does, by saying both.
188
+ const outcome = await executeVerb(
189
+ listVerb(GADGET_PACK),
190
+ { kind: "input" },
191
+ REAL,
192
+ as(PLAIN),
193
+ );
194
+ expect(outcome.exitCode).toBe(0);
195
+ const said = `${outcome.stderr ?? ""}`;
196
+ expect(said).toContain("No gadget matches `--kind input`.");
197
+ expect(said).toContain("recorded by the catalogue build");
198
+ expect(said).toContain("pragma sources update");
199
+ });
200
+
201
+ it("an EMPTY POPULATION unfiltered gets the kernel's own sentence", async () => {
202
+ const outcome = await executeVerb(
203
+ listVerb(GADGET_PACK),
204
+ {},
205
+ REAL,
206
+ as(PLAIN),
207
+ );
208
+ expect(outcome.exitCode).toBe(0);
209
+ expect(outcome.stderr).toContain("No gadget entries found.");
210
+ expect(outcome.stderr).toContain("recorded by the catalogue build");
211
+ expect(outcome.stderr).toContain("pragma sources update");
212
+ });
213
+ });
@@ -31,7 +31,7 @@ import type {
31
31
  PackLookup,
32
32
  PackNestedExpand,
33
33
  } from "../types.js";
34
- import { isNestedExpand } from "../types.js";
34
+ import { expandIsSparql, isNestedExpand } from "../types.js";
35
35
  import { MAX_PAGE_SIZE, pluralize, stripVerbPrefix } from "./nameMap.js";
36
36
 
37
37
  type GraphQLSchema = import("graphql").GraphQLSchema;
@@ -367,16 +367,23 @@ export function buildLookupDocument(
367
367
  ),
368
368
  ),
369
369
  ),
370
- ...activeExpands(lookup, level).map((expand, index) =>
371
- buildScopedSelection(schema, fragmentType, (container) =>
372
- buildCollectionSelection(
373
- container,
374
- expand,
375
- `lookup.expand[${index}]`,
376
- source,
370
+ // An expand that declares the SPARQL lane is not this document's — the
371
+ // entity fetcher runs its sub-SELECT beside this query and merges the rows
372
+ // in. Skipped rather than attempted: its terms are chosen for a lane where
373
+ // property paths read and every defined property is reachable, and asking
374
+ // for them here would either drop them in silence or raise.
375
+ ...activeExpands(lookup, level)
376
+ .filter((expand) => !expandIsSparql(lookup, expand))
377
+ .map((expand, index) =>
378
+ buildScopedSelection(schema, fragmentType, (container) =>
379
+ buildCollectionSelection(
380
+ container,
381
+ expand,
382
+ `lookup.expand[${index}]`,
383
+ source,
384
+ ),
377
385
  ),
378
386
  ),
379
- ),
380
387
  ].filter((selection): selection is Selection => selection !== undefined);
381
388
 
382
389
  const body = selections.map((selection) => ` ${selection.text}`);
@@ -130,6 +130,158 @@ describe("source rule — zod refinements reject dual/ill-sourced lookups (PROTE
130
130
  ).toThrow(/single-hop|graphql/i);
131
131
  });
132
132
 
133
+ it("accepts a SPARQL-lane expand inside a graphql lookup", () => {
134
+ // The two lanes are not interchangeable and the block lookup needs both:
135
+ // a nested expand only a document can make, beside a table whose columns
136
+ // the compiled schema has no field for. So the lane is per-expand, and the
137
+ // rules that follow are about the lane that will actually resolve it.
138
+ const definition = {
139
+ noun: "x",
140
+ lookup: {
141
+ source: "graphql",
142
+ by: "ds:name",
143
+ type: "ds:Component",
144
+ graphqlType: "Component",
145
+ expand: [
146
+ {
147
+ name: "t",
148
+ relation: "ds:hasTokenBinding",
149
+ source: "sparql",
150
+ orderBy: ["node"],
151
+ note: "How to read the rows.",
152
+ select: [
153
+ { name: "symbol", property: "ds:consumesSymbol/rdfs:label" },
154
+ {
155
+ name: "via",
156
+ property: "ds:viaBlock/ds:name",
157
+ blankWhenSelf: true,
158
+ },
159
+ { name: "node", property: "ds:node" },
160
+ ],
161
+ },
162
+ ],
163
+ },
164
+ };
165
+ expect(
166
+ parsePackDefinition(JSON.parse(JSON.stringify(definition)), "t"),
167
+ ).toEqual(definition);
168
+ });
169
+
170
+ it("rejects a nested expand on a SPARQL-lane expand of a graphql lookup", () => {
171
+ // The single-hop rule follows the EXPAND's lane, not the lookup's — it
172
+ // used to read the lookup's, so an expand that opted out of the document
173
+ // could still declare a second hop no sub-SELECT can make.
174
+ expect(() =>
175
+ parsePackDefinition(
176
+ {
177
+ noun: "x",
178
+ lookup: {
179
+ source: "graphql",
180
+ by: "ds:name",
181
+ type: "ds:Component",
182
+ graphqlType: "Component",
183
+ expand: [
184
+ {
185
+ name: "f",
186
+ relation: "ds:hasModifierFamily",
187
+ source: "sparql",
188
+ select: [
189
+ {
190
+ name: "v",
191
+ relation: "ds:hasModifier",
192
+ select: [{ name: "n", property: "ds:name" }],
193
+ },
194
+ ],
195
+ },
196
+ ],
197
+ },
198
+ },
199
+ "t",
200
+ ),
201
+ ).toThrow(/single-hop|graphql/i);
202
+ });
203
+
204
+ it("rejects orderBy and blankWhenSelf on a lane that cannot express them", () => {
205
+ // A GraphQL collection's order is the schema's and a document cannot
206
+ // filter a cell against the entity's own name, so both are refused where
207
+ // they are written rather than ignored at runtime — a silent no-op is how
208
+ // a declaration that never applied survives review.
209
+ const base = {
210
+ noun: "x",
211
+ lookup: {
212
+ source: "graphql",
213
+ by: "ds:name",
214
+ type: "ds:Component",
215
+ graphqlType: "Component",
216
+ },
217
+ };
218
+ expect(() =>
219
+ parsePackDefinition(
220
+ {
221
+ ...base,
222
+ lookup: {
223
+ ...base.lookup,
224
+ expand: [
225
+ {
226
+ name: "t",
227
+ relation: "ds:hasTokenBinding",
228
+ orderBy: ["node"],
229
+ select: [{ name: "node", property: "ds:node" }],
230
+ },
231
+ ],
232
+ },
233
+ },
234
+ "t",
235
+ ),
236
+ ).toThrow(/orderBy/);
237
+ expect(() =>
238
+ parsePackDefinition(
239
+ {
240
+ ...base,
241
+ lookup: {
242
+ ...base.lookup,
243
+ expand: [
244
+ {
245
+ name: "t",
246
+ relation: "ds:hasTokenBinding",
247
+ select: [
248
+ { name: "via", property: "ds:viaBlock", blankWhenSelf: true },
249
+ ],
250
+ },
251
+ ],
252
+ },
253
+ },
254
+ "t",
255
+ ),
256
+ ).toThrow(/blankWhenSelf/);
257
+ });
258
+
259
+ it("rejects an orderBy naming a field the select does not declare", () => {
260
+ // It would order by an unbound variable, which SPARQL permits and which
261
+ // orders by nothing at all — the rows would come back in the store's order
262
+ // with a declaration claiming otherwise.
263
+ expect(() =>
264
+ parsePackDefinition(
265
+ {
266
+ noun: "x",
267
+ lookup: {
268
+ by: "ds:name",
269
+ type: "ds:Component",
270
+ expand: [
271
+ {
272
+ name: "t",
273
+ relation: "ds:hasTokenBinding",
274
+ orderBy: ["rank"],
275
+ select: [{ name: "node", property: "ds:node" }],
276
+ },
277
+ ],
278
+ },
279
+ },
280
+ "t",
281
+ ),
282
+ ).toThrow(/orders by "rank"/);
283
+ });
284
+
133
285
  it("rejects a graphql field addressed by a property path", () => {
134
286
  expect(() =>
135
287
  parsePackDefinition(