@canonical/pragma-cli 0.27.1-experimental.0 → 0.30.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 (344) hide show
  1. package/README.md +67 -15
  2. package/dist/pragma +0 -0
  3. package/package.json +16 -17
  4. package/src/config/defaultConfig.test.ts +33 -0
  5. package/src/config/defaultConfig.ts +59 -0
  6. package/src/config/defaults.json +17 -0
  7. package/src/config/findProjectConfigPath.test.ts +53 -0
  8. package/src/config/findProjectConfigPath.ts +36 -0
  9. package/src/config/index.ts +17 -2
  10. package/src/config/mergeConfigFileUpdate.ts +58 -0
  11. package/src/config/parseConfigValues.ts +215 -0
  12. package/src/config/readConfig.test.ts +104 -13
  13. package/src/config/readConfig.ts +12 -117
  14. package/src/config/readConfigLayers.test.ts +107 -0
  15. package/src/config/readConfigLayers.ts +115 -0
  16. package/src/config/resolveGlobalConfigPath.ts +13 -0
  17. package/src/config/resolveWriteConfigPath.test.ts +53 -0
  18. package/src/config/resolveWriteConfigPath.ts +31 -0
  19. package/src/config/types.ts +88 -2
  20. package/src/config/writeConfig.ts +25 -43
  21. package/src/constants.ts +12 -1
  22. package/src/domains/block/commands/index.ts +2 -1
  23. package/src/domains/block/commands/list.ts +5 -90
  24. package/src/domains/block/commands/lookup.ts +8 -167
  25. package/src/domains/block/commands/sample.ts +55 -0
  26. package/src/domains/block/formatters/index.ts +2 -1
  27. package/src/domains/block/formatters/sample.ts +21 -0
  28. package/src/domains/block/index.ts +3 -3
  29. package/src/domains/block/mcp/specs.ts +36 -168
  30. package/src/domains/block/operations/index.ts +2 -1
  31. package/src/domains/block/operations/lookup.ts +9 -0
  32. package/src/domains/block/operations/sample.ts +41 -0
  33. package/src/domains/block/orchestration/blockEmptyError.test.ts +26 -0
  34. package/src/domains/block/orchestration/resolveBlockLookup.ts +11 -3
  35. package/src/domains/block/stories.test.ts +28 -0
  36. package/src/domains/block/stories.ts +254 -0
  37. package/src/domains/config/commands/channel.test.ts +14 -3
  38. package/src/domains/config/commands/channel.ts +11 -7
  39. package/src/domains/config/commands/configScope.ts +46 -0
  40. package/src/domains/config/commands/framework.test.ts +105 -0
  41. package/src/domains/config/commands/framework.ts +91 -0
  42. package/src/domains/config/commands/index.ts +2 -0
  43. package/src/domains/config/commands/show.ts +5 -45
  44. package/src/domains/config/commands/tier.test.ts +13 -2
  45. package/src/domains/config/commands/tier.ts +11 -7
  46. package/src/domains/config/commands/trace.test.ts +165 -0
  47. package/src/domains/config/commands/trace.ts +83 -0
  48. package/src/domains/config/formatters/channel.test.ts +20 -6
  49. package/src/domains/config/formatters/channel.ts +14 -11
  50. package/src/domains/config/formatters/framework.ts +37 -0
  51. package/src/domains/config/formatters/index.ts +2 -0
  52. package/src/domains/config/formatters/show.test.ts +27 -0
  53. package/src/domains/config/formatters/show.ts +27 -4
  54. package/src/domains/config/formatters/tier.test.ts +26 -10
  55. package/src/domains/config/formatters/tier.ts +19 -11
  56. package/src/domains/config/formatters/trace.ts +28 -0
  57. package/src/domains/config/index.ts +15 -2
  58. package/src/domains/config/mcp/specs.ts +33 -53
  59. package/src/domains/config/operations/index.ts +1 -0
  60. package/src/domains/config/operations/show.test.ts +18 -0
  61. package/src/domains/config/operations/show.ts +7 -1
  62. package/src/domains/config/operations/types.ts +7 -0
  63. package/src/domains/config/operations/validateFramework.ts +24 -0
  64. package/src/domains/config/stories.ts +42 -0
  65. package/src/domains/config/tasks/index.ts +3 -0
  66. package/src/domains/config/tasks/setChannel.ts +13 -22
  67. package/src/domains/config/tasks/setFramework.ts +21 -0
  68. package/src/domains/config/tasks/setTier.ts +10 -23
  69. package/src/domains/config/tasks/setTrace.ts +20 -0
  70. package/src/domains/config/tasks/writeConfigField.ts +41 -0
  71. package/src/domains/create/commands/component.ts +5 -5
  72. package/src/domains/doctor/operations/checks/checkConfigFile.ts +19 -5
  73. package/src/domains/doctor/operations/checks/checkKeStore.ts +5 -5
  74. package/src/domains/doctor/operations/checks/checkMcpCommands.ts +130 -0
  75. package/src/domains/doctor/operations/checks/checkMcpConfigured.ts +1 -1
  76. package/src/domains/doctor/operations/checks/checkPackageRefs.ts +30 -48
  77. package/src/domains/doctor/operations/checks/checkSkillsSymlinked.ts +1 -1
  78. package/src/domains/doctor/operations/checks/index.ts +1 -0
  79. package/src/domains/doctor/operations/checks.test.ts +162 -8
  80. package/src/domains/doctor/operations/runChecks.test.ts +85 -4
  81. package/src/domains/doctor/operations/runChecks.ts +50 -12
  82. package/src/domains/graph/helpers/resolveUri.test.ts +50 -0
  83. package/src/domains/graph/helpers/resolveUri.ts +28 -21
  84. package/src/domains/graphql/commands/build.test.ts +196 -0
  85. package/src/domains/graphql/commands/build.ts +117 -0
  86. package/src/domains/graphql/commands/check.test.ts +141 -0
  87. package/src/domains/graphql/commands/check.ts +81 -0
  88. package/src/domains/graphql/commands/index.ts +5 -0
  89. package/src/domains/graphql/commands/serve.test.ts +40 -0
  90. package/src/domains/graphql/commands/serve.ts +131 -0
  91. package/src/domains/graphql/constants.ts +10 -0
  92. package/src/domains/graphql/formatters/index.ts +3 -0
  93. package/src/domains/graphql/formatters/report.ts +78 -0
  94. package/src/domains/graphql/helpers/gatherSources.test.ts +92 -0
  95. package/src/domains/graphql/helpers/gatherSources.ts +58 -0
  96. package/src/domains/graphql/helpers/index.ts +6 -0
  97. package/src/domains/graphql/helpers/parsePrefixes.test.ts +44 -0
  98. package/src/domains/graphql/helpers/parsePrefixes.ts +38 -0
  99. package/src/domains/graphql/helpers/resolveSourceFiles.test.ts +56 -0
  100. package/src/domains/graphql/helpers/resolveSourceFiles.ts +45 -0
  101. package/src/domains/graphql/helpers/routeGraphql.test.ts +43 -0
  102. package/src/domains/graphql/helpers/routeGraphql.ts +28 -0
  103. package/src/domains/graphql/index.ts +24 -0
  104. package/src/domains/graphql/operations/compileSchema.test.ts +95 -0
  105. package/src/domains/graphql/operations/compileSchema.ts +107 -0
  106. package/src/domains/graphql/operations/createSchemaServer.test.ts +50 -0
  107. package/src/domains/graphql/operations/createSchemaServer.ts +75 -0
  108. package/src/domains/graphql/operations/index.ts +12 -0
  109. package/src/domains/graphql/types.ts +29 -0
  110. package/src/domains/info/operations/collectInfo.test.ts +16 -5
  111. package/src/domains/info/operations/collectInfo.ts +16 -19
  112. package/src/domains/llm/capabilities.test.ts +91 -0
  113. package/src/domains/llm/commands/capabilities.ts +28 -0
  114. package/src/domains/llm/commands/index.ts +1 -0
  115. package/src/domains/llm/data/commandReference.ts +90 -18
  116. package/src/domains/llm/data/decisionTrees.ts +15 -13
  117. package/src/domains/llm/data/index.ts +2 -0
  118. package/src/domains/llm/data/toolCatalog.ts +190 -0
  119. package/src/domains/llm/formatters/capabilities.ts +50 -0
  120. package/src/domains/llm/formatters/index.ts +1 -0
  121. package/src/domains/llm/index.ts +3 -2
  122. package/src/domains/llm/mcp/index.ts +1 -1
  123. package/src/domains/llm/mcp/specs.ts +59 -52
  124. package/src/domains/llm/types.ts +40 -0
  125. package/src/domains/modifier/commands/index.ts +2 -1
  126. package/src/domains/modifier/commands/list.ts +5 -38
  127. package/src/domains/modifier/commands/lookup.ts +8 -79
  128. package/src/domains/modifier/commands/sample.ts +53 -0
  129. package/src/domains/modifier/formatters/index.ts +2 -1
  130. package/src/domains/modifier/formatters/sample.ts +15 -0
  131. package/src/domains/modifier/index.ts +7 -3
  132. package/src/domains/modifier/mcp/specs.ts +38 -70
  133. package/src/domains/modifier/operations/index.ts +2 -1
  134. package/src/domains/modifier/operations/lookup.ts +9 -0
  135. package/src/domains/modifier/operations/sample.ts +32 -0
  136. package/src/domains/modifier/orchestration/modifierEmptyError.test.ts +26 -0
  137. package/src/domains/modifier/orchestration/modifierEmptyError.ts +7 -2
  138. package/src/domains/modifier/orchestration/resolveModifierLookup.ts +15 -2
  139. package/src/domains/modifier/stories.test.ts +20 -0
  140. package/src/domains/modifier/stories.ts +77 -0
  141. package/src/domains/ontology/commands/list.ts +5 -58
  142. package/src/domains/ontology/commands/show.ts +6 -58
  143. package/src/domains/ontology/formatters/show.ts +7 -1
  144. package/src/domains/ontology/mcp/specs.ts +7 -84
  145. package/src/domains/ontology/stories.test.ts +36 -0
  146. package/src/domains/ontology/stories.ts +108 -0
  147. package/src/domains/refs/operations/index.ts +1 -1
  148. package/src/domains/refs/operations/paths.ts +29 -1
  149. package/src/domains/refs/operations/updateRefs.test.ts +6 -3
  150. package/src/domains/refs/operations/updateRefs.ts +2 -36
  151. package/src/domains/setup/commands/skills.test.ts +6 -2
  152. package/src/domains/setup/commands/skills.ts +6 -2
  153. package/src/domains/setup/helpers/runSetupTask.test.ts +148 -150
  154. package/src/domains/setup/helpers/runSetupTask.ts +62 -70
  155. package/src/domains/setup/operations/setupMcp.test.ts +6 -1
  156. package/src/domains/shared/bootStore.ts +92 -50
  157. package/src/domains/shared/compactUri.test.ts +11 -0
  158. package/src/domains/shared/compactUri.ts +10 -5
  159. package/src/domains/shared/context.ts +2 -2
  160. package/src/domains/shared/contracts.ts +12 -0
  161. package/src/domains/shared/createInteractivePromptSession.test.ts +439 -0
  162. package/src/domains/shared/createInteractivePromptSession.ts +236 -0
  163. package/src/domains/shared/emptyErrorRecovery.test.ts +109 -0
  164. package/src/domains/shared/loaders/bundledLoader.test.ts +248 -0
  165. package/src/domains/shared/loaders/bundledLoader.ts +206 -0
  166. package/src/domains/shared/loaders/gitLoader.ts +31 -0
  167. package/src/domains/shared/loaders/index.ts +9 -0
  168. package/src/domains/shared/loaders/localLoader.ts +87 -0
  169. package/src/domains/shared/loaders/readPackageDir.ts +147 -0
  170. package/src/domains/shared/loaders/storyAssetsPlugin.test.ts +84 -0
  171. package/src/domains/shared/loaders/storyAssetsPlugin.ts +46 -0
  172. package/src/domains/shared/lookupMany.test.ts +120 -0
  173. package/src/domains/shared/lookupMany.ts +43 -3
  174. package/src/domains/shared/mergeAndParseRefs.test.ts +89 -0
  175. package/src/domains/shared/mergeAndParseRefs.ts +44 -0
  176. package/src/domains/shared/packages.ts +14 -120
  177. package/src/domains/shared/pickRandom.ts +22 -0
  178. package/src/domains/shared/resolveConfiguredGraphs.test.ts +27 -0
  179. package/src/domains/shared/resolveConfiguredGraphs.ts +41 -0
  180. package/src/domains/shared/runtime.ts +7 -63
  181. package/src/domains/shared/sampleFormatters.ts +47 -0
  182. package/src/domains/shared/semanticPackage.ts +112 -0
  183. package/src/domains/shared/stories/compileLookupCommand.test.ts +145 -0
  184. package/src/domains/shared/stories/compileLookupCommand.ts +100 -0
  185. package/src/domains/shared/stories/compileLookupTool.test.ts +105 -0
  186. package/src/domains/shared/stories/compileLookupTool.ts +96 -0
  187. package/src/domains/shared/stories/compileReadCommand.test.ts +121 -0
  188. package/src/domains/shared/stories/compileReadCommand.ts +48 -0
  189. package/src/domains/shared/stories/compileReadTool.test.ts +64 -0
  190. package/src/domains/shared/stories/compileReadTool.ts +38 -0
  191. package/src/domains/shared/stories/condense.test.ts +18 -0
  192. package/src/domains/shared/stories/condense.ts +15 -0
  193. package/src/domains/shared/stories/index.ts +20 -0
  194. package/src/domains/shared/stories/normalizeNames.test.ts +20 -0
  195. package/src/domains/shared/stories/normalizeNames.ts +22 -0
  196. package/src/domains/shared/stories/pack/applyPackFilters.test.ts +102 -0
  197. package/src/domains/shared/stories/pack/applyPackFilters.ts +76 -0
  198. package/src/domains/shared/stories/pack/buildLookupQuery.test.ts +51 -0
  199. package/src/domains/shared/stories/pack/buildLookupQuery.ts +80 -0
  200. package/src/domains/shared/stories/pack/collectPackStories.test.ts +108 -0
  201. package/src/domains/shared/stories/pack/collectPackStories.ts +76 -0
  202. package/src/domains/shared/stories/pack/compilePackCommands.ts +40 -0
  203. package/src/domains/shared/stories/pack/compilePackStories.test.ts +146 -0
  204. package/src/domains/shared/stories/pack/compilePackStories.ts +253 -0
  205. package/src/domains/shared/stories/pack/compilePackToolSpecs.ts +43 -0
  206. package/src/domains/shared/stories/pack/index.ts +28 -0
  207. package/src/domains/shared/stories/pack/runSelectQuery.ts +31 -0
  208. package/src/domains/shared/stories/pack/standardParity.test.ts +308 -0
  209. package/src/domains/shared/stories/pack/types.ts +104 -0
  210. package/src/domains/shared/stories/pack/validateStoryPackDefinition.test.ts +240 -0
  211. package/src/domains/shared/stories/pack/validateStoryPackDefinition.ts +408 -0
  212. package/src/domains/shared/stories/projectCliParameters.test.ts +59 -0
  213. package/src/domains/shared/stories/projectCliParameters.ts +57 -0
  214. package/src/domains/shared/stories/projectToolParams.test.ts +50 -0
  215. package/src/domains/shared/stories/projectToolParams.ts +32 -0
  216. package/src/domains/shared/stories/requirePragmaContext.test.ts +23 -0
  217. package/src/domains/shared/stories/requirePragmaContext.ts +23 -0
  218. package/src/domains/shared/stories/resolveLookupDetailed.test.ts +57 -0
  219. package/src/domains/shared/stories/resolveLookupDetailed.ts +27 -0
  220. package/src/domains/shared/stories/types.ts +171 -0
  221. package/src/domains/shared/suggestions/detectCrossDomain.test.ts +43 -0
  222. package/src/domains/shared/suggestions/detectCrossDomain.ts +116 -0
  223. package/src/domains/shared/suggestions/expandGlob.test.ts +92 -0
  224. package/src/domains/shared/suggestions/expandGlob.ts +52 -0
  225. package/src/domains/shared/suggestions/expandLookupQueries.ts +57 -0
  226. package/src/domains/shared/suggestions/index.ts +7 -0
  227. package/src/domains/shared/suggestions/listDomainNames.ts +61 -0
  228. package/src/domains/shared/suggestions/suggestNames.test.ts +70 -0
  229. package/src/domains/shared/suggestions/suggestNames.ts +130 -0
  230. package/src/domains/shared/types/index.ts +1 -0
  231. package/src/domains/shared/types/runtime.ts +4 -1
  232. package/src/domains/shared/types/sample.ts +17 -0
  233. package/src/domains/skill/commands/index.ts +3 -1
  234. package/src/domains/skill/commands/list.test.ts +6 -2
  235. package/src/domains/skill/commands/list.ts +5 -63
  236. package/src/domains/skill/commands/lookup.test.ts +118 -0
  237. package/src/domains/skill/commands/lookup.ts +14 -0
  238. package/src/domains/skill/formatters/index.ts +1 -0
  239. package/src/domains/skill/formatters/lookup.test.ts +46 -0
  240. package/src/domains/skill/formatters/lookup.ts +57 -0
  241. package/src/domains/skill/helpers/resolveSkillSources.ts +48 -16
  242. package/src/domains/skill/index.ts +7 -3
  243. package/src/domains/skill/mcp/specs.ts +10 -29
  244. package/src/domains/skill/operations/discover.test.ts +61 -30
  245. package/src/domains/skill/operations/discover.ts +28 -37
  246. package/src/domains/skill/operations/index.ts +2 -1
  247. package/src/domains/skill/operations/list.test.ts +15 -7
  248. package/src/domains/skill/operations/lookup.test.ts +91 -0
  249. package/src/domains/skill/operations/lookup.ts +70 -0
  250. package/src/domains/skill/stories.test.ts +43 -0
  251. package/src/domains/skill/stories.ts +103 -0
  252. package/src/domains/skill/types.ts +8 -0
  253. package/src/domains/standard/commands/index.ts +2 -1
  254. package/src/domains/standard/commands/list.ts +5 -84
  255. package/src/domains/standard/commands/lookup.ts +8 -93
  256. package/src/domains/standard/commands/sample.ts +57 -0
  257. package/src/domains/standard/formatters/index.ts +2 -1
  258. package/src/domains/standard/formatters/sample.ts +15 -0
  259. package/src/domains/standard/index.ts +8 -1
  260. package/src/domains/standard/mcp/specs.ts +44 -152
  261. package/src/domains/standard/operations/index.ts +2 -1
  262. package/src/domains/standard/operations/lookup.ts +9 -0
  263. package/src/domains/standard/operations/sample.ts +35 -0
  264. package/src/domains/standard/orchestration/resolveStandardLookup.ts +15 -2
  265. package/src/domains/standard/orchestration/standardEmptyError.test.ts +33 -0
  266. package/src/domains/standard/orchestration/standardEmptyError.ts +16 -6
  267. package/src/domains/standard/stories.test.ts +24 -0
  268. package/src/domains/standard/stories.ts +151 -0
  269. package/src/domains/tier/commands/list.ts +5 -55
  270. package/src/domains/tier/mcp/specs.ts +5 -31
  271. package/src/domains/tier/stories.test.ts +22 -0
  272. package/src/domains/tier/stories.ts +52 -0
  273. package/src/domains/token/commands/index.ts +2 -1
  274. package/src/domains/token/commands/sample.ts +53 -0
  275. package/src/domains/token/formatters/index.ts +2 -1
  276. package/src/domains/token/formatters/sample.ts +16 -0
  277. package/src/domains/token/index.ts +8 -1
  278. package/src/domains/token/mcp/specs.ts +52 -2
  279. package/src/domains/token/operations/index.ts +2 -1
  280. package/src/domains/token/operations/lookup.ts +9 -0
  281. package/src/domains/token/operations/sample.ts +32 -0
  282. package/src/domains/token/orchestration/resolveTokenLookup.ts +15 -2
  283. package/src/domains/token/orchestration/tokenEmptyError.test.ts +33 -0
  284. package/src/domains/token/orchestration/tokenEmptyError.ts +5 -2
  285. package/src/domains/trace/commands/clear.ts +71 -0
  286. package/src/domains/trace/commands/index.ts +5 -0
  287. package/src/domains/trace/commands/sessions.ts +32 -0
  288. package/src/domains/trace/commands/show.ts +145 -0
  289. package/src/domains/trace/formatters/index.ts +9 -0
  290. package/src/domains/trace/formatters/sessions.ts +80 -0
  291. package/src/domains/trace/formatters/show.ts +122 -0
  292. package/src/domains/trace/index.ts +17 -0
  293. package/src/domains/trace/operations/index.ts +8 -0
  294. package/src/domains/trace/operations/listSessions.test.ts +55 -0
  295. package/src/domains/trace/operations/listSessions.ts +52 -0
  296. package/src/domains/trace/operations/readTraceLog.test.ts +100 -0
  297. package/src/domains/trace/operations/readTraceLog.ts +104 -0
  298. package/src/domains/trace/tracePlugin.test.ts +171 -0
  299. package/src/domains/trace/tracePlugin.ts +159 -0
  300. package/src/domains/trace/types.ts +65 -0
  301. package/src/error/PragmaError.ts +15 -2
  302. package/src/error/types.ts +15 -1
  303. package/src/mcp/error/serializeErrorPayload.ts +7 -0
  304. package/src/mcp/error/types.ts +8 -0
  305. package/src/mcp/registerResources.test.ts +405 -75
  306. package/src/mcp/registerResources.ts +5 -299
  307. package/src/mcp/resources/buildGraphIndex.ts +167 -0
  308. package/src/mcp/resources/buildResourceList.test.ts +97 -0
  309. package/src/mcp/resources/buildResourceList.ts +176 -0
  310. package/src/mcp/resources/classifyEntity.test.ts +40 -0
  311. package/src/mcp/resources/classifyEntity.ts +27 -0
  312. package/src/mcp/resources/constants.ts +105 -0
  313. package/src/mcp/resources/displayPredicates.ts +62 -0
  314. package/src/mcp/resources/pickFirstValue.test.ts +29 -0
  315. package/src/mcp/resources/pickFirstValue.ts +26 -0
  316. package/src/mcp/resources/pickPrimaryType.test.ts +41 -0
  317. package/src/mcp/resources/pickPrimaryType.ts +26 -0
  318. package/src/mcp/resources/rankUriCompletions.test.ts +52 -0
  319. package/src/mcp/resources/rankUriCompletions.ts +64 -0
  320. package/src/mcp/resources/readEntity.ts +169 -0
  321. package/src/mcp/resources/registerResources.ts +126 -0
  322. package/src/mcp/resources/types.ts +128 -0
  323. package/src/mcp/tools/index.ts +9 -0
  324. package/src/mcp/tools/registerTools.test.ts +84 -12
  325. package/src/mcp/types.ts +4 -0
  326. package/src/mcp/utils/wrapTool.test.ts +1 -0
  327. package/src/pipeline/collectCommands.ts +15 -2
  328. package/src/pipeline/renderError.ts +15 -0
  329. package/src/pipeline/resolveCommandKind.ts +8 -1
  330. package/src/pipeline/runCli.ts +51 -6
  331. package/src/testing/createTestMcpClient.ts +5 -1
  332. package/src/testing/graphqlFixtures.ts +72 -0
  333. package/src/testing/index.ts +17 -0
  334. package/src/testing/integration/__snapshots__/executor-golden.test.ts.snap +1207 -0
  335. package/src/testing/integration/cross-binary-byte-equality.test.ts +129 -0
  336. package/src/testing/integration/executor-execution.test.ts +101 -0
  337. package/src/testing/integration/executor-golden.test.ts +145 -0
  338. package/src/testing/integration/mcp-surface.test.ts +11 -5
  339. package/src/testing/integration/parity.test.ts +17 -12
  340. package/src/testing/regression/0001-skill-discovery-granularity.test.ts +71 -0
  341. package/src/testing/setupXdgIsolation.ts +15 -0
  342. package/src/testing/standardParityFixtures.ts +92 -0
  343. package/src/testing/storyFixtures.ts +63 -0
  344. package/src/pipeline/runInteractiveCommand.ts +0 -198
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { PragmaError } from "#error";
3
+ import applyPackFilters from "./applyPackFilters.js";
4
+ import type { StoryPackFilter } from "./types.js";
5
+
6
+ const ROWS = [
7
+ { name: "Gazpacho", category: "soup" },
8
+ { name: "Pancakes", category: "breakfast" },
9
+ ];
10
+ const FILTERS: StoryPackFilter[] = [
11
+ { param: "category", variable: "category", values: ["breakfast", "soup"] },
12
+ ];
13
+
14
+ describe("applyPackFilters", () => {
15
+ it("returns rows unchanged when no filter value is provided", () => {
16
+ expect(applyPackFilters(ROWS, FILTERS, {})).toEqual(ROWS);
17
+ });
18
+
19
+ it("returns rows unchanged when no filters are declared", () => {
20
+ expect(applyPackFilters(ROWS, undefined, { category: "soup" })).toEqual(
21
+ ROWS,
22
+ );
23
+ });
24
+
25
+ it("keeps only rows matching the provided value", () => {
26
+ const rows = applyPackFilters(ROWS, FILTERS, { category: "soup" });
27
+ expect(rows.map((row) => row.name)).toEqual(["Gazpacho"]);
28
+ });
29
+
30
+ it("rejects values outside the declared set", () => {
31
+ expect(() =>
32
+ applyPackFilters(ROWS, FILTERS, { category: "dinner" }),
33
+ ).toThrowError(PragmaError);
34
+ try {
35
+ applyPackFilters(ROWS, FILTERS, { category: "dinner" });
36
+ } catch (error) {
37
+ expect((error as PragmaError).code).toBe("INVALID_INPUT");
38
+ expect((error as PragmaError).recovery?.message).toContain("breakfast");
39
+ }
40
+ });
41
+
42
+ it("rejects non-string values and suggests the declared set", () => {
43
+ expect(() => applyPackFilters(ROWS, FILTERS, { category: 3 })).toThrowError(
44
+ PragmaError,
45
+ );
46
+ try {
47
+ applyPackFilters(ROWS, FILTERS, { category: ["soup"] });
48
+ } catch (error) {
49
+ expect((error as PragmaError).validOptions).toEqual([
50
+ "breakfast",
51
+ "soup",
52
+ ]);
53
+ }
54
+ });
55
+
56
+ it("canonicalizes case-insensitive input to the declared value", () => {
57
+ const rows = applyPackFilters(ROWS, FILTERS, { category: "SOUP" });
58
+ expect(rows.map((row) => row.name)).toEqual(["Gazpacho"]);
59
+ });
60
+
61
+ it("trims surrounding whitespace from the provided value", () => {
62
+ const rows = applyPackFilters(ROWS, FILTERS, { category: " soup " });
63
+ expect(rows.map((row) => row.name)).toEqual(["Gazpacho"]);
64
+ });
65
+
66
+ it("matches unicode values across NFC/NFD normalization forms", () => {
67
+ const filters: StoryPackFilter[] = [
68
+ { param: "kind", variable: "kind", values: ["entr\u00e9e"] },
69
+ ];
70
+ const rows = [{ name: "Salad", kind: "entre\u0301e" }];
71
+ expect(
72
+ applyPackFilters(rows, filters, { kind: "entre\u0301e" }),
73
+ ).toHaveLength(1);
74
+ });
75
+
76
+ it("never matches rows without a binding for the variable", () => {
77
+ const rows = [{ name: "Mystery" }, ...ROWS];
78
+ const filtered = applyPackFilters(rows, FILTERS, { category: "soup" });
79
+ expect(filtered.map((row) => row.name)).toEqual(["Gazpacho"]);
80
+ });
81
+
82
+ it("carries the declared values as valid options on rejection", () => {
83
+ try {
84
+ applyPackFilters(ROWS, FILTERS, { category: "sopu" });
85
+ } catch (error) {
86
+ expect((error as PragmaError).code).toBe("INVALID_INPUT");
87
+ expect((error as PragmaError).validOptions).toContain("soup");
88
+ }
89
+ });
90
+
91
+ it("applies several filters conjunctively", () => {
92
+ const filters: StoryPackFilter[] = [
93
+ ...FILTERS,
94
+ { param: "name", variable: "name", values: ["Gazpacho", "Pancakes"] },
95
+ ];
96
+ const rows = applyPackFilters(ROWS, filters, {
97
+ category: "soup",
98
+ name: "Pancakes",
99
+ });
100
+ expect(rows).toEqual([]);
101
+ });
102
+ });
@@ -0,0 +1,76 @@
1
+ import { PragmaError } from "#error";
2
+ import type { StoryPackFilter } from "./types.js";
3
+
4
+ /**
5
+ * Apply declared pack filters to resolved list rows.
6
+ *
7
+ * Filters are row predicates on projected SELECT variables — the author
8
+ * query is never modified, so user input cannot inject SPARQL and the
9
+ * query's ordering is preserved.
10
+ *
11
+ * Matching semantics:
12
+ * - Input is trimmed, unicode-normalized (NFC), and matched against the
13
+ * declared values case-insensitively; the DECLARED value is canonical
14
+ * and is what rows are compared against.
15
+ * - Row comparison is exact on the canonical value (NFC on both sides).
16
+ * ke bindings carry lexical forms — language tags and datatypes are
17
+ * already stripped — so plain equality is sound.
18
+ * - A row without a binding for the variable (e.g. from OPTIONAL) never
19
+ * matches an active filter.
20
+ * - Several provided filters combine conjunctively.
21
+ *
22
+ * @param rows - Rows produced by the pack's list query.
23
+ * @param filters - Declared filters (absent means no filtering).
24
+ * @param params - Story parameters as provided by the surface.
25
+ * @returns Rows matching every provided filter.
26
+ * @throws PragmaError with code `INVALID_INPUT` (carrying the declared
27
+ * values as valid options) when a provided value is not in the
28
+ * filter's declared set.
29
+ */
30
+ export default function applyPackFilters(
31
+ rows: Record<string, string>[],
32
+ filters: readonly StoryPackFilter[] | undefined,
33
+ params: Record<string, unknown>,
34
+ ): Record<string, string>[] {
35
+ // Start from the caller's array; each active filter's `.filter()` yields a
36
+ // fresh array, so with no active filter the rows pass through unmodified —
37
+ // no defensive clone (the caller owns the query result).
38
+ let result = rows;
39
+ for (const filter of filters ?? []) {
40
+ const provided = params[filter.param];
41
+ if (provided === undefined) continue;
42
+ const canonical = canonicalizeFilterValue(provided, filter);
43
+ result = result.filter(
44
+ (row) => row[filter.variable]?.normalize("NFC") === canonical,
45
+ );
46
+ }
47
+ return result;
48
+ }
49
+
50
+ /**
51
+ * Resolve a provided filter value to its declared canonical form.
52
+ *
53
+ * @throws PragmaError with code `INVALID_INPUT` when the value is not a
54
+ * string or not in the declared set.
55
+ */
56
+ function canonicalizeFilterValue(
57
+ provided: unknown,
58
+ filter: StoryPackFilter,
59
+ ): string {
60
+ if (typeof provided === "string") {
61
+ const normalized = provided.trim().normalize("NFC");
62
+ const match = filter.values.find(
63
+ (value) =>
64
+ value.normalize("NFC").toLowerCase() === normalized.toLowerCase(),
65
+ );
66
+ if (match !== undefined) {
67
+ return match.normalize("NFC");
68
+ }
69
+ }
70
+ throw PragmaError.invalidInput(filter.param, String(provided), {
71
+ validOptions: [...filter.values],
72
+ recovery: {
73
+ message: `Allowed values for --${filter.param}: ${filter.values.join(", ")}.`,
74
+ },
75
+ });
76
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import buildLookupQuery, {
3
+ buildLookupNamesQuery,
4
+ escapeSparqlString,
5
+ formatTerm,
6
+ } from "./buildLookupQuery.js";
7
+
8
+ describe("escapeSparqlString", () => {
9
+ it("escapes quotes, backslashes, and newlines", () => {
10
+ expect(escapeSparqlString('a"b\\c\nd')).toBe('a\\"b\\\\c\\nd');
11
+ });
12
+ });
13
+
14
+ describe("formatTerm", () => {
15
+ it("keeps prefixed names and wraps absolute IRIs", () => {
16
+ expect(formatTerm("ex:Recipe")).toBe("ex:Recipe");
17
+ expect(formatTerm("http://example.org/x")).toBe("<http://example.org/x>");
18
+ });
19
+ });
20
+
21
+ describe("buildLookupQuery", () => {
22
+ it("generates a typed, escaped, case-insensitive match", () => {
23
+ const query = buildLookupQuery(
24
+ {
25
+ by: "ex:name",
26
+ type: "ex:Recipe",
27
+ fields: [{ name: "category", property: "ex:category" }],
28
+ },
29
+ 'Pan"cakes',
30
+ );
31
+ expect(query).toContain("?uri ex:name ?name .");
32
+ expect(query).toContain("?uri a ex:Recipe .");
33
+ expect(query).toContain("OPTIONAL { ?uri ex:category ?category . }");
34
+ expect(query).toContain('LCASE("Pan\\"cakes")');
35
+ expect(query).toContain("LIMIT 1");
36
+ });
37
+
38
+ it("omits the type constraint when absent", () => {
39
+ const query = buildLookupQuery({ by: "ex:name" }, "x");
40
+ expect(query).not.toContain("?uri a ");
41
+ });
42
+ });
43
+
44
+ describe("buildLookupNamesQuery", () => {
45
+ it("selects all names for the by property", () => {
46
+ const query = buildLookupNamesQuery({ by: "ex:name", type: "ex:Recipe" });
47
+ expect(query).toContain("SELECT ?name WHERE {");
48
+ expect(query).toContain("?uri ex:name ?name .");
49
+ expect(query).toContain("?uri a ex:Recipe .");
50
+ });
51
+ });
@@ -0,0 +1,80 @@
1
+ import type { StoryPackLookup } from "./types.js";
2
+
3
+ /**
4
+ * Build the generated, injection-safe lookup queries for a pack story.
5
+ *
6
+ * Pack authors declare *what* names an entity (`by`, optional `type`) and
7
+ * which properties to read; the query text is generated here so
8
+ * user-supplied names are always escaped SPARQL string literals — a pack
9
+ * definition never interpolates user input itself. Matching is exact and
10
+ * case-insensitive on the `by` property's string value.
11
+ */
12
+
13
+ /** Escape a user-supplied value as a SPARQL string literal body. */
14
+ export function escapeSparqlString(value: string): string {
15
+ return value
16
+ .replaceAll("\\", "\\\\")
17
+ .replaceAll('"', '\\"')
18
+ .replaceAll("\n", "\\n")
19
+ .replaceAll("\r", "\\r");
20
+ }
21
+
22
+ /** Render a validated term (prefixed name or absolute IRI) as a query token. */
23
+ export function formatTerm(term: string): string {
24
+ return term.includes("://") ? `<${term}>` : term;
25
+ }
26
+
27
+ /**
28
+ * Build the SELECT retrieving one named entity with its declared fields.
29
+ *
30
+ * @param lookup - The pack's lookup declaration.
31
+ * @param name - User-supplied entity name (escaped here).
32
+ * @returns SPARQL SELECT text.
33
+ */
34
+ export default function buildLookupQuery(
35
+ lookup: StoryPackLookup,
36
+ name: string,
37
+ ): string {
38
+ const fields = [...(lookup.fields ?? []), ...(lookup.sections ?? [])];
39
+ const vars = fields.map((field) => `?${field.name}`).join(" ");
40
+ const optionals = fields
41
+ .map(
42
+ (field) =>
43
+ ` OPTIONAL { ?uri ${formatTerm(field.property)} ?${field.name} . }`,
44
+ )
45
+ .join("\n");
46
+ const typeConstraint = lookup.type
47
+ ? ` ?uri a ${formatTerm(lookup.type)} .\n`
48
+ : "";
49
+
50
+ return [
51
+ `SELECT ?uri ?name${vars.length > 0 ? ` ${vars}` : ""} WHERE {`,
52
+ ` ?uri ${formatTerm(lookup.by)} ?name .`,
53
+ typeConstraint + optionals,
54
+ ` FILTER (LCASE(STR(?name)) = LCASE("${escapeSparqlString(name)}"))`,
55
+ "}",
56
+ "LIMIT 1",
57
+ ]
58
+ .filter((line) => line !== "")
59
+ .join("\n");
60
+ }
61
+
62
+ /**
63
+ * Build the SELECT listing all entity names — lookup-miss suggestions.
64
+ *
65
+ * @param lookup - The pack's lookup declaration.
66
+ * @returns SPARQL SELECT text yielding a `?name` per entity.
67
+ */
68
+ export function buildLookupNamesQuery(lookup: StoryPackLookup): string {
69
+ const typeConstraint = lookup.type
70
+ ? ` ?uri a ${formatTerm(lookup.type)} .\n`
71
+ : "";
72
+ return [
73
+ "SELECT ?name WHERE {",
74
+ ` ?uri ${formatTerm(lookup.by)} ?name .`,
75
+ typeConstraint.trimEnd(),
76
+ "}",
77
+ ]
78
+ .filter((line) => line !== "")
79
+ .join("\n");
80
+ }
@@ -0,0 +1,108 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { PragmaError } from "#error";
3
+ import { RECIPE_STORY } from "#testing";
4
+ import type { SemanticPackage } from "../../semanticPackage.js";
5
+ import collectPackStories from "./collectPackStories.js";
6
+
7
+ function makePackage(stories: SemanticPackage["stories"]): SemanticPackage {
8
+ return {
9
+ name: "@canonical/example",
10
+ version: "1.0.0",
11
+ source: "local",
12
+ graphs: [],
13
+ skills: [],
14
+ stories,
15
+ };
16
+ }
17
+
18
+ const CONFIG = { tier: undefined, channel: "normal" as const };
19
+
20
+ describe("collectPackStories", () => {
21
+ let warn: ReturnType<typeof vi.spyOn>;
22
+
23
+ beforeEach(() => {
24
+ warn = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
25
+ });
26
+
27
+ afterEach(() => {
28
+ warn.mockRestore();
29
+ });
30
+
31
+ it("collects config stories and validated package stories", () => {
32
+ const entries = collectPackStories(
33
+ { ...CONFIG, stories: [RECIPE_STORY] },
34
+ [
35
+ makePackage([
36
+ {
37
+ path: "/pkg/stories/other.json",
38
+ definition: { ...RECIPE_STORY, noun: "other" },
39
+ },
40
+ ]),
41
+ ],
42
+ new Set(["block"]),
43
+ );
44
+ expect(entries.map((entry) => entry.definition.noun)).toEqual([
45
+ "recipe",
46
+ "other",
47
+ ]);
48
+ expect(entries.at(0)?.source).toBe("config");
49
+ expect(entries.at(1)?.source).toBe("/pkg/stories/other.json");
50
+ });
51
+
52
+ it("lets config stories win noun collisions against packages", () => {
53
+ const entries = collectPackStories(
54
+ { ...CONFIG, stories: [RECIPE_STORY] },
55
+ [
56
+ makePackage([
57
+ { path: "/pkg/stories/recipe.json", definition: RECIPE_STORY },
58
+ ]),
59
+ ],
60
+ new Set(),
61
+ );
62
+ expect(entries).toHaveLength(1);
63
+ expect(entries.at(0)?.source).toBe("config");
64
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("already taken"));
65
+ });
66
+
67
+ it("skips invalid package stories with a warning", () => {
68
+ const entries = collectPackStories(
69
+ CONFIG,
70
+ [
71
+ makePackage([
72
+ { path: "/pkg/stories/broken.json", definition: { nope: true } },
73
+ ]),
74
+ ],
75
+ new Set(),
76
+ );
77
+ expect(entries).toHaveLength(0);
78
+ expect(warn).toHaveBeenCalledWith(
79
+ expect.stringContaining("skipping story"),
80
+ );
81
+ });
82
+
83
+ it("throws when a config story shadows a built-in noun", () => {
84
+ expect(() =>
85
+ collectPackStories(
86
+ { ...CONFIG, stories: [{ ...RECIPE_STORY, noun: "block" }] },
87
+ [],
88
+ new Set(["block"]),
89
+ ),
90
+ ).toThrow(PragmaError);
91
+ });
92
+
93
+ it("skips package stories that shadow built-in nouns", () => {
94
+ const entries = collectPackStories(
95
+ CONFIG,
96
+ [
97
+ makePackage([
98
+ {
99
+ path: "/pkg/stories/block.json",
100
+ definition: { ...RECIPE_STORY, noun: "block" },
101
+ },
102
+ ]),
103
+ ],
104
+ new Set(["block"]),
105
+ );
106
+ expect(entries).toHaveLength(0);
107
+ });
108
+ });
@@ -0,0 +1,76 @@
1
+ import type { PragmaConfig } from "#config";
2
+ import { PragmaError } from "#error";
3
+ import type { SemanticPackage } from "../../semanticPackage.js";
4
+ import type { StoryPackDefinition } from "./types.js";
5
+ import validateStoryPackDefinition from "./validateStoryPackDefinition.js";
6
+
7
+ /** A validated story definition paired with where it was declared. */
8
+ export interface PackStoryEntry {
9
+ readonly definition: StoryPackDefinition;
10
+ readonly source: string;
11
+ }
12
+
13
+ /**
14
+ * Gather the story-pack definitions active for a runtime.
15
+ *
16
+ * Config-declared stories come first (already validated by the config
17
+ * parser) and win noun collisions against package-shipped ones — the
18
+ * config is the user's override layer. Package story files are validated
19
+ * here; an invalid or colliding package story is skipped with a warning
20
+ * so one bad file cannot break boot. A config story colliding with a
21
+ * reserved (built-in) noun is a hard config error.
22
+ *
23
+ * @param config - The effective merged configuration.
24
+ * @param packages - Resolved semantic packages (may ship `stories/*.json`).
25
+ * @param reservedNouns - Built-in nouns story packs must not shadow.
26
+ * @returns Validated, collision-free story entries.
27
+ * @throws PragmaError with code `CONFIG_ERROR` when a config story shadows
28
+ * a reserved noun.
29
+ * @note Impure — warns on stderr for skipped package stories.
30
+ */
31
+ export default function collectPackStories(
32
+ config: PragmaConfig,
33
+ packages: readonly SemanticPackage[],
34
+ reservedNouns: ReadonlySet<string>,
35
+ ): PackStoryEntry[] {
36
+ const entries: PackStoryEntry[] = [];
37
+ const taken = new Set<string>();
38
+
39
+ for (const definition of config.stories ?? []) {
40
+ if (reservedNouns.has(definition.noun)) {
41
+ throw PragmaError.configError(
42
+ `Story noun "${definition.noun}" shadows a built-in command.`,
43
+ );
44
+ }
45
+ if (taken.has(definition.noun)) {
46
+ throw PragmaError.configError(
47
+ `Duplicate story noun "${definition.noun}" in config.`,
48
+ );
49
+ }
50
+ taken.add(definition.noun);
51
+ entries.push({ definition, source: "config" });
52
+ }
53
+
54
+ for (const pkg of packages) {
55
+ for (const file of pkg.stories) {
56
+ let definition: StoryPackDefinition;
57
+ try {
58
+ definition = validateStoryPackDefinition(file.definition, file.path);
59
+ } catch (error) {
60
+ const reason = error instanceof Error ? error.message : String(error);
61
+ process.stderr.write(`Warning: skipping story — ${reason}\n`);
62
+ continue;
63
+ }
64
+ if (reservedNouns.has(definition.noun) || taken.has(definition.noun)) {
65
+ process.stderr.write(
66
+ `Warning: skipping story "${definition.noun}" from ${file.path} — the noun is already taken.\n`,
67
+ );
68
+ continue;
69
+ }
70
+ taken.add(definition.noun);
71
+ entries.push({ definition, source: file.path });
72
+ }
73
+ }
74
+
75
+ return entries;
76
+ }
@@ -0,0 +1,40 @@
1
+ import type { CommandDefinition } from "@canonical/cli-core";
2
+ import type { PragmaContext } from "../../context.js";
3
+ import { PREFIX_MAP } from "../../prefixes.js";
4
+ import compileLookupCommand from "../compileLookupCommand.js";
5
+ import compileReadCommand from "../compileReadCommand.js";
6
+ import collectPackStories from "./collectPackStories.js";
7
+ import compilePackStories from "./compilePackStories.js";
8
+
9
+ /**
10
+ * Compile every active story pack into CLI command definitions.
11
+ *
12
+ * Story packs come from the merged config (`stories`) and from resolved
13
+ * semantic packages (`stories/*.json`); each yields `<noun> list` and,
14
+ * when declared, `<noun> lookup` — projected through the same kernel as
15
+ * the built-in read stories, so completions and help come for free.
16
+ *
17
+ * @param ctx - Pragma context carrying config, packages, and the store.
18
+ * @param reservedNouns - Built-in nouns packs must not shadow.
19
+ * @returns Command definitions for every pack story.
20
+ */
21
+ export default function compilePackCommands(
22
+ ctx: PragmaContext,
23
+ reservedNouns: ReadonlySet<string>,
24
+ ): CommandDefinition[] {
25
+ const prefixes = { ...PREFIX_MAP, ...ctx.config.prefixes };
26
+ const entries = collectPackStories(ctx.config, ctx.packages, reservedNouns);
27
+
28
+ return entries.flatMap((entry) => {
29
+ const compiled = compilePackStories(
30
+ entry.definition,
31
+ entry.source,
32
+ prefixes,
33
+ );
34
+ const commands = [compileReadCommand(ctx, compiled.list)];
35
+ if (compiled.lookup) {
36
+ commands.push(compileLookupCommand(ctx, compiled.lookup));
37
+ }
38
+ return commands;
39
+ });
40
+ }
@@ -0,0 +1,146 @@
1
+ import type { Store } from "@canonical/ke";
2
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
3
+ import { PragmaError } from "#error";
4
+ import {
5
+ createTestStore,
6
+ RECIPE_PREFIXES,
7
+ RECIPE_STORY,
8
+ RECIPE_TTL,
9
+ } from "#testing";
10
+ import { PREFIX_MAP } from "../../prefixes.js";
11
+ import type { PragmaRuntime } from "../../types/index.js";
12
+ import compilePackStories from "./compilePackStories.js";
13
+
14
+ const PREFIXES = { ...PREFIX_MAP, ...RECIPE_PREFIXES };
15
+
16
+ let store: Store;
17
+ let cleanup: () => void;
18
+ let rt: PragmaRuntime;
19
+
20
+ beforeAll(async () => {
21
+ const result = await createTestStore({
22
+ ttl: RECIPE_TTL,
23
+ prefixes: RECIPE_PREFIXES,
24
+ });
25
+ store = result.store;
26
+ cleanup = result.cleanup;
27
+ rt = { store } as PragmaRuntime;
28
+ });
29
+
30
+ afterAll(() => cleanup());
31
+
32
+ describe("compilePackStories — list", () => {
33
+ it("resolves the pack's preferred query into rows", async () => {
34
+ const { list } = compilePackStories(RECIPE_STORY, "test", PREFIXES);
35
+ const rows = await list.resolve(rt, {});
36
+ expect(rows.map((row) => row.name)).toEqual(["Gazpacho", "Pancakes"]);
37
+ expect(list.toEnvelope(rows)).toEqual({
38
+ data: rows,
39
+ meta: { count: 2 },
40
+ });
41
+ });
42
+
43
+ it("renders the generic formatters with compacted prefixes", async () => {
44
+ const { list } = compilePackStories(RECIPE_STORY, "test", PREFIXES);
45
+ const rows = await list.resolve(rt, {});
46
+ const plain = list.formatters.plain(rows);
47
+ expect(plain).toContain("Pancakes");
48
+ expect(plain).toContain("breakfast");
49
+ const llm = list.formatters.llm(rows);
50
+ expect(llm).toContain("## Recipe (2)");
51
+ expect(llm).toContain("`ex:gazpacho`");
52
+ expect(JSON.parse(list.formatters.json(rows))).toHaveLength(2);
53
+ });
54
+ });
55
+
56
+ describe("compilePackStories — list filters", () => {
57
+ it("projects declared filters as enum story parameters", () => {
58
+ const { list } = compilePackStories(RECIPE_STORY, "test", PREFIXES);
59
+ const param = list.params.at(0);
60
+ expect(param?.name).toBe("category");
61
+ expect(param?.type).toBe("string");
62
+ expect(param?.enum).toEqual(["breakfast", "soup"]);
63
+ });
64
+
65
+ it("filters resolved rows by the provided value", async () => {
66
+ const { list } = compilePackStories(RECIPE_STORY, "test", PREFIXES);
67
+ const rows = await list.resolve(rt, { category: "soup" });
68
+ expect(rows.map((row) => row.name)).toEqual(["Gazpacho"]);
69
+ expect(list.toEnvelope(rows).meta).toEqual({ count: 1 });
70
+ });
71
+
72
+ it("canonicalizes case-insensitive input through resolve", async () => {
73
+ const { list } = compilePackStories(RECIPE_STORY, "test", PREFIXES);
74
+ const rows = await list.resolve(rt, { category: "SOUP" });
75
+ expect(rows.map((row) => row.name)).toEqual(["Gazpacho"]);
76
+ });
77
+
78
+ it("rejects a value outside the declared set with INVALID_INPUT", async () => {
79
+ const { list } = compilePackStories(RECIPE_STORY, "test", PREFIXES);
80
+ await expect(
81
+ list.resolve(rt, { category: "dinner" }),
82
+ ).rejects.toMatchObject({ code: "INVALID_INPUT" });
83
+ });
84
+
85
+ it("mentions the first filter in the examples", () => {
86
+ const { list } = compilePackStories(RECIPE_STORY, "test", PREFIXES);
87
+ expect(list.examples).toContain("pragma recipe list --category breakfast");
88
+ });
89
+ });
90
+
91
+ describe("compilePackStories — lookup", () => {
92
+ it("looks an entity up by name, case-insensitively", async () => {
93
+ const { lookup } = compilePackStories(RECIPE_STORY, "test", PREFIXES);
94
+ const result = await lookup?.resolve(rt, ["pancakes"], {});
95
+ expect(result?.results).toHaveLength(1);
96
+ const entity = result?.results.at(0);
97
+ expect(entity?.name).toBe("Pancakes");
98
+ expect(entity?.category).toBe("breakfast");
99
+ expect(entity?.instructions).toBe("Mix, fry, flip.");
100
+ });
101
+
102
+ it("renders fields and sections through the generic lookup renderer", async () => {
103
+ const { lookup } = compilePackStories(RECIPE_STORY, "test", PREFIXES);
104
+ const result = await lookup?.resolve(rt, ["Gazpacho"], {});
105
+ const entity = result?.results.at(0);
106
+ expect(entity).toBeDefined();
107
+ if (!entity || !lookup) throw new Error("expected entity");
108
+ const llm = lookup.formatters.llm(
109
+ lookup.toFmtInput(entity, {
110
+ surface: "cli",
111
+ detailed: false,
112
+ params: {},
113
+ }),
114
+ );
115
+ expect(llm).toContain("Gazpacho");
116
+ expect(llm).toContain("soup");
117
+ expect(llm).toContain("Blend everything cold.");
118
+ });
119
+
120
+ it("collects not-found errors with ranked suggestions", async () => {
121
+ const { lookup } = compilePackStories(RECIPE_STORY, "test", PREFIXES);
122
+ const result = await lookup?.resolve(rt, ["Pancake"], {});
123
+ expect(result?.results).toHaveLength(0);
124
+ const error = result?.errors.at(0);
125
+ expect(error?.code).toBe("ENTITY_NOT_FOUND");
126
+ expect(error?.suggestions).toContain("Pancakes");
127
+ });
128
+
129
+ it("rejects empty names with a recovery hint", () => {
130
+ const { lookup } = compilePackStories(RECIPE_STORY, "test", PREFIXES);
131
+ expect(() => {
132
+ const error = lookup?.emptyNamesError?.();
133
+ if (error) throw error;
134
+ }).toThrow(PragmaError);
135
+ });
136
+
137
+ it("completes names from the store", async () => {
138
+ const { lookup } = compilePackStories(RECIPE_STORY, "test", PREFIXES);
139
+ const ctx = {
140
+ cwd: "/tmp",
141
+ globalFlags: { llm: false, format: "text" as const, verbose: false },
142
+ store,
143
+ };
144
+ await expect(lookup?.complete?.("pan", ctx)).resolves.toEqual(["Pancakes"]);
145
+ });
146
+ });