@canonical/pragma-cli 0.30.0 → 0.31.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 (254) hide show
  1. package/README.md +64 -35
  2. package/dist/pragma +0 -0
  3. package/package.json +15 -13
  4. package/src/completions/constants.ts +7 -0
  5. package/src/completions/handleQuery.test.ts +32 -0
  6. package/src/completions/handleQuery.ts +20 -1
  7. package/src/completions/queryCompletions.test.ts +5 -0
  8. package/src/completions/queryCompletions.ts +17 -3
  9. package/src/completions/querySocket.test.ts +31 -0
  10. package/src/completions/querySocket.ts +36 -9
  11. package/src/config/parseConfigValues.ts +2 -0
  12. package/src/config/readConfigLayers.test.ts +2 -0
  13. package/src/config/readConfigLayers.ts +3 -0
  14. package/src/config/types.ts +7 -0
  15. package/src/constants.ts +3 -1
  16. package/src/domains/block/commands/sample.ts +2 -1
  17. package/src/domains/block/formatters/list.test.ts +3 -3
  18. package/src/domains/block/formatters/lookup.test.ts +5 -5
  19. package/src/domains/block/orchestration/enrichBlocks.ts +6 -3
  20. package/src/domains/block/stories.ts +9 -3
  21. package/src/domains/config/commands/channel.test.ts +16 -0
  22. package/src/domains/config/commands/channel.ts +14 -1
  23. package/src/domains/config/commands/framework.test.ts +14 -0
  24. package/src/domains/config/commands/framework.ts +13 -1
  25. package/src/domains/config/commands/tier.test.ts +7 -0
  26. package/src/domains/config/commands/tier.ts +14 -1
  27. package/src/domains/config/operations/validateTier.test.ts +9 -0
  28. package/src/domains/config/operations/validateTier.ts +10 -2
  29. package/src/domains/create/generatorPack/bundled.ts +49 -0
  30. package/src/domains/create/generatorPack/compileGeneratorPack.test.ts +159 -0
  31. package/src/domains/create/generatorPack/compileGeneratorPack.ts +312 -0
  32. package/src/domains/create/index.ts +13 -7
  33. package/src/domains/create/mcp/specs.ts +6 -161
  34. package/src/domains/create/renderGeneratorUi.test.ts +103 -0
  35. package/src/domains/create/renderGeneratorUi.ts +65 -0
  36. package/src/domains/doctor/formatters/doctor.test.ts +50 -2
  37. package/src/domains/doctor/formatters/doctor.ts +90 -38
  38. package/src/domains/doctor/operations/checks/checkMcpCommands.ts +9 -6
  39. package/src/domains/doctor/operations/checks/checkPackageRefs.ts +30 -12
  40. package/src/domains/doctor/operations/checks.test.ts +6 -4
  41. package/src/domains/doctor/operations/types.ts +23 -1
  42. package/src/domains/graph/commands/inspect.test.ts +2 -2
  43. package/src/domains/graph/commands/query.test.ts +5 -3
  44. package/src/domains/graph/formatters/inspect.ts +7 -7
  45. package/src/domains/graph/formatters/query.test.ts +87 -0
  46. package/src/domains/graph/formatters/query.ts +42 -5
  47. package/src/domains/graph/operations/inspectUri.test.ts +5 -5
  48. package/src/domains/graphql/commands/serve.test.ts +12 -6
  49. package/src/domains/graphql/commands/serve.ts +16 -6
  50. package/src/domains/graphql/helpers/parsePrefixes.test.ts +3 -3
  51. package/src/domains/graphql/helpers/parsePrefixes.ts +3 -3
  52. package/src/domains/graphql/helpers/resolveSourceFiles.test.ts +9 -0
  53. package/src/domains/graphql/helpers/resolveSourceFiles.ts +24 -3
  54. package/src/domains/graphql/operations/compileSchema.test.ts +2 -2
  55. package/src/domains/graphql/operations/createSchemaServer.test.ts +2 -2
  56. package/src/domains/graphql/operations/ensureSchemaArtifact.test.ts +167 -0
  57. package/src/domains/graphql/operations/ensureSchemaArtifact.ts +178 -0
  58. package/src/domains/graphql/operations/index.ts +5 -0
  59. package/src/domains/info/formatters/upgrade.test.ts +7 -4
  60. package/src/domains/info/formatters/upgrade.ts +3 -1
  61. package/src/domains/info/operations/collectEntityCounts.test.ts +156 -0
  62. package/src/domains/info/operations/collectEntityCounts.ts +123 -0
  63. package/src/domains/info/operations/index.ts +1 -0
  64. package/src/domains/info/types.ts +13 -0
  65. package/src/domains/llm/capabilities.test.ts +23 -3
  66. package/src/domains/llm/commands/capabilities.ts +12 -3
  67. package/src/domains/llm/commands/llm.ts +5 -1
  68. package/src/domains/llm/data/commandReference.ts +20 -11
  69. package/src/domains/llm/data/decisionTrees.ts +10 -5
  70. package/src/domains/llm/data/toolCatalog.ts +49 -18
  71. package/src/domains/llm/formatters/orientation.ts +5 -1
  72. package/src/domains/llm/llmCommand.test.ts +11 -3
  73. package/src/domains/llm/mcp/specs.ts +3 -4
  74. package/src/domains/llm/operations/collectContext.test.ts +64 -0
  75. package/src/domains/llm/operations/collectContext.ts +7 -17
  76. package/src/domains/modifier/commands/sample.ts +2 -1
  77. package/src/domains/modifier/operations/lookup.ts +5 -3
  78. package/src/domains/modifier/stories.ts +9 -0
  79. package/src/domains/ontology/commands/list.test.ts +5 -3
  80. package/src/domains/ontology/commands/show.test.ts +85 -9
  81. package/src/domains/ontology/formatters/index.ts +1 -0
  82. package/src/domains/ontology/formatters/list.ts +37 -12
  83. package/src/domains/ontology/formatters/show.ts +358 -67
  84. package/src/domains/ontology/helpers/buildClassTree.ts +89 -0
  85. package/src/domains/ontology/helpers/completeClassNames.ts +43 -0
  86. package/src/domains/ontology/helpers/expandOntologyIris.ts +79 -0
  87. package/src/domains/ontology/helpers/index.ts +14 -0
  88. package/src/domains/ontology/helpers/queryClasses.ts +32 -16
  89. package/src/domains/ontology/helpers/queryProperties.ts +39 -6
  90. package/src/domains/ontology/helpers/queryTboxFacts.ts +160 -0
  91. package/src/domains/ontology/operations/listOntologies.test.ts +80 -3
  92. package/src/domains/ontology/operations/listOntologies.ts +126 -30
  93. package/src/domains/ontology/operations/showOntology.test.ts +131 -12
  94. package/src/domains/ontology/operations/showOntology.ts +289 -7
  95. package/src/domains/ontology/operations/showOntologyRaw.test.ts +4 -2
  96. package/src/domains/ontology/stories.ts +53 -8
  97. package/src/domains/refs/operations/updateRefs.ts +0 -1
  98. package/src/domains/setup/commands/index.ts +1 -0
  99. package/src/domains/setup/commands/setup.ts +70 -0
  100. package/src/domains/setup/index.ts +7 -3
  101. package/src/domains/setup/operations/setupLsp.test.ts +3 -2
  102. package/src/domains/setup/operations/setupLsp.ts +7 -2
  103. package/src/domains/shared/bootStore.bareCore.test.ts +63 -0
  104. package/src/domains/shared/bootStore.test.ts +82 -0
  105. package/src/domains/shared/bootStore.ts +56 -20
  106. package/src/domains/shared/emptyErrorRecovery.test.ts +23 -26
  107. package/src/domains/shared/extractLocalName.test.ts +3 -3
  108. package/src/domains/shared/formatters.ts +23 -2
  109. package/src/domains/shared/listTiers.ts +40 -0
  110. package/src/domains/shared/loaders/bundledLoader.ts +48 -0
  111. package/src/domains/shared/loaders/gitLoader.ts +1 -2
  112. package/src/domains/shared/loaders/localLoader.ts +2 -18
  113. package/src/domains/shared/loaders/readPackageDir.test.ts +58 -0
  114. package/src/domains/shared/loaders/readPackageDir.ts +46 -8
  115. package/src/domains/shared/lookupMany.test.ts +15 -0
  116. package/src/domains/shared/lookupMany.ts +22 -6
  117. package/src/domains/shared/namespaces.ts +13 -5
  118. package/src/domains/shared/parseSampleCount.test.ts +47 -0
  119. package/src/domains/shared/parseSampleCount.ts +45 -0
  120. package/src/domains/shared/prefixes.test.ts +211 -0
  121. package/src/domains/shared/prefixes.ts +182 -15
  122. package/src/domains/shared/renderers.ts +5 -5
  123. package/src/domains/shared/resolveLookupExitCode.test.ts +48 -0
  124. package/src/domains/shared/resolveLookupExitCode.ts +42 -0
  125. package/src/domains/shared/semanticPackage.test.ts +89 -0
  126. package/src/domains/shared/semanticPackage.ts +17 -2
  127. package/src/domains/shared/stories/compileLookupCommand.ts +3 -1
  128. package/src/domains/shared/stories/compileLookupTool.ts +1 -1
  129. package/src/domains/shared/stories/pack/applyPackFilters.test.ts +49 -0
  130. package/src/domains/shared/stories/pack/applyPackFilters.ts +46 -11
  131. package/src/domains/shared/stories/pack/applyPackSearch.test.ts +57 -0
  132. package/src/domains/shared/stories/pack/applyPackSearch.ts +41 -0
  133. package/src/domains/shared/stories/pack/buildLookupQuery.test.ts +30 -0
  134. package/src/domains/shared/stories/pack/buildLookupQuery.ts +75 -1
  135. package/src/domains/shared/stories/pack/bundled/index.ts +23 -0
  136. package/src/domains/shared/stories/pack/bundled/standardPack.ts +164 -0
  137. package/src/domains/shared/stories/pack/bundled/tierPack.ts +50 -0
  138. package/src/domains/shared/stories/pack/collectPackStories.test.ts +305 -7
  139. package/src/domains/shared/stories/pack/collectPackStories.ts +90 -8
  140. package/src/domains/shared/stories/pack/compilePackCommands.ts +17 -7
  141. package/src/domains/shared/stories/pack/compilePackStories.test.ts +607 -2
  142. package/src/domains/shared/stories/pack/compilePackStories.ts +544 -73
  143. package/src/domains/shared/stories/pack/compilePackToolSpecs.ts +20 -8
  144. package/src/domains/shared/stories/pack/index.ts +14 -0
  145. package/src/domains/shared/stories/pack/reservedVerbs.test.ts +213 -0
  146. package/src/domains/shared/stories/pack/reservedVerbs.ts +170 -0
  147. package/src/domains/shared/stories/pack/standardParity.test.ts +293 -220
  148. package/src/domains/shared/stories/pack/types.ts +123 -3
  149. package/src/domains/shared/stories/pack/validateStoryPackDefinition.test.ts +502 -0
  150. package/src/domains/shared/stories/pack/validateStoryPackDefinition.ts +475 -24
  151. package/src/domains/shared/stories/types.ts +9 -1
  152. package/src/domains/shared/types/index.ts +5 -9
  153. package/src/domains/shared/types/ontology.ts +119 -24
  154. package/src/domains/shared/types/standard.ts +9 -52
  155. package/src/domains/token/commands/lookup.test.ts +8 -0
  156. package/src/domains/token/commands/lookup.ts +12 -0
  157. package/src/domains/token/commands/sample.ts +2 -1
  158. package/src/domains/token/featureFlag.ts +17 -0
  159. package/src/domains/token/index.ts +5 -0
  160. package/src/domains/token/mcp/specs.ts +13 -1
  161. package/src/domains/trace/commands/resolveTraceLimit.test.ts +31 -0
  162. package/src/domains/trace/commands/show.ts +30 -4
  163. package/src/index.ts +1 -13
  164. package/src/mcp/registerResources.test.ts +2 -2
  165. package/src/mcp/resources/pickPrimaryType.test.ts +5 -5
  166. package/src/mcp/tools/index.ts +18 -10
  167. package/src/mcp/tools/registerTools.test.ts +106 -30
  168. package/src/pipeline/collectCommands.test.ts +151 -1
  169. package/src/pipeline/collectCommands.ts +34 -19
  170. package/src/pipeline/createProgram.test.ts +5 -1
  171. package/src/pipeline/createProgram.ts +2 -2
  172. package/src/pipeline/firstRun.test.ts +80 -0
  173. package/src/pipeline/firstRun.ts +99 -0
  174. package/src/pipeline/parseGlobalFlags.test.ts +102 -1
  175. package/src/pipeline/parseGlobalFlags.ts +98 -9
  176. package/src/pipeline/resolveCommandKind.test.ts +12 -0
  177. package/src/pipeline/resolveCommandKind.ts +4 -0
  178. package/src/pipeline/rootHelp.test.ts +87 -0
  179. package/src/pipeline/rootHelp.ts +265 -0
  180. package/src/pipeline/runCli.orchestration.test.ts +28 -12
  181. package/src/pipeline/runCli.test.ts +17 -4
  182. package/src/pipeline/runCli.ts +162 -19
  183. package/src/testing/cli.ts +30 -2
  184. package/src/testing/dsFixtures.ts +21 -4
  185. package/src/testing/index.ts +1 -4
  186. package/src/testing/integration/__snapshots__/executor-golden.test.ts.snap +4 -4
  187. package/src/testing/integration/cli-user-stories.test.ts +63 -0
  188. package/src/testing/integration/cross-binary-byte-equality.test.ts +2 -2
  189. package/src/testing/integration/edge-cases.test.ts +62 -50
  190. package/src/testing/integration/executor-execution.test.ts +6 -4
  191. package/src/testing/integration/executor-golden.test.ts +2 -2
  192. package/src/testing/integration/mcp-surface.test.ts +40 -15
  193. package/src/testing/integration/parity.test.ts +119 -70
  194. package/src/testing/setupXdgIsolation.ts +16 -5
  195. package/src/testing/standardParityFixtures.ts +26 -83
  196. package/src/testing/store.ts +4 -4
  197. package/src/domains/create/commands/component.test.ts +0 -166
  198. package/src/domains/create/commands/component.ts +0 -123
  199. package/src/domains/create/commands/index.ts +0 -3
  200. package/src/domains/create/commands/package.test.ts +0 -35
  201. package/src/domains/create/commands/package.ts +0 -21
  202. package/src/domains/create/generators.ts +0 -8
  203. package/src/domains/standard/commands/categories.test.ts +0 -70
  204. package/src/domains/standard/commands/categories.ts +0 -48
  205. package/src/domains/standard/commands/index.ts +0 -6
  206. package/src/domains/standard/commands/list.test.ts +0 -105
  207. package/src/domains/standard/commands/list.ts +0 -11
  208. package/src/domains/standard/commands/lookup.test.ts +0 -135
  209. package/src/domains/standard/commands/lookup.ts +0 -14
  210. package/src/domains/standard/commands/sample.ts +0 -57
  211. package/src/domains/standard/formatters/categories.test.ts +0 -44
  212. package/src/domains/standard/formatters/categories.ts +0 -38
  213. package/src/domains/standard/formatters/index.ts +0 -10
  214. package/src/domains/standard/formatters/list.test.ts +0 -169
  215. package/src/domains/standard/formatters/list.ts +0 -184
  216. package/src/domains/standard/formatters/lookup.test.ts +0 -95
  217. package/src/domains/standard/formatters/lookup.ts +0 -101
  218. package/src/domains/standard/formatters/sample.ts +0 -15
  219. package/src/domains/standard/formatters/types.ts +0 -31
  220. package/src/domains/standard/index.ts +0 -40
  221. package/src/domains/standard/mcp/index.ts +0 -2
  222. package/src/domains/standard/mcp/specs.ts +0 -89
  223. package/src/domains/standard/operations/categories.test.ts +0 -61
  224. package/src/domains/standard/operations/categories.ts +0 -38
  225. package/src/domains/standard/operations/index.ts +0 -6
  226. package/src/domains/standard/operations/list.test.ts +0 -75
  227. package/src/domains/standard/operations/list.ts +0 -64
  228. package/src/domains/standard/operations/lookup.test.ts +0 -58
  229. package/src/domains/standard/operations/lookup.ts +0 -151
  230. package/src/domains/standard/operations/sample.ts +0 -35
  231. package/src/domains/standard/orchestration/buildStandardFilters.ts +0 -11
  232. package/src/domains/standard/orchestration/index.ts +0 -4
  233. package/src/domains/standard/orchestration/resolveStandardList.ts +0 -45
  234. package/src/domains/standard/orchestration/resolveStandardLookup.ts +0 -33
  235. package/src/domains/standard/orchestration/standardEmptyError.test.ts +0 -33
  236. package/src/domains/standard/orchestration/standardEmptyError.ts +0 -29
  237. package/src/domains/standard/standardConfig.ts +0 -42
  238. package/src/domains/standard/stories.test.ts +0 -24
  239. package/src/domains/standard/stories.ts +0 -151
  240. package/src/domains/standard/types.ts +0 -15
  241. package/src/domains/tier/commands/index.ts +0 -3
  242. package/src/domains/tier/commands/list.test.ts +0 -67
  243. package/src/domains/tier/commands/list.ts +0 -9
  244. package/src/domains/tier/formatters/index.ts +0 -3
  245. package/src/domains/tier/formatters/list.test.ts +0 -33
  246. package/src/domains/tier/formatters/list.ts +0 -45
  247. package/src/domains/tier/index.ts +0 -17
  248. package/src/domains/tier/mcp/index.ts +0 -2
  249. package/src/domains/tier/mcp/specs.ts +0 -12
  250. package/src/domains/tier/operations/index.ts +0 -3
  251. package/src/domains/tier/operations/list.test.ts +0 -32
  252. package/src/domains/tier/operations/list.ts +0 -33
  253. package/src/domains/tier/stories.test.ts +0 -22
  254. package/src/domains/tier/stories.ts +0 -52
@@ -1,8 +1,29 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { PragmaConfig } from "#config";
2
3
  import { PragmaError } from "#error";
3
4
  import { RECIPE_STORY } from "#testing";
4
5
  import type { SemanticPackage } from "../../semanticPackage.js";
5
- import collectPackStories from "./collectPackStories.js";
6
+ import { BUNDLED_PACKS } from "./bundled/index.js";
7
+ import realCollectPackStories from "./collectPackStories.js";
8
+ import {
9
+ buildReservedVerbs,
10
+ deriveReservedVerbs,
11
+ isReserved,
12
+ type ReservedVerbs,
13
+ } from "./reservedVerbs.js";
14
+
15
+ /**
16
+ * Test wrapper that isolates the always-present bundled packs: unit tests
17
+ * asserting exact entries pass `[]` so the default bundled `tier` pack does not
18
+ * pollute their expectations. Bundled behavior is covered separately below.
19
+ */
20
+ function collectPackStories(
21
+ config: PragmaConfig,
22
+ packages: readonly SemanticPackage[],
23
+ reserved: ReservedVerbs,
24
+ ): ReturnType<typeof realCollectPackStories> {
25
+ return realCollectPackStories(config, packages, reserved, []);
26
+ }
6
27
 
7
28
  function makePackage(stories: SemanticPackage["stories"]): SemanticPackage {
8
29
  return {
@@ -17,6 +38,45 @@ function makePackage(stories: SemanticPackage["stories"]): SemanticPackage {
17
38
 
18
39
  const CONFIG = { tier: undefined, channel: "normal" as const };
19
40
 
41
+ // A representative slice of the real built-in surface: leaf read nouns that
42
+ // own `list`/`lookup`, plus operational nouns (graph, config, create, graphql,
43
+ // setup, tokens) that own neither and so must be reserved wholesale.
44
+ const BUILTIN_PAIRS: readonly (readonly [string, string | undefined])[] = [
45
+ ["standard", "list"],
46
+ ["standard", "lookup"],
47
+ ["standard", "categories"],
48
+ ["standard", "sample"],
49
+ ["block", "list"],
50
+ ["block", "lookup"],
51
+ ["token", "list"],
52
+ ["token", "lookup"],
53
+ ["tier", "list"],
54
+ ["config", "show"],
55
+ ["config", "tier"],
56
+ ["config", "channel"],
57
+ ["graph", "query"],
58
+ ["graph", "inspect"],
59
+ ["create", "component"],
60
+ ["create", "package"],
61
+ ["graphql", "build"],
62
+ ["graphql", "check"],
63
+ ["graphql", "serve"],
64
+ ["setup", "all"],
65
+ ["setup", "lsp"],
66
+ ["tokens", "add-config"],
67
+ ["info", undefined],
68
+ ];
69
+
70
+ /** Run a thunk and return the error it throws (or `undefined`). */
71
+ function caught(run: () => unknown): unknown {
72
+ try {
73
+ run();
74
+ } catch (error) {
75
+ return error;
76
+ }
77
+ return undefined;
78
+ }
79
+
20
80
  describe("collectPackStories", () => {
21
81
  let warn: ReturnType<typeof vi.spyOn>;
22
82
 
@@ -39,7 +99,7 @@ describe("collectPackStories", () => {
39
99
  },
40
100
  ]),
41
101
  ],
42
- new Set(["block"]),
102
+ buildReservedVerbs([["block", undefined]]),
43
103
  );
44
104
  expect(entries.map((entry) => entry.definition.noun)).toEqual([
45
105
  "recipe",
@@ -57,11 +117,13 @@ describe("collectPackStories", () => {
57
117
  { path: "/pkg/stories/recipe.json", definition: RECIPE_STORY },
58
118
  ]),
59
119
  ],
60
- new Set(),
120
+ buildReservedVerbs([]),
61
121
  );
62
122
  expect(entries).toHaveLength(1);
63
123
  expect(entries.at(0)?.source).toBe("config");
64
- expect(warn).toHaveBeenCalledWith(expect.stringContaining("already taken"));
124
+ expect(warn).toHaveBeenCalledWith(
125
+ expect.stringContaining("already provided"),
126
+ );
65
127
  });
66
128
 
67
129
  it("skips invalid package stories with a warning", () => {
@@ -72,7 +134,7 @@ describe("collectPackStories", () => {
72
134
  { path: "/pkg/stories/broken.json", definition: { nope: true } },
73
135
  ]),
74
136
  ],
75
- new Set(),
137
+ buildReservedVerbs([]),
76
138
  );
77
139
  expect(entries).toHaveLength(0);
78
140
  expect(warn).toHaveBeenCalledWith(
@@ -85,7 +147,7 @@ describe("collectPackStories", () => {
85
147
  collectPackStories(
86
148
  { ...CONFIG, stories: [{ ...RECIPE_STORY, noun: "block" }] },
87
149
  [],
88
- new Set(["block"]),
150
+ buildReservedVerbs([["block", undefined]]),
89
151
  ),
90
152
  ).toThrow(PragmaError);
91
153
  });
@@ -101,8 +163,244 @@ describe("collectPackStories", () => {
101
163
  },
102
164
  ]),
103
165
  ],
104
- new Set(["block"]),
166
+ buildReservedVerbs([["block", undefined]]),
105
167
  );
106
168
  expect(entries).toHaveLength(0);
107
169
  });
170
+
171
+ it("admits a pack once its noun's verbs leave the reserved map", () => {
172
+ // Post-migration simulation: `standard`'s list/lookup wrappers are gone,
173
+ // so only categories/sample remain reserved — a `standard` pack (which
174
+ // emits list + lookup) is now admissible. `info` is a bare (whole-noun)
175
+ // built-in, so an `info` pack stays blocked. This is the keystone: the
176
+ // per-verb guard makes the leaf cutover incremental.
177
+ const reserved = buildReservedVerbs([
178
+ ["standard", "categories"],
179
+ ["standard", "sample"],
180
+ ["info", undefined],
181
+ ]);
182
+
183
+ const entries = collectPackStories(
184
+ CONFIG,
185
+ [
186
+ makePackage([
187
+ {
188
+ path: "/pkg/stories/standard.json",
189
+ definition: { ...RECIPE_STORY, noun: "standard" },
190
+ },
191
+ {
192
+ path: "/pkg/stories/info.json",
193
+ definition: { ...RECIPE_STORY, noun: "info" },
194
+ },
195
+ ]),
196
+ ],
197
+ reserved,
198
+ );
199
+
200
+ expect(entries.map((entry) => entry.definition.noun)).toEqual(["standard"]);
201
+ expect(warn).toHaveBeenCalledWith(
202
+ expect.stringContaining("shadows built-in command"),
203
+ );
204
+ });
205
+
206
+ // Regression restoration (FIX 1): operational nouns own no list/lookup, so
207
+ // deriveReservedVerbs promotes them to a whole-noun reservation. Since a
208
+ // pack always emits `list`, a pack targeting an operational namespace must
209
+ // stay BLOCKED — this is the load-bearing proof that the per-verb flip did
210
+ // not silently open pragma's core operational namespaces to packs.
211
+ describe("operational nouns stay reserved wholesale", () => {
212
+ const reserved = deriveReservedVerbs(BUILTIN_PAIRS);
213
+
214
+ it("promotes every operational noun to a whole-noun reservation", () => {
215
+ for (const noun of ["config", "graph", "create", "graphql", "setup"]) {
216
+ expect(isReserved(reserved, noun, "list")).toBe(true);
217
+ }
218
+ // `tokens` (the plural add-config noun) owns only `add-config`.
219
+ expect(isReserved(reserved, "tokens", "list")).toBe(true);
220
+ });
221
+
222
+ it("throws a config error when a config story shadows an operational noun", () => {
223
+ const error = caught(() =>
224
+ collectPackStories(
225
+ { ...CONFIG, stories: [{ ...RECIPE_STORY, noun: "graph" }] },
226
+ [],
227
+ reserved,
228
+ ),
229
+ );
230
+ expect(error).toBeInstanceOf(PragmaError);
231
+ expect((error as PragmaError).code).toBe("CONFIG_ERROR");
232
+ });
233
+
234
+ it("skips a package story that shadows an operational noun", () => {
235
+ const entries = collectPackStories(
236
+ CONFIG,
237
+ [
238
+ makePackage([
239
+ {
240
+ path: "/pkg/stories/graph.json",
241
+ definition: { ...RECIPE_STORY, noun: "graph" },
242
+ },
243
+ ]),
244
+ ],
245
+ reserved,
246
+ );
247
+ expect(entries).toHaveLength(0);
248
+ expect(warn).toHaveBeenCalledWith(
249
+ expect.stringContaining("shadows built-in command"),
250
+ );
251
+ });
252
+ });
253
+
254
+ // Keystone incremental window (FIX 3): `standard`'s `list` wrapper is
255
+ // deleted but `lookup` is still built-in (alongside categories/sample).
256
+ describe("incremental leaf cutover window", () => {
257
+ const reserved = deriveReservedVerbs([
258
+ ["standard", "lookup"],
259
+ ["standard", "categories"],
260
+ ["standard", "sample"],
261
+ ["info", undefined],
262
+ ]);
263
+
264
+ it("keeps `standard` per-verb because it still owns lookup (not promoted)", () => {
265
+ // Disjointness check: the noun retains a read verb, so it must NOT be
266
+ // wholesale-promoted — otherwise `list` could never be freed.
267
+ expect(isReserved(reserved, "standard", "list")).toBe(false);
268
+ expect(isReserved(reserved, "standard", "lookup")).toBe(true);
269
+ });
270
+
271
+ it("admits a standard pack that declares only list", () => {
272
+ const listOnly = { ...RECIPE_STORY, noun: "standard", lookup: undefined };
273
+ const entries = collectPackStories(
274
+ CONFIG,
275
+ [
276
+ makePackage([
277
+ { path: "/pkg/stories/standard.json", definition: listOnly },
278
+ ]),
279
+ ],
280
+ reserved,
281
+ );
282
+ expect(entries.map((entry) => entry.definition.noun)).toEqual([
283
+ "standard",
284
+ ]);
285
+ });
286
+
287
+ it("rejects a standard pack that also declares lookup (config throws)", () => {
288
+ const error = caught(() =>
289
+ collectPackStories(
290
+ { ...CONFIG, stories: [{ ...RECIPE_STORY, noun: "standard" }] },
291
+ [],
292
+ reserved,
293
+ ),
294
+ );
295
+ expect(error).toBeInstanceOf(PragmaError);
296
+ expect((error as PragmaError).code).toBe("CONFIG_ERROR");
297
+ });
298
+
299
+ it("rejects a standard pack that also declares lookup (package skips)", () => {
300
+ const entries = collectPackStories(
301
+ CONFIG,
302
+ [
303
+ makePackage([
304
+ {
305
+ path: "/pkg/stories/standard.json",
306
+ definition: { ...RECIPE_STORY, noun: "standard" },
307
+ },
308
+ ]),
309
+ ],
310
+ reserved,
311
+ );
312
+ expect(entries).toHaveLength(0);
313
+ expect(warn).toHaveBeenCalledWith(
314
+ expect.stringContaining("shadows built-in command"),
315
+ );
316
+ });
317
+
318
+ it("rejects a list-only standard pack whose extra verb is still built-in", () => {
319
+ // Pack v1 emissions beyond list/lookup are guarded too: `categories`
320
+ // here collides with the still-registered built-in wrapper.
321
+ const withCategories = {
322
+ ...RECIPE_STORY,
323
+ noun: "standard",
324
+ lookup: undefined,
325
+ verbs: [
326
+ {
327
+ verb: "categories",
328
+ query: RECIPE_STORY.list.query,
329
+ columns: RECIPE_STORY.list.columns,
330
+ },
331
+ ],
332
+ };
333
+ const error = caught(() =>
334
+ collectPackStories(
335
+ { ...CONFIG, stories: [withCategories] },
336
+ [],
337
+ reserved,
338
+ ),
339
+ );
340
+ expect(error).toBeInstanceOf(PragmaError);
341
+ expect((error as PragmaError).message).toContain("standard categories");
342
+ });
343
+
344
+ it("rejects a pack whose sample capability is still built-in", () => {
345
+ const withSample = {
346
+ ...RECIPE_STORY,
347
+ noun: "standard",
348
+ lookup: {
349
+ // biome-ignore lint/style/noNonNullAssertion: fixture always declares lookup
350
+ ...RECIPE_STORY.lookup!,
351
+ sample: true as const,
352
+ },
353
+ };
354
+ const error = caught(() =>
355
+ collectPackStories({ ...CONFIG, stories: [withSample] }, [], reserved),
356
+ );
357
+ expect(error).toBeInstanceOf(PragmaError);
358
+ expect((error as PragmaError).message).toContain("standard sample");
359
+ });
360
+ });
361
+
362
+ // Bundled transitional packs use the REAL collect (not the isolating wrapper).
363
+ describe("bundled packs", () => {
364
+ it("includes bundled packs by default, lowest precedence", () => {
365
+ const entries = realCollectPackStories(
366
+ CONFIG,
367
+ [],
368
+ buildReservedVerbs([]),
369
+ );
370
+ const nouns = entries.map((entry) => entry.definition.noun);
371
+ // Every bundled pack is present, tagged with a `bundled:` source.
372
+ for (const pack of BUNDLED_PACKS) {
373
+ expect(nouns).toContain(pack.noun);
374
+ const entry = entries.find((e) => e.definition.noun === pack.noun);
375
+ expect(entry?.source).toBe(`bundled:${pack.noun}`);
376
+ }
377
+ });
378
+
379
+ it("lets a config story override a bundled noun (config wins)", () => {
380
+ const bundledNoun = BUNDLED_PACKS[0]?.noun ?? "tier";
381
+ const entries = realCollectPackStories(
382
+ { ...CONFIG, stories: [{ ...RECIPE_STORY, noun: bundledNoun }] },
383
+ [],
384
+ buildReservedVerbs([]),
385
+ );
386
+ const entry = entries.find((e) => e.definition.noun === bundledNoun);
387
+ // The config-declared story wins; the bundled one yields silently.
388
+ expect(entry?.source).toBe("config");
389
+ expect(
390
+ entries.filter((e) => e.definition.noun === bundledNoun),
391
+ ).toHaveLength(1);
392
+ });
393
+
394
+ it("yields a bundled noun to a still-reserved built-in", () => {
395
+ // If a bundled noun is still a built-in (not yet cut over), the bundled
396
+ // pack must not shadow it.
397
+ const bundledNoun = BUNDLED_PACKS[0]?.noun ?? "tier";
398
+ const entries = realCollectPackStories(
399
+ CONFIG,
400
+ [],
401
+ buildReservedVerbs([[bundledNoun, undefined]]),
402
+ );
403
+ expect(entries.map((e) => e.definition.noun)).not.toContain(bundledNoun);
404
+ });
405
+ });
108
406
  });
@@ -1,6 +1,8 @@
1
1
  import type { PragmaConfig } from "#config";
2
2
  import { PragmaError } from "#error";
3
3
  import type { SemanticPackage } from "../../semanticPackage.js";
4
+ import { BUNDLED_PACKS } from "./bundled/index.js";
5
+ import { isReserved, type ReservedVerbs } from "./reservedVerbs.js";
4
6
  import type { StoryPackDefinition } from "./types.js";
5
7
  import validateStoryPackDefinition from "./validateStoryPackDefinition.js";
6
8
 
@@ -10,6 +12,31 @@ export interface PackStoryEntry {
10
12
  readonly source: string;
11
13
  }
12
14
 
15
+ /**
16
+ * The verbs a pack definition compiles to — mirrors `compilePackStories`,
17
+ * which always emits `list`, adds any declared extra verbs, and adds
18
+ * `lookup`/`sample` only when declared. The reserved-verb guard sees the
19
+ * FULL emission so a pack cannot shadow any built-in verb.
20
+ */
21
+ function emittedVerbs(definition: StoryPackDefinition): string[] {
22
+ return [
23
+ "list",
24
+ ...(definition.verbs ?? []).map((verb) => verb.verb),
25
+ ...(definition.lookup ? ["lookup"] : []),
26
+ ...(definition.lookup?.sample ? ["sample"] : []),
27
+ ];
28
+ }
29
+
30
+ /**
31
+ * Name the built-in `(noun, verb)` command(s) a pack collides with, for
32
+ * actionable diagnostics — e.g. `built-in command "standard lookup"`.
33
+ * Since the guard is per-verb, reporting the noun alone is misleading.
34
+ */
35
+ function describeShadow(noun: string, verbs: readonly string[]): string {
36
+ const pairs = verbs.map((verb) => `"${noun} ${verb}"`).join(", ");
37
+ return `built-in command${verbs.length > 1 ? "s" : ""} ${pairs}`;
38
+ }
39
+
13
40
  /**
14
41
  * Gather the story-pack definitions active for a runtime.
15
42
  *
@@ -18,28 +45,40 @@ export interface PackStoryEntry {
18
45
  * config is the user's override layer. Package story files are validated
19
46
  * here; an invalid or colliding package story is skipped with a warning
20
47
  * so one bad file cannot break boot. A config story colliding with a
21
- * reserved (built-in) noun is a hard config error.
48
+ * reserved (built-in) noun is a hard config error. Bundled transitional
49
+ * packs come last (lowest precedence), so config or a package can override
50
+ * a bundled noun.
22
51
  *
23
52
  * @param config - The effective merged configuration.
24
53
  * @param packages - Resolved semantic packages (may ship `stories/*.json`).
25
- * @param reservedNouns - Built-in nouns story packs must not shadow.
54
+ * @param reserved - Built-in `(noun, verb)` reservations packs must not
55
+ * shadow; a pack collides only when it emits a reserved verb.
56
+ * @param bundled - Transitional packs pragma ships by default (lowest
57
+ * precedence). Defaults to {@link BUNDLED_PACKS}; tests pass `[]` to isolate.
26
58
  * @returns Validated, collision-free story entries.
27
59
  * @throws PragmaError with code `CONFIG_ERROR` when a config story shadows
28
- * a reserved noun.
60
+ * a reserved built-in verb.
29
61
  * @note Impure — warns on stderr for skipped package stories.
30
62
  */
31
63
  export default function collectPackStories(
32
64
  config: PragmaConfig,
33
65
  packages: readonly SemanticPackage[],
34
- reservedNouns: ReadonlySet<string>,
66
+ reserved: ReservedVerbs,
67
+ bundled: readonly StoryPackDefinition[] = BUNDLED_PACKS,
35
68
  ): PackStoryEntry[] {
36
69
  const entries: PackStoryEntry[] = [];
37
70
  const taken = new Set<string>();
38
71
 
39
72
  for (const definition of config.stories ?? []) {
40
- if (reservedNouns.has(definition.noun)) {
73
+ const shadowed = emittedVerbs(definition).filter((verb) =>
74
+ isReserved(reserved, definition.noun, verb),
75
+ );
76
+ if (shadowed.length > 0) {
41
77
  throw PragmaError.configError(
42
- `Story noun "${definition.noun}" shadows a built-in command.`,
78
+ `Story noun "${definition.noun}" shadows ${describeShadow(
79
+ definition.noun,
80
+ shadowed,
81
+ )}.`,
43
82
  );
44
83
  }
45
84
  if (taken.has(definition.noun)) {
@@ -61,9 +100,21 @@ export default function collectPackStories(
61
100
  process.stderr.write(`Warning: skipping story — ${reason}\n`);
62
101
  continue;
63
102
  }
64
- if (reservedNouns.has(definition.noun) || taken.has(definition.noun)) {
103
+ const shadowed = emittedVerbs(definition).filter((verb) =>
104
+ isReserved(reserved, definition.noun, verb),
105
+ );
106
+ if (shadowed.length > 0) {
107
+ process.stderr.write(
108
+ `Warning: skipping story "${definition.noun}" from ${file.path} — it shadows ${describeShadow(
109
+ definition.noun,
110
+ shadowed,
111
+ )}.\n`,
112
+ );
113
+ continue;
114
+ }
115
+ if (taken.has(definition.noun)) {
65
116
  process.stderr.write(
66
- `Warning: skipping story "${definition.noun}" from ${file.path} — the noun is already taken.\n`,
117
+ `Warning: skipping story "${definition.noun}" from ${file.path} — the noun is already provided by a higher-precedence source.\n`,
67
118
  );
68
119
  continue;
69
120
  }
@@ -72,5 +123,36 @@ export default function collectPackStories(
72
123
  }
73
124
  }
74
125
 
126
+ // Bundled transitional packs — lowest precedence, so a config- or
127
+ // package-declared story for the same noun overrides them (the P4 handoff).
128
+ // Authored in-repo but still validated: only a validation failure (an
129
+ // authoring slip) is skipped with a warning (consistent with package
130
+ // stories — one bad pack cannot break boot), and the pack's own parity tests
131
+ // catch a bundled pack that fails to load. When a still-reserved built-in or
132
+ // a higher-precedence source already owns the noun, the bundled pack yields
133
+ // silently (no warning) — the shadow/duplicate check below, matching the
134
+ // precedence order rather than flagging an error.
135
+ for (const raw of bundled) {
136
+ const source = `bundled:${raw.noun}`;
137
+ let definition: StoryPackDefinition;
138
+ try {
139
+ definition = validateStoryPackDefinition(raw, source);
140
+ } catch (error) {
141
+ const reason = error instanceof Error ? error.message : String(error);
142
+ process.stderr.write(`Warning: skipping bundled story — ${reason}\n`);
143
+ continue;
144
+ }
145
+ const shadowed = emittedVerbs(definition).filter((verb) =>
146
+ isReserved(reserved, definition.noun, verb),
147
+ );
148
+ if (shadowed.length > 0 || taken.has(definition.noun)) {
149
+ // A still-registered built-in (not yet cut over) or a higher-precedence
150
+ // source already owns this noun — the bundled pack yields to it silently.
151
+ continue;
152
+ }
153
+ taken.add(definition.noun);
154
+ entries.push({ definition, source });
155
+ }
156
+
75
157
  return entries;
76
158
  }
@@ -1,29 +1,31 @@
1
1
  import type { CommandDefinition } from "@canonical/cli-core";
2
2
  import type { PragmaContext } from "../../context.js";
3
- import { PREFIX_MAP } from "../../prefixes.js";
3
+ import { resolvePrefixes } from "../../prefixes.js";
4
4
  import compileLookupCommand from "../compileLookupCommand.js";
5
5
  import compileReadCommand from "../compileReadCommand.js";
6
6
  import collectPackStories from "./collectPackStories.js";
7
7
  import compilePackStories from "./compilePackStories.js";
8
+ import type { ReservedVerbs } from "./reservedVerbs.js";
8
9
 
9
10
  /**
10
11
  * Compile every active story pack into CLI command definitions.
11
12
  *
12
13
  * Story packs come from the merged config (`stories`) and from resolved
13
14
  * 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.
15
+ * when declared, `<noun> lookup`, extra list verbs, and `<noun> sample` —
16
+ * projected through the same kernel as the built-in read stories, so
17
+ * completions and help come for free.
16
18
  *
17
19
  * @param ctx - Pragma context carrying config, packages, and the store.
18
- * @param reservedNouns - Built-in nouns packs must not shadow.
20
+ * @param reserved - Built-in `(noun, verb)` reservations packs must not shadow.
19
21
  * @returns Command definitions for every pack story.
20
22
  */
21
23
  export default function compilePackCommands(
22
24
  ctx: PragmaContext,
23
- reservedNouns: ReadonlySet<string>,
25
+ reserved: ReservedVerbs,
24
26
  ): CommandDefinition[] {
25
- const prefixes = { ...PREFIX_MAP, ...ctx.config.prefixes };
26
- const entries = collectPackStories(ctx.config, ctx.packages, reservedNouns);
27
+ const prefixes = resolvePrefixes(ctx.packages ?? [], ctx.config.prefixes);
28
+ const entries = collectPackStories(ctx.config, ctx.packages ?? [], reserved);
27
29
 
28
30
  return entries.flatMap((entry) => {
29
31
  const compiled = compilePackStories(
@@ -31,10 +33,18 @@ export default function compilePackCommands(
31
33
  entry.source,
32
34
  prefixes,
33
35
  );
36
+ // Surface order mirrors the built-in leaf domains: list, lookup,
37
+ // extra verbs, sample.
34
38
  const commands = [compileReadCommand(ctx, compiled.list)];
35
39
  if (compiled.lookup) {
36
40
  commands.push(compileLookupCommand(ctx, compiled.lookup));
37
41
  }
42
+ for (const verb of compiled.verbs) {
43
+ commands.push(compileReadCommand(ctx, verb));
44
+ }
45
+ if (compiled.sample) {
46
+ commands.push(compileReadCommand(ctx, compiled.sample));
47
+ }
38
48
  return commands;
39
49
  });
40
50
  }