@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,215 @@
1
+ import {
2
+ type Channel,
3
+ type Framework,
4
+ VALID_CHANNELS,
5
+ VALID_FRAMEWORKS,
6
+ } from "../constants.js";
7
+ import {
8
+ parsePackageEntry,
9
+ type RawPackageEntry,
10
+ } from "../domains/refs/operations/parseRef.js";
11
+ import type { StoryPackDefinition } from "../domains/shared/stories/pack/types.js";
12
+ import validateStoryPackDefinition from "../domains/shared/stories/pack/validateStoryPackDefinition.js";
13
+ import { PragmaError } from "../error/PragmaError.js";
14
+ import type { ConfigFileValues } from "./types.js";
15
+
16
+ /**
17
+ * Type guard: check whether a value is a recognized channel string.
18
+ *
19
+ * @param value - Candidate value.
20
+ * @returns `true` if value is a valid Channel.
21
+ */
22
+ function isValidChannel(value: unknown): value is Channel {
23
+ return typeof value === "string" && VALID_CHANNELS.includes(value as Channel);
24
+ }
25
+
26
+ /**
27
+ * Type guard: check whether a value is a recognized framework string.
28
+ *
29
+ * @param value - Candidate value.
30
+ * @returns `true` if value is a valid Framework.
31
+ */
32
+ function isValidFramework(value: unknown): value is Framework {
33
+ return (
34
+ typeof value === "string" && VALID_FRAMEWORKS.includes(value as Framework)
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Parse and validate one config file's raw contents.
40
+ *
41
+ * Returns only the fields the file actually sets — presence drives layer
42
+ * merging in `readConfigLayers`. An empty or whitespace-only file sets
43
+ * nothing.
44
+ *
45
+ * @param raw - The file's raw text.
46
+ * @param sourcePath - Absolute path, used in error messages.
47
+ * @returns The values this file declares.
48
+ * @throws PragmaError with code `CONFIG_ERROR` on invalid JSON or values.
49
+ */
50
+ export default function parseConfigValues(
51
+ raw: string,
52
+ sourcePath: string,
53
+ ): ConfigFileValues {
54
+ const trimmed = raw.trim();
55
+ if (trimmed === "") {
56
+ return {};
57
+ }
58
+
59
+ let parsed: Record<string, unknown>;
60
+ try {
61
+ parsed = JSON.parse(trimmed) as Record<string, unknown>;
62
+ } catch {
63
+ throw PragmaError.configError(`Invalid JSON in ${sourcePath}.`);
64
+ }
65
+
66
+ const tier = typeof parsed.tier === "string" ? parsed.tier : undefined;
67
+
68
+ let channel: Channel | undefined;
69
+ if (parsed.channel !== undefined) {
70
+ if (!isValidChannel(parsed.channel)) {
71
+ throw PragmaError.configError(
72
+ `Invalid channel "${String(parsed.channel)}".`,
73
+ { validOptions: [...VALID_CHANNELS] },
74
+ );
75
+ }
76
+ channel = parsed.channel;
77
+ }
78
+
79
+ const trace = typeof parsed.trace === "boolean" ? parsed.trace : undefined;
80
+
81
+ let framework: Framework | undefined;
82
+ if (parsed.framework !== undefined) {
83
+ if (!isValidFramework(parsed.framework)) {
84
+ throw PragmaError.configError(
85
+ `Invalid framework "${String(parsed.framework)}".`,
86
+ { validOptions: [...VALID_FRAMEWORKS] },
87
+ );
88
+ }
89
+ framework = parsed.framework;
90
+ }
91
+
92
+ const packages = parsePackagesField(parsed.packages);
93
+ const stories = parseStoriesField(parsed.stories, sourcePath);
94
+ const prefixes = parsePrefixesField(parsed.prefixes);
95
+
96
+ return {
97
+ ...(tier !== undefined ? { tier } : {}),
98
+ ...(channel !== undefined ? { channel } : {}),
99
+ ...(packages ? { packages } : {}),
100
+ ...(trace !== undefined ? { trace } : {}),
101
+ ...(framework !== undefined ? { framework } : {}),
102
+ ...(stories ? { stories } : {}),
103
+ ...(prefixes ? { prefixes } : {}),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Validate and parse the experimental `stories` config field.
109
+ *
110
+ * @returns The validated definitions, or `undefined` when absent.
111
+ * @throws PragmaError if the field is present but malformed.
112
+ */
113
+ function parseStoriesField(
114
+ raw: unknown,
115
+ sourcePath: string,
116
+ ): ReadonlyArray<StoryPackDefinition> | undefined {
117
+ if (raw === undefined || raw === null) return undefined;
118
+ if (!Array.isArray(raw)) {
119
+ throw PragmaError.configError(
120
+ '"stories" must be an array of story-pack definitions.',
121
+ );
122
+ }
123
+ return raw.map((entry, index) =>
124
+ validateStoryPackDefinition(entry, `${sourcePath} (stories[${index}])`),
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Validate and parse the `prefixes` config field.
130
+ *
131
+ * @returns The prefix map, or `undefined` when absent.
132
+ * @throws PragmaError if the field is present but malformed.
133
+ */
134
+ function parsePrefixesField(
135
+ raw: unknown,
136
+ ): Readonly<Record<string, string>> | undefined {
137
+ if (raw === undefined || raw === null) return undefined;
138
+ if (typeof raw !== "object" || Array.isArray(raw)) {
139
+ throw PragmaError.configError(
140
+ '"prefixes" must be an object mapping prefix to namespace IRI.',
141
+ );
142
+ }
143
+ const entries = Object.entries(raw as Record<string, unknown>);
144
+ for (const [prefix, namespace] of entries) {
145
+ if (typeof namespace !== "string" || !namespace.includes("://")) {
146
+ throw PragmaError.configError(
147
+ `Invalid namespace for prefix "${prefix}": expected an absolute IRI.`,
148
+ );
149
+ }
150
+ }
151
+ return Object.fromEntries(entries) as Record<string, string>;
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Packages field parsing
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /**
159
+ * Validate and parse the `packages` config field.
160
+ *
161
+ * @returns The validated array, or `undefined` when the field is absent.
162
+ * @throws PragmaError if the field is present but malformed.
163
+ */
164
+ function parsePackagesField(
165
+ raw: unknown,
166
+ ): ReadonlyArray<RawPackageEntry> | undefined {
167
+ if (raw === undefined || raw === null) return undefined;
168
+
169
+ if (!Array.isArray(raw)) {
170
+ throw PragmaError.configError(
171
+ '"packages" must be an array of strings or { name, source? } objects.',
172
+ );
173
+ }
174
+
175
+ // An explicitly empty list means "not configured": ref resolution falls
176
+ // back to global refs/defaults for an empty list (see mergeAndParseRefs),
177
+ // so dropping the field here keeps provenance in config show/doctor/info
178
+ // consistent with the behavior instead of reporting a project/global
179
+ // origin that resolution then ignores.
180
+ if (raw.length === 0) return undefined;
181
+
182
+ const entries: RawPackageEntry[] = [];
183
+ for (const item of raw) {
184
+ if (typeof item === "string") {
185
+ entries.push(item);
186
+ continue;
187
+ }
188
+ if (typeof item === "object" && item !== null && !Array.isArray(item)) {
189
+ const obj = item as Record<string, unknown>;
190
+ if (typeof obj.name !== "string" || obj.name.length === 0) {
191
+ throw PragmaError.configError(
192
+ 'Each object in "packages" must have a non-empty "name" string.',
193
+ );
194
+ }
195
+ const entry: { name: string; source?: string } = { name: obj.name };
196
+ if (obj.source !== undefined) {
197
+ if (typeof obj.source !== "string") {
198
+ throw PragmaError.configError(
199
+ `Invalid source for "${obj.name}": expected a string.`,
200
+ );
201
+ }
202
+ entry.source = obj.source;
203
+ }
204
+ // Validate format eagerly — fail fast on bad config
205
+ parsePackageEntry(entry);
206
+ entries.push(entry);
207
+ continue;
208
+ }
209
+ throw PragmaError.configError(
210
+ 'Each entry in "packages" must be a string or { name, source? } object.',
211
+ );
212
+ }
213
+
214
+ return entries;
215
+ }
@@ -1,4 +1,10 @@
1
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
2
8
  import { tmpdir } from "node:os";
3
9
  import { join } from "node:path";
4
10
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -6,11 +12,22 @@ import { PragmaError } from "../error/PragmaError.js";
6
12
  import readConfig from "./readConfig.js";
7
13
  import writeConfig from "./writeConfig.js";
8
14
 
15
+ /** Write to `<dir>/pragma.config.json` — these tests pin file mechanics. */
16
+ function writeConfigLocal(
17
+ dir: string,
18
+ update: Parameters<typeof writeConfig>[1],
19
+ ): string {
20
+ return writeConfig(dir, update, "local");
21
+ }
22
+
9
23
  describe("readConfig", () => {
10
24
  let dir: string;
11
25
 
12
26
  beforeEach(() => {
13
27
  dir = mkdtempSync(join(tmpdir(), "pragma-config-"));
28
+ // Bound the project-config walk at this fixture root so a stray
29
+ // pragma.config.json in an ancestor directory can never leak in.
30
+ mkdirSync(join(dir, ".git"));
14
31
  });
15
32
 
16
33
  afterEach(() => {
@@ -174,6 +191,64 @@ describe("readConfig", () => {
174
191
  });
175
192
  });
176
193
 
194
+ describe("readConfig — stories and prefixes fields", () => {
195
+ let dir: string;
196
+
197
+ beforeEach(() => {
198
+ dir = mkdtempSync(join(tmpdir(), "pragma-config-stories-"));
199
+ mkdirSync(join(dir, ".git"));
200
+ });
201
+
202
+ afterEach(() => {
203
+ rmSync(dir, { recursive: true, force: true });
204
+ });
205
+
206
+ it("parses a valid stories array and prefixes map", () => {
207
+ writeFileSync(
208
+ join(dir, "pragma.config.json"),
209
+ JSON.stringify({
210
+ prefixes: { ex: "http://example.org/" },
211
+ stories: [
212
+ {
213
+ noun: "recipe",
214
+ list: {
215
+ query: "SELECT ?uri WHERE { ?uri a ex:Recipe }",
216
+ columns: [{ field: "uri" }],
217
+ },
218
+ },
219
+ ],
220
+ }),
221
+ );
222
+ const config = readConfig(dir);
223
+ expect(config.prefixes).toEqual({ ex: "http://example.org/" });
224
+ expect(config.stories?.at(0)?.noun).toBe("recipe");
225
+ });
226
+
227
+ it("throws on a non-array stories field", () => {
228
+ writeFileSync(
229
+ join(dir, "pragma.config.json"),
230
+ '{"stories": {"noun": "x"}}',
231
+ );
232
+ expect(() => readConfig(dir)).toThrow(/stories/);
233
+ });
234
+
235
+ it("throws on an invalid story definition", () => {
236
+ writeFileSync(
237
+ join(dir, "pragma.config.json"),
238
+ '{"stories": [{"noun": "Bad Noun"}]}',
239
+ );
240
+ expect(() => readConfig(dir)).toThrow(PragmaError);
241
+ });
242
+
243
+ it("throws on a prefixes value that is not an IRI", () => {
244
+ writeFileSync(
245
+ join(dir, "pragma.config.json"),
246
+ '{"prefixes": {"ex": "not-an-iri"}}',
247
+ );
248
+ expect(() => readConfig(dir)).toThrow(/prefix/);
249
+ });
250
+ });
251
+
177
252
  describe("writeConfig", () => {
178
253
  let dir: string;
179
254
 
@@ -186,14 +261,14 @@ describe("writeConfig", () => {
186
261
  });
187
262
 
188
263
  it("creates a new config file with tier", () => {
189
- writeConfig(dir, { tier: "apps/lxd" });
264
+ writeConfigLocal(dir, { tier: "apps/lxd" });
190
265
  const config = readConfig(dir);
191
266
  expect(config.tier).toBe("apps/lxd");
192
267
  expect(config.channel).toBe("normal");
193
268
  });
194
269
 
195
270
  it("creates a new config file with channel", () => {
196
- writeConfig(dir, { channel: "experimental" });
271
+ writeConfigLocal(dir, { channel: "experimental" });
197
272
  const config = readConfig(dir);
198
273
  expect(config.channel).toBe("experimental");
199
274
  expect(config.tier).toBeUndefined();
@@ -201,7 +276,7 @@ describe("writeConfig", () => {
201
276
 
202
277
  it("merges tier into existing config", () => {
203
278
  writeFileSync(join(dir, "pragma.config.json"), '{"channel":"prerelease"}');
204
- writeConfig(dir, { tier: "global" });
279
+ writeConfigLocal(dir, { tier: "global" });
205
280
  const config = readConfig(dir);
206
281
  expect(config.tier).toBe("global");
207
282
  expect(config.channel).toBe("prerelease");
@@ -212,7 +287,7 @@ describe("writeConfig", () => {
212
287
  join(dir, "pragma.config.json"),
213
288
  '{"tier":"apps","channel":"normal"}',
214
289
  );
215
- writeConfig(dir, { tier: undefined });
290
+ writeConfigLocal(dir, { tier: undefined });
216
291
  const config = readConfig(dir);
217
292
  expect(config.tier).toBeUndefined();
218
293
  expect(config.channel).toBe("normal");
@@ -223,7 +298,7 @@ describe("writeConfig", () => {
223
298
  join(dir, "pragma.config.json"),
224
299
  '{"tier":"apps","channel":"experimental"}',
225
300
  );
226
- writeConfig(dir, { channel: undefined });
301
+ writeConfigLocal(dir, { channel: undefined });
227
302
  const config = readConfig(dir);
228
303
  expect(config.tier).toBe("apps");
229
304
  // Channel defaults to "normal" when absent from file
@@ -231,7 +306,7 @@ describe("writeConfig", () => {
231
306
  });
232
307
 
233
308
  it("round-trips tier and channel through write then read", () => {
234
- writeConfig(dir, { tier: "apps/lxd", channel: "prerelease" });
309
+ writeConfigLocal(dir, { tier: "apps/lxd", channel: "prerelease" });
235
310
  const config = readConfig(dir);
236
311
  expect(config.tier).toBe("apps/lxd");
237
312
  expect(config.channel).toBe("prerelease");
@@ -239,19 +314,19 @@ describe("writeConfig", () => {
239
314
 
240
315
  it("writes valid empty JSON when all fields removed", () => {
241
316
  writeFileSync(join(dir, "pragma.config.json"), '{"tier":"apps"}');
242
- writeConfig(dir, { tier: undefined });
317
+ writeConfigLocal(dir, { tier: undefined });
243
318
  const raw = readFileSync(join(dir, "pragma.config.json"), "utf-8");
244
319
  expect(raw).toBe("{}\n");
245
320
  });
246
321
 
247
322
  it("throws on unparseable existing config instead of silently overwriting", () => {
248
323
  writeFileSync(join(dir, "pragma.config.json"), "{invalid json");
249
- expect(() => writeConfig(dir, { tier: "apps" })).toThrow();
324
+ expect(() => writeConfigLocal(dir, { tier: "apps" })).toThrow();
250
325
  });
251
326
 
252
327
  describe("packages field", () => {
253
328
  it("writes packages array", () => {
254
- writeConfig(dir, {
329
+ writeConfigLocal(dir, {
255
330
  packages: [
256
331
  "@canonical/design-system",
257
332
  {
@@ -272,7 +347,7 @@ describe("writeConfig", () => {
272
347
 
273
348
  it("merges packages into existing config without touching tier", () => {
274
349
  writeFileSync(join(dir, "pragma.config.json"), '{"tier":"global"}');
275
- writeConfig(dir, { packages: ["@canonical/design-system"] });
350
+ writeConfigLocal(dir, { packages: ["@canonical/design-system"] });
276
351
  const config = readConfig(dir);
277
352
  expect(config.tier).toBe("global");
278
353
  expect(config.packages).toEqual(["@canonical/design-system"]);
@@ -283,7 +358,7 @@ describe("writeConfig", () => {
283
358
  join(dir, "pragma.config.json"),
284
359
  JSON.stringify({ packages: ["@canonical/design-system"] }),
285
360
  );
286
- writeConfig(dir, { packages: undefined });
361
+ writeConfigLocal(dir, { packages: undefined });
287
362
  const config = readConfig(dir);
288
363
  expect(config.packages).toBeUndefined();
289
364
  });
@@ -296,9 +371,25 @@ describe("writeConfig", () => {
296
371
  source: "git+https://github.com/canonical/anatomy-dsl.git#main",
297
372
  },
298
373
  ];
299
- writeConfig(dir, { packages });
374
+ writeConfigLocal(dir, { packages });
300
375
  const config = readConfig(dir);
301
376
  expect(config.packages).toEqual(packages);
302
377
  });
303
378
  });
304
379
  });
380
+
381
+ describe("readConfig — empty packages normalization", () => {
382
+ it("treats an explicitly empty packages list as not configured", () => {
383
+ const dir = mkdtempSync(join(tmpdir(), "pragma-empty-pkgs-"));
384
+ mkdirSync(join(dir, ".git"), { recursive: true });
385
+ writeFileSync(
386
+ join(dir, "pragma.config.json"),
387
+ JSON.stringify({ channel: "normal", packages: [] }),
388
+ );
389
+ const config = readConfig(dir);
390
+ // The field is dropped at parse time so provenance matches the ref
391
+ // resolution behavior (empty list falls back to defaults/global refs).
392
+ expect(config.packages).toBeUndefined();
393
+ rmSync(dir, { recursive: true, force: true });
394
+ });
395
+ });
@@ -1,126 +1,21 @@
1
- import { readFileSync } from "node:fs";
2
- import { type Channel, VALID_CHANNELS } from "../constants.js";
3
- import {
4
- parsePackageEntry,
5
- type RawPackageEntry,
6
- } from "../domains/refs/operations/parseRef.js";
7
- import { PragmaError } from "../error/PragmaError.js";
8
- import resolveConfigPath from "./resolveConfigPath.js";
1
+ import readConfigLayers from "./readConfigLayers.js";
9
2
  import type { PragmaConfig } from "./types.js";
10
3
 
11
4
  /**
12
- * Type guard: check whether a value is a recognized channel string.
5
+ * Read the effective pragma configuration for a directory.
13
6
  *
14
- * @param value - Candidate value.
15
- * @returns `true` if value is a valid Channel.
16
- */
17
- function isValidChannel(value: unknown): value is Channel {
18
- return typeof value === "string" && VALID_CHANNELS.includes(value as Channel);
19
- }
20
-
21
- /**
22
- * Read pragma config from `pragma.config.json` in the given directory.
23
- *
24
- * Returns defaults (no tier, `"normal"` channel) when the file is missing or empty.
7
+ * Layered resolution: built-in defaults, then the global XDG file
8
+ * (`~/.config/pragma/config.json`), then the nearest project
9
+ * `pragma.config.json` at or above `cwd` — each field won by the most
10
+ * specific layer that sets it. Returns defaults (no tier, `"normal"`
11
+ * channel) when no layer configures anything.
25
12
  *
26
- * @param cwd - Directory containing pragma.config.json (defaults to `process.cwd()`).
27
- * @returns Parsed configuration.
28
- * @throws PragmaError with code `CONFIG_ERROR` if JSON is invalid or channel is unrecognized.
13
+ * @param cwd - Directory the project layer is resolved from (defaults to `process.cwd()`).
14
+ * @returns The effective merged configuration.
15
+ * @throws PragmaError with code `CONFIG_ERROR` if a layer file has invalid JSON or values.
29
16
  *
30
- * @note Impure — reads config from filesystem.
17
+ * @note Impure — reads config files from the filesystem.
31
18
  */
32
19
  export default function readConfig(cwd: string = process.cwd()): PragmaConfig {
33
- const configPath = resolveConfigPath(cwd);
34
-
35
- let raw: string;
36
- try {
37
- raw = readFileSync(configPath, "utf-8");
38
- } catch {
39
- return { tier: undefined, channel: "normal" };
40
- }
41
-
42
- const trimmed = raw.trim();
43
- if (trimmed === "") {
44
- return { tier: undefined, channel: "normal" };
45
- }
46
-
47
- let parsed: Record<string, unknown>;
48
- try {
49
- parsed = JSON.parse(trimmed) as Record<string, unknown>;
50
- } catch {
51
- throw PragmaError.configError(`Invalid JSON in ${configPath}.`);
52
- }
53
-
54
- const tier = typeof parsed.tier === "string" ? parsed.tier : undefined;
55
-
56
- let channel: Channel = "normal";
57
- if (parsed.channel !== undefined) {
58
- if (!isValidChannel(parsed.channel)) {
59
- throw PragmaError.configError(
60
- `Invalid channel "${String(parsed.channel)}".`,
61
- { validOptions: [...VALID_CHANNELS] },
62
- );
63
- }
64
- channel = parsed.channel;
65
- }
66
-
67
- const packages = parsePackagesField(parsed.packages);
68
-
69
- return packages ? { tier, channel, packages } : { tier, channel };
70
- }
71
-
72
- // ---------------------------------------------------------------------------
73
- // Packages field parsing
74
- // ---------------------------------------------------------------------------
75
-
76
- /**
77
- * Validate and parse the `packages` config field.
78
- *
79
- * @returns The validated array, or `undefined` when the field is absent.
80
- * @throws PragmaError if the field is present but malformed.
81
- */
82
- function parsePackagesField(
83
- raw: unknown,
84
- ): ReadonlyArray<RawPackageEntry> | undefined {
85
- if (raw === undefined || raw === null) return undefined;
86
-
87
- if (!Array.isArray(raw)) {
88
- throw PragmaError.configError(
89
- '"packages" must be an array of strings or { name, source? } objects.',
90
- );
91
- }
92
-
93
- const entries: RawPackageEntry[] = [];
94
- for (const item of raw) {
95
- if (typeof item === "string") {
96
- entries.push(item);
97
- continue;
98
- }
99
- if (typeof item === "object" && item !== null && !Array.isArray(item)) {
100
- const obj = item as Record<string, unknown>;
101
- if (typeof obj.name !== "string" || obj.name.length === 0) {
102
- throw PragmaError.configError(
103
- 'Each object in "packages" must have a non-empty "name" string.',
104
- );
105
- }
106
- const entry: { name: string; source?: string } = { name: obj.name };
107
- if (obj.source !== undefined) {
108
- if (typeof obj.source !== "string") {
109
- throw PragmaError.configError(
110
- `Invalid source for "${obj.name}": expected a string.`,
111
- );
112
- }
113
- entry.source = obj.source;
114
- }
115
- // Validate format eagerly — fail fast on bad config
116
- parsePackageEntry(entry);
117
- entries.push(entry);
118
- continue;
119
- }
120
- throw PragmaError.configError(
121
- 'Each entry in "packages" must be a string or { name, source? } object.',
122
- );
123
- }
124
-
125
- return entries;
20
+ return readConfigLayers(cwd).config;
126
21
  }
@@ -0,0 +1,107 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { PragmaError } from "../error/PragmaError.js";
6
+ import readConfigLayers from "./readConfigLayers.js";
7
+
8
+ describe("readConfigLayers", () => {
9
+ let xdgDir: string;
10
+ let projectDir: string;
11
+ let originalXdg: string | undefined;
12
+
13
+ beforeEach(() => {
14
+ originalXdg = process.env.XDG_CONFIG_HOME;
15
+ xdgDir = mkdtempSync(join(tmpdir(), "pragma-layers-xdg-"));
16
+ process.env.XDG_CONFIG_HOME = xdgDir;
17
+ projectDir = mkdtempSync(join(tmpdir(), "pragma-layers-project-"));
18
+ mkdirSync(join(projectDir, ".git"));
19
+ });
20
+
21
+ afterEach(() => {
22
+ process.env.XDG_CONFIG_HOME = originalXdg;
23
+ rmSync(xdgDir, { recursive: true, force: true });
24
+ rmSync(projectDir, { recursive: true, force: true });
25
+ });
26
+
27
+ function writeGlobal(content: string): void {
28
+ mkdirSync(join(xdgDir, "pragma"), { recursive: true });
29
+ writeFileSync(join(xdgDir, "pragma/config.json"), content);
30
+ }
31
+
32
+ function writeProject(content: string): void {
33
+ writeFileSync(join(projectDir, "pragma.config.json"), content);
34
+ }
35
+
36
+ it("returns defaults with default origins when no layer exists", () => {
37
+ const layers = readConfigLayers(projectDir);
38
+ expect(layers.config).toEqual({ tier: undefined, channel: "normal" });
39
+ expect(layers.origins).toEqual({
40
+ tier: "default",
41
+ channel: "default",
42
+ packages: "default",
43
+ trace: "default",
44
+ framework: "default",
45
+ stories: "default",
46
+ prefixes: "default",
47
+ });
48
+ expect(layers.global.exists).toBe(false);
49
+ expect(layers.project.exists).toBe(false);
50
+ expect(layers.project.path).toBe(join(projectDir, "pragma.config.json"));
51
+ });
52
+
53
+ it("reads the global layer when no project file exists", () => {
54
+ writeGlobal('{"channel":"experimental","tier":"global"}');
55
+ const layers = readConfigLayers(projectDir);
56
+ expect(layers.config.channel).toBe("experimental");
57
+ expect(layers.config.tier).toBe("global");
58
+ expect(layers.origins.channel).toBe("global");
59
+ expect(layers.origins.tier).toBe("global");
60
+ expect(layers.global.exists).toBe(true);
61
+ });
62
+
63
+ it("merges per field — project wins only the fields it sets", () => {
64
+ writeGlobal('{"channel":"experimental","trace":true}');
65
+ writeProject('{"tier":"apps/lxd"}');
66
+ const layers = readConfigLayers(projectDir);
67
+ expect(layers.config).toEqual({
68
+ tier: "apps/lxd",
69
+ channel: "experimental",
70
+ trace: true,
71
+ });
72
+ expect(layers.origins).toEqual({
73
+ tier: "project",
74
+ channel: "global",
75
+ packages: "default",
76
+ trace: "global",
77
+ framework: "default",
78
+ stories: "default",
79
+ prefixes: "default",
80
+ });
81
+ });
82
+
83
+ it("lets the project override a global field wholesale", () => {
84
+ writeGlobal('{"packages":["@canonical/a"],"channel":"experimental"}');
85
+ writeProject('{"packages":["@canonical/b"],"channel":"normal"}');
86
+ const layers = readConfigLayers(projectDir);
87
+ expect(layers.config.packages).toEqual(["@canonical/b"]);
88
+ expect(layers.config.channel).toBe("normal");
89
+ expect(layers.origins.packages).toBe("project");
90
+ expect(layers.origins.channel).toBe("project");
91
+ });
92
+
93
+ it("finds the project layer from a subdirectory", () => {
94
+ writeProject('{"tier":"apps/lxd"}');
95
+ const sub = join(projectDir, "packages/app");
96
+ mkdirSync(sub, { recursive: true });
97
+ const layers = readConfigLayers(sub);
98
+ expect(layers.config.tier).toBe("apps/lxd");
99
+ expect(layers.project.path).toBe(join(projectDir, "pragma.config.json"));
100
+ expect(layers.project.exists).toBe(true);
101
+ });
102
+
103
+ it("throws on invalid JSON in the global layer", () => {
104
+ writeGlobal("{not json");
105
+ expect(() => readConfigLayers(projectDir)).toThrow(PragmaError);
106
+ });
107
+ });