@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,344 @@
1
+ /**
2
+ * Compile a list story's declared filters, its search term, its tier scope and
3
+ * one page's bounds into the SPARQL the store actually runs.
4
+ *
5
+ * WHY IN THE QUERY. Filters used to be predicates over the rows a query had
6
+ * already returned. That was injection-safe and order-preserving — the two
7
+ * properties this module has to keep — but it put filtering on the wrong side
8
+ * of any cap: a `LIMIT` in a story's own text truncates BEFORE the filter runs,
9
+ * so `--category testing` over a capped population answers from whatever
10
+ * happened to be inside the window. Nothing was being truncated yet, because no
11
+ * story declared a cap and the kernel had none to declare; the moment one
12
+ * exists, every declared filter starts deciding over a truncated population.
13
+ * Pagination is that cap, so the two move together and they move inward.
14
+ *
15
+ * The TIER SCOPE is here for that reason and not by analogy: it narrows the
16
+ * 252-row `block list` under a 300-row page today, so a page that started
17
+ * truncating would decide the default scope by where the window fell. It is the
18
+ * one clause that constrains the ENTITY rather than a projected cell — see
19
+ * {@link ListTierScope}.
20
+ *
21
+ * ONE SHAPE, ALWAYS. The author query becomes a sub-select inside a wrapping
22
+ * SELECT that carries the predicates and the page bounds — whether or not there
23
+ * is anything to filter. It has to be a wrap rather than an appended `HAVING`
24
+ * when there IS something: a `match: "set"` filter reads a cell the author's own
25
+ * `GROUP BY` computed (`standard list`'s `GROUP_CONCAT`), so the predicate can
26
+ * only run after aggregation, and the cap only after the predicate.
27
+ *
28
+ * It is a wrap when there is NOTHING to filter, too, and that is a correction.
29
+ * Appending `LIMIT n` to the author's own text is a syntax error for every
30
+ * story whose text already ends in a solution modifier or a trailing `VALUES`
31
+ * block — `LIMIT` after `LIMIT`, after `OFFSET`, after `VALUES` — and because
32
+ * the page has a DEFAULT, that error fired on every call rather than only on
33
+ * paged ones. The shortcut existed to keep the store running, character for
34
+ * character, the query it ran before. Measured over all nine shipped bodies
35
+ * (median of 15 warm store queries per body, both texts, 2026-09-10) the wrap
36
+ * ran between 2.7 ms FASTER and 0.5 ms slower, deltas straddling zero and the
37
+ * one clear win being `block list`, the 252-row body; every body's rows came
38
+ * back byte-identical through the wrap, key order included. So the shortcut
39
+ * bought nothing measurable and cost a whole class of author query. Keeping it
40
+ * would have meant a guard recognising every trailing-modifier shape the
41
+ * grammar admits — more parsing than the wrap itself needs, to reach the
42
+ * answer the wrap already gives.
43
+ *
44
+ * THE AUTHOR'S TEXT IS SPLIT, NEVER EDITED. Two parts of a query cannot live
45
+ * inside a group graph pattern, and {@link ./authorQuery.readAuthorQuery} is
46
+ * what tells them apart:
47
+ *
48
+ * - The PROLOGUE (`PREFIX`, `BASE`) is lifted to the wrapper's own prologue, in
49
+ * the author's order. That is a split at the `SELECT` keyword, not a rewrite:
50
+ * the body is a suffix of the author's text, byte for byte. A COLLIDING
51
+ * prefix resolves the way it always did — the store prepends the pack's own
52
+ * prefix map ahead of whatever it is handed, the author's declaration is
53
+ * emitted after it, and SPARQL's last declaration wins, so an author who
54
+ * redeclares `ds:` shadows the pack's `ds:` exactly as they did before any
55
+ * wrap existed (verified both shapes against the pinned oxigraph). The
56
+ * wrapper declares nothing and names no prefixed term, so it brings no
57
+ * prefix of its own for an author to collide with.
58
+ * - A DATASET clause (`FROM`, `FROM NAMED`) is refused, at declaration and
59
+ * again here. It sits in the MIDDLE of the author's text rather than at its
60
+ * front, so lifting it would mean cutting a span out — an edit, not a split.
61
+ *
62
+ * The wrapper projects the author's own variables, in the author's own order
63
+ * ({@link ./authorQuery}) — `SELECT *` in the wrapper would hand back
64
+ * alphabetised binding keys and change every JSON answer's shape. An author who
65
+ * wrote `SELECT *` themselves gets `SELECT *`, which is the same alphabetisation
66
+ * twice and so preserves their key order; a FILTERABLE story that writes it is
67
+ * refused, because a projection of `*` names nothing to constrain.
68
+ *
69
+ * USER INPUT IS BOUND, NOT SPLICED. Every value a caller supplies reaches the
70
+ * query as a row of a `VALUES` block — the closest thing the store's text-only
71
+ * interface has to a bound parameter — escaped by
72
+ * {@link ./escape.escapeSparqlString}, and the predicate compares against that
73
+ * variable rather than against interpolated text. The `VALUES` block sits
74
+ * inside `FILTER EXISTS` deliberately: joined into the outer group it would
75
+ * MULTIPLY rows, and a repeated flag over a `set` cell carrying two of the
76
+ * requested members would return that row twice.
77
+ *
78
+ * The comparison itself is the row predicate's, transposed:
79
+ * case-insensitively equal for `exact`, membership of a whitespace-separated
80
+ * set for `set`, substring for search, and a cell that is unbound or empty
81
+ * never matches. One divergence is worth naming: the row predicate NFC-
82
+ * normalised both sides, and SPARQL has no normaliser, so the graph's own
83
+ * spelling is compared as it is stored (the caller's side is still normalised,
84
+ * in {@link ./filterValues}).
85
+ */
86
+
87
+ import { PragmaError } from "../../error/index.js";
88
+ import { RESERVED_VARIABLE_PREFIX } from "../types.js";
89
+ import { readAuthorQuery } from "./authorQuery.js";
90
+ import { escapeSparqlString, formatTerm } from "./escape.js";
91
+
92
+ /** One declared filter, with the values a caller actually supplied. */
93
+ export interface ListPredicate {
94
+ /** SELECT variable the filter constrains (without `?`). */
95
+ readonly variable: string;
96
+ /** Whole-cell comparison, or membership of a whitespace-separated set. */
97
+ readonly match: "exact" | "set";
98
+ /**
99
+ * The admitted values, in the graph's own display spelling. Several are a
100
+ * union (the row matches any of them); several FILTERS are a conjunction.
101
+ */
102
+ readonly terms: readonly string[];
103
+ }
104
+
105
+ /** A declared search, with the term a caller actually supplied. */
106
+ export interface ListSearch {
107
+ /** SELECT variables searched (without `?`). */
108
+ readonly variables: readonly string[];
109
+ /** The normalised search term (non-empty). */
110
+ readonly term: string;
111
+ }
112
+
113
+ /**
114
+ * The tier scope a list is narrowed to, compiled in beside the filters.
115
+ *
116
+ * IN THE QUERY for the same reason every filter is: a scope applied to the rows
117
+ * a page already returned would decide over a truncated population, and `block
118
+ * list` is a 252-row body under a 300-row page today — one product tier more
119
+ * upstream and the default scope would start depending on where the page fell.
120
+ *
121
+ * It constrains the ENTITY rather than a projected tier column. A story's tier
122
+ * column is derived text (`block list` BINDs the IRI's local name), and two of
123
+ * the three scoped stories project no tier at all — `modifier list` and
124
+ * `concept list` publish name and values. The entity variable is the one thing
125
+ * every list-shaped story has in common (`?uri`, the IRI column), and the edge
126
+ * from it to its tier is what the noun already declares.
127
+ */
128
+ export interface ListTierScope {
129
+ /** The SELECT variable carrying the entity's IRI (without `?`). */
130
+ readonly entity: string;
131
+ /** The entity → tier edge, a validated pack term. */
132
+ readonly via: string;
133
+ /** The in-scope tier IRIs, read from the store (never caller input). */
134
+ readonly tiers: readonly string[];
135
+ }
136
+
137
+ /** One page's bounds, applied inside the query after filtering and ordering. */
138
+ export interface ListWindow {
139
+ /** Maximum rows to return. */
140
+ readonly limit: number;
141
+ /** Rows to skip first. */
142
+ readonly offset: number;
143
+ }
144
+
145
+ /** Everything the generated list query is composed from. */
146
+ export interface ListQueryInput {
147
+ /** The author's SPARQL SELECT, used verbatim (split, never edited). */
148
+ readonly query: string;
149
+ /** The declared filters a caller supplied values for. */
150
+ readonly predicates: readonly ListPredicate[];
151
+ /** The declared search a caller supplied a term for. */
152
+ readonly search?: ListSearch;
153
+ /** The tier scope this read answers under, absent when it answers from all. */
154
+ readonly scope?: ListTierScope;
155
+ /** The page to return. */
156
+ readonly window: ListWindow;
157
+ /** The story's label, for a configuration diagnosis. */
158
+ readonly label: string;
159
+ }
160
+
161
+ /**
162
+ * Build the SELECT one page of a list story runs.
163
+ *
164
+ * @param input - The author query, the supplied filters/search, and the page.
165
+ * @returns SPARQL SELECT text composed only from author terms, escaped values
166
+ * and the page's own integers.
167
+ * @throws PragmaError CONFIG_ERROR when the author query cannot be wrapped in a
168
+ * page, when a filterable story's projection cannot be reproduced, or when
169
+ * its query already uses the generated variable prefix. Each names what it
170
+ * found: the same refusal for three different reasons would send an author to
171
+ * the wrong part of their own file.
172
+ */
173
+ export function buildListQuery(input: ListQueryInput): string {
174
+ const { query, predicates, search, scope, window, label } = input;
175
+ const clauses = [
176
+ ...predicates.map((predicate, index) =>
177
+ filterClause(predicate, `${RESERVED_VARIABLE_PREFIX}Filter${index}`),
178
+ ),
179
+ ...(search
180
+ ? [searchClause(search, `${RESERVED_VARIABLE_PREFIX}Search`)]
181
+ : []),
182
+ ...(scope && scope.tiers.length > 0
183
+ ? [scopeClause(scope, `${RESERVED_VARIABLE_PREFIX}Tier`)]
184
+ : []),
185
+ ];
186
+ if (clauses.length > 0 && query.includes(RESERVED_VARIABLE_PREFIX)) {
187
+ throw PragmaError.configError(
188
+ `Story query in ${label} uses the reserved variable prefix "?${RESERVED_VARIABLE_PREFIX}", ` +
189
+ "which the generated filter clauses bind a caller's values to. Rename it.",
190
+ );
191
+ }
192
+ const read = readAuthorQuery(query);
193
+ if (!read.ok) {
194
+ throw PragmaError.configError(
195
+ `Story query in ${label} cannot be paged: ${read.reason}. Every list ` +
196
+ "answer is one page, and a page is a wrapping SELECT over the story's own query.",
197
+ );
198
+ }
199
+ const { prologue, projection, body } = read.query;
200
+ // The SCOPE clause is deliberately not counted here: it constrains the entity
201
+ // through a graph edge, not a projected cell, and a sub-select's `SELECT *`
202
+ // still makes the entity variable visible to the wrapper. A FILTER is the
203
+ // case that cannot be checked — it names a variable, and `*` names none.
204
+ if (
205
+ (predicates.length > 0 || search !== undefined) &&
206
+ projection === undefined
207
+ ) {
208
+ throw PragmaError.configError(
209
+ `Story query in ${label} declares filters or a search, so its SELECT must ` +
210
+ "project its variables by name — the page projects the same names in the " +
211
+ "same order, which `SELECT *` cannot promise.",
212
+ );
213
+ }
214
+ return [
215
+ ...(prologue === "" ? [] : [prologue]),
216
+ `SELECT ${projection ? projection.map((variable) => `?${variable}`).join(" ") : "*"}`,
217
+ "WHERE {",
218
+ " {",
219
+ body,
220
+ " }",
221
+ ...clauses.map((clause) => ` ${clause}`),
222
+ "}",
223
+ modifier(window),
224
+ ].join("\n");
225
+ }
226
+
227
+ /** The page as SPARQL solution modifiers (`OFFSET 0` omitted as the no-op it is). */
228
+ function modifier(window: ListWindow): string {
229
+ return window.offset > 0
230
+ ? `LIMIT ${window.limit} OFFSET ${window.offset}`
231
+ : `LIMIT ${window.limit}`;
232
+ }
233
+
234
+ /**
235
+ * One filter as a row predicate that cannot duplicate a row.
236
+ *
237
+ * `FILTER EXISTS` makes the `VALUES` block a per-row existence test: joining it
238
+ * into the group instead would pair each row with every requested value, and a
239
+ * `set` cell carrying two of them would be returned twice.
240
+ */
241
+ function filterClause(predicate: ListPredicate, bound: string): string {
242
+ const values = predicate.terms
243
+ .map((term) => `"${escapeSparqlString(term)}"`)
244
+ .join(" ");
245
+ const cell = `?${predicate.variable}`;
246
+ const comparison =
247
+ predicate.match === "set"
248
+ ? setMembership(cell, bound)
249
+ : `STR(${cell}) != "" && LCASE(STR(${cell})) = LCASE(?${bound})`;
250
+ return `FILTER EXISTS { VALUES ?${bound} { ${values} } FILTER(${comparison}) }`;
251
+ }
252
+
253
+ /**
254
+ * Membership of a whitespace-separated set, spelled as a padded substring.
255
+ *
256
+ * The cell is a `GROUP_CONCAT` of every value a row belongs to at once — a
257
+ * category and each of its ancestors. Padding both sides with a space is what
258
+ * keeps it a WHOLE-member comparison, so `testing` does not match
259
+ * `testing-unit`; collapsing runs of whitespace first reproduces the row
260
+ * predicate's own `\s+` split, whatever separator the author's aggregate used.
261
+ *
262
+ * TWO WAYS IT IS WIDER THAN THE SPLIT IT REPLACED, both out of reach of every
263
+ * vocabulary the distribution declares and both worth knowing before a
264
+ * vocabulary is widened:
265
+ *
266
+ * - A value that CONTAINS whitespace matches a contiguous RUN of members: cell
267
+ * `"foo bar baz"`, value `"bar baz"` answers true here where
268
+ * `split(/\s+/).includes(value)` answered false. Unreachable today because no
269
+ * admissible value carries whitespace (all 21 `cs:slug` values are clean), and
270
+ * reachable the moment a filter declares `values: ["a b"]` or a graph
271
+ * vocabulary grows a multi-word term.
272
+ * - An EMPTY value matches an EMPTY cell, because `CONTAINS(" ", " ")` is true.
273
+ * The row predicate answered false on an empty cell, and the `exact` branch
274
+ * above still does via its explicit `STR(?c) != ""` guard, which this branch
275
+ * has no equivalent of. Unreachable today because `readFilterVocabularies`
276
+ * drops empty strings from a vocabulary and `rejectUnknownValue` then refuses
277
+ * `""` as unadmitted — i.e. it is the ADMISSION step that closes this, not the
278
+ * predicate.
279
+ */
280
+ function setMembership(cell: string, bound: string): string {
281
+ const members = `CONCAT(" ", REPLACE(LCASE(STR(${cell})), "\\\\s+", " "), " ")`;
282
+ return `CONTAINS(${members}, CONCAT(" ", LCASE(?${bound}), " "))`;
283
+ }
284
+
285
+ /**
286
+ * The tier scope as a row predicate: the entity is in an in-scope tier, or in
287
+ * no tier at all.
288
+ *
289
+ * `EXISTS` with the tiers in a `VALUES` block, for both reasons the filter
290
+ * clauses use that shape. It cannot MULTIPLY a row (an entity in two in-scope
291
+ * tiers is still one entity — the design system asserts one `ds:tier` per block
292
+ * today, and a second one tomorrow must not double the row), and the tier IRIs
293
+ * ride a bound variable rather than being spliced into a pattern.
294
+ *
295
+ * The IRIs are the store's own, read back from the tier hierarchy by
296
+ * {@link ../tierScope.readTierHierarchy} — a caller supplies a tier NAME, which
297
+ * is resolved to a tier before it ever reaches here, so the only thing
298
+ * interpolated is a term the graph itself published.
299
+ *
300
+ * THE UNTIERED HALF IS NOT A COURTESY. `ds:button.icon` is a subcomponent with
301
+ * no `ds:tier`, and a required tier join is exactly what used to hide it: the
302
+ * hand-written block list inner-joined the tier and surfaced it only under
303
+ * `--all-tiers`, which is the defect the declared query's OPTIONAL join closed
304
+ * and `block.parity.test.ts` holds it to. A membership test alone would reopen
305
+ * it — an entity the scope cannot PLACE would become an entity the default
306
+ * scope cannot SHOW. So the predicate is "in scope, or in no tier", which is
307
+ * the same rule the lookup resolver applies to the same rows
308
+ * ({@link ../resolveEntity.applyScope}).
309
+ *
310
+ * `IN` would read closer to the rule as stated, and this is the same predicate:
311
+ * membership of a set, tested per row. A `VALUES` block is how every other
312
+ * generated clause in this module spells a set, so the scope spells it that way
313
+ * too rather than introducing a second idiom for one clause.
314
+ */
315
+ function scopeClause(scope: ListTierScope, bound: string): string {
316
+ const values = scope.tiers.map((iri) => `<${iri}>`).join(" ");
317
+ const entity = `?${scope.entity}`;
318
+ const via = formatTerm(scope.via);
319
+ return (
320
+ `FILTER(EXISTS { VALUES ?${bound} { ${values} } ${entity} ${via} ?${bound} } ` +
321
+ `|| NOT EXISTS { ${entity} ${via} ?${bound}Any })`
322
+ );
323
+ }
324
+
325
+ /**
326
+ * Search as a substring test over every declared variable.
327
+ *
328
+ * `COALESCE(…, false)` per variable rather than one bare disjunction: an
329
+ * unbound cell makes `CONTAINS` raise, and an error inside `||` is not reliably
330
+ * false. The row predicate treated a missing cell as "does not contain", and so
331
+ * does this.
332
+ */
333
+ function searchClause(search: ListSearch, bound: string): string {
334
+ const tests = search.variables
335
+ .map(
336
+ (variable) =>
337
+ `COALESCE(CONTAINS(LCASE(STR(?${variable})), LCASE(?${bound})), false)`,
338
+ )
339
+ .join(" || ");
340
+ return (
341
+ `FILTER EXISTS { VALUES ?${bound} { "${escapeSparqlString(search.term)}" } ` +
342
+ `FILTER(${tests}) }`
343
+ );
344
+ }
@@ -27,6 +27,7 @@
27
27
  import { describe, expect, it } from "vitest";
28
28
  import type { PackLookup } from "../types.js";
29
29
  import {
30
+ buildExpandQuery,
30
31
  buildLookupByIriQuery,
31
32
  buildLookupNamesQuery,
32
33
  buildLookupQuery,
@@ -168,6 +169,54 @@ const RANKED: PackLookup = {
168
169
  },
169
170
  };
170
171
 
172
+ describe("the name FILTER a name-addressed resolve matches by", () => {
173
+ // Pinned as EMITTED TEXT because no store can prove the second half. A
174
+ // fixture graph shows that a padded name resolves (`packs/resolveEntity.test`
175
+ // does, over an entity named "Timeline " the way 66 shipped names are); only
176
+ // the emission says that the ARGUMENT is trimmed too, and that the regex is
177
+ // the two anchored runs rather than something that would also eat the spaces
178
+ // inside "Button Icon".
179
+ const TRIMMED =
180
+ 'FILTER (LCASE(REPLACE(STR(?name), "^\\\\s+|\\\\s+$", "")) = LCASE(';
181
+
182
+ it("compares the name with BOTH sides trimmed, on both name forms", () => {
183
+ for (const query of [
184
+ buildLookupQuery(RANKED, "Timeline"),
185
+ buildNameResolveQuery(RANKED, "Timeline"),
186
+ buildLookupQuery(CONSTRAINED, "spacing.medium"),
187
+ ]) {
188
+ expect(query).toContain(`${TRIMMED}"`);
189
+ }
190
+ });
191
+
192
+ it("trims the ARGUMENT in TypeScript rather than asking the store to", () => {
193
+ // The store side can only trim `?name`. A padded ARGUMENT — a name pasted
194
+ // out of a table cell — has to be trimmed before it becomes a literal, or
195
+ // `lookup "Button "` still misses an entity whose own name is clean.
196
+ expect(buildLookupQuery(RANKED, " Timeline\t")).toContain(
197
+ `${TRIMMED}"Timeline"))`,
198
+ );
199
+ expect(buildNameResolveQuery(RANKED, "\nTimeline ")).toContain(
200
+ `${TRIMMED}"Timeline"))`,
201
+ );
202
+ });
203
+
204
+ it("strips only the ENDS, leaving the spaces a name carries inside it", () => {
205
+ // `Button Icon` is a live block name. A whitespace-collapsing REPLACE would
206
+ // make it unaddressable, so the anchors are the whole point of the pattern.
207
+ const query = buildLookupQuery(RANKED, "Button Icon");
208
+ expect(query).toContain(`${TRIMMED}"Button Icon"))`);
209
+ });
210
+
211
+ it("leaves the IRI-addressed forms with no name FILTER at all", () => {
212
+ // An IRI is the address; `?name` is a label to project there, and filtering
213
+ // on it would re-impose the requirement the IRI form exists to avoid.
214
+ expect(
215
+ buildLookupByIriQuery(RANKED, "https://ds.canonical.com/a"),
216
+ ).not.toContain("FILTER (LCASE(");
217
+ });
218
+ });
219
+
171
220
  describe("the ranking a name resolve orders by", () => {
172
221
  it("orders by the score and then by a key that cannot tie", () => {
173
222
  // Both halves matter. Without DESC(?score) the ranking is not read at all;
@@ -288,3 +337,92 @@ describe("the ranking a name resolve orders by", () => {
288
337
  );
289
338
  });
290
339
  });
340
+
341
+ describe("the expand sub-SELECT", () => {
342
+ const BUTTON = "https://ds.canonical.com/global.component.button";
343
+ const BLOCK: PackLookup = {
344
+ source: "graphql",
345
+ by: "ds:name",
346
+ type: "ds:Component",
347
+ graphqlType: "UIBlock",
348
+ };
349
+
350
+ it("reads each selected field through one OPTIONAL, and orders by nothing", () => {
351
+ // The shape every expand had before an ordering could be declared: rows
352
+ // arrive in the store's own scan order, which SPARQL does not define.
353
+ const query = buildExpandQuery(
354
+ {
355
+ name: "properties",
356
+ relation: "ds:hasProperty",
357
+ select: [{ name: "n", property: "ds:name" }],
358
+ },
359
+ BUTTON,
360
+ BLOCK,
361
+ );
362
+ expect(query).toContain(` <${BUTTON}> ds:hasProperty ?child .`);
363
+ expect(query).toContain(" OPTIONAL { ?child ds:name ?n . }");
364
+ expect(query).not.toContain("ORDER BY");
365
+ });
366
+
367
+ it("emits the declared ordering, in the declared order", () => {
368
+ const query = buildExpandQuery(
369
+ {
370
+ name: "tokens",
371
+ relation: "ds:hasTokenBinding",
372
+ orderBy: ["node", "key", "rank"],
373
+ select: [
374
+ { name: "key", property: "anatomy:styleKey" },
375
+ { name: "rank", property: "ds:rank" },
376
+ { name: "node", property: "ds:node" },
377
+ ],
378
+ },
379
+ BUTTON,
380
+ BLOCK,
381
+ );
382
+ expect(query).toContain("ORDER BY ?node ?key ?rank");
383
+ });
384
+
385
+ it("keeps a blankWhenSelf field's row and UNBINDS the cell when it is the entity's own", () => {
386
+ // Inside the OPTIONAL, which is the whole behaviour: a FILTER outside it
387
+ // would drop the child row entirely, and what is wanted is the row with an
388
+ // empty cell. Compared as strings because the entity's identity value is a
389
+ // literal and so is the cell.
390
+ const query = buildExpandQuery(
391
+ {
392
+ name: "tokens",
393
+ relation: "ds:hasTokenBinding",
394
+ select: [
395
+ { name: "via", property: "ds:viaBlock/ds:name", blankWhenSelf: true },
396
+ ],
397
+ },
398
+ BUTTON,
399
+ BLOCK,
400
+ );
401
+ expect(query).toContain(
402
+ " OPTIONAL {\n ?child ds:viaBlock/ds:name ?via .",
403
+ );
404
+ expect(query).toContain(` <${BUTTON}> ds:name ?__pragmaSelf .`);
405
+ expect(query).toContain(" FILTER(STR(?via) != STR(?__pragmaSelf))");
406
+ });
407
+
408
+ it("interpolates ONLY the resolved entity IRI", () => {
409
+ // Injection safety, unchanged by either addition: the relation, the
410
+ // properties, the ordering names and the identity property are all
411
+ // validated pack terms, and the IRI is the one the base lookup already
412
+ // resolved from the store.
413
+ const query = buildExpandQuery(
414
+ {
415
+ name: "tokens",
416
+ relation: "ds:hasTokenBinding",
417
+ orderBy: ["node"],
418
+ select: [
419
+ { name: "node", property: "ds:node" },
420
+ { name: "via", property: "ds:viaBlock/ds:name", blankWhenSelf: true },
421
+ ],
422
+ },
423
+ BUTTON,
424
+ BLOCK,
425
+ );
426
+ expect(query.match(/<[^>]+>/g)).toEqual([`<${BUTTON}>`, `<${BUTTON}>`]);
427
+ });
428
+ });