@canonical/pragma-cli 0.35.0 → 0.37.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 (295) hide show
  1. package/README.md +2 -2
  2. package/bundled-skills/add-standard/SKILL.md +338 -0
  3. package/bundled-skills/adoption-a1-styles/SKILL.md +269 -0
  4. package/bundled-skills/adoption-a2-components/SKILL.md +291 -0
  5. package/bundled-skills/adoption-a3-forms/SKILL.md +260 -0
  6. package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +406 -0
  7. package/bundled-skills/anatomy-author/SKILL.md +1226 -0
  8. package/bundled-skills/design-auditor/SKILL.md +455 -0
  9. package/bundled-skills/implement-component/SKILL.md +333 -0
  10. package/bundled-skills/specify-component/SKILL.md +331 -0
  11. package/bundled-skills/specify-pattern/SKILL.md +292 -0
  12. package/dist/package.json +24 -20
  13. package/dist/pragma.conf.js +266 -34
  14. package/dist/pragma.conf.js.map +1 -1
  15. package/dist/src/bin.js +1 -1
  16. package/dist/src/bin.js.map +1 -1
  17. package/dist/src/capabilities/capabilities/hints.js +1 -1
  18. package/dist/src/capabilities/capabilities/hints.js.map +1 -1
  19. package/dist/src/capabilities/colophon/colophon.render.js +1 -1
  20. package/dist/src/capabilities/colophon/colophon.render.js.map +1 -1
  21. package/dist/src/capabilities/create/create.verb.js +2 -1
  22. package/dist/src/capabilities/create/create.verb.js.map +1 -1
  23. package/dist/src/capabilities/create/createSurface.generated.js +23 -1
  24. package/dist/src/capabilities/create/createSurface.generated.js.map +1 -1
  25. package/dist/src/capabilities/create/mount.js +4 -3
  26. package/dist/src/capabilities/create/mount.js.map +1 -1
  27. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js +11 -6
  28. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -1
  29. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js +60 -4
  30. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js.map +1 -1
  31. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js +8 -8
  32. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js.map +1 -1
  33. package/dist/src/capabilities/doctor/checks/harnessInventory.js +195 -0
  34. package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -0
  35. package/dist/src/capabilities/doctor/checks/index.js +6 -6
  36. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js +38 -0
  37. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js.map +1 -0
  38. package/dist/src/capabilities/doctor/checks/targetHealth.js +194 -50
  39. package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
  40. package/dist/src/capabilities/doctor/doctor.render.js +43 -64
  41. package/dist/src/capabilities/doctor/doctor.render.js.map +1 -1
  42. package/dist/src/capabilities/doctor/doctor.verb.js +6 -2
  43. package/dist/src/capabilities/doctor/doctor.verb.js.map +1 -1
  44. package/dist/src/capabilities/doctor/runChecks.js +14 -11
  45. package/dist/src/capabilities/doctor/runChecks.js.map +1 -1
  46. package/dist/src/capabilities/graph/query.render.js +2 -2
  47. package/dist/src/capabilities/graph/query.render.js.map +1 -1
  48. package/dist/src/capabilities/info/collectInfo.js +8 -2
  49. package/dist/src/capabilities/info/collectInfo.js.map +1 -1
  50. package/dist/src/capabilities/info/info.render.js +6 -2
  51. package/dist/src/capabilities/info/info.render.js.map +1 -1
  52. package/dist/src/capabilities/prompt/prompt.render.js +2 -2
  53. package/dist/src/capabilities/prompt/prompt.render.js.map +1 -1
  54. package/dist/src/capabilities/setup/buildPlan.js +43 -39
  55. package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
  56. package/dist/src/capabilities/setup/mode.js +2 -2
  57. package/dist/src/capabilities/setup/operations/index.js +4 -4
  58. package/dist/src/capabilities/setup/operations/index.js.map +1 -1
  59. package/dist/src/capabilities/setup/operations/setupCompletions.js +6 -2
  60. package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -1
  61. package/dist/src/capabilities/setup/operations/setupConfig.js +4 -2
  62. package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -1
  63. package/dist/src/capabilities/setup/operations/setupGenerator.js +156 -58
  64. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  65. package/dist/src/capabilities/setup/operations/setupLsp.js +136 -38
  66. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  67. package/dist/src/capabilities/setup/operations/setupMcp.js +101 -52
  68. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  69. package/dist/src/capabilities/setup/operations/setupSkills.js +329 -49
  70. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  71. package/dist/src/capabilities/setup/plan.js.map +1 -1
  72. package/dist/src/capabilities/setup/plan.render.js +144 -66
  73. package/dist/src/capabilities/setup/plan.render.js.map +1 -1
  74. package/dist/src/capabilities/setup/setup.render.js +1 -1
  75. package/dist/src/capabilities/setup/setup.verb.js +165 -63
  76. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  77. package/dist/src/capabilities/setup/targets.js +122 -51
  78. package/dist/src/capabilities/setup/targets.js.map +1 -1
  79. package/dist/src/capabilities/shared/index.js +7 -6
  80. package/dist/src/capabilities/shared/index.js.map +1 -1
  81. package/dist/src/capabilities/shared/packageManager.js +300 -54
  82. package/dist/src/capabilities/shared/packageManager.js.map +1 -1
  83. package/dist/src/capabilities/skill/discover.js +97 -11
  84. package/dist/src/capabilities/skill/discover.js.map +1 -1
  85. package/dist/src/capabilities/skill/render.js +2 -2
  86. package/dist/src/capabilities/skill/render.js.map +1 -1
  87. package/dist/src/capabilities/sources/installSkills.js +145 -3
  88. package/dist/src/capabilities/sources/installSkills.js.map +1 -1
  89. package/dist/src/capabilities/sources/runUpdate.js +68 -2
  90. package/dist/src/capabilities/sources/runUpdate.js.map +1 -1
  91. package/dist/src/capabilities/sources/update.verb.js +3 -3
  92. package/dist/src/capabilities/sources/update.verb.js.map +1 -1
  93. package/dist/src/capabilities/upgrade/runUpgrade.js +43 -7
  94. package/dist/src/capabilities/upgrade/runUpgrade.js.map +1 -1
  95. package/dist/src/capabilities/upgrade/upgrade.render.js +10 -2
  96. package/dist/src/capabilities/upgrade/upgrade.render.js.map +1 -1
  97. package/dist/src/constants.js +13 -2
  98. package/dist/src/constants.js.map +1 -1
  99. package/dist/src/kernel/index.js +11 -2
  100. package/dist/src/kernel/index.js.map +1 -1
  101. package/dist/src/kernel/interactivity.js +68 -0
  102. package/dist/src/kernel/interactivity.js.map +1 -0
  103. package/dist/src/kernel/packs/renderPack.js +3 -3
  104. package/dist/src/kernel/packs/renderPack.js.map +1 -1
  105. package/dist/src/kernel/packs/resolveEntity.js +63 -12
  106. package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
  107. package/dist/src/kernel/packs/runBodies.js +57 -4
  108. package/dist/src/kernel/packs/runBodies.js.map +1 -1
  109. package/dist/src/kernel/packs/schema.js +54 -2
  110. package/dist/src/kernel/packs/schema.js.map +1 -1
  111. package/dist/src/kernel/packs/sparql/applyFilters.js +104 -5
  112. package/dist/src/kernel/packs/sparql/applyFilters.js.map +1 -1
  113. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +310 -43
  114. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  115. package/dist/src/kernel/packs/types.js.map +1 -1
  116. package/dist/src/kernel/project/cli/buildProgram.js +1 -1
  117. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
  118. package/dist/src/kernel/project/cli/dispatch.js +46 -24
  119. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  120. package/dist/src/kernel/project/cli/globalFlags.js +4 -1
  121. package/dist/src/kernel/project/cli/globalFlags.js.map +1 -1
  122. package/dist/src/kernel/project/cli/helpFormat.js +1 -2
  123. package/dist/src/kernel/project/cli/helpFormat.js.map +1 -1
  124. package/dist/src/kernel/project/cli/rootHelp.js +42 -9
  125. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  126. package/dist/src/kernel/project/mcp/registerVerb.js +16 -1
  127. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
  128. package/dist/src/kernel/render/renderers.js +1 -1
  129. package/dist/src/kernel/render/style.js +29 -9
  130. package/dist/src/kernel/render/style.js.map +1 -1
  131. package/dist/src/kernel/render/vocabulary.js +174 -0
  132. package/dist/src/kernel/render/vocabulary.js.map +1 -0
  133. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js +2 -3
  134. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -1
  135. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +1 -1
  136. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -1
  137. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js +4 -0
  138. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js.map +1 -0
  139. package/dist/src/kernel/runtime/graphpack/embedded.js +13 -6
  140. package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -1
  141. package/dist/src/kernel/runtime/graphpack/read.js +14 -9
  142. package/dist/src/kernel/runtime/graphpack/read.js.map +1 -1
  143. package/dist/src/kernel/runtime/loadSession.js +3 -1
  144. package/dist/src/kernel/runtime/loadSession.js.map +1 -1
  145. package/dist/src/kernel/runtime/probe.js +1 -1
  146. package/dist/src/kernel/runtime/probe.js.map +1 -1
  147. package/dist/src/kernel/runtime/refs/parseRef.js +17 -1
  148. package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -1
  149. package/dist/src/kernel/runtime/refs/resolve.js +9 -3
  150. package/dist/src/kernel/runtime/refs/resolve.js.map +1 -1
  151. package/dist/src/kernel/spec/emitReference.js +1 -1
  152. package/dist/src/kernel/spec/emitReference.js.map +1 -1
  153. package/package.json +24 -20
  154. package/pragma.conf.ts +272 -34
  155. package/src/bin.ts +8 -1
  156. package/src/capabilities/block.parity.test.ts +25 -11
  157. package/src/capabilities/block.shipped.exec.test.ts +282 -0
  158. package/src/capabilities/block.tierRank.exec.test.ts +300 -0
  159. package/src/capabilities/capabilities/hints.ts +1 -1
  160. package/src/capabilities/colophon/colophon.render.ts +1 -2
  161. package/src/capabilities/colophon/colophon.test.ts +2 -2
  162. package/src/capabilities/config/collectConfigShow.test.ts +1 -0
  163. package/src/capabilities/create/byteEquality.test.ts +28 -1
  164. package/src/capabilities/create/create.test.ts +1 -0
  165. package/src/capabilities/create/create.verb.ts +2 -1
  166. package/src/capabilities/create/createGrammar.test.ts +5 -2
  167. package/src/capabilities/create/createSurface.generated.ts +23 -1
  168. package/src/capabilities/create/crossCli.subprocess.test.ts +10 -3
  169. package/src/capabilities/create/mount.ts +3 -3
  170. package/src/capabilities/distribution.parity.test.ts +4 -0
  171. package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
  172. package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
  173. package/src/capabilities/doctor/checks/checkPackageRefs.ts +69 -4
  174. package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +6 -4
  175. package/src/capabilities/doctor/checks/checkShellCompletions.ts +8 -8
  176. package/src/capabilities/doctor/checks/harnessInventory.test.ts +219 -0
  177. package/src/capabilities/doctor/checks/harnessInventory.ts +298 -0
  178. package/src/capabilities/doctor/checks/index.ts +6 -6
  179. package/src/capabilities/doctor/checks/isSupportedNodeVersion.test.ts +111 -0
  180. package/src/capabilities/doctor/checks/isSupportedNodeVersion.ts +34 -0
  181. package/src/capabilities/doctor/checks/mcpCommand.test.ts +7 -1
  182. package/src/capabilities/doctor/checks/targetHealth.ts +229 -59
  183. package/src/capabilities/doctor/doctor.render.test.ts +108 -32
  184. package/src/capabilities/doctor/doctor.render.ts +56 -80
  185. package/src/capabilities/doctor/doctor.test.ts +247 -35
  186. package/src/capabilities/doctor/doctor.verb.ts +7 -2
  187. package/src/capabilities/doctor/runChecks.ts +14 -11
  188. package/src/capabilities/doctor/types.ts +15 -24
  189. package/src/capabilities/emptyRecovery.test.ts +10 -2
  190. package/src/capabilities/graph/query.render.ts +2 -2
  191. package/src/capabilities/info/collectInfo.ts +8 -1
  192. package/src/capabilities/info/info.render.ts +6 -2
  193. package/src/capabilities/info/info.test.ts +37 -1
  194. package/src/capabilities/info/types.ts +10 -2
  195. package/src/capabilities/lazy.test.ts +26 -2
  196. package/src/capabilities/listLookup.shipped.exec.test.ts +340 -0
  197. package/src/capabilities/prompt/prompt.render.ts +2 -2
  198. package/src/capabilities/renderVocabularySync.test.ts +82 -0
  199. package/src/capabilities/resources/resources.test.ts +38 -2
  200. package/src/capabilities/scopeTypeSync.test.ts +29 -15
  201. package/src/capabilities/setup/buildPlan.ts +45 -41
  202. package/src/capabilities/setup/index.ts +1 -1
  203. package/src/capabilities/setup/mode.ts +2 -2
  204. package/src/capabilities/setup/operations/index.ts +6 -1
  205. package/src/capabilities/setup/operations/setupCompletions.ts +9 -2
  206. package/src/capabilities/setup/operations/setupConfig.ts +7 -2
  207. package/src/capabilities/setup/operations/setupGenerator.ts +227 -84
  208. package/src/capabilities/setup/operations/setupLsp.ts +203 -38
  209. package/src/capabilities/setup/operations/setupMcp.ts +139 -62
  210. package/src/capabilities/setup/operations/setupSkills.ts +399 -60
  211. package/src/capabilities/setup/plan.render.ts +171 -76
  212. package/src/capabilities/setup/plan.ts +17 -12
  213. package/src/capabilities/setup/setup.mcp.correctness.test.ts +9 -9
  214. package/src/capabilities/setup/setup.render.test.ts +50 -36
  215. package/src/capabilities/setup/setup.render.ts +1 -1
  216. package/src/capabilities/setup/setup.test.ts +1126 -50
  217. package/src/capabilities/setup/setup.verb.ts +174 -68
  218. package/src/capabilities/setup/targets.ts +138 -60
  219. package/src/capabilities/setup/types.ts +10 -18
  220. package/src/capabilities/shared/index.ts +18 -6
  221. package/src/capabilities/shared/packageManager.test.ts +421 -6
  222. package/src/capabilities/shared/packageManager.ts +394 -59
  223. package/src/capabilities/skill/bundledSkills.test.ts +347 -0
  224. package/src/capabilities/skill/discover.ts +100 -11
  225. package/src/capabilities/skill/render.ts +2 -2
  226. package/src/capabilities/skill/skill.test.ts +17 -4
  227. package/src/capabilities/sources/installSkills.ts +181 -3
  228. package/src/capabilities/sources/runUpdate.ts +78 -2
  229. package/src/capabilities/sources/sources.test.ts +228 -0
  230. package/src/capabilities/sources/update.verb.ts +3 -3
  231. package/src/capabilities/surface.test.ts +14 -6
  232. package/src/capabilities/upgrade/runUpgrade.ts +45 -6
  233. package/src/capabilities/upgrade/types.ts +10 -2
  234. package/src/capabilities/upgrade/upgrade.render.ts +9 -2
  235. package/src/capabilities/upgrade/upgrade.test.ts +72 -0
  236. package/src/constants.ts +14 -1
  237. package/src/identity.test.ts +18 -5
  238. package/src/kernel/completion/entitySource.test.ts +15 -2
  239. package/src/kernel/completion/safety.test.ts +16 -9
  240. package/src/kernel/config/defaults.test.ts +19 -3
  241. package/src/kernel/config/readConfig.test.ts +1 -1
  242. package/src/kernel/index.ts +11 -2
  243. package/src/kernel/interactivity.test.ts +76 -0
  244. package/src/kernel/interactivity.ts +69 -0
  245. package/src/kernel/packs/compile.test.ts +92 -0
  246. package/src/kernel/packs/renderPack.ts +3 -3
  247. package/src/kernel/packs/resolveEntity.test.ts +75 -14
  248. package/src/kernel/packs/resolveEntity.ts +88 -24
  249. package/src/kernel/packs/runBodies.ts +84 -19
  250. package/src/kernel/packs/schema.ts +60 -4
  251. package/src/kernel/packs/sparql/applyFilters.ts +125 -4
  252. package/src/kernel/packs/sparql/buildLookupQuery.test.ts +290 -0
  253. package/src/kernel/packs/sparql/buildLookupQuery.ts +327 -45
  254. package/src/kernel/packs/toolDescription.test.ts +35 -1
  255. package/src/kernel/packs/types.ts +125 -2
  256. package/src/kernel/project/cli/buildProgram.test.ts +73 -2
  257. package/src/kernel/project/cli/buildProgram.ts +8 -1
  258. package/src/kernel/project/cli/dispatch.test.ts +5 -4
  259. package/src/kernel/project/cli/dispatch.ts +48 -25
  260. package/src/kernel/project/cli/globalFlags.ts +4 -1
  261. package/src/kernel/project/cli/helpFormat.ts +1 -2
  262. package/src/kernel/project/cli/rootHelp.ts +44 -8
  263. package/src/kernel/project/mcp/registerVerb.ts +20 -1
  264. package/src/kernel/render/prefixes.test.ts +2 -0
  265. package/src/kernel/render/renderers.ts +1 -1
  266. package/src/kernel/render/style.ts +32 -9
  267. package/src/kernel/render/vocabulary.ts +276 -0
  268. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +2 -3
  269. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
  270. package/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.ts +3 -0
  271. package/src/kernel/runtime/graphpack/embedded.ts +14 -6
  272. package/src/kernel/runtime/graphpack/graphpack.test.ts +31 -5
  273. package/src/kernel/runtime/graphpack/read.ts +14 -12
  274. package/src/kernel/runtime/loadSession.ts +3 -1
  275. package/src/kernel/runtime/probe.ts +1 -1
  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 +9 -3
  279. package/src/kernel/runtime/types.ts +17 -0
  280. package/src/kernel/spec/emitReference.ts +1 -1
  281. package/src/kernel/spec/types.ts +35 -6
  282. package/src/testing/PARITY_GAPS.ts +9 -3
  283. package/src/testing/behavioral/docExamples.test.ts +4 -2
  284. package/src/testing/behavioral/errorMatrix.mcp.test.ts +110 -7
  285. package/src/testing/behavioral/journeys.firstInstall.test.ts +7 -2
  286. package/src/testing/behavioral/journeys.packageStories.test.ts +6 -3
  287. package/src/testing/eval/__snapshots__/eval.test.ts.snap +26 -1
  288. package/src/testing/eval/cases/readNouns.ts +245 -9
  289. package/src/testing/fixtures/blockGraph.ts +28 -13
  290. package/src/testing/fixtures/graph/canonical.ts +95 -12
  291. package/src/testing/helpers/runCli.test.ts +44 -0
  292. package/src/testing/helpers/runCli.ts +27 -3
  293. package/dist/src/capabilities/shared/bands.js +0 -16
  294. package/dist/src/capabilities/shared/bands.js.map +0 -1
  295. package/src/capabilities/shared/bands.ts +0 -16
@@ -41,6 +41,16 @@ const TERM_PATTERN =
41
41
  /** Params a filter/verb may not claim (they are the shared read vocabulary). */
42
42
  const RESERVED_PARAMS = new Set(["search", "detail", "name", "count"]);
43
43
 
44
+ /**
45
+ * Whether an author-supplied query is a SELECT (optionally preceded by its own
46
+ * PREFIX lines). Every author query a story declares runs through `runSelect`,
47
+ * which reads `result.bindings` — so a non-SELECT is a shape mismatch caught
48
+ * here rather than downstream.
49
+ */
50
+ function isSelectQuery(query: string): boolean {
51
+ return /^\s*(?:PREFIX\s+[^\n]*\n\s*)*SELECT\s/i.test(query);
52
+ }
53
+
44
54
  const term = z.string().regex(TERM_PATTERN, "must be a prefixed name or IRI");
45
55
  const graphqlName = z.string().regex(GRAPHQL_NAME_PATTERN);
46
56
  const fieldName = z.string().regex(FIELD_PATTERN);
@@ -49,16 +59,35 @@ const columnSchema = z
49
59
  .object({ field: fieldName, label: z.string().optional() })
50
60
  .strict();
51
61
 
62
+ const vocabularySchema = z
63
+ .object({
64
+ query: z.string().min(1),
65
+ variable: fieldName.optional(),
66
+ })
67
+ .strict();
68
+
52
69
  const filterSchema = z
53
70
  .object({
54
71
  param: z.string().regex(FILTER_PARAM_PATTERN),
55
72
  variable: fieldName,
56
73
  values: z.array(z.string()).min(1).optional(),
74
+ match: z.enum(["exact", "set"]).optional(),
75
+ vocabulary: vocabularySchema.optional(),
57
76
  description: z.string().optional(),
58
77
  })
59
78
  .strict()
60
79
  .refine((f) => !RESERVED_PARAMS.has(f.param), {
61
80
  message: "filter param is a reserved name",
81
+ })
82
+ // A declared `values` set IS the vocabulary — the filter projects it as an
83
+ // enum and canonicalizes against it. A second, graph-read one alongside would
84
+ // be a silent no-op at best and a disagreement at worst.
85
+ .refine((f) => !(f.values && f.vocabulary), {
86
+ message:
87
+ '"values" and "vocabulary" are mutually exclusive — a declared value set is already the vocabulary',
88
+ })
89
+ .refine((f) => !f.vocabulary || isSelectQuery(f.vocabulary.query), {
90
+ message: '"vocabulary.query" must be a SPARQL SELECT query',
62
91
  });
63
92
 
64
93
  const searchSchema = z
@@ -204,15 +233,31 @@ const completionSchema = z
204
233
  })
205
234
  .strict();
206
235
 
236
+ /**
237
+ * The declared scope-ranking factor (`PackScopeWeight`). `falloff` is bounded
238
+ * the same way `weights` is — a factor outside 0–1 is a promotion, and this
239
+ * declaration only ever DEMOTES a deeper scope below a shallower one.
240
+ */
241
+ const scopeWeightSchema = z
242
+ .object({
243
+ via: term,
244
+ by: term,
245
+ falloff: z.number().min(0).max(1),
246
+ asserted: term.optional(),
247
+ })
248
+ .strict();
249
+
207
250
  const lookupSchema = z
208
251
  .object({
209
252
  source: z.enum(["sparql", "graphql"]).optional(),
210
253
  by: term,
254
+ nameFallback: z.enum(["iri"]).optional(),
211
255
  type: term.optional(),
212
256
  description: z.string().optional(),
213
257
  toolDescription: z.string().optional(),
214
258
  types: z.array(term).min(1).optional(),
215
259
  weights: z.record(term, z.number().min(0).max(1)).optional(),
260
+ scopeWeight: scopeWeightSchema.optional(),
216
261
  graphqlType: graphqlName.optional(),
217
262
  fields: z.array(fieldSchema).min(1).optional(),
218
263
  sections: z.array(sectionSchema).min(1).optional(),
@@ -241,10 +286,7 @@ const definitionSchema = z
241
286
  message: 'a pack must declare at least one of "list" or "lookup".',
242
287
  });
243
288
  }
244
- if (
245
- def.list &&
246
- !/^\s*(?:PREFIX\s+[^\n]*\n\s*)*SELECT\s/i.test(def.list.query)
247
- ) {
289
+ if (def.list && !isSelectQuery(def.list.query)) {
248
290
  ctx.addIssue({
249
291
  code: z.ZodIssueCode.custom,
250
292
  message: '"list.query" must be a SPARQL SELECT query.',
@@ -333,6 +375,20 @@ function refineLookup(
333
375
  });
334
376
  }
335
377
 
378
+ // An IRI-derived name is only as trustworthy as the class vouching for the
379
+ // entity it came from, and without a class constraint nothing bounds the scan
380
+ // the derived-name population would run. Rejected rather than ignored, for the
381
+ // same reason a stale weight is: a silent no-op is how a declaration that
382
+ // never applied survives review.
383
+ if (lookup.nameFallback !== undefined && !lookup.type && !lookup.types) {
384
+ ctx.addIssue({
385
+ code: z.ZodIssueCode.custom,
386
+ message:
387
+ '"lookup.nameFallback" requires a class constraint ("lookup.type" or "lookup.types") to vouch for the entities it names.',
388
+ path: ["lookup", "nameFallback"],
389
+ });
390
+ }
391
+
336
392
  // A weight naming a type the lookup does not address can never apply. Reject
337
393
  // it rather than ignore it — a silent no-op is how a stale weight survives a
338
394
  // rename (the type moves, the weight stays, and nothing says so).
@@ -8,15 +8,48 @@
8
8
  * the input is a free term matched the same way. A row lacking the variable never
9
9
  * matches. Several filters combine conjunctively; several values for one
10
10
  * filter (a repeated flag) combine as a union.
11
+ *
12
+ * A filter declaring `match: "set"` reads its cell as a space-separated SET and
13
+ * matches any member — the shape a query produces for a dimension a row belongs
14
+ * to several values of at once (a category and its ancestors, say). The
15
+ * comparison is otherwise identical, so exactly one code path decides what a
16
+ * match is.
17
+ *
18
+ * A value-free filter still REJECTS a value the data does not carry, with the
19
+ * admissible values as `validOptions` — the same courtesy a declared-`values`
20
+ * filter has always extended, without copying the vocabulary into the consumer.
21
+ * The vocabulary is read from the graph, because the graph IS the vocabulary; a
22
+ * story that hard-coded the slugs would go stale the first time the data grew a
23
+ * category. Before this, a typo'd `--category` returned
24
+ * `{"ok":true,"data":[],"meta":{}}` — indistinguishable, over MCP, from a
25
+ * genuinely empty category.
26
+ *
27
+ * WHICH part of the graph is the vocabulary matters, and it is NOT the rows.
28
+ * Rows are a population: a value can be perfectly real while no row carries it —
29
+ * `standard categories` lists a category with count 0, `ds:ConceptType` declares
30
+ * "Decision guide" before any concept uses it. Validating against rows called
31
+ * those `INVALID_INPUT`, when the documented answer is a calm empty list. So a
32
+ * filter DECLARES where its vocabulary lives ({@link PackFilter.vocabulary},
33
+ * resolved by the caller and handed in here), and the observed rows are the
34
+ * fallback for a filter that declares none — the only evidence there is, and
35
+ * knowingly narrower than the truth.
11
36
  */
12
37
 
13
38
  import { PragmaError } from "../../error/index.js";
14
39
  import type { PackFilter, PackRow } from "../types.js";
15
40
 
41
+ /**
42
+ * Authoritative values per filter `param`, read from each filter's declared
43
+ * `vocabulary`. A param absent from the map falls back to the observed rows.
44
+ */
45
+ export type FilterVocabularies = ReadonlyMap<string, readonly string[]>;
46
+
16
47
  /**
17
48
  * @param rows - Rows produced by the pack's list query.
18
49
  * @param filters - Declared filters (absent means no filtering).
19
50
  * @param params - Story parameters as provided by the surface.
51
+ * @param vocabularies - Authoritative values per filter param, resolved from
52
+ * each filter's declared `vocabulary` (see {@link FilterVocabularies}).
20
53
  * @returns Rows matching every provided filter.
21
54
  * @throws PragmaError INVALID_INPUT when a value is not in a filter's declared
22
55
  * set, or when a value-free filter receives a non-string value.
@@ -25,6 +58,7 @@ export function applyPackFilters(
25
58
  rows: PackRow[],
26
59
  filters: readonly PackFilter[] | undefined,
27
60
  params: Record<string, unknown>,
61
+ vocabularies?: FilterVocabularies,
28
62
  ): PackRow[] {
29
63
  let result = rows;
30
64
  for (const filter of filters ?? []) {
@@ -41,14 +75,101 @@ export function applyPackFilters(
41
75
  ? requireStringValue(occurrence, filter).toLowerCase()
42
76
  : canonicalizeFilterValue(occurrence, filter, values).toLowerCase(),
43
77
  );
44
- result = result.filter((row) => {
45
- const cell = row[filter.variable]?.normalize("NFC").toLowerCase();
46
- return cell !== undefined && terms.includes(cell);
47
- });
78
+ // The fallback vocabulary is read from the UNFILTERED rows: a value that
79
+ // exists but is excluded by a conjunction is a legitimately empty answer,
80
+ // not a bad argument.
81
+ if (values === undefined)
82
+ rejectUnknownValue(
83
+ vocabularies?.get(filter.param) ?? observedValues(rows, filter),
84
+ filter,
85
+ occurrences,
86
+ terms,
87
+ );
88
+ result = result.filter((row) =>
89
+ cellValues(row, filter).some((value) =>
90
+ terms.includes(value.toLowerCase()),
91
+ ),
92
+ );
48
93
  }
49
94
  return result;
50
95
  }
51
96
 
97
+ /**
98
+ * The values the rows themselves carry — the fallback vocabulary for a filter
99
+ * that declares no authoritative one, in the data's own DISPLAY spelling.
100
+ */
101
+ function observedValues(
102
+ rows: readonly PackRow[],
103
+ filter: PackFilter,
104
+ ): string[] {
105
+ const observed = new Map<string, string>();
106
+ for (const row of rows) {
107
+ for (const value of cellValues(row, filter)) {
108
+ const key = value.toLowerCase();
109
+ if (!observed.has(key)) observed.set(key, value);
110
+ }
111
+ }
112
+ return [...observed.values()];
113
+ }
114
+
115
+ /**
116
+ * Reject a value-free filter value the vocabulary does not admit, naming the
117
+ * ones it does.
118
+ *
119
+ * Silent when the vocabulary is EMPTY: an unbuilt or genuinely-empty store is
120
+ * not the caller's mistake, and the list's own `emptyRecovery` is the right
121
+ * voice for it.
122
+ *
123
+ * A value the vocabulary admits is never rejected here even when no row carries
124
+ * it — a real slug with zero standards is a calm empty list, which is what the
125
+ * tool descriptions document and what `standard categories` reporting count 0
126
+ * promises.
127
+ *
128
+ * @throws PragmaError INVALID_INPUT when a term matches none of the admissible
129
+ * values, carrying them as `validOptions`.
130
+ */
131
+ function rejectUnknownValue(
132
+ vocabulary: readonly string[],
133
+ filter: PackFilter,
134
+ occurrences: readonly unknown[],
135
+ terms: readonly string[],
136
+ ): void {
137
+ const admissible = new Map<string, string>();
138
+ for (const value of vocabulary) {
139
+ const key = value.normalize("NFC").toLowerCase();
140
+ if (!admissible.has(key)) admissible.set(key, value.normalize("NFC"));
141
+ }
142
+ if (admissible.size === 0) return;
143
+ const index = terms.findIndex((term) => !admissible.has(term));
144
+ if (index === -1) return;
145
+ const validOptions = [...admissible.values()].sort();
146
+ throw PragmaError.invalidInput(filter.param, String(occurrences[index]), {
147
+ validOptions,
148
+ recovery: {
149
+ message: `Values allowed for --${filter.param}: ${validOptions.join(", ")}.`,
150
+ },
151
+ });
152
+ }
153
+
154
+ /**
155
+ * The values a row offers for one filter, in their DISPLAY spelling (NFC).
156
+ *
157
+ * One value for an ordinary filter; every space-separated member for a `"set"`
158
+ * one. A row lacking the variable offers none, so it never matches. Kept in the
159
+ * data's own casing because these are also what an INVALID_INPUT lists back as
160
+ * `validOptions` — lowercasing here would hand the caller a value the graph does
161
+ * not spell that way. Comparison lowercases at the point of comparison instead.
162
+ */
163
+ function cellValues(row: PackRow, filter: PackFilter): string[] {
164
+ const cell = row[filter.variable];
165
+ if (cell === undefined) return [];
166
+ const normalized = cell.normalize("NFC");
167
+ if (filter.match !== "set") {
168
+ return normalized === "" ? [] : [normalized];
169
+ }
170
+ return normalized.split(/\s+/).filter((value) => value !== "");
171
+ }
172
+
52
173
  /** @throws PragmaError INVALID_INPUT when a value-free filter value is not a string. */
53
174
  function requireStringValue(provided: unknown, filter: PackFilter): string {
54
175
  if (typeof provided !== "string") {
@@ -0,0 +1,290 @@
1
+ /**
2
+ * The `?name` binding every generated lookup query shares.
3
+ *
4
+ * The unit under test is narrow but load-bearing: WHAT an entity's name is
5
+ * decides which entities a `lookup` can address at all, and it must agree with
6
+ * what the `list` story publishes. When it did not, `standard list` handed out
7
+ * `react/component/tsdoc` and `standard lookup react/component/tsdoc` answered
8
+ * ENTITY_NOT_FOUND — the two halves of the declared two-step grammar
9
+ * disagreeing about the same entity.
10
+ *
11
+ * The agreement runs in BOTH directions, which is why the fallback is a
12
+ * DECLARATION (`nameFallback: "iri"`) and not an inference from the presence of
13
+ * a class constraint. A story whose list REQUIRES its `by` property publishes no
14
+ * derived name, so deriving one for its lookup would make entities addressable
15
+ * — and sampleable — under names the list never handed out. Both directions are
16
+ * pinned below.
17
+ *
18
+ * The second half of this file is the ranking a name resolve ORDERS BY, pinned
19
+ * as EMITTED TEXT with no store. That is deliberate: the store-backed suites
20
+ * prove which block wins, and a store can only ever prove it for the entities it
21
+ * holds — the emission is what says the rank is computed from a scope's declared
22
+ * NAME rather than enumerated, that an asserted rank would take precedence over
23
+ * the derived one, and that a story declaring no ranking is left exactly as it
24
+ * was. None of those three survive as an assertion about one graph.
25
+ */
26
+
27
+ import { describe, expect, it } from "vitest";
28
+ import type { PackLookup } from "../types.js";
29
+ import {
30
+ buildLookupByIriQuery,
31
+ buildLookupNamesQuery,
32
+ buildLookupQuery,
33
+ buildNameResolveQuery,
34
+ } from "./buildLookupQuery.js";
35
+
36
+ /** The one story that declares the fallback: `standard`, whose list derives names. */
37
+ const DERIVED: PackLookup = {
38
+ source: "sparql",
39
+ by: "cs:name",
40
+ nameFallback: "iri",
41
+ type: "cs:CodeStandard",
42
+ };
43
+
44
+ /**
45
+ * A class-constrained lookup that does NOT declare the fallback — the shape
46
+ * `block`/`token`/`tier`/`concept` declare, whose lists require the `by`
47
+ * property.
48
+ */
49
+ const CONSTRAINED: PackLookup = {
50
+ source: "sparql",
51
+ by: "ds:tokenId",
52
+ type: "ds:Token",
53
+ };
54
+
55
+ /** No class to vouch for `?uri`: the strictest binding of the three. */
56
+ const UNCONSTRAINED: PackLookup = { source: "sparql", by: "ds:name" };
57
+
58
+ describe("the shared ?name binding", () => {
59
+ it("makes the `by` triple OPTIONAL and derives a name when the story declares it", () => {
60
+ const query = buildLookupQuery(DERIVED, "react/component/props");
61
+ expect(query).toContain("?uri a cs:CodeStandard .");
62
+ expect(query).toContain("OPTIONAL { ?uri cs:name ?byName . }");
63
+ expect(query).toContain("BIND(COALESCE(?byName,");
64
+ });
65
+
66
+ it("derives dot-separated local names with slashes — the spelling `list` publishes", () => {
67
+ // `cs:react.component.props` → `react/component/props`. Pinned because the
68
+ // list story performs the SAME derivation to publish a row's name; a change
69
+ // on one side without the other re-opens the unaddressable-name defect.
70
+ expect(buildLookupQuery(DERIVED, "x")).toContain(
71
+ 'REPLACE(REPLACE(STR(?uri), "^.*[#/]", ""), "\\\\.", "/")',
72
+ );
73
+ });
74
+
75
+ it("REQUIRES the `by` triple for a class-constrained story that declares no fallback", () => {
76
+ // The narrowing this option exists for. `token list` requires `ds:tokenId`,
77
+ // so a `ds:Token` without one is a row the list never publishes — inferring
78
+ // a derived name from the class constraint alone made it addressable under
79
+ // a name no surface ever handed out.
80
+ const query = buildLookupQuery(CONSTRAINED, "spacing.medium");
81
+ expect(query).toContain("?uri a ds:Token .");
82
+ expect(query).toContain("?uri ds:tokenId ?name .");
83
+ expect(query).not.toContain("COALESCE");
84
+ });
85
+
86
+ it("still REQUIRES the `by` triple when the lookup constrains no class", () => {
87
+ const query = buildLookupQuery(UNCONSTRAINED, "Button");
88
+ expect(query).toContain("?uri ds:name ?name .");
89
+ expect(query).not.toContain("OPTIONAL { ?uri ds:name");
90
+ expect(query).not.toContain("COALESCE");
91
+ });
92
+
93
+ it("ignores a fallback declared without a class to vouch for it", () => {
94
+ // The schema rejects the pairing outright; this is the builders' own guard,
95
+ // because a derived name with no class constraint leaves `?uri` bound by
96
+ // nothing and scans the whole graph.
97
+ const query = buildLookupQuery(
98
+ { ...UNCONSTRAINED, nameFallback: "iri" },
99
+ "Button",
100
+ );
101
+ expect(query).toContain("?uri ds:name ?name .");
102
+ expect(query).not.toContain("COALESCE");
103
+ });
104
+
105
+ it("binds `?uri` before the BIND that reads it (SPARQL scoping)", () => {
106
+ const query = buildLookupQuery(DERIVED, "x");
107
+ expect(query.indexOf("?uri a cs:CodeStandard .")).toBeLessThan(
108
+ query.indexOf("BIND(COALESCE("),
109
+ );
110
+ const byIri = buildLookupByIriQuery(DERIVED, "https://example.test/a");
111
+ expect(
112
+ byIri.indexOf("BIND(<https://example.test/a> AS ?uri)"),
113
+ ).toBeLessThan(byIri.indexOf("BIND(COALESCE("));
114
+ });
115
+
116
+ it("keeps the `by` value a LABEL on the IRI-addressed form", () => {
117
+ // An IRI names the entity by itself, so a class constraint is already a
118
+ // sufficient existence check and the `by` triple is a label to project.
119
+ // Unchanged by the fallback option: a `ds:Token` reached by its IRI still
120
+ // resolves whether or not it carries a `ds:tokenId`.
121
+ const byIri = buildLookupByIriQuery(CONSTRAINED, "https://example.test/a");
122
+ expect(byIri).toContain("OPTIONAL { ?uri ds:tokenId ?name . }");
123
+ expect(byIri).not.toContain("COALESCE");
124
+ // With no class to vouch for it, the triple is the only existence check
125
+ // between a typo'd IRI and an empty entity, so it stays required.
126
+ expect(
127
+ buildLookupByIriQuery(UNCONSTRAINED, "https://example.test/a"),
128
+ ).toContain(" ?uri ds:name ?name .");
129
+ });
130
+
131
+ it("uses the SAME binding for the addressable population as for the resolve", () => {
132
+ // Miss-suggestions, glob expansion and `sample`'s draw pool all read this
133
+ // query. A population wider or narrower than the resolve's is how a
134
+ // suggested name could itself miss, and how the advertised
135
+ // `react/component/*` glob expanded over a pool that contained no slashes.
136
+ const names = buildLookupNamesQuery(DERIVED);
137
+ expect(names).toContain("OPTIONAL { ?uri cs:name ?byName . }");
138
+ expect(names).toContain("BIND(COALESCE(?byName,");
139
+ // And the narrowing reaches the draw pool too: an unnamed `ds:Token` is not
140
+ // a candidate `token sample` can draw.
141
+ expect(buildLookupNamesQuery(CONSTRAINED)).toContain(
142
+ "?uri ds:tokenId ?name .",
143
+ );
144
+ expect(buildLookupNamesQuery(CONSTRAINED)).not.toContain("COALESCE");
145
+ expect(buildLookupNamesQuery(UNCONSTRAINED)).toContain(
146
+ "?uri ds:name ?name .",
147
+ );
148
+ });
149
+ });
150
+
151
+ /**
152
+ * The shape the live `block` story declares: several classes, one of them
153
+ * weighted below the rest, and a scope whose own name carries the hierarchy.
154
+ * Copied from `pragma.conf.ts` rather than imported so a change THERE shows up
155
+ * here as a failing assertion about emitted text, not as a silently different
156
+ * query.
157
+ */
158
+ const RANKED: PackLookup = {
159
+ source: "graphql",
160
+ by: "ds:name",
161
+ types: ["ds:Component", "ds:Pattern", "ds:Layout", "ds:Subcomponent"],
162
+ weights: { "ds:Subcomponent": 0.6 },
163
+ scopeWeight: {
164
+ via: "ds:tier",
165
+ by: "ds:name",
166
+ falloff: 0.2,
167
+ asserted: "ds:tierRank",
168
+ },
169
+ };
170
+
171
+ describe("the ranking a name resolve orders by", () => {
172
+ it("orders by the score and then by a key that cannot tie", () => {
173
+ // Both halves matter. Without DESC(?score) the ranking is not read at all;
174
+ // without the final STR(?uri) two equally-ranked entities are handed back
175
+ // to the store's scan order, which is the defect one layer up.
176
+ for (const query of [
177
+ buildLookupQuery(RANKED, "Button"),
178
+ buildNameResolveQuery(RANKED, "Button"),
179
+ ]) {
180
+ expect(query).toContain("ORDER BY DESC(?score) STR(?uri)");
181
+ }
182
+ });
183
+
184
+ it("returns the rows it ranked instead of discarding them in the store", () => {
185
+ // The lookup still ANSWERS with one entity — that arity is the tool's
186
+ // contract. The limit is gone one layer lower, so the resolver can see what
187
+ // it is choosing between and name it; under `LIMIT 1` the alternatives never
188
+ // left the store and no surface could mention what it had not been told.
189
+ for (const query of [
190
+ buildLookupQuery(RANKED, "Button"),
191
+ buildNameResolveQuery(RANKED, "Button"),
192
+ buildLookupQuery(CONSTRAINED, "spacing.medium"),
193
+ ]) {
194
+ expect(query).not.toContain("LIMIT");
195
+ }
196
+ });
197
+
198
+ it("multiplies the two factors rather than tiebreaking between them", () => {
199
+ // The editorial ruling, in the one line that implements it. As a TIEBREAK,
200
+ // the scope would decide before the type ever spoke and a global
201
+ // SUBcomponent would outrank a whole component elsewhere; as a product it
202
+ // does not (0.6 × 1 < 1 × 0.8).
203
+ expect(buildNameResolveQuery(RANKED, "TextInput")).toContain(
204
+ "BIND(IF(?rankType = 0 || ?rankScopeWeight = 0, 0, ?rankType * ?rankScopeWeight) AS ?score)",
205
+ );
206
+ });
207
+
208
+ it("DERIVES the scope rank from the scope's declared name, never a list of scopes", () => {
209
+ const query = buildNameResolveQuery(RANKED, "Button");
210
+ // The depth is the separator count in the scope's OWN name. The live tiers
211
+ // spell one scope `ds:apps_launchpad` and "Apps/Launchpad" — only the name
212
+ // carries the slash — so reading the IRI would rank every tier at depth 1.
213
+ expect(query).toContain("OPTIONAL { ?rankScope ds:name ?rankScopeName . }");
214
+ expect(query).toContain(
215
+ 'BIND(STRLEN(?rankScopeName) - STRLEN(REPLACE(?rankScopeName, "/", "")) AS ?rankDepth)',
216
+ );
217
+ // No tier is NAMED anywhere in the query: a scope added upstream tomorrow
218
+ // is ranked by the same expression, with no edit here or in the config.
219
+ expect(query).not.toContain("apps_launchpad");
220
+ expect(query).not.toContain("ds:global");
221
+ });
222
+
223
+ it("guards the depth-0 product the top scope always hits", () => {
224
+ // oxigraph raises on `0.2 * 0`, and a raising BIND leaves its variable
225
+ // unbound — so the ONE scope that is supposed to win is the one whose
226
+ // weight silently disappears. Pinned as text because it reads like a
227
+ // redundant branch and is not.
228
+ expect(buildNameResolveQuery(RANKED, "Button")).toContain(
229
+ "BIND(1 - IF(?rankDepth = 0, 0, 0.2 * ?rankDepth) AS ?rankDerived)",
230
+ );
231
+ });
232
+
233
+ it("lets an ASSERTED rank take precedence over the derived one", () => {
234
+ // The retirement path, stated in the query itself: the day the ontology
235
+ // asserts the ranking, the derived depth stops being consulted and the
236
+ // config declaration is deleted with no code change. Asserting the emitted
237
+ // COALESCE is the only way to pin this — the shipped graph asserts no
238
+ // `ds:tierRank` today, which is exactly the state this outlives.
239
+ expect(buildNameResolveQuery(RANKED, "Button")).toContain(
240
+ "BIND(COALESCE(?rankAsserted, IF(?rankDerived < 0, 0, ?rankDerived), 1) AS ?rankScopeWeight)",
241
+ );
242
+ // …and an entity in no scope at all scores a neutral 1, rather than
243
+ // sinking below every entity that has one.
244
+ const { scopeWeight, ...unscoped } = RANKED;
245
+ expect(scopeWeight).toBeDefined();
246
+ expect(buildNameResolveQuery(unscoped, "Button")).not.toContain(
247
+ "?rankScopeWeight",
248
+ );
249
+ });
250
+
251
+ it("weighs the LOWEST declared type, so a demotion is not cancelled", () => {
252
+ // The same rule the MCP listing's `effectiveWeight` states in prose. Tested
253
+ // lowest-first, so an entity in two weighted classes takes the demotion any
254
+ // membership asked for.
255
+ expect(buildNameResolveQuery(RANKED, "Button")).toContain(
256
+ "BIND(IF(EXISTS { ?uri a ds:Subcomponent }, 0.6, 1) AS ?rankType)",
257
+ );
258
+ });
259
+
260
+ it("leaves a story that declares NO ranking exactly as it was", () => {
261
+ // Every other noun. The limit is still gone — a name reaching two entities
262
+ // is answered with two on every story — but nothing else is added, and the
263
+ // order stays the total `STR(?uri)` it already had.
264
+ const query = buildLookupQuery(CONSTRAINED, "spacing.medium");
265
+ expect(query).toContain("ORDER BY STR(?uri)");
266
+ expect(query).not.toContain("?score");
267
+ expect(query).not.toContain("?rankType");
268
+ expect(query).not.toContain("LIMIT");
269
+ });
270
+
271
+ it("keeps the IRI-addressed forms at LIMIT 1, unranked", () => {
272
+ // An IRI is one entity by construction, so there is nothing to rank and
273
+ // nothing to discard. Ranking it would be pure cost on the hot path.
274
+ const byIri = buildLookupByIriQuery(RANKED, "https://ds.canonical.com/a");
275
+ expect(byIri).toContain("LIMIT 1");
276
+ expect(byIri).not.toContain("?score");
277
+ });
278
+
279
+ it("does not PROJECT the score it orders by", () => {
280
+ // `?score` is a ranking artefact, and the sparql path spreads its resolve
281
+ // row straight into the entity — projecting it would put a stray number in
282
+ // `--format json` and in the MCP payload.
283
+ expect(buildNameResolveQuery(RANKED, "Button").split("\n")[0]).toBe(
284
+ "SELECT ?uri ?name WHERE {",
285
+ );
286
+ expect(buildLookupQuery(RANKED, "Button").split("\n")[0]).not.toContain(
287
+ "?score",
288
+ );
289
+ });
290
+ });