@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,156 @@
1
+ /**
2
+ * A truncated list says so — in EVERY format it is read in.
3
+ *
4
+ * The page notice reached `plain` (stderr) and `json` (`meta.notice`) but never
5
+ * `llm`, which is the form an agent reads and the form ANY piped invocation
6
+ * auto-detects. So `pragma token list --type color` ended at row 300 under a
7
+ * heading that said `(300)`, with nothing anywhere saying a page had been cut.
8
+ *
9
+ * The heading admits it too. The total is NOT named: a page is cut with one
10
+ * extra row rather than a second COUNT over the filtered population, so "more
11
+ * exist" is the strongest claim the read can honestly make.
12
+ */
13
+
14
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
15
+ import { buildFixtureRuntime } from "../../testing/helpers/packRuntime.js";
16
+ import { executeVerb } from "../project/cli/dispatch.js";
17
+ import type { GlobalFlags, PragmaRuntime } from "../runtime/types.js";
18
+ import type { VerbSpec } from "../spec/types.js";
19
+ import { compilePack } from "./compile.js";
20
+ import type { PackDefinition } from "./types.js";
21
+ import { distributionSource } from "./types.js";
22
+ import { verbKey } from "./uniqueness.js";
23
+
24
+ const PREFIXES = {
25
+ ex: "https://example.org/widgets#",
26
+ owl: "http://www.w3.org/2002/07/owl#",
27
+ rdfs: "http://www.w3.org/2000/01/rdf-schema#",
28
+ xsd: "http://www.w3.org/2001/XMLSchema#",
29
+ };
30
+
31
+ const TTL = `
32
+ @prefix ex: <https://example.org/widgets#> .
33
+ @prefix owl: <http://www.w3.org/2002/07/owl#> .
34
+ @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
35
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
36
+
37
+ ex:Widget a owl:Class .
38
+ ex:name a owl:DatatypeProperty ; rdfs:domain ex:Widget ; rdfs:range xsd:string .
39
+ ex:kind a owl:DatatypeProperty ; rdfs:domain ex:Widget ; rdfs:range xsd:string .
40
+
41
+ ex:button a ex:Widget ; ex:name "Button" ; ex:kind "input" .
42
+ ex:label a ex:Widget ; ex:name "Label" ; ex:kind "display" .
43
+ ex:slider a ex:Widget ; ex:name "Slider" ; ex:kind "input" .
44
+ `;
45
+
46
+ /** The recovery a story authors for an EMPTY POPULATION — never for a filter. */
47
+ const EMPTY_RECOVERY = {
48
+ message: "No widgets in the store.",
49
+ cli: "sources update",
50
+ } as const;
51
+
52
+ const WIDGET_LIST = {
53
+ query: [
54
+ "SELECT ?uri ?name ?kind WHERE {",
55
+ " ?uri a ex:Widget ; ex:name ?name .",
56
+ " OPTIONAL { ?uri ex:kind ?kind }",
57
+ "} ORDER BY ?name",
58
+ ].join("\n"),
59
+ columns: [
60
+ { field: "uri", label: "IRI" },
61
+ { field: "name", label: "Name" },
62
+ { field: "kind", label: "Kind" },
63
+ ],
64
+ filters: [{ param: "kind", variable: "kind", values: ["input", "display"] }],
65
+ search: { variables: ["name"] },
66
+ emptyRecovery: EMPTY_RECOVERY,
67
+ };
68
+
69
+ /** Three rows — enough to cut a page in two. */
70
+ const WIDGET_PACK: PackDefinition = {
71
+ noun: "widget",
72
+ description: "List widgets.",
73
+ list: WIDGET_LIST,
74
+ };
75
+
76
+ const listVerb = (pack: PackDefinition): VerbSpec =>
77
+ compilePack(pack, distributionSource("test:list"), PREFIXES).find(
78
+ (v) => verbKey(v.path) === `${pack.noun} list`,
79
+ ) as VerbSpec;
80
+
81
+ const REAL = { dryRun: false, undo: false, yes: false };
82
+
83
+ const PLAIN: GlobalFlags = {
84
+ llm: false,
85
+ autoLlm: false,
86
+ format: "plain",
87
+ verbose: false,
88
+ };
89
+ const LLM: GlobalFlags = { ...PLAIN, llm: true, format: "llm" };
90
+ const JSON_FLAGS: GlobalFlags = { ...PLAIN, format: "json" };
91
+
92
+ let rt: PragmaRuntime;
93
+ /** The same runtime, reading in another format. */
94
+ const as = (flags: GlobalFlags): PragmaRuntime => ({
95
+ ...rt,
96
+ globalFlags: flags,
97
+ });
98
+
99
+ beforeAll(async () => {
100
+ ({ rt } = await buildFixtureRuntime({ ttl: TTL, prefixes: PREFIXES }));
101
+ });
102
+ afterAll(async () => {
103
+ (await rt.store.get()).store.dispose();
104
+ });
105
+
106
+ describe("a truncated page says so in every format", () => {
107
+ it("plain: the notice is the last thing the reader is told", async () => {
108
+ const outcome = await executeVerb(
109
+ listVerb(WIDGET_PACK),
110
+ { limit: 2 },
111
+ REAL,
112
+ as(PLAIN),
113
+ );
114
+ expect(outcome.exitCode).toBe(0);
115
+ expect(outcome.stdout).toContain("Button");
116
+ const notice = `${outcome.stdout ?? ""}${outcome.stderr ?? ""}`;
117
+ expect(notice).toContain("Showing 2 widget entries, and more exist.");
118
+ expect(notice).toContain("--after");
119
+ });
120
+
121
+ it("llm: the heading admits the page and the body ends with the notice", async () => {
122
+ const outcome = await executeVerb(
123
+ listVerb(WIDGET_PACK),
124
+ { limit: 2 },
125
+ REAL,
126
+ as(LLM),
127
+ );
128
+ const stdout = outcome.stdout ?? "";
129
+ expect(stdout).toContain("## Widget (2, more exist)");
130
+ expect(stdout.trimEnd().split("\n").at(-1)).toContain(
131
+ "Showing 2 widget entries, and more exist.",
132
+ );
133
+ });
134
+
135
+ it("json: the notice keeps riding meta", async () => {
136
+ const outcome = await executeVerb(
137
+ listVerb(WIDGET_PACK),
138
+ { limit: 2 },
139
+ REAL,
140
+ as(JSON_FLAGS),
141
+ );
142
+ const envelope = JSON.parse(outcome.stdout as string);
143
+ expect(envelope.meta.notice).toContain("and more exist");
144
+ expect(envelope.data).toHaveLength(2);
145
+ });
146
+
147
+ it("a whole answer says nothing extra, in any format", async () => {
148
+ const plain = await executeVerb(listVerb(WIDGET_PACK), {}, REAL, as(PLAIN));
149
+ expect(`${plain.stdout ?? ""}${plain.stderr ?? ""}`).not.toContain(
150
+ "more exist",
151
+ );
152
+ const llm = await executeVerb(listVerb(WIDGET_PACK), {}, REAL, as(LLM));
153
+ expect(llm.stdout).toContain("## Widget (3)");
154
+ expect(llm.stdout).not.toContain("more exist");
155
+ });
156
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The page a list-shaped verb answers with: how many rows, and where from.
3
+ *
4
+ * WHY A DEFAULT AT ALL. Before this, no list carried a cap: every declared
5
+ * story returned its whole population on every call, and an agent calling one
6
+ * speculatively paid for all of it. A default is what makes the cap real — a
7
+ * `--limit` nobody passes bounds nothing.
8
+ *
9
+ * WHY THIS DEFAULT. It answers to TWO constraints, and it is the largest number
10
+ * that satisfies both.
11
+ *
12
+ * From below: it stays above the row count of every story that already existed
13
+ * when the page arrived (the largest, `block list`, is 252), so no answer that
14
+ * was whole before the pair is truncated by it now.
15
+ *
16
+ * From above: the payload budget bounds ONE answer, and the suite that enforces
17
+ * it requires the largest default answer to sit under 0.8 of the ceiling —
18
+ * 100,000 bytes — so a ceiling nothing approaches keeps catching things and one
19
+ * sitting on the measurement does not flake. The fattest row any declared story
20
+ * serialises is `token values` at 244 bytes, so 300 rows of it is about 73 KB
21
+ * and the gate holds with room to spare.
22
+ *
23
+ * The first constraint alone gave 500, which is what this was, and the second
24
+ * retired it: the token-graph stories brought populations of 745, 1,156, 1,237
25
+ * and 1,746 rows, and at 500 rows `token values` measured 121,776 bytes —
26
+ * inside the ceiling and past the gate, with no headroom left. So the number is
27
+ * now derived from the budget rather than from a population that no longer
28
+ * bounds anything.
29
+ *
30
+ * It is still a KERNEL default and not a per-story one — a story has no
31
+ * page-size knob, so there is exactly one number to know, and it is written
32
+ * here, in the help text of every list verb, and in `BUDGETS.md` beside the
33
+ * payload it bounds. A per-story knob is what to reach for when one story's
34
+ * rows are so much fatter than the rest that one number cannot serve both;
35
+ * that is not true of any story today.
36
+ *
37
+ * WHY A CEILING AS WELL. The page's two integers are emitted into the query
38
+ * ({@link ./sparql/buildListQuery}), and the store's own `LIMIT`/`OFFSET` must
39
+ * fit a 32-bit integer while `Number.isInteger` admits anything up to 2^53. So
40
+ * `--limit 9007199254740991` — the natural "give me everything" an agent
41
+ * writes — used to reach the engine and come back as a raw parse error wrapped
42
+ * in INTERNAL_ERROR and "report this issue", from a flag whose own help invited
43
+ * a larger number. {@link MAX_LIST_WINDOW} is where both halves of the window
44
+ * are refused instead, with the typed INVALID_INPUT the rest of the pair uses
45
+ * and the ceiling named in the message.
46
+ *
47
+ * The cursor that carries a caller from one page to the next is
48
+ * {@link ./cursor}, kept apart because it hashes and this module is reached
49
+ * from the compiler on the storeless `--help` path. It bounds its own offset by
50
+ * the same ceiling, from here.
51
+ */
52
+
53
+ import { PragmaError } from "../error/index.js";
54
+
55
+ /**
56
+ * Rows a list-shaped verb returns when the caller names no `--limit`.
57
+ *
58
+ * Derived from the payload budget and from the populations that predate the
59
+ * page — see the module note for both halves of the arithmetic.
60
+ */
61
+ export const DEFAULT_LIST_LIMIT = 300;
62
+
63
+ /**
64
+ * The largest `--limit`, and the largest cursor offset, the kernel admits.
65
+ *
66
+ * NOT the engine's limit, which is an implementation detail of the pinned store
67
+ * and would make the refusal a fact about oxigraph. It comes from the payload
68
+ * budget instead, which is a fact about the answer: `BUDGETS.md`'s
69
+ * `LIST_PAYLOAD_BUDGET_BYTES` is 125,000, and the narrowest row a story can
70
+ * possibly serialise is `{}` plus its separating comma — three bytes — so no
71
+ * answer of more than 41,666 rows can be inside the budget whatever a story's
72
+ * columns are. A limit that cannot produce a legal answer is not a legal limit;
73
+ * rounded down to a number a message can carry, that is 40,000. It stays 22×
74
+ * the largest population the distribution ships (1,746) and 133× the default
75
+ * page, so it refuses nothing a caller could want today.
76
+ *
77
+ * The cursor's OFFSET takes the same ceiling, for a reason of its own: an offset
78
+ * is a count of rows already answered, and a walk that has passed 40,000 of
79
+ * them is walking a population more than two orders of magnitude larger than
80
+ * any single legal answer — the point at which the cursor to spend is a keyset
81
+ * cursor, which is what the encoding's `v` field exists to allow. Until such a
82
+ * story exists, an offset that large is a hand-built or tampered token, and one
83
+ * typed refusal is the honest answer to both.
84
+ *
85
+ * `listBudget.shipped.exec.test.ts` asserts the arithmetic tying this to the
86
+ * budget, so the two cannot drift apart in silence.
87
+ */
88
+ export const MAX_LIST_WINDOW = 40_000;
89
+
90
+ /**
91
+ * Read the `--limit`/`limit` argument.
92
+ *
93
+ * @param provided - The raw argument, if any.
94
+ * @returns The requested limit, or {@link DEFAULT_LIST_LIMIT}.
95
+ * @throws PragmaError INVALID_INPUT for anything that is not a whole number
96
+ * between 1 and {@link MAX_LIST_WINDOW}. The ceiling is named in the message:
97
+ * a refusal that does not say what it would accept is a guessing game.
98
+ */
99
+ export function readLimit(provided: unknown): number {
100
+ if (provided === undefined) return DEFAULT_LIST_LIMIT;
101
+ const value = typeof provided === "number" ? provided : Number(provided);
102
+ if (!Number.isInteger(value) || value < 1 || value > MAX_LIST_WINDOW) {
103
+ throw PragmaError.invalidInput("limit", String(provided), {
104
+ recovery: {
105
+ message: `--limit takes a whole number of rows, 1 to ${MAX_LIST_WINDOW} (default ${DEFAULT_LIST_LIMIT}).`,
106
+ },
107
+ });
108
+ }
109
+ return value;
110
+ }
@@ -26,13 +26,17 @@ import {
26
26
  renderLookupPlain,
27
27
  } from "../render/renderers.js";
28
28
  import type { Formatters } from "../spec/index.js";
29
+ import { kebabCase } from "../spec/index.js";
29
30
  import type { LookupOutput } from "./resolveEntity.js";
30
- import type {
31
- PackChildRow,
32
- PackEntity,
33
- PackList,
34
- PackLookup,
35
- PackRow,
31
+ import {
32
+ EVERY_TIER,
33
+ type PackAppliedFilter,
34
+ type PackChildRow,
35
+ type PackEntity,
36
+ type PackList,
37
+ type PackLookup,
38
+ type PackPage,
39
+ type PackRow,
36
40
  } from "./types.js";
37
41
 
38
42
  /** Sample output: the drawn exemplars, the population size, and agent follow-ups. */
@@ -57,11 +61,31 @@ export interface RenderMeta {
57
61
  */
58
62
  const DEFAULT_EMPTY_HINT = `Either nothing matched — try a wider filter — or the store has nothing in it yet: build it with \`${BIN_NAME} sources update\`.`;
59
63
 
60
- /** Build the list formatters for a list-shaped verb (list or an extra verb). */
64
+ /**
65
+ * Build the list formatters for a list-shaped verb (list or an extra verb).
66
+ *
67
+ * Every mode renders the page's ROWS, so the three output contracts are exactly
68
+ * what they were before a list carried a page: `plain` a table, `llm` its
69
+ * frozen condensed form, `json` the bare array. What the page adds rides the
70
+ * `notice` seam — the channel that already exists for what the data cannot say
71
+ * about itself, and which both machine surfaces project into `meta.notice`.
72
+ *
73
+ * EVERY format says it, though, because a truncation nobody is told about is a
74
+ * wrong answer in any dialect. The seam alone did not reach `llm`: the
75
+ * dispatcher routes a notice to stderr in `plain` and into `meta.notice` in
76
+ * `json`, but the `llm` branch renders the body and nothing else — and `llm`
77
+ * is what an agent (or any piped invocation, which auto-detects it) actually
78
+ * reads. So the condensed form carries the sentence in its own body, as a final
79
+ * line, and its heading admits the page is a page.
80
+ *
81
+ * A page that is the whole answer says nothing, which is why declaring the
82
+ * default page size above every story's population left every existing answer
83
+ * untouched, notice included.
84
+ */
61
85
  export function listFormatters(
62
86
  shape: PackList,
63
87
  meta: RenderMeta,
64
- ): Formatters<PackRow[]> {
88
+ ): Formatters<PackPage> {
65
89
  const columns: ColumnDef<PackRow>[] = shape.columns.map((column) => ({
66
90
  key: column.field,
67
91
  label: column.label ?? column.field,
@@ -84,16 +108,138 @@ export function listFormatters(
84
108
  emptyHint,
85
109
  };
86
110
  return {
87
- plain: (rows, context) => renderListPlain(rows, options, context),
88
- llm: (rows) => renderListLlm(rows, options),
89
- json: (rows) => JSON.stringify(rows, null, 2),
111
+ plain: (page, context) => renderListPlain(page.rows, options, context),
112
+ llm: (page) => {
113
+ const body = renderListLlm(page.rows, emptyCopy(page, meta, options), {
114
+ more: page.nextAfter !== undefined,
115
+ ...(scopeText(page) === undefined
116
+ ? {}
117
+ : { scope: scopeText(page) as string }),
118
+ });
119
+ const notice = listNotice(page, meta);
120
+ return notice ? `${body}\n\n${notice}` : body;
121
+ },
122
+ json: (page) => JSON.stringify(page.rows, null, 2),
90
123
  // Zero rows: the dispatcher routes this to stderr (exit 0) so the plain
91
124
  // stdout stream stays pure data; llm/json keep their own empty shapes.
92
- notice: (rows) =>
93
- rows.length === 0 ? renderListEmptyNotice(options) : undefined,
125
+ notice: (page) =>
126
+ page.rows.length === 0
127
+ ? joinNotices([
128
+ renderListEmptyNotice(emptyCopy(page, meta, options)),
129
+ scopeNotice(page),
130
+ ])
131
+ : listNotice(page, meta),
132
+ // The scope rides the envelope as DATA as well as prose: an agent deciding
133
+ // whether to widen the read should not have to parse a sentence to learn
134
+ // which tiers it got.
135
+ meta: (page) => (page.scope ? { scope: page.scope } : undefined),
94
136
  };
95
137
  }
96
138
 
139
+ /** The tier scope in the words `--tier` accepts, or nothing when unscoped. */
140
+ function scopeText(page: PackPage): string | undefined {
141
+ return page.scope ? page.scope.tiers.join(", ") : undefined;
142
+ }
143
+
144
+ /**
145
+ * What a SCOPED page says for itself: which tiers it answered from, and the
146
+ * argument that widens it.
147
+ *
148
+ * The plain table has no heading to carry it (a plain list is columns a pipe
149
+ * reads as records), so the sentence is the plain surface's whole account of
150
+ * the scope — and the notice seam is where this package already puts what the
151
+ * data cannot say about itself.
152
+ */
153
+ function scopeNotice(page: PackPage): string | undefined {
154
+ const scope = scopeText(page);
155
+ if (scope === undefined) return undefined;
156
+ return (
157
+ `Tier scope: ${scope}. ` +
158
+ `Pass \`--tier <name>\` for one tier and its ancestors, or \`--tier ${EVERY_TIER}\` for every tier.`
159
+ );
160
+ }
161
+
162
+ /** The page's notices, in the order a reader needs them: scope, then paging. */
163
+ function listNotice(page: PackPage, meta: RenderMeta): string | undefined {
164
+ return joinNotices([scopeNotice(page), pageNotice(page, meta)]);
165
+ }
166
+
167
+ /** Join what a page has to say into one notice, dropping what it has not. */
168
+ function joinNotices(
169
+ notices: readonly (string | undefined)[],
170
+ ): string | undefined {
171
+ const present = notices.filter((notice): notice is string => Boolean(notice));
172
+ return present.length === 0 ? undefined : present.join(" ");
173
+ }
174
+
175
+ /**
176
+ * The empty-state copy this page deserves.
177
+ *
178
+ * Two facts can empty a list and zero rows cannot tell them apart: nothing was
179
+ * there, or a filter missed what was. Both are worth saying and they are not
180
+ * exclusive, so a filtered empty page says both — the kernel's own sentence
181
+ * names the arguments the caller typed, and the story's `emptyRecovery` follows
182
+ * it on the next line, exactly as it does on an unfiltered page.
183
+ *
184
+ * It did not, briefly: a filtered empty page reported only the narrowing,
185
+ * because the recoveries of the day asserted an empty store ("No token symbols
186
+ * in the store … run `pragma sources update`") and so told a reader with 745
187
+ * symbols in it to rebuild for nothing. That is a WORDING defect, and it is
188
+ * fixed where it lives — a story's recovery is now written to hold whether the
189
+ * population is empty or a filter missed a populated one, or the story declares
190
+ * none. Suppressing it here cost more than it saved: `token consumers --symbol
191
+ * color.text` narrows a table that records no bindings at all, and "No token
192
+ * matches `--symbol color.text`." on its own read as a mistyped symbol while
193
+ * withholding the one account of the emptiness that verb has.
194
+ *
195
+ * @param page - The rendered page (its rows and the filters that cut them).
196
+ * @param meta - The noun, for the sentence.
197
+ * @param base - The story's own empty copy.
198
+ * @returns `base` unchanged, or `base` with the filter-shaped message — the
199
+ * story's own hint is kept either way.
200
+ */
201
+ function emptyCopy(
202
+ page: PackPage,
203
+ meta: RenderMeta,
204
+ base: RenderListOptions<PackRow>,
205
+ ): RenderListOptions<PackRow> {
206
+ const applied = page.filters ?? [];
207
+ if (page.rows.length > 0 || applied.length === 0) return base;
208
+ return {
209
+ ...base,
210
+ emptyMessage: `No ${meta.noun} matches ${listFilters(applied)}.`,
211
+ };
212
+ }
213
+
214
+ /** The filters in force, as flags a reader can edit: `\`--kind input\`` … */
215
+ function listFilters(applied: readonly PackAppliedFilter[]): string {
216
+ const flags = applied.map(
217
+ (filter) => `\`--${kebabCase(filter.param)} ${filter.value}\``,
218
+ );
219
+ const last = flags.at(-1) as string;
220
+ return flags.length === 1
221
+ ? last
222
+ : `${flags.slice(0, -1).join(", ")} and ${last}`;
223
+ }
224
+
225
+ /**
226
+ * What a truncated page says for itself: that it is one, and the exact argument
227
+ * that fetches the next.
228
+ *
229
+ * The cursor is printed rather than described. An opaque token a caller has to
230
+ * be told how to obtain is a token they will guess at, and the two surfaces
231
+ * that carry this are read by agents — so the sentence contains the flag, the
232
+ * value, and the tool parameter, ready to copy.
233
+ */
234
+ function pageNotice(page: PackPage, meta: RenderMeta): string | undefined {
235
+ if (page.nextAfter === undefined) return undefined;
236
+ return (
237
+ `Showing ${page.rows.length} ${meta.noun} entries, and more exist. ` +
238
+ `For the next page pass \`--after ${page.nextAfter}\` ` +
239
+ `(\`after\` over MCP), or raise \`--limit\` (currently ${page.limit}).`
240
+ );
241
+ }
242
+
97
243
  /** Build the shared per-entity render options for a lookup (reused by sample). */
98
244
  export function lookupOptions(
99
245
  lookup: PackLookup,
@@ -110,6 +256,7 @@ export function lookupOptions(
110
256
  key: section.name,
111
257
  heading: section.label ?? section.name,
112
258
  kind: section.kind ?? "field",
259
+ ...(section.note ? { note: section.note } : {}),
113
260
  }),
114
261
  );
115
262
  const expandSections: SectionDef<PackEntity>[] = (lookup.expand ?? []).map(
@@ -118,6 +265,7 @@ export function lookupOptions(
118
265
  heading: expand.heading ?? expand.name,
119
266
  kind: expand.kind ?? "list",
120
267
  ...(expand.showWhenEmpty ? { showWhenEmpty: true } : {}),
268
+ ...(expand.note ? { note: expand.note } : {}),
121
269
  }),
122
270
  );
123
271
  return {
@@ -177,6 +325,29 @@ export function sampleFormatters(
177
325
  };
178
326
  }
179
327
 
328
+ /**
329
+ * What a lookup answered from OUTSIDE the tier scope says for itself.
330
+ *
331
+ * One line per name, and it is in the BODY rather than on the notice seam: the
332
+ * answer it explains is on stdout, and a reader piping `block lookup back-link`
333
+ * would otherwise see a block from a tier they are not reading with no way to
334
+ * tell from the output why. The machine surfaces carry the same fact
335
+ * structurally (`outOfScope` rides the payload), so this is one fact in two
336
+ * registers, not two facts.
337
+ */
338
+ function scopeFallbackLines(
339
+ output: LookupOutput,
340
+ mode: "plain" | "llm",
341
+ ): string[] {
342
+ const bullet = mode === "llm" ? "- " : "";
343
+ return (output.outOfScope ?? []).map(
344
+ (answer) =>
345
+ `${bullet}No "${answer.query}" in the tier scope (${answer.scope}) — ` +
346
+ `answering from ${answer.tiers.join(", ")}. ` +
347
+ `Pass \`--tier ${EVERY_TIER}\` for every tier.`,
348
+ );
349
+ }
350
+
180
351
  /** Render each result entity, appending a compact note for any error entries. */
181
352
  function renderOutput(
182
353
  output: LookupOutput,
@@ -184,6 +355,8 @@ function renderOutput(
184
355
  mode: "plain" | "llm" = "plain",
185
356
  ): string {
186
357
  const bodies = output.results.map(render);
358
+ const fallback = scopeFallbackLines(output, mode);
359
+ if (fallback.length > 0) bodies.push(fallback.join("\n"));
187
360
  if (output.errors.length > 0) {
188
361
  const bullet = mode === "llm" ? "- " : " ";
189
362
  const lines = output.errors.map(
@@ -7,9 +7,10 @@
7
7
  * and nothing else — as accepting a name, a prefixed name, an absolute IRI, or
8
8
  * a glob. These assert that the resolver honours the shape it is handed rather
9
9
  * than the source its pack declares, that an entity is addressable by IRI even
10
- * when it carries no `by` value, and that an ambiguous name answers with the
11
- * same entity on every store and every machine — while NAMING the ones it did
12
- * not answer with.
10
+ * when it carries no `by` value, that a name reaches its entity through the
11
+ * whitespace either side may be padded with, and that an ambiguous name answers
12
+ * with the same entity on every store and every machine — while NAMING the ones
13
+ * it did not answer with.
13
14
  *
14
15
  * That last clause is a REVERSAL, signed off by the owner. It used to read "an
15
16
  * ambiguous name resolves to the SAME entity every time", and the suite proved
@@ -149,6 +150,64 @@ describe("pack lookup addressing (PROTECTED)", () => {
149
150
  });
150
151
  });
151
152
 
153
+ describe("a name resolves through the whitespace the graph carries", () => {
154
+ // `ds:padded.timeline` is named "Timeline " in the fixture, exactly as 66
155
+ // shipped names are — the transform that reads the source document keeps the
156
+ // cell's trailing space. That padding is not part of the name: no surface
157
+ // prints it and no user can type it on purpose, so a resolve it can defeat
158
+ // is broken for every one of those entities.
159
+ it("resolves a padded name by the name a user can type", async () => {
160
+ const out = await lookupVia(SPQ, "Timeline");
161
+ expect(out.errors).toEqual([]);
162
+ expect(uris(out)).toEqual([`${DS}padded.timeline`]);
163
+ });
164
+
165
+ it("resolves it on the graphql path too, where the resolve is the same SPARQL", async () => {
166
+ const out = await lookupVia(GQL, "Timeline");
167
+ expect(out.errors).toEqual([]);
168
+ expect(uris(out)).toEqual([`${DS}padded.timeline`]);
169
+ });
170
+
171
+ it("trims the ARGUMENT as well, so a pasted name reaches a clean entity", async () => {
172
+ // The other direction, and the reason both sides are trimmed rather than
173
+ // just `?name`: a name copied out of a table cell arrives padded, and
174
+ // Modal's own name is not.
175
+ const out = await lookupVia(SPQ, " Modal\t");
176
+ expect(out.errors).toEqual([]);
177
+ expect(uris(out)).toEqual([`${DS}modal`]);
178
+ });
179
+
180
+ it("still folds case while it folds the padding", async () => {
181
+ expect(uris(await lookupVia(SPQ, "timeline"))).toEqual([
182
+ `${DS}padded.timeline`,
183
+ ]);
184
+ });
185
+
186
+ it("keeps the entity's own name LITERAL in the answer", async () => {
187
+ // The trim is a matching rule, not a rendering one. What the graph holds
188
+ // is what a reader is shown, so the upstream correction to the transform
189
+ // is visible when it lands instead of being papered over here.
190
+ const out = await lookupVia(SPQ, "Timeline");
191
+ expect(out.results.at(0)?.name).toBe("Timeline ");
192
+ });
193
+
194
+ it("suggests it by its literal name on a genuine typo", async () => {
195
+ // A real miss still names the padded candidate exactly as the graph holds
196
+ // it — the trim decides what MATCHES and changes nothing about what a
197
+ // reader is shown. What can no longer happen is the reported
198
+ // `Did you mean? - Timeline` whose only difference from the query was the
199
+ // padding: that case resolves now, and the suggester's own refusal to
200
+ // restate a query is asserted in `project/cli/suggest.test.ts`.
201
+ const reason = await lookupVia(SPQ, "Timelime").catch(
202
+ (error: unknown) => error,
203
+ );
204
+ expect(reason).toMatchObject({ code: "ENTITY_NOT_FOUND" });
205
+ expect((reason as { suggestions: string[] }).suggestions).toContain(
206
+ "Timeline ",
207
+ );
208
+ });
209
+ });
210
+
152
211
  describe("a glob expands over the population its own shape addresses", () => {
153
212
  it("expands an IRI-shaped glob on a sparql-sourced pack", async () => {
154
213
  const out = await lookupVia(SPQ, "ds:*.chip");