@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,239 @@
1
+ /**
2
+ * The response-size budget for a list-shaped answer, measured over the SHIPPED
3
+ * pack (PROTECTED).
4
+ *
5
+ * No list had a size budget before this. The five enforced performance
6
+ * constants are all latency, none of them runs a story's list query, and the
7
+ * only size ceiling in the package covered the MCP resource listing rather than
8
+ * a verb — so a story could grow its payload without limit and nothing turned
9
+ * red. The largest answer the distribution ships today is 78 KB from one call,
10
+ * and an agent pays for it twice: once in transfer, once in the context window
11
+ * it can no longer spend on the task.
12
+ *
13
+ * WHY A BUDGET AND NOT JUST PAGINATION. `--limit` gives a caller a way to ask
14
+ * for less. A budget is what fails the build when a story forgets that its
15
+ * DEFAULT answer is the one every speculative call gets. The two are
16
+ * complementary: the default page (500 rows) bounds the row count, and this
17
+ * bounds the bytes those rows turn into — because a row's width is a story's
18
+ * choice and no row count implies a payload size.
19
+ *
20
+ * WHERE IT IS ENFORCED. Where the payload is BUILT — the `json` formatter, the
21
+ * one seam both machine surfaces serialise through (`--format json` parses it
22
+ * into the envelope; the MCP tool result parses it into `toolSuccess`). Not in
23
+ * the timed perf pass: that pass is deliberately not run by CI (owner ruling
24
+ * 2026-08-30), and a budget nothing runs is not a gate. This is the same choice
25
+ * the resource listing's ceiling made, for the same reason.
26
+ *
27
+ * WHAT IT DOES NOT COVER. Latency. Declaring stories is measurably free — when
28
+ * five read stories moved into the distribution's configuration and grew it
29
+ * from 55 to 513 lines, the fast-path deltas straddled zero — and no latency
30
+ * constant moves for this either.
31
+ */
32
+
33
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
34
+ import { compileStoryModule } from "../kernel/packs/compile.js";
35
+ import { DEFAULT_LIST_LIMIT, MAX_LIST_WINDOW } from "../kernel/packs/paging.js";
36
+ import {
37
+ distributionSource,
38
+ type PackList,
39
+ type PackPage,
40
+ } from "../kernel/packs/types.js";
41
+ import { verbKey } from "../kernel/packs/uniqueness.js";
42
+ import { bootRuntime } from "../kernel/runtime/boot.js";
43
+ import type { PragmaRuntime } from "../kernel/runtime/types.js";
44
+ import type { VerbSpec } from "../kernel/spec/types.js";
45
+ import { TEST_FLAGS } from "../testing/helpers/projectCli.js";
46
+ import { declaredStories } from "./distribution.js";
47
+
48
+ /**
49
+ * The ceiling on ONE list-shaped answer, in bytes of the serialised payload.
50
+ *
51
+ * **Derived from measurement, 2026-09-10**, over the pack the distribution
52
+ * actually ships: the largest answer is `standard list` at 147 rows and 78,129
53
+ * bytes, whose rows are fat rather than many, and the most ROWS is `block list`
54
+ * at 252 for 32,956 bytes. 1.6 × 78,129 = 125,006, rounded to 125,000 — the
55
+ * same shape of derivation the resource listing used (60,000 → 100,000 at a
56
+ * measured 65,119, i.e. 1.54×), and headroom enough that ordinary upstream
57
+ * growth in the code-standards pack does not turn this red on a pack bump.
58
+ * Every figure and the full table are in BUDGETS.md.
59
+ *
60
+ * **When this is next reached, the fix is not a bigger number.** A ceiling
61
+ * raised on demand is a formality. The fix is either a narrower default page
62
+ * for the story that blew it — which is the kernel decision K-1 deliberately
63
+ * left un-taken, there being no story that needs it yet — or narrower columns,
64
+ * which trades an honest surface for an arithmetic one and is the worse of the
65
+ * two. Whichever is chosen, it is a decision with a reason, which is what a
66
+ * budget exists to force.
67
+ */
68
+ const LIST_PAYLOAD_BUDGET_BYTES = 125_000;
69
+
70
+ const SOURCE = distributionSource("pragma.conf.ts");
71
+
72
+ /** One list-shaped body of a declared story, with the verb that runs it. */
73
+ interface Body {
74
+ readonly label: string;
75
+ readonly verb: VerbSpec;
76
+ }
77
+
78
+ /**
79
+ * Every list-shaped body the distribution declares, DERIVED from the config —
80
+ * a story declared tomorrow is held to this budget by the author who declares
81
+ * it, without their help and without an entry in a list here.
82
+ */
83
+ const BODIES: readonly Body[] = [...declaredStories].flatMap(
84
+ ([noun, story]) => {
85
+ const module = compileStoryModule(story, SOURCE, {});
86
+ const find = (verb: string): Body => {
87
+ const found = module.verbs.find(
88
+ (v) => verbKey(v.path) === `${noun} ${verb}`,
89
+ );
90
+ if (!found) throw new Error(`no compiled "${noun} ${verb}" verb`);
91
+ return { label: `${noun} ${verb}`, verb: found };
92
+ };
93
+ const bodies: Body[] = [];
94
+ if ((story.list as PackList | undefined) !== undefined) {
95
+ bodies.push(find("list"));
96
+ }
97
+ for (const verb of story.verbs ?? []) bodies.push(find(verb.verb));
98
+ return bodies;
99
+ },
100
+ );
101
+
102
+ let rt: PragmaRuntime;
103
+ beforeAll(async () => {
104
+ rt = bootRuntime(TEST_FLAGS);
105
+ await rt.store.get();
106
+ }, 60_000);
107
+
108
+ afterAll(async () => {
109
+ (await rt.store.get()).store.dispose();
110
+ });
111
+
112
+ /**
113
+ * The payload one call transmits, in bytes.
114
+ *
115
+ * The `json` formatter's own output is indented — it is read by humans through
116
+ * `--format json | jq` as often as by machines — but neither machine surface
117
+ * sends those spaces: both parse it and re-serialise into their envelope. So
118
+ * the budget measures what crosses the wire, not what the formatter's string
119
+ * happens to weigh.
120
+ */
121
+ async function payloadBytes(
122
+ body: Body,
123
+ params: Record<string, unknown>,
124
+ ): Promise<{ rows: number; bytes: number }> {
125
+ const page = (await body.verb.run(params, rt)) as { rows: unknown[] };
126
+ const built = body.verb.output.formatters.json(page as never);
127
+ return {
128
+ rows: page.rows.length,
129
+ bytes: Buffer.byteLength(JSON.stringify(JSON.parse(built))),
130
+ };
131
+ }
132
+
133
+ describe("list response budget, shipped pack (PROTECTED)", () => {
134
+ it("derives every list-shaped body from the config", () => {
135
+ // A guard, not a no-op: an empty derivation would make the sweep below
136
+ // vacuously green, which is the failure mode a budget can least afford.
137
+ expect(BODIES.map((body) => body.label)).toContain("standard list");
138
+ expect(BODIES.length).toBeGreaterThanOrEqual(9);
139
+ });
140
+
141
+ it.each(BODIES.map((body) => [body.label, body] as const))(
142
+ "%s: the default answer is inside the budget",
143
+ async (_label, body) => {
144
+ const { rows, bytes } = await payloadBytes(body, {});
145
+ expect(bytes).toBeLessThanOrEqual(LIST_PAYLOAD_BUDGET_BYTES);
146
+ // The row half of the budget: the default page is the ceiling, and a
147
+ // body answering more of them would mean the page was not applied.
148
+ expect(rows).toBeLessThanOrEqual(DEFAULT_LIST_LIMIT);
149
+ },
150
+ 60_000,
151
+ );
152
+
153
+ it.each(BODIES.map((body) => [body.label, body] as const))(
154
+ "%s: a population past the budget is PAGED, and each page is inside it",
155
+ async (_label, body) => {
156
+ // This case used to assert that the whole population also fitted one
157
+ // answer, and recorded that the two measurements were the same number
158
+ // because every story fitted one default page. Its own note said the day
159
+ // a story outgrew the page they would diverge and that this assertion
160
+ // would be what said so. They have diverged: the token-graph stories
161
+ // brought populations of 745, 1,156, 1,237 and 1,746 rows, and asking one
162
+ // of them for everything at once now measures 158 KB to 273 KB.
163
+ //
164
+ // So the claim moves to the one that survives, and it is the stronger of
165
+ // the two for exactly the bodies that broke the old one. The budget
166
+ // bounds an ANSWER, never a population — a population outgrowing it is
167
+ // what pagination is for, and the promise is that every page a caller can
168
+ // reach is inside the budget and that the pages together are the whole
169
+ // answer. A story still inside the budget is held to the old assertion
170
+ // unchanged, so nothing is given up where nothing had to be.
171
+ const whole = await payloadBytes(body, { limit: MAX_LIST_WINDOW });
172
+ if (whole.bytes <= LIST_PAYLOAD_BUDGET_BYTES) {
173
+ expect(whole.bytes).toBeLessThanOrEqual(LIST_PAYLOAD_BUDGET_BYTES);
174
+ return;
175
+ }
176
+ // Past the budget: every page inside it, and the pages exhaust the
177
+ // population rather than stopping short of it.
178
+ let after: string | undefined;
179
+ let seen = 0;
180
+ let pages = 0;
181
+ do {
182
+ const page = (await body.verb.run(
183
+ after === undefined ? {} : { after },
184
+ rt,
185
+ )) as PackPage;
186
+ const bytes = Buffer.byteLength(
187
+ JSON.stringify(
188
+ JSON.parse(body.verb.output.formatters.json(page as never)),
189
+ ),
190
+ );
191
+ expect(
192
+ bytes,
193
+ `page ${pages + 1} of ${body.label} is outside the budget`,
194
+ ).toBeLessThanOrEqual(LIST_PAYLOAD_BUDGET_BYTES);
195
+ expect(page.rows.length).toBeLessThanOrEqual(DEFAULT_LIST_LIMIT);
196
+ seen += page.rows.length;
197
+ after = page.nextAfter;
198
+ pages += 1;
199
+ // A guard against a cursor that never terminates: the kernel's own row
200
+ // ceiling bounds how far a caller may walk, so a walk needing more
201
+ // pages than that is a defect in the cursor rather than a big corpus.
202
+ expect(pages).toBeLessThan(MAX_LIST_WINDOW / DEFAULT_LIST_LIMIT);
203
+ } while (after !== undefined);
204
+ expect(pages).toBeGreaterThan(1);
205
+ expect(seen).toBe(whole.rows);
206
+ },
207
+ 120_000,
208
+ );
209
+
210
+ it("the budget has headroom over the largest answer, and is not slack", async () => {
211
+ // A ceiling that no longer bounds anything catches nothing, and one sitting
212
+ // on the measurement flakes. Both halves are asserted so a future edit to
213
+ // the constant has to face them.
214
+ //
215
+ // The effective headroom is the 0.8 factor, not the 1.6 the ceiling was
216
+ // derived from: the largest answer may grow by 28 per cent before this
217
+ // turns red, and BUDGETS.md says 28 per cent for that reason. The two
218
+ // numbers answer different questions — 1.6 is how the ceiling was chosen,
219
+ // 0.8 is what the suite enforces — and the tighter one is the gate.
220
+ const measured = await Promise.all(
221
+ BODIES.map((body) => payloadBytes(body, {})),
222
+ );
223
+ const largest = Math.max(...measured.map((entry) => entry.bytes));
224
+ expect(largest).toBeGreaterThan(LIST_PAYLOAD_BUDGET_BYTES / 4);
225
+ expect(largest).toBeLessThan(LIST_PAYLOAD_BUDGET_BYTES * 0.8);
226
+ }, 60_000);
227
+
228
+ it("the kernel's row ceiling cannot ask for an answer above the budget", () => {
229
+ // The arithmetic that makes `MAX_LIST_WINDOW` a derived number rather than
230
+ // a taste: the narrowest row a story can serialise is `{}` and its
231
+ // separating comma, three bytes, so a limit above BUDGET/3 could not
232
+ // produce a legal answer whatever the story's columns are — and a limit
233
+ // that cannot produce a legal answer is not a legal limit. Asserted here
234
+ // because the ceiling lives in `paging.ts`, which cannot import a test.
235
+ expect(MAX_LIST_WINDOW * 3).toBeLessThanOrEqual(LIST_PAYLOAD_BUDGET_BYTES);
236
+ // And it is not so tight that it refuses a caller who wants everything.
237
+ expect(MAX_LIST_WINDOW).toBeGreaterThan(DEFAULT_LIST_LIMIT);
238
+ });
239
+ });
@@ -0,0 +1,387 @@
1
+ /**
2
+ * The list→lookup grammar, EXECUTED against the shipped pack, for every noun
3
+ * that has both halves (PROTECTED).
4
+ *
5
+ * The tool descriptions instruct an agent to take a row's `name` VERBATIM from
6
+ * `<noun>_list` to `<noun>_lookup`. That promise broke for `standard` when the
7
+ * shipped data and the story keyed the two halves on different properties:
8
+ * `standard list` published names synthesized from the IRI while
9
+ * `standard lookup` answered only the ~13% of standards carrying an asserted
10
+ * `cs:name` — a closed loop where every published address missed (#1047). The
11
+ * fixture suites could not see it, because a fixture obligingly asserts
12
+ * whatever the story reads; only the corpus the release actually ships can
13
+ * falsify the promise the release actually makes.
14
+ *
15
+ * So this file boots the SHIPPED graph and asserts the round trip over the
16
+ * WHOLE corpus, not a sample, for every noun with a list+lookup pair — the
17
+ * defect class is not standard-specific, since every pair shares this
18
+ * machinery. Three guarantees per noun:
19
+ *
20
+ * - every name `list` publishes resolves through `lookup` (executed through
21
+ * the real verb bodies, so glob/IRI argument-shape dispatch is exercised
22
+ * with the very strings an agent would paste);
23
+ * - every published name is in the addressable population (`listEntityNames`
24
+ * — the ONE pool that feeds miss-suggestions, glob expansion AND `sample`'s
25
+ * draw), so a suggestion can never offer a name the resolve cannot answer;
26
+ * - for a story that derives names (`nameFallback: "iri"`), that pool covers
27
+ * the WHOLE class population — `sample` is presented as "see real data
28
+ * shapes", and drawing only from the subset carrying an asserted name would
29
+ * be the #1047 defect wearing sample's clothes.
30
+ *
31
+ * The final describe is the negative control: the same assertions run against
32
+ * a fixture compiled in the exact pre-#1047 shape (a list that synthesizes
33
+ * names over a lookup that requires the asserted property) and are shown to
34
+ * FAIL. A guard whose failing case no longer fails is worse than no guard.
35
+ */
36
+
37
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
38
+ import { compileStoryModule } from "../kernel/packs/compile.js";
39
+ import { DEFAULT_LIST_LIMIT, MAX_LIST_WINDOW } from "../kernel/packs/paging.js";
40
+ import { listEntityNames } from "../kernel/packs/resolveEntity.js";
41
+ import {
42
+ distributionSource,
43
+ type PackDefinition,
44
+ type PackPage,
45
+ } from "../kernel/packs/types.js";
46
+ import { verbKey } from "../kernel/packs/uniqueness.js";
47
+ import { bootRuntime } from "../kernel/runtime/boot.js";
48
+ import type { PragmaRuntime } from "../kernel/runtime/types.js";
49
+ import type { CapabilityModule, VerbSpec } from "../kernel/spec/types.js";
50
+ import { buildFixtureRuntime } from "../testing/helpers/packRuntime.js";
51
+ import { TEST_FLAGS } from "../testing/helpers/projectCli.js";
52
+ import { declaredStories, storyModules } from "./distribution.js";
53
+
54
+ /** The provenance label the distribution's own compiled stories carry. */
55
+ const SOURCE = distributionSource("pragma.conf.ts");
56
+
57
+ /**
58
+ * Every noun declaring BOTH halves of the grammar, derived from the config
59
+ * rather than listed — a story added tomorrow is covered the moment it is
60
+ * declared, by the author who declares it and without their help.
61
+ */
62
+ const NOUNS: readonly string[] = [...declaredStories]
63
+ .filter(([, story]) => story.list !== undefined && story.lookup !== undefined)
64
+ .map(([noun]) => noun);
65
+
66
+ /**
67
+ * Nouns whose shipped corpus is empty TODAY — a data-content gap this suite
68
+ * does not own.
69
+ *
70
+ * EMPTY, and that is the allowlist working as designed rather than a list
71
+ * nobody maintains. `token` was its only entry, because the noun addressed
72
+ * `ds:Token` — a class no shipped graph asserted. Repointed at the token
73
+ * SYMBOLS, and with the token-ontology pack now publishing the name literal
74
+ * both halves of the grammar key on, the noun answers 745 rows and the entry
75
+ * had to go: an entry here is ASSERTED to have zero rows, so it goes red the
76
+ * moment the corpus arrives.
77
+ *
78
+ * The allowlist EXPIRES BY CONSTRUCTION, which is why it is kept rather than
79
+ * deleted. A list that merely skipped the non-empty assertion would be a
80
+ * permanent blind spot — a noun gains data, nobody removes the entry, a later
81
+ * regression back to zero rows stays vacuously green — which is exactly the
82
+ * hidden-empty case this guard exists to prevent, sitting inside the guard
83
+ * itself.
84
+ */
85
+ const EMPTY_CORPUS_TODAY: readonly string[] = [];
86
+
87
+ /** The verb `<noun> <verb>` from a compiled module, or throw naming the gap. */
88
+ function verbOf(
89
+ module: CapabilityModule,
90
+ noun: string,
91
+ verb: string,
92
+ ): VerbSpec {
93
+ const found = module.verbs.find((v) => verbKey(v.path) === `${noun} ${verb}`);
94
+ if (!found) throw new Error(`no compiled "${noun} ${verb}" verb`);
95
+ return found;
96
+ }
97
+
98
+ /**
99
+ * Run `<noun> list` and return every name it publishes, verbatim, deduped.
100
+ *
101
+ * WALKS THE PAGES, and it has to. This file's promise is the round trip over the
102
+ * WHOLE corpus rather than a sample, and that used to come free: every declared
103
+ * population fitted inside one default page, so one call was the corpus. Three
104
+ * populations now exceed it — the token symbols, the platform variables and the
105
+ * chain — so reading one page would quietly turn this suite into a test of the
106
+ * first 300 names in alphabetical order, which is the sampling it exists to
107
+ * refuse.
108
+ *
109
+ * The walk is bounded by the kernel's own row ceiling for the same reason the
110
+ * budget sweep's is: a list needing more pages than that is a cursor defect, not
111
+ * a large corpus, and an unbounded `while` over a broken cursor would hang the
112
+ * suite instead of failing it.
113
+ */
114
+ async function publishedNames(
115
+ rt: PragmaRuntime,
116
+ module: CapabilityModule,
117
+ noun: string,
118
+ ): Promise<string[]> {
119
+ const verb = verbOf(module, noun, "list");
120
+ const names = new Set<string>();
121
+ let after: string | undefined;
122
+ let pages = 0;
123
+ do {
124
+ const page = (await verb.run(
125
+ after === undefined ? {} : { after },
126
+ rt,
127
+ )) as PackPage;
128
+ for (const row of page.rows) {
129
+ const name = row.name ?? "";
130
+ if (name !== "") names.add(name);
131
+ }
132
+ after = page.nextAfter;
133
+ pages += 1;
134
+ if (pages > MAX_LIST_WINDOW / DEFAULT_LIST_LIMIT) {
135
+ throw new Error(
136
+ `${noun} list did not terminate after ${pages} pages — the cursor is not advancing`,
137
+ );
138
+ }
139
+ } while (after !== undefined);
140
+ return [...names];
141
+ }
142
+
143
+ /**
144
+ * Take every published name VERBATIM through the real lookup verb, exactly as
145
+ * the tool description instructs an agent to, and return the per-name errors.
146
+ * One batched call: the resolver treats each name independently, and a total
147
+ * miss throws — surfaced as one synthetic error so the assertion that reads
148
+ * this stays a set comparison rather than a try/catch.
149
+ */
150
+ async function lookupErrors(
151
+ rt: PragmaRuntime,
152
+ module: CapabilityModule,
153
+ noun: string,
154
+ names: readonly string[],
155
+ ): Promise<{ query: string; code: string }[]> {
156
+ if (names.length === 0) return [];
157
+ try {
158
+ const output = (await verbOf(module, noun, "lookup").run(
159
+ { name: [...names] },
160
+ rt,
161
+ )) as { errors: { query: string; code: string }[] };
162
+ return output.errors;
163
+ } catch (error) {
164
+ return [
165
+ {
166
+ query: `(all ${names.length})`,
167
+ code: error instanceof Error ? error.message : String(error),
168
+ },
169
+ ];
170
+ }
171
+ }
172
+
173
+ // ONE store for the file — booting the shipped pack is the expensive part, and
174
+ // this package already contends under parallel runs.
175
+ let rt: PragmaRuntime;
176
+ beforeAll(async () => {
177
+ rt = bootRuntime(TEST_FLAGS);
178
+ await rt.store.get();
179
+ }, 60_000);
180
+ afterAll(async () => {
181
+ (await rt.store.get()).store.dispose();
182
+ });
183
+
184
+ /** One scalar from the shipped graph, queried directly. */
185
+ async function scalar(query: string): Promise<string | undefined> {
186
+ const result = await rt.query.sparql(query);
187
+ if (result.type !== "select") return undefined;
188
+ const row = result.bindings[0];
189
+ return row === undefined ? undefined : Object.values(row)[0];
190
+ }
191
+
192
+ describe("every list-published name resolves through lookup, whole corpus (PROTECTED)", () => {
193
+ it("derives at least one list+lookup noun from the config", () => {
194
+ expect(NOUNS.length).toBeGreaterThan(0);
195
+ // The two nouns this guarantee has already been broken for / leaned on.
196
+ expect(NOUNS).toContain("standard");
197
+ expect(NOUNS).toContain("block");
198
+ });
199
+
200
+ it.each(NOUNS.map((noun) => [noun] as const))(
201
+ "%s: every published name round-trips, and its corpus is not silently empty",
202
+ async (noun) => {
203
+ const module = storyModules.get(noun) as CapabilityModule;
204
+ const names = await publishedNames(rt, module, noun);
205
+ if (EMPTY_CORPUS_TODAY.includes(noun)) {
206
+ // Asserted empty, not skipped: this is what expires the allowlist.
207
+ expect(
208
+ names,
209
+ `${noun} is allowlisted as an empty corpus but published rows — ` +
210
+ "delete it from EMPTY_CORPUS_TODAY so a later regression to zero rows cannot hide behind the entry",
211
+ ).toEqual([]);
212
+ } else {
213
+ expect(
214
+ names.length,
215
+ `${noun} list published no rows at all from the shipped pack`,
216
+ ).toBeGreaterThan(0);
217
+ }
218
+
219
+ const errors = await lookupErrors(rt, module, noun, names);
220
+ expect(
221
+ errors,
222
+ `${noun} list published ${errors.length} name(s) its own lookup cannot resolve — ` +
223
+ "the two-step grammar the tool descriptions document is broken for every agent following it",
224
+ ).toEqual([]);
225
+ },
226
+ 120_000,
227
+ );
228
+
229
+ it.each(NOUNS.map((noun) => [noun] as const))(
230
+ "%s: every published name is in the pool that feeds suggestions, globs and sample",
231
+ async (noun) => {
232
+ const story = declaredStories.get(noun) as PackDefinition;
233
+ const lookup = story.lookup;
234
+ if (!lookup) throw new Error(`no "${noun}" lookup`);
235
+ const module = storyModules.get(noun) as CapabilityModule;
236
+
237
+ const names = await publishedNames(rt, module, noun);
238
+ const pool = await listEntityNames(rt, lookup, SOURCE);
239
+ // The resolve matches case-insensitively; hold the pool to the same bar.
240
+ const addressable = new Set(pool.map((name) => name.toLowerCase()));
241
+ const missing = names.filter(
242
+ (name) => !addressable.has(name.toLowerCase()),
243
+ );
244
+ expect(
245
+ missing,
246
+ `${noun}: published but not addressable — a miss-suggestion or glob over this pool can hand out none of these, and sample can never draw them`,
247
+ ).toEqual([]);
248
+ },
249
+ 60_000,
250
+ );
251
+
252
+ it.each(
253
+ NOUNS.filter(
254
+ (noun) => declaredStories.get(noun)?.lookup?.nameFallback === "iri",
255
+ ).map((noun) => [noun] as const),
256
+ )(
257
+ "%s: the derived-name pool covers the WHOLE class population (sample is representative)",
258
+ async (noun) => {
259
+ const story = declaredStories.get(noun) as PackDefinition;
260
+ const lookup = story.lookup;
261
+ if (!lookup?.type && !lookup?.types?.length) {
262
+ throw new Error(`"${noun}" derives names without a class constraint`);
263
+ }
264
+ const types = (lookup.types ?? [lookup.type as string]).join(" ");
265
+ // The pool is DISTINCT NAMES, and a name may be legitimately shared (two
266
+ // shipped standards carry the same upstream rdfs:label; the ranked
267
+ // resolve answers a shared name with every entity it reaches). So the
268
+ // full-population claim is counted the way the pool is built: one name
269
+ // per asserted `by` value, plus one derived name per instance carrying
270
+ // none. A pool smaller than that is the #1047 defect — sample and
271
+ // suggestions drawing from the labelled subset only. (A prose label
272
+ // colliding with a slash-derived name would make this one too strict;
273
+ // that collision would itself be a data defect worth the red bar.)
274
+ const labelled = Number(
275
+ await scalar(
276
+ [
277
+ "SELECT (COUNT(DISTINCT ?name) AS ?n) WHERE {",
278
+ ` VALUES ?class { ${types} }`,
279
+ ` ?uri a ?class ; ${lookup.by} ?name .`,
280
+ "}",
281
+ ].join("\n"),
282
+ ),
283
+ );
284
+ const bare = Number(
285
+ await scalar(
286
+ [
287
+ "SELECT (COUNT(DISTINCT ?uri) AS ?n) WHERE {",
288
+ ` VALUES ?class { ${types} }`,
289
+ " ?uri a ?class .",
290
+ ` FILTER NOT EXISTS { ?uri ${lookup.by} ?any . }`,
291
+ "}",
292
+ ].join("\n"),
293
+ ),
294
+ );
295
+ const pool = await listEntityNames(rt, lookup, SOURCE);
296
+
297
+ expect(labelled + bare).toBeGreaterThan(0);
298
+ expect(
299
+ pool.length,
300
+ `${noun}: ${labelled} asserted name(s) + ${bare} instance(s) with none, but only ` +
301
+ `${pool.length} addressable names — sample's draw pool is an unrepresentative slice of the corpus`,
302
+ ).toBe(labelled + bare);
303
+ },
304
+ 60_000,
305
+ );
306
+ });
307
+
308
+ describe("negative control: the pre-#1047 shape fails these assertions", () => {
309
+ // The exact divergence that shipped: a list that COALESCEs an OPTIONAL
310
+ // display name with an IRI-derived one, over a lookup that requires the
311
+ // asserted property (no nameFallback). One fixture standard carries the
312
+ // property; the other is published under a name the lookup cannot reach.
313
+ const PREFIXES = {
314
+ rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
315
+ cs: "http://pragma.canonical.com/codestandards#",
316
+ };
317
+ const TTL = `
318
+ @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
319
+ @prefix owl: <http://www.w3.org/2002/07/owl#> .
320
+ @prefix cs: <http://pragma.canonical.com/codestandards#> .
321
+
322
+ cs:name a owl:DatatypeProperty .
323
+ cs:description a owl:DatatypeProperty .
324
+
325
+ cs:react.component.tsdoc a cs:CodeStandard ;
326
+ cs:description "Published under a derived name; carries no cs:name." .
327
+ cs:code.function.purity a cs:CodeStandard ;
328
+ cs:name "Function purity" ;
329
+ cs:description "Carries an asserted cs:name." .
330
+ `;
331
+ const BROKEN_STORY: PackDefinition = {
332
+ noun: "standard",
333
+ description: "pre-#1047 shape",
334
+ list: {
335
+ query: [
336
+ "SELECT ?uri ?name ?description WHERE {",
337
+ " ?uri a cs:CodeStandard ;",
338
+ " cs:description ?description .",
339
+ " OPTIONAL { ?uri cs:name ?n . }",
340
+ ' BIND(COALESCE(?n, REPLACE(STRAFTER(STR(?uri), "#"), "\\\\.", "/")) AS ?name)',
341
+ "}",
342
+ "ORDER BY ?name",
343
+ ].join("\n"),
344
+ columns: [
345
+ { field: "name", label: "Name" },
346
+ { field: "description", label: "Description" },
347
+ ],
348
+ },
349
+ lookup: {
350
+ source: "sparql",
351
+ by: "cs:name", // required — no nameFallback: the pre-#1047 population
352
+ type: "cs:CodeStandard",
353
+ fields: [
354
+ {
355
+ name: "description",
356
+ property: "cs:description",
357
+ label: "Description",
358
+ },
359
+ ],
360
+ },
361
+ };
362
+
363
+ it("publishes a name its own lookup cannot resolve, and these assertions catch it", async () => {
364
+ const { rt: fixtureRt } = await buildFixtureRuntime({
365
+ ttl: TTL,
366
+ prefixes: PREFIXES,
367
+ });
368
+ const module = compileStoryModule(BROKEN_STORY, SOURCE, PREFIXES);
369
+
370
+ const names = await publishedNames(fixtureRt, module, "standard");
371
+ expect(names).toContain("react/component/tsdoc");
372
+ expect(names).toContain("Function purity");
373
+
374
+ // The round-trip assertion goes RED on the synthesized name…
375
+ const errors = await lookupErrors(fixtureRt, module, "standard", names);
376
+ expect(errors.map((error) => error.query)).toEqual([
377
+ "react/component/tsdoc",
378
+ ]);
379
+
380
+ // …and so does the pool-inclusion assertion: the addressable population
381
+ // is only the labelled subset.
382
+ const lookup = BROKEN_STORY.lookup;
383
+ if (!lookup) throw new Error("fixture story has no lookup");
384
+ const pool = await listEntityNames(fixtureRt, lookup, SOURCE);
385
+ expect(pool).toEqual(["Function purity"]);
386
+ }, 60_000);
387
+ });