@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
@@ -36,9 +36,66 @@ export interface StoryPackSection extends StoryPackField {
36
36
  readonly kind?: "field" | "code";
37
37
  }
38
38
 
39
+ /** A field read from each child node of an {@link StoryPackExpand}. */
40
+ export interface StoryPackExpandField {
41
+ /** Output field name on the child record. */
42
+ readonly name: string;
43
+ /** Property to read on the child node — prefixed name, IRI, or path. */
44
+ readonly property: string;
45
+ /** Display label (defaults to the field name). */
46
+ readonly label?: string;
47
+ }
48
+
49
+ /**
50
+ * A multi-valued nested projection: `entity → relation → child nodes`, each
51
+ * child projected to a small record. This is pack v1's structured-section
52
+ * primitive — the one thing v0 could not express (dos/donts, token scales,
53
+ * modifier values). Resolved with a generated, injection-safe sub-SELECT bound
54
+ * to the already-resolved entity IRI (never user input), so it needs no
55
+ * GraphQL. Rendered through the shared `list`/`table` render-kinds.
56
+ */
57
+ export interface StoryPackExpand {
58
+ /** Output field holding the child array on the looked-up entity. */
59
+ readonly name: string;
60
+ /** Section heading (defaults to the field name). */
61
+ readonly heading?: string;
62
+ /** Render kind for the array (v1: `list` or `table`; defaults to `list`). */
63
+ readonly kind?: "list" | "table";
64
+ /** Relation from the entity to each child node — prefixed name, IRI, or path. */
65
+ readonly relation: string;
66
+ /** Fields to read from each child node. */
67
+ readonly select: readonly StoryPackExpandField[];
68
+ /** Render the section even when the array is empty (default: false). */
69
+ readonly showWhenEmpty?: boolean;
70
+ /**
71
+ * Minimum disclosure level at which this expand is fetched and rendered — a
72
+ * name from {@link StoryPackDisclosure.levels}. Omitted means the base level
73
+ * (always included). Below its level the sub-SELECT never runs, so gating is
74
+ * both a cost and a rendering control.
75
+ */
76
+ readonly level?: string;
77
+ }
78
+
79
+ /**
80
+ * Progressive-disclosure capability for a lookup.
81
+ *
82
+ * Declares an **ordered** list of named levels (the first is the base/default,
83
+ * always shown). Fields/expands tag themselves with the minimum level at which
84
+ * they appear. The compiler derives from this one declaration: a `--detail
85
+ * <level>` CLI flag, a `detail` MCP parameter (enumerated from the levels), and
86
+ * the JSON/plain/llm projection for the selected level. Levels are additive and
87
+ * optional — a lookup with no disclosure exposes no detail flag.
88
+ */
89
+ export interface StoryPackDisclosure {
90
+ /** Ordered level names, lowest → highest. The first is the base/default. */
91
+ readonly levels: readonly string[];
92
+ /** Default level when none is requested (must be one of `levels`). */
93
+ readonly default?: string;
94
+ }
95
+
39
96
  /**
40
97
  * A declarative list filter: a CLI/MCP parameter constraining one SELECT
41
- * variable to a declared value set.
98
+ * variable.
42
99
  *
43
100
  * Filters are row predicates applied AFTER the author query runs — the
44
101
  * query text is never modified, so filter input cannot inject SPARQL,
@@ -52,8 +109,27 @@ export interface StoryPackFilter {
52
109
  readonly param: string;
53
110
  /** SELECT variable the filter constrains (without `?`). */
54
111
  readonly variable: string;
55
- /** Allowed values; anything else is rejected with INVALID_INPUT. */
56
- readonly values: readonly string[];
112
+ /**
113
+ * Allowed values; anything else is rejected with INVALID_INPUT and the
114
+ * set projects to CLI select choices and an MCP enum. Omitted when the
115
+ * value set is data-driven (e.g. categories that live in the graph): the
116
+ * parameter is then a free string matched case-insensitively against the
117
+ * variable — still a post-query row predicate, never query text.
118
+ */
119
+ readonly values?: readonly string[];
120
+ /** Help text (defaults to a generated description). */
121
+ readonly description?: string;
122
+ }
123
+
124
+ /**
125
+ * Free-text search over list rows: a `--search` string parameter that
126
+ * keeps a row when ANY named SELECT variable's value contains the term
127
+ * (case-insensitive substring). Like filters, search is applied after the
128
+ * author query runs — user input never touches the query text.
129
+ */
130
+ export interface StoryPackSearch {
131
+ /** SELECT variables searched (without `?`). */
132
+ readonly variables: readonly string[];
57
133
  /** Help text (defaults to a generated description). */
58
134
  readonly description?: string;
59
135
  }
@@ -66,6 +142,38 @@ export interface StoryPackList {
66
142
  readonly columns: readonly StoryPackColumn[];
67
143
  /** Declarative filters projected to CLI flags and MCP parameters. */
68
144
  readonly filters?: readonly StoryPackFilter[];
145
+ /** Free-text search projected to a `--search` flag / `search` parameter. */
146
+ readonly search?: StoryPackSearch;
147
+ }
148
+
149
+ /**
150
+ * An additional list-shaped verb: `<noun> <verb>` and `<noun>_<verb>`
151
+ * compiled through the SAME machinery as `list` (query, columns, filters,
152
+ * search) — e.g. standard's `categories`. The verb may not collide with
153
+ * the compiled `list`/`lookup`/`sample` verbs.
154
+ */
155
+ export interface StoryPackVerb extends StoryPackList {
156
+ /** Verb name (kebab-case), e.g. `"categories"`. */
157
+ readonly verb: string;
158
+ /** CLI description for the command. */
159
+ readonly description?: string;
160
+ /** MCP tool description (defaults to the CLI description). */
161
+ readonly toolDescription?: string;
162
+ }
163
+
164
+ /**
165
+ * The sample capability: `<noun> sample [count]` and `<noun>_sample`
166
+ * return 1–5 randomly selected complete entities (resolved through the
167
+ * lookup path at the HIGHEST disclosure level) so agents can learn the
168
+ * data shape before querying.
169
+ */
170
+ export interface StoryPackSample {
171
+ /** Default sample count when none is requested (1–5; default 2). */
172
+ readonly count?: number;
173
+ /** CLI description for the command. */
174
+ readonly description?: string;
175
+ /** MCP tool description (defaults to the CLI description). */
176
+ readonly toolDescription?: string;
69
177
  }
70
178
 
71
179
  /**
@@ -78,10 +186,20 @@ export interface StoryPackLookup {
78
186
  readonly by: string;
79
187
  /** Optional class constraint — prefixed name or IRI. */
80
188
  readonly type?: string;
189
+ /** CLI description for the lookup command (defaults to a generated one). */
190
+ readonly description?: string;
191
+ /** MCP tool description (defaults to a generated one). */
192
+ readonly toolDescription?: string;
81
193
  /** Inline fields shown under the entity title. */
82
194
  readonly fields?: readonly StoryPackField[];
83
195
  /** Long-form sections shown after the fields. */
84
196
  readonly sections?: readonly StoryPackSection[];
197
+ /** Multi-valued nested projections (pack v1 structured sections). */
198
+ readonly expand?: readonly StoryPackExpand[];
199
+ /** Progressive-disclosure levels; enables the derived `--detail` flag. */
200
+ readonly disclosure?: StoryPackDisclosure;
201
+ /** Sample capability: `true` for defaults, or a configured sample. */
202
+ readonly sample?: true | StoryPackSample;
85
203
  }
86
204
 
87
205
  /** One declarative read story: a noun with its preferred queries. */
@@ -93,6 +211,8 @@ export interface StoryPackDefinition {
93
211
  /** MCP tool description (defaults to the CLI description). */
94
212
  readonly toolDescription?: string;
95
213
  readonly list: StoryPackList;
214
+ /** Additional list-shaped verbs beyond `list` (e.g. `categories`). */
215
+ readonly verbs?: readonly StoryPackVerb[];
96
216
  readonly lookup?: StoryPackLookup;
97
217
  }
98
218
 
@@ -237,4 +237,506 @@ describe("validateStoryPackDefinition — list filters", () => {
237
237
  ),
238
238
  ).toThrow(/values/);
239
239
  });
240
+
241
+ it("accepts a value-free filter (data-driven value set)", () => {
242
+ const validated = validateStoryPackDefinition(
243
+ withFilters([{ param: "category", variable: "category" }]),
244
+ "test",
245
+ );
246
+ expect(validated.list.filters?.at(0)).toEqual({
247
+ param: "category",
248
+ variable: "category",
249
+ });
250
+ });
251
+
252
+ it("rejects the compiled search parameter name", () => {
253
+ expect(() =>
254
+ validateStoryPackDefinition(
255
+ withFilters([{ param: "search", variable: "category", values: ["x"] }]),
256
+ "test",
257
+ ),
258
+ ).toThrow(/reserved/);
259
+ });
260
+
261
+ it("rejects the derived detail parameter name", () => {
262
+ expect(() =>
263
+ validateStoryPackDefinition(
264
+ withFilters([{ param: "detail", variable: "category", values: ["x"] }]),
265
+ "test",
266
+ ),
267
+ ).toThrow(/reserved/);
268
+ });
269
+
270
+ /** RECIPE_STORY with the given `list.search`. */
271
+ function withSearch(search: unknown): unknown {
272
+ const list = { ...(base.list as Record<string, unknown>), search };
273
+ return { ...base, list };
274
+ }
275
+
276
+ it("accepts a search over variables the query mentions", () => {
277
+ const validated = validateStoryPackDefinition(
278
+ withSearch({ variables: ["name", "category"] }),
279
+ "test",
280
+ );
281
+ expect(validated.list.search?.variables).toEqual(["name", "category"]);
282
+ });
283
+
284
+ it("rejects a search without variables", () => {
285
+ expect(() =>
286
+ validateStoryPackDefinition(withSearch({ variables: [] }), "test"),
287
+ ).toThrow(/search.variables/);
288
+ });
289
+
290
+ it("rejects a search variable the query never mentions", () => {
291
+ expect(() =>
292
+ validateStoryPackDefinition(
293
+ withSearch({ variables: ["season"] }),
294
+ "test",
295
+ ),
296
+ ).toThrow(/does not appear in "list.query"/);
297
+ });
298
+
299
+ /** RECIPE_STORY with the given `lookup.expand`, for expand validation. */
300
+ function withExpand(expand: unknown): unknown {
301
+ return {
302
+ ...RECIPE_STORY,
303
+ lookup: { ...RECIPE_STORY.lookup, expand },
304
+ };
305
+ }
306
+
307
+ it("accepts a valid expand", () => {
308
+ const validated = validateStoryPackDefinition(
309
+ withExpand([
310
+ {
311
+ name: "ingredients",
312
+ heading: "Ingredients",
313
+ kind: "table",
314
+ relation: "ex:ingredient",
315
+ select: [{ name: "label", property: "ex:label" }],
316
+ },
317
+ ]),
318
+ "test",
319
+ );
320
+ expect(validated.lookup?.expand?.[0]?.name).toBe("ingredients");
321
+ });
322
+
323
+ it("rejects an expand with an empty select", () => {
324
+ expect(() =>
325
+ validateStoryPackDefinition(
326
+ withExpand([
327
+ { name: "ingredients", relation: "ex:ingredient", select: [] },
328
+ ]),
329
+ "test",
330
+ ),
331
+ ).toThrow(/select/);
332
+ });
333
+
334
+ it("rejects an expand kind other than list or table", () => {
335
+ expect(() =>
336
+ validateStoryPackDefinition(
337
+ withExpand([
338
+ {
339
+ name: "ingredients",
340
+ relation: "ex:ingredient",
341
+ kind: "tree",
342
+ select: [{ name: "label", property: "ex:label" }],
343
+ },
344
+ ]),
345
+ "test",
346
+ ),
347
+ ).toThrow(/must be "list" or "table"/);
348
+ });
349
+
350
+ it("rejects duplicate expand names", () => {
351
+ expect(() =>
352
+ validateStoryPackDefinition(
353
+ withExpand([
354
+ {
355
+ name: "dup",
356
+ relation: "ex:a",
357
+ select: [{ name: "x", property: "ex:x" }],
358
+ },
359
+ {
360
+ name: "dup",
361
+ relation: "ex:b",
362
+ select: [{ name: "y", property: "ex:y" }],
363
+ },
364
+ ]),
365
+ "test",
366
+ ),
367
+ ).toThrow(/duplicate expand name/);
368
+ });
369
+
370
+ /** RECIPE_STORY with the given `lookup` extras merged in. */
371
+ function withLookupExtras(extras: Record<string, unknown>): unknown {
372
+ return {
373
+ ...RECIPE_STORY,
374
+ lookup: { ...RECIPE_STORY.lookup, ...extras },
375
+ };
376
+ }
377
+
378
+ it("accepts a valid disclosure with a level-gated expand", () => {
379
+ const validated = validateStoryPackDefinition(
380
+ withLookupExtras({
381
+ disclosure: { levels: ["summary", "detailed"], default: "summary" },
382
+ expand: [
383
+ {
384
+ name: "ingredients",
385
+ relation: "ex:ingredient",
386
+ select: [{ name: "label", property: "ex:label" }],
387
+ level: "detailed",
388
+ },
389
+ ],
390
+ }),
391
+ "test",
392
+ );
393
+ expect(validated.lookup?.disclosure?.levels).toEqual([
394
+ "summary",
395
+ "detailed",
396
+ ]);
397
+ });
398
+
399
+ it("rejects empty disclosure levels", () => {
400
+ expect(() =>
401
+ validateStoryPackDefinition(
402
+ withLookupExtras({ disclosure: { levels: [] } }),
403
+ "test",
404
+ ),
405
+ ).toThrow(/non-empty array/);
406
+ });
407
+
408
+ it("rejects a disclosure default not among the levels", () => {
409
+ expect(() =>
410
+ validateStoryPackDefinition(
411
+ withLookupExtras({
412
+ disclosure: { levels: ["summary"], default: "detailed" },
413
+ }),
414
+ "test",
415
+ ),
416
+ ).toThrow(/default "detailed" is not one of/);
417
+ });
418
+
419
+ it("rejects a disclosure level named detail (derived param collision)", () => {
420
+ expect(() =>
421
+ validateStoryPackDefinition(
422
+ withLookupExtras({ disclosure: { levels: ["summary", "detail"] } }),
423
+ "test",
424
+ ),
425
+ ).toThrow(/reserved/);
426
+ });
427
+
428
+ it("rejects a disclosure level colliding with a kernel param name", () => {
429
+ expect(() =>
430
+ validateStoryPackDefinition(
431
+ withLookupExtras({ disclosure: { levels: ["summary", "names"] } }),
432
+ "test",
433
+ ),
434
+ ).toThrow(/reserved/);
435
+ });
436
+
437
+ it("rejects a disclosure level that is not a single lowercase word", () => {
438
+ expect(() =>
439
+ validateStoryPackDefinition(
440
+ withLookupExtras({ disclosure: { levels: ["summary", "Fully-Deep"] } }),
441
+ "test",
442
+ ),
443
+ ).toThrow(/single lowercase word/);
444
+ });
445
+
446
+ it("accepts a disclosure level named detailed (the legacy alias)", () => {
447
+ const validated = validateStoryPackDefinition(
448
+ withLookupExtras({ disclosure: { levels: ["summary", "detailed"] } }),
449
+ "test",
450
+ );
451
+ expect(validated.lookup?.disclosure?.levels).toEqual([
452
+ "summary",
453
+ "detailed",
454
+ ]);
455
+ });
456
+
457
+ it("rejects an expand level that is not a declared disclosure level", () => {
458
+ expect(() =>
459
+ validateStoryPackDefinition(
460
+ withLookupExtras({
461
+ disclosure: { levels: ["summary"] },
462
+ expand: [
463
+ {
464
+ name: "ingredients",
465
+ relation: "ex:ingredient",
466
+ select: [{ name: "label", property: "ex:label" }],
467
+ level: "detailed",
468
+ },
469
+ ],
470
+ }),
471
+ "test",
472
+ ),
473
+ ).toThrow(/is not a declared disclosure level/);
474
+ });
475
+ });
476
+
477
+ describe("validateStoryPackDefinition — identifier hardening", () => {
478
+ const base = JSON.parse(JSON.stringify(RECIPE_STORY)) as Record<
479
+ string,
480
+ unknown
481
+ >;
482
+
483
+ it("rejects a column field the query never mentions", () => {
484
+ const list = {
485
+ ...(base.list as Record<string, unknown>),
486
+ columns: [{ field: "name" }, { field: "season" }],
487
+ };
488
+ expect(() =>
489
+ validateStoryPackDefinition({ ...base, list }, "test"),
490
+ ).toThrow(/column field "\?season" does not appear in "list.query"/);
491
+ });
492
+
493
+ it("validates verb columns against the verb query", () => {
494
+ expect(() =>
495
+ validateStoryPackDefinition(
496
+ {
497
+ ...RECIPE_STORY,
498
+ verbs: [
499
+ {
500
+ verb: "categories",
501
+ query: "SELECT ?name WHERE { ?uri ex:category ?name }",
502
+ columns: [{ field: "count" }],
503
+ },
504
+ ],
505
+ },
506
+ "test",
507
+ ),
508
+ ).toThrow(/column field "\?count" does not appear in "verbs\[0\].query"/);
509
+ });
510
+
511
+ it("accepts a column bound via AS in the SELECT projection", () => {
512
+ const validated = validateStoryPackDefinition(
513
+ {
514
+ ...RECIPE_STORY,
515
+ verbs: [
516
+ {
517
+ verb: "categories",
518
+ query:
519
+ "SELECT ?name (COUNT(?uri) AS ?count) WHERE { ?uri ex:category ?name } GROUP BY ?name",
520
+ columns: [{ field: "name" }, { field: "count" }],
521
+ },
522
+ ],
523
+ },
524
+ "test",
525
+ );
526
+ expect(validated.verbs?.at(0)?.columns).toHaveLength(2);
527
+ });
528
+
529
+ // Reserved object-prototype keys: pack identifiers become plain-object
530
+ // keys at run time, so __proto__/constructor/prototype must fail at boot.
531
+ it("rejects a reserved object key as a column field", () => {
532
+ const list = {
533
+ ...(base.list as Record<string, unknown>),
534
+ query: "SELECT ?__proto__ WHERE { ?uri ex:name ?__proto__ }",
535
+ columns: [{ field: "__proto__" }],
536
+ filters: undefined,
537
+ };
538
+ expect(() =>
539
+ validateStoryPackDefinition({ ...base, list }, "test"),
540
+ ).toThrow(/reserved object key "__proto__"/);
541
+ });
542
+
543
+ it("rejects a reserved object key as a filter param or variable", () => {
544
+ const withFilter = (filter: Record<string, unknown>): unknown => ({
545
+ ...base,
546
+ list: {
547
+ ...(base.list as Record<string, unknown>),
548
+ query:
549
+ "SELECT ?uri ?name ?category ?constructor WHERE { ?uri ex:name ?name ; ex:category ?category ; ex:kind ?constructor }",
550
+ filters: [filter],
551
+ },
552
+ });
553
+ expect(() =>
554
+ validateStoryPackDefinition(
555
+ withFilter({ param: "constructor", variable: "category" }),
556
+ "test",
557
+ ),
558
+ ).toThrow(/reserved object key "constructor"/);
559
+ expect(() =>
560
+ validateStoryPackDefinition(
561
+ withFilter({ param: "kind", variable: "constructor" }),
562
+ "test",
563
+ ),
564
+ ).toThrow(/reserved object key "constructor"/);
565
+ });
566
+
567
+ it("rejects a reserved object key as a search variable", () => {
568
+ expect(() =>
569
+ validateStoryPackDefinition(
570
+ {
571
+ ...base,
572
+ list: {
573
+ ...(base.list as Record<string, unknown>),
574
+ query:
575
+ "SELECT ?uri ?name ?category ?prototype WHERE { ?uri ex:name ?name ; ex:category ?category ; ex:proto ?prototype }",
576
+ search: { variables: ["prototype"] },
577
+ },
578
+ },
579
+ "test",
580
+ ),
581
+ ).toThrow(/reserved object key "prototype"/);
582
+ });
583
+
584
+ it("rejects a reserved object key as a lookup field or expand name", () => {
585
+ expect(() =>
586
+ validateStoryPackDefinition(
587
+ {
588
+ ...RECIPE_STORY,
589
+ lookup: {
590
+ ...RECIPE_STORY.lookup,
591
+ fields: [{ name: "__proto__", property: "ex:category" }],
592
+ },
593
+ },
594
+ "test",
595
+ ),
596
+ ).toThrow(/reserved object key "__proto__"/);
597
+ expect(() =>
598
+ validateStoryPackDefinition(
599
+ {
600
+ ...RECIPE_STORY,
601
+ lookup: {
602
+ ...RECIPE_STORY.lookup,
603
+ expand: [
604
+ {
605
+ name: "__proto__",
606
+ relation: "ex:ingredient",
607
+ select: [{ name: "label", property: "ex:label" }],
608
+ },
609
+ ],
610
+ },
611
+ },
612
+ "test",
613
+ ),
614
+ ).toThrow(/reserved object key "__proto__"/);
615
+ });
616
+
617
+ it("rejects a reserved object key inside an expand select", () => {
618
+ expect(() =>
619
+ validateStoryPackDefinition(
620
+ {
621
+ ...RECIPE_STORY,
622
+ lookup: {
623
+ ...RECIPE_STORY.lookup,
624
+ expand: [
625
+ {
626
+ name: "ingredients",
627
+ relation: "ex:ingredient",
628
+ select: [{ name: "constructor", property: "ex:label" }],
629
+ },
630
+ ],
631
+ },
632
+ },
633
+ "test",
634
+ ),
635
+ ).toThrow(/reserved object key "constructor"/);
636
+ });
637
+
638
+ it("rejects a reserved object key as a disclosure level", () => {
639
+ expect(() =>
640
+ validateStoryPackDefinition(
641
+ {
642
+ ...RECIPE_STORY,
643
+ lookup: {
644
+ ...RECIPE_STORY.lookup,
645
+ disclosure: { levels: ["summary", "prototype"] },
646
+ },
647
+ },
648
+ "test",
649
+ ),
650
+ ).toThrow(/reserved object key "prototype"/);
651
+ });
652
+ });
653
+
654
+ // Pack v1: extra list verbs and the sample capability.
655
+ describe("validateStoryPackDefinition — verbs and sample", () => {
656
+ const VERB = {
657
+ verb: "categories",
658
+ query: "SELECT ?name WHERE { ?uri ex:category ?name }",
659
+ columns: [{ field: "name" }],
660
+ };
661
+
662
+ function withVerbs(verbs: unknown): unknown {
663
+ return { ...RECIPE_STORY, verbs };
664
+ }
665
+
666
+ it("accepts a valid extra verb and validates its list shape", () => {
667
+ const validated = validateStoryPackDefinition(withVerbs([VERB]), "test");
668
+ expect(validated.verbs?.at(0)?.verb).toBe("categories");
669
+ expect(validated.verbs?.at(0)?.columns).toEqual([{ field: "name" }]);
670
+ });
671
+
672
+ it("rejects a verb colliding with a compiled verb", () => {
673
+ for (const verb of ["list", "lookup", "sample"]) {
674
+ expect(() =>
675
+ validateStoryPackDefinition(withVerbs([{ ...VERB, verb }]), "test"),
676
+ ).toThrow(/collides with a compiled verb/);
677
+ }
678
+ });
679
+
680
+ it("rejects duplicate verbs", () => {
681
+ expect(() =>
682
+ validateStoryPackDefinition(withVerbs([VERB, VERB]), "test"),
683
+ ).toThrow(/duplicate verb/);
684
+ });
685
+
686
+ it("rejects a non-kebab verb", () => {
687
+ expect(() =>
688
+ validateStoryPackDefinition(
689
+ withVerbs([{ ...VERB, verb: "Cats" }]),
690
+ "test",
691
+ ),
692
+ ).toThrow(/kebab-case/);
693
+ });
694
+
695
+ it("rejects a verb whose query is not a SELECT", () => {
696
+ expect(() =>
697
+ validateStoryPackDefinition(
698
+ withVerbs([{ ...VERB, query: "ASK { ?s ?p ?o }" }]),
699
+ "test",
700
+ ),
701
+ ).toThrow(/verbs\[0\].query/);
702
+ });
703
+
704
+ it("validates verb filters against the verb query", () => {
705
+ expect(() =>
706
+ validateStoryPackDefinition(
707
+ withVerbs([
708
+ {
709
+ ...VERB,
710
+ filters: [{ param: "season", variable: "season", values: ["x"] }],
711
+ },
712
+ ]),
713
+ "test",
714
+ ),
715
+ ).toThrow(/does not appear in "verbs\[0\].query"/);
716
+ });
717
+
718
+ function withSample(sample: unknown): unknown {
719
+ return { ...RECIPE_STORY, lookup: { ...RECIPE_STORY.lookup, sample } };
720
+ }
721
+
722
+ it("accepts sample: true", () => {
723
+ const validated = validateStoryPackDefinition(withSample(true), "test");
724
+ expect(validated.lookup?.sample).toBe(true);
725
+ });
726
+
727
+ it("accepts a configured sample with a valid default count", () => {
728
+ const validated = validateStoryPackDefinition(
729
+ withSample({ count: 3 }),
730
+ "test",
731
+ );
732
+ expect(validated.lookup?.sample).toEqual({ count: 3 });
733
+ });
734
+
735
+ it("rejects an out-of-range or non-integer sample count", () => {
736
+ for (const count of [0, 6, 2.5, "2"]) {
737
+ expect(() =>
738
+ validateStoryPackDefinition(withSample({ count }), "test"),
739
+ ).toThrow(/sample.count/);
740
+ }
741
+ });
240
742
  });