@canonical/pragma-cli 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/README.md +2 -2
  2. package/bundled-skills/add-standard/SKILL.md +338 -0
  3. package/bundled-skills/adoption-a1-styles/SKILL.md +269 -0
  4. package/bundled-skills/adoption-a2-components/SKILL.md +291 -0
  5. package/bundled-skills/adoption-a3-forms/SKILL.md +260 -0
  6. package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +406 -0
  7. package/bundled-skills/anatomy-author/SKILL.md +1226 -0
  8. package/bundled-skills/design-auditor/SKILL.md +455 -0
  9. package/bundled-skills/implement-component/SKILL.md +333 -0
  10. package/bundled-skills/specify-component/SKILL.md +331 -0
  11. package/bundled-skills/specify-pattern/SKILL.md +292 -0
  12. package/dist/package.json +24 -20
  13. package/dist/pragma.conf.js +266 -34
  14. package/dist/pragma.conf.js.map +1 -1
  15. package/dist/src/bin.js +1 -1
  16. package/dist/src/bin.js.map +1 -1
  17. package/dist/src/capabilities/capabilities/hints.js +1 -1
  18. package/dist/src/capabilities/capabilities/hints.js.map +1 -1
  19. package/dist/src/capabilities/colophon/colophon.render.js +1 -1
  20. package/dist/src/capabilities/colophon/colophon.render.js.map +1 -1
  21. package/dist/src/capabilities/create/create.verb.js +2 -1
  22. package/dist/src/capabilities/create/create.verb.js.map +1 -1
  23. package/dist/src/capabilities/create/createSurface.generated.js +23 -1
  24. package/dist/src/capabilities/create/createSurface.generated.js.map +1 -1
  25. package/dist/src/capabilities/create/mount.js +4 -3
  26. package/dist/src/capabilities/create/mount.js.map +1 -1
  27. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js +11 -6
  28. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -1
  29. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js +60 -4
  30. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js.map +1 -1
  31. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js +8 -8
  32. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js.map +1 -1
  33. package/dist/src/capabilities/doctor/checks/harnessInventory.js +195 -0
  34. package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -0
  35. package/dist/src/capabilities/doctor/checks/index.js +6 -6
  36. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js +38 -0
  37. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js.map +1 -0
  38. package/dist/src/capabilities/doctor/checks/targetHealth.js +194 -50
  39. package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
  40. package/dist/src/capabilities/doctor/doctor.render.js +43 -64
  41. package/dist/src/capabilities/doctor/doctor.render.js.map +1 -1
  42. package/dist/src/capabilities/doctor/doctor.verb.js +6 -2
  43. package/dist/src/capabilities/doctor/doctor.verb.js.map +1 -1
  44. package/dist/src/capabilities/doctor/runChecks.js +14 -11
  45. package/dist/src/capabilities/doctor/runChecks.js.map +1 -1
  46. package/dist/src/capabilities/graph/query.render.js +2 -2
  47. package/dist/src/capabilities/graph/query.render.js.map +1 -1
  48. package/dist/src/capabilities/info/collectInfo.js +8 -2
  49. package/dist/src/capabilities/info/collectInfo.js.map +1 -1
  50. package/dist/src/capabilities/info/info.render.js +6 -2
  51. package/dist/src/capabilities/info/info.render.js.map +1 -1
  52. package/dist/src/capabilities/prompt/prompt.render.js +2 -2
  53. package/dist/src/capabilities/prompt/prompt.render.js.map +1 -1
  54. package/dist/src/capabilities/setup/buildPlan.js +43 -39
  55. package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
  56. package/dist/src/capabilities/setup/mode.js +2 -2
  57. package/dist/src/capabilities/setup/operations/index.js +4 -4
  58. package/dist/src/capabilities/setup/operations/index.js.map +1 -1
  59. package/dist/src/capabilities/setup/operations/setupCompletions.js +6 -2
  60. package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -1
  61. package/dist/src/capabilities/setup/operations/setupConfig.js +4 -2
  62. package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -1
  63. package/dist/src/capabilities/setup/operations/setupGenerator.js +156 -58
  64. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  65. package/dist/src/capabilities/setup/operations/setupLsp.js +136 -38
  66. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  67. package/dist/src/capabilities/setup/operations/setupMcp.js +101 -52
  68. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  69. package/dist/src/capabilities/setup/operations/setupSkills.js +329 -49
  70. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  71. package/dist/src/capabilities/setup/plan.js.map +1 -1
  72. package/dist/src/capabilities/setup/plan.render.js +144 -66
  73. package/dist/src/capabilities/setup/plan.render.js.map +1 -1
  74. package/dist/src/capabilities/setup/setup.render.js +1 -1
  75. package/dist/src/capabilities/setup/setup.verb.js +165 -63
  76. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  77. package/dist/src/capabilities/setup/targets.js +122 -51
  78. package/dist/src/capabilities/setup/targets.js.map +1 -1
  79. package/dist/src/capabilities/shared/index.js +7 -6
  80. package/dist/src/capabilities/shared/index.js.map +1 -1
  81. package/dist/src/capabilities/shared/packageManager.js +300 -54
  82. package/dist/src/capabilities/shared/packageManager.js.map +1 -1
  83. package/dist/src/capabilities/skill/discover.js +97 -11
  84. package/dist/src/capabilities/skill/discover.js.map +1 -1
  85. package/dist/src/capabilities/skill/render.js +2 -2
  86. package/dist/src/capabilities/skill/render.js.map +1 -1
  87. package/dist/src/capabilities/sources/installSkills.js +145 -3
  88. package/dist/src/capabilities/sources/installSkills.js.map +1 -1
  89. package/dist/src/capabilities/sources/runUpdate.js +68 -2
  90. package/dist/src/capabilities/sources/runUpdate.js.map +1 -1
  91. package/dist/src/capabilities/sources/update.verb.js +3 -3
  92. package/dist/src/capabilities/sources/update.verb.js.map +1 -1
  93. package/dist/src/capabilities/upgrade/runUpgrade.js +43 -7
  94. package/dist/src/capabilities/upgrade/runUpgrade.js.map +1 -1
  95. package/dist/src/capabilities/upgrade/upgrade.render.js +10 -2
  96. package/dist/src/capabilities/upgrade/upgrade.render.js.map +1 -1
  97. package/dist/src/constants.js +13 -2
  98. package/dist/src/constants.js.map +1 -1
  99. package/dist/src/kernel/index.js +11 -2
  100. package/dist/src/kernel/index.js.map +1 -1
  101. package/dist/src/kernel/interactivity.js +68 -0
  102. package/dist/src/kernel/interactivity.js.map +1 -0
  103. package/dist/src/kernel/packs/renderPack.js +3 -3
  104. package/dist/src/kernel/packs/renderPack.js.map +1 -1
  105. package/dist/src/kernel/packs/resolveEntity.js +63 -12
  106. package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
  107. package/dist/src/kernel/packs/runBodies.js +57 -4
  108. package/dist/src/kernel/packs/runBodies.js.map +1 -1
  109. package/dist/src/kernel/packs/schema.js +54 -2
  110. package/dist/src/kernel/packs/schema.js.map +1 -1
  111. package/dist/src/kernel/packs/sparql/applyFilters.js +104 -5
  112. package/dist/src/kernel/packs/sparql/applyFilters.js.map +1 -1
  113. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +310 -43
  114. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  115. package/dist/src/kernel/packs/types.js.map +1 -1
  116. package/dist/src/kernel/project/cli/buildProgram.js +1 -1
  117. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
  118. package/dist/src/kernel/project/cli/dispatch.js +46 -24
  119. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  120. package/dist/src/kernel/project/cli/globalFlags.js +4 -1
  121. package/dist/src/kernel/project/cli/globalFlags.js.map +1 -1
  122. package/dist/src/kernel/project/cli/helpFormat.js +1 -2
  123. package/dist/src/kernel/project/cli/helpFormat.js.map +1 -1
  124. package/dist/src/kernel/project/cli/rootHelp.js +42 -9
  125. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  126. package/dist/src/kernel/project/mcp/registerVerb.js +16 -1
  127. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
  128. package/dist/src/kernel/render/renderers.js +1 -1
  129. package/dist/src/kernel/render/style.js +29 -9
  130. package/dist/src/kernel/render/style.js.map +1 -1
  131. package/dist/src/kernel/render/vocabulary.js +174 -0
  132. package/dist/src/kernel/render/vocabulary.js.map +1 -0
  133. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js +2 -3
  134. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -1
  135. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +1 -1
  136. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -1
  137. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js +4 -0
  138. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js.map +1 -0
  139. package/dist/src/kernel/runtime/graphpack/embedded.js +13 -6
  140. package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -1
  141. package/dist/src/kernel/runtime/graphpack/read.js +14 -9
  142. package/dist/src/kernel/runtime/graphpack/read.js.map +1 -1
  143. package/dist/src/kernel/runtime/loadSession.js +3 -1
  144. package/dist/src/kernel/runtime/loadSession.js.map +1 -1
  145. package/dist/src/kernel/runtime/probe.js +1 -1
  146. package/dist/src/kernel/runtime/probe.js.map +1 -1
  147. package/dist/src/kernel/runtime/refs/parseRef.js +17 -1
  148. package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -1
  149. package/dist/src/kernel/runtime/refs/resolve.js +9 -3
  150. package/dist/src/kernel/runtime/refs/resolve.js.map +1 -1
  151. package/dist/src/kernel/spec/emitReference.js +1 -1
  152. package/dist/src/kernel/spec/emitReference.js.map +1 -1
  153. package/package.json +24 -20
  154. package/pragma.conf.ts +272 -34
  155. package/src/bin.ts +8 -1
  156. package/src/capabilities/block.parity.test.ts +25 -11
  157. package/src/capabilities/block.shipped.exec.test.ts +282 -0
  158. package/src/capabilities/block.tierRank.exec.test.ts +300 -0
  159. package/src/capabilities/capabilities/hints.ts +1 -1
  160. package/src/capabilities/colophon/colophon.render.ts +1 -2
  161. package/src/capabilities/colophon/colophon.test.ts +2 -2
  162. package/src/capabilities/config/collectConfigShow.test.ts +1 -0
  163. package/src/capabilities/create/byteEquality.test.ts +28 -1
  164. package/src/capabilities/create/create.test.ts +1 -0
  165. package/src/capabilities/create/create.verb.ts +2 -1
  166. package/src/capabilities/create/createGrammar.test.ts +5 -2
  167. package/src/capabilities/create/createSurface.generated.ts +23 -1
  168. package/src/capabilities/create/crossCli.subprocess.test.ts +10 -3
  169. package/src/capabilities/create/mount.ts +3 -3
  170. package/src/capabilities/distribution.parity.test.ts +4 -0
  171. package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
  172. package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
  173. package/src/capabilities/doctor/checks/checkPackageRefs.ts +69 -4
  174. package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +6 -4
  175. package/src/capabilities/doctor/checks/checkShellCompletions.ts +8 -8
  176. package/src/capabilities/doctor/checks/harnessInventory.test.ts +219 -0
  177. package/src/capabilities/doctor/checks/harnessInventory.ts +298 -0
  178. package/src/capabilities/doctor/checks/index.ts +6 -6
  179. package/src/capabilities/doctor/checks/isSupportedNodeVersion.test.ts +111 -0
  180. package/src/capabilities/doctor/checks/isSupportedNodeVersion.ts +34 -0
  181. package/src/capabilities/doctor/checks/mcpCommand.test.ts +7 -1
  182. package/src/capabilities/doctor/checks/targetHealth.ts +229 -59
  183. package/src/capabilities/doctor/doctor.render.test.ts +108 -32
  184. package/src/capabilities/doctor/doctor.render.ts +56 -80
  185. package/src/capabilities/doctor/doctor.test.ts +247 -35
  186. package/src/capabilities/doctor/doctor.verb.ts +7 -2
  187. package/src/capabilities/doctor/runChecks.ts +14 -11
  188. package/src/capabilities/doctor/types.ts +15 -24
  189. package/src/capabilities/emptyRecovery.test.ts +10 -2
  190. package/src/capabilities/graph/query.render.ts +2 -2
  191. package/src/capabilities/info/collectInfo.ts +8 -1
  192. package/src/capabilities/info/info.render.ts +6 -2
  193. package/src/capabilities/info/info.test.ts +37 -1
  194. package/src/capabilities/info/types.ts +10 -2
  195. package/src/capabilities/lazy.test.ts +26 -2
  196. package/src/capabilities/listLookup.shipped.exec.test.ts +340 -0
  197. package/src/capabilities/prompt/prompt.render.ts +2 -2
  198. package/src/capabilities/renderVocabularySync.test.ts +82 -0
  199. package/src/capabilities/resources/resources.test.ts +38 -2
  200. package/src/capabilities/scopeTypeSync.test.ts +29 -15
  201. package/src/capabilities/setup/buildPlan.ts +45 -41
  202. package/src/capabilities/setup/index.ts +1 -1
  203. package/src/capabilities/setup/mode.ts +2 -2
  204. package/src/capabilities/setup/operations/index.ts +6 -1
  205. package/src/capabilities/setup/operations/setupCompletions.ts +9 -2
  206. package/src/capabilities/setup/operations/setupConfig.ts +7 -2
  207. package/src/capabilities/setup/operations/setupGenerator.ts +227 -84
  208. package/src/capabilities/setup/operations/setupLsp.ts +203 -38
  209. package/src/capabilities/setup/operations/setupMcp.ts +139 -62
  210. package/src/capabilities/setup/operations/setupSkills.ts +399 -60
  211. package/src/capabilities/setup/plan.render.ts +171 -76
  212. package/src/capabilities/setup/plan.ts +17 -12
  213. package/src/capabilities/setup/setup.mcp.correctness.test.ts +9 -9
  214. package/src/capabilities/setup/setup.render.test.ts +50 -36
  215. package/src/capabilities/setup/setup.render.ts +1 -1
  216. package/src/capabilities/setup/setup.test.ts +1126 -50
  217. package/src/capabilities/setup/setup.verb.ts +174 -68
  218. package/src/capabilities/setup/targets.ts +138 -60
  219. package/src/capabilities/setup/types.ts +10 -18
  220. package/src/capabilities/shared/index.ts +18 -6
  221. package/src/capabilities/shared/packageManager.test.ts +421 -6
  222. package/src/capabilities/shared/packageManager.ts +394 -59
  223. package/src/capabilities/skill/bundledSkills.test.ts +347 -0
  224. package/src/capabilities/skill/discover.ts +100 -11
  225. package/src/capabilities/skill/render.ts +2 -2
  226. package/src/capabilities/skill/skill.test.ts +17 -4
  227. package/src/capabilities/sources/installSkills.ts +181 -3
  228. package/src/capabilities/sources/runUpdate.ts +78 -2
  229. package/src/capabilities/sources/sources.test.ts +228 -0
  230. package/src/capabilities/sources/update.verb.ts +3 -3
  231. package/src/capabilities/surface.test.ts +14 -6
  232. package/src/capabilities/upgrade/runUpgrade.ts +45 -6
  233. package/src/capabilities/upgrade/types.ts +10 -2
  234. package/src/capabilities/upgrade/upgrade.render.ts +9 -2
  235. package/src/capabilities/upgrade/upgrade.test.ts +72 -0
  236. package/src/constants.ts +14 -1
  237. package/src/identity.test.ts +18 -5
  238. package/src/kernel/completion/entitySource.test.ts +15 -2
  239. package/src/kernel/completion/safety.test.ts +16 -9
  240. package/src/kernel/config/defaults.test.ts +19 -3
  241. package/src/kernel/config/readConfig.test.ts +1 -1
  242. package/src/kernel/index.ts +11 -2
  243. package/src/kernel/interactivity.test.ts +76 -0
  244. package/src/kernel/interactivity.ts +69 -0
  245. package/src/kernel/packs/compile.test.ts +92 -0
  246. package/src/kernel/packs/renderPack.ts +3 -3
  247. package/src/kernel/packs/resolveEntity.test.ts +75 -14
  248. package/src/kernel/packs/resolveEntity.ts +88 -24
  249. package/src/kernel/packs/runBodies.ts +84 -19
  250. package/src/kernel/packs/schema.ts +60 -4
  251. package/src/kernel/packs/sparql/applyFilters.ts +125 -4
  252. package/src/kernel/packs/sparql/buildLookupQuery.test.ts +290 -0
  253. package/src/kernel/packs/sparql/buildLookupQuery.ts +327 -45
  254. package/src/kernel/packs/toolDescription.test.ts +35 -1
  255. package/src/kernel/packs/types.ts +125 -2
  256. package/src/kernel/project/cli/buildProgram.test.ts +73 -2
  257. package/src/kernel/project/cli/buildProgram.ts +8 -1
  258. package/src/kernel/project/cli/dispatch.test.ts +5 -4
  259. package/src/kernel/project/cli/dispatch.ts +48 -25
  260. package/src/kernel/project/cli/globalFlags.ts +4 -1
  261. package/src/kernel/project/cli/helpFormat.ts +1 -2
  262. package/src/kernel/project/cli/rootHelp.ts +44 -8
  263. package/src/kernel/project/mcp/registerVerb.ts +20 -1
  264. package/src/kernel/render/prefixes.test.ts +2 -0
  265. package/src/kernel/render/renderers.ts +1 -1
  266. package/src/kernel/render/style.ts +32 -9
  267. package/src/kernel/render/vocabulary.ts +276 -0
  268. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +2 -3
  269. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
  270. package/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.ts +3 -0
  271. package/src/kernel/runtime/graphpack/embedded.ts +14 -6
  272. package/src/kernel/runtime/graphpack/graphpack.test.ts +31 -5
  273. package/src/kernel/runtime/graphpack/read.ts +14 -12
  274. package/src/kernel/runtime/loadSession.ts +3 -1
  275. package/src/kernel/runtime/probe.ts +1 -1
  276. package/src/kernel/runtime/refs/parseRef.test.ts +49 -1
  277. package/src/kernel/runtime/refs/parseRef.ts +25 -1
  278. package/src/kernel/runtime/refs/resolve.ts +9 -3
  279. package/src/kernel/runtime/types.ts +17 -0
  280. package/src/kernel/spec/emitReference.ts +1 -1
  281. package/src/kernel/spec/types.ts +35 -6
  282. package/src/testing/PARITY_GAPS.ts +9 -3
  283. package/src/testing/behavioral/docExamples.test.ts +4 -2
  284. package/src/testing/behavioral/errorMatrix.mcp.test.ts +110 -7
  285. package/src/testing/behavioral/journeys.firstInstall.test.ts +7 -2
  286. package/src/testing/behavioral/journeys.packageStories.test.ts +6 -3
  287. package/src/testing/eval/__snapshots__/eval.test.ts.snap +26 -1
  288. package/src/testing/eval/cases/readNouns.ts +245 -9
  289. package/src/testing/fixtures/blockGraph.ts +28 -13
  290. package/src/testing/fixtures/graph/canonical.ts +95 -12
  291. package/src/testing/helpers/runCli.test.ts +44 -0
  292. package/src/testing/helpers/runCli.ts +27 -3
  293. package/dist/src/capabilities/shared/bands.js +0 -16
  294. package/dist/src/capabilities/shared/bands.js.map +0 -1
  295. package/src/capabilities/shared/bands.ts +0 -16
@@ -0,0 +1,282 @@
1
+ /**
2
+ * The `block` story's declared vocabulary, EXECUTED against the shipped pack
3
+ * (PROTECTED).
4
+ *
5
+ * `block.parity.test.ts` asserts the story's semantics over a FIXTURE graph the
6
+ * repository writes. That is a real check, but it can only ever prove the story
7
+ * agrees with the fixture — and when the two drifted apart, that is exactly
8
+ * what happened: the story read `ds:whenToUse`/`ds:whenNotToUse`, the fixture
9
+ * obligingly declared them, the suite stayed green, and every real install
10
+ * rendered `block lookup` with no usage narrative at all, because the ontology
11
+ * had long since retired both terms in favour of a single `ds:usage`. Nothing
12
+ * in the pipeline treats that as an error: the GraphQL projection omits a name
13
+ * it cannot express the way OPTIONAL omits an unbound variable, and the
14
+ * renderer skips a section with no value. Silence all the way down.
15
+ *
16
+ * So this file boots the SHIPPED graph and asserts what a fixture structurally
17
+ * cannot. It distinguishes two failures that look identical in the output but
18
+ * are not the same defect:
19
+ *
20
+ * - a declared property the ontology does not define is a STORY bug — the CLI
21
+ * is asking for a name the schema can never answer, and no upstream content
22
+ * edit can ever make it render. That is this defect, and it is forbidden here.
23
+ * - a declared property the ontology defines but no instance asserts yet is a
24
+ * CONTENT gap upstream (`ds:figmaLink` is one today). The story is correct and
25
+ * degrades gracefully; that is not this suite's business.
26
+ *
27
+ * The `graph/examples.exec.test.ts` precedent: an example is a promise to a
28
+ * stranger, and only running it against what actually ships reads it as one. A
29
+ * declared property is the same kind of promise.
30
+ */
31
+
32
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
33
+ import {
34
+ isNestedExpand,
35
+ type PackExpandSelect,
36
+ } from "../kernel/packs/types.js";
37
+ import { verbKey } from "../kernel/packs/uniqueness.js";
38
+ import { bootRuntime } from "../kernel/runtime/boot.js";
39
+ import type { PragmaRuntime } from "../kernel/runtime/types.js";
40
+ import type { VerbSpec } from "../kernel/spec/types.js";
41
+ import { TEST_FLAGS } from "../testing/helpers/projectCli.js";
42
+ import { declaredStories, storyModules } from "./distribution.js";
43
+
44
+ const story = declaredStories.get("block");
45
+ if (!story?.lookup) {
46
+ throw new Error('pragma.conf.ts declares no "block" lookup');
47
+ }
48
+ const blockLookup = story.lookup;
49
+
50
+ const blockModule = storyModules.get("block");
51
+ if (!blockModule) {
52
+ throw new Error('pragma.conf.ts declares no story for "block"');
53
+ }
54
+
55
+ const lookupVerb = blockModule.verbs.find(
56
+ (v) => verbKey(v.path) === "block lookup",
57
+ ) as VerbSpec;
58
+
59
+ /**
60
+ * The block classes the lookup addresses, read from the story rather than
61
+ * retyped — a class added there widens these checks with it.
62
+ */
63
+ const TYPE_VALUES = (blockLookup.types ?? []).join(" ");
64
+
65
+ /** One graph term the story names, paired with the site that declares it. */
66
+ interface DeclaredTerm {
67
+ readonly what: string;
68
+ readonly property: string;
69
+ }
70
+
71
+ /**
72
+ * Every term an `expand.select` names, to whatever depth it nests.
73
+ *
74
+ * WALKED, not listed. A hand-maintained enumeration would carry this file's own
75
+ * defect one level down: `ds:hasModifier`, `ds:propertyType` and `ds:optional`
76
+ * could be retired exactly the way `ds:whenToUse` was, and `block lookup` would
77
+ * quietly lose its modifier values and its property table while a list nobody
78
+ * thought to update stayed green. Recursing over the declaration instead means
79
+ * a term added to any `select` is covered the moment it is declared, by the
80
+ * author who declares it and without their help.
81
+ */
82
+ function selectedTerms(
83
+ site: string,
84
+ select: readonly PackExpandSelect[],
85
+ ): readonly DeclaredTerm[] {
86
+ return select.flatMap((entry) =>
87
+ isNestedExpand(entry)
88
+ ? [
89
+ {
90
+ what: `${site} → nested expand "${entry.name}"`,
91
+ property: entry.relation,
92
+ },
93
+ ...selectedTerms(`${site} → "${entry.name}"`, entry.select),
94
+ ]
95
+ : [
96
+ {
97
+ what: `${site} → field "${entry.name}"`,
98
+ property: entry.property,
99
+ },
100
+ ],
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Every property the story names in the graph, read from the declaration.
106
+ *
107
+ * The identity property, fields, sections, expand relations and everything
108
+ * selected beneath them all name graph vocabulary and all fail the same silent
109
+ * way, so all of them are held to the same standard. `by` is included because a
110
+ * lookup whose identity property the ontology has retired resolves NOTHING —
111
+ * the loudest form of the same defect, and the one no rendering test would
112
+ * reach, since there would be no entity to render.
113
+ *
114
+ * One case per declaration SITE rather than per distinct term: `ds:name` is
115
+ * named at six of them, and a failure that names the site it was declared at
116
+ * is the one a reader can act on.
117
+ */
118
+ const DECLARED_PROPERTIES: readonly DeclaredTerm[] = [
119
+ { what: "lookup `by`", property: blockLookup.by },
120
+ ...(blockLookup.fields ?? []).map((f) => ({
121
+ what: `field "${f.name}"`,
122
+ property: f.property,
123
+ })),
124
+ ...(blockLookup.sections ?? []).map((s) => ({
125
+ what: `section "${s.name}"`,
126
+ property: s.property,
127
+ })),
128
+ ...(blockLookup.expand ?? []).flatMap((e) => [
129
+ { what: `expand "${e.name}"`, property: e.relation },
130
+ ...selectedTerms(`expand "${e.name}"`, e.select),
131
+ ]),
132
+ ];
133
+
134
+ /** The IRI of the block whose usage narrative is asserted on below. */
135
+ const BUTTON = "https://ds.canonical.com/global.component.button";
136
+
137
+ // ONE store for the file — booting the shipped pack is the expensive part, and
138
+ // this package already contends under parallel runs.
139
+ let rt: PragmaRuntime;
140
+ beforeAll(async () => {
141
+ rt = bootRuntime(TEST_FLAGS);
142
+ // Load the shipped store HERE, once. `it.each` splits the vocabulary check
143
+ // into one small case per declared property, and whichever ran first would
144
+ // otherwise pay the whole lazy-load cost and time out under contention.
145
+ await rt.store.get();
146
+ }, 60_000);
147
+ afterAll(async () => {
148
+ (await rt.store.get()).store.dispose();
149
+ });
150
+
151
+ /** One scalar from the shipped graph, queried directly. */
152
+ async function scalar(query: string): Promise<string | undefined> {
153
+ const result = await rt.query.sparql(query);
154
+ if (result.type !== "select") return undefined;
155
+ const row = result.bindings[0];
156
+ return row === undefined ? undefined : Object.values(row)[0];
157
+ }
158
+
159
+ /** The `llm` rendering of `block lookup <value>`. */
160
+ async function renderLookup(value: string): Promise<string> {
161
+ const out = (await lookupVerb.run({ name: [value] }, rt)) as never;
162
+ return lookupVerb.output.formatters.llm(out);
163
+ }
164
+
165
+ describe("the `block` story declares vocabulary the shipped ontology defines (PROTECTED)", () => {
166
+ it("declares at least one property to check", () => {
167
+ expect(DECLARED_PROPERTIES.length).toBeGreaterThan(0);
168
+ });
169
+
170
+ it.each(
171
+ DECLARED_PROPERTIES,
172
+ )("$what reads $property, which the shipped ontology defines", async ({
173
+ what,
174
+ property,
175
+ }) => {
176
+ const result = await rt.query.sparql(
177
+ [
178
+ "ASK {",
179
+ ` ${property} a ?kind .`,
180
+ " VALUES ?kind { owl:DatatypeProperty owl:ObjectProperty }",
181
+ "}",
182
+ ].join("\n"),
183
+ );
184
+ expect(result.type).toBe("ask");
185
+ expect(
186
+ result.type === "ask" ? result.result : false,
187
+ `${what} reads ${property}, which the shipped ontology does not define — it can never render, for any block, on any install`,
188
+ ).toBe(true);
189
+ });
190
+ });
191
+
192
+ describe("the shipped graph carries the usage narrative on every block (PROTECTED)", () => {
193
+ it("asserts ds:usage on every block, and neither retired term on any", async () => {
194
+ const blocks = await scalar(
195
+ [
196
+ "SELECT (COUNT(DISTINCT ?b) AS ?n) WHERE {",
197
+ ` VALUES ?class { ${TYPE_VALUES} }`,
198
+ " ?b a ?class .",
199
+ "}",
200
+ ].join("\n"),
201
+ );
202
+ const withUsage = await scalar(
203
+ [
204
+ "SELECT (COUNT(DISTINCT ?b) AS ?n) WHERE {",
205
+ ` VALUES ?class { ${TYPE_VALUES} }`,
206
+ " ?b a ?class ; ds:usage ?u .",
207
+ "}",
208
+ ].join("\n"),
209
+ );
210
+ expect(Number(blocks)).toBeGreaterThan(0);
211
+ expect(withUsage).toBe(blocks);
212
+
213
+ // The retirement itself, pinned: reading these again would reintroduce the
214
+ // defect, silently, exactly as before.
215
+ for (const retired of ["ds:whenToUse", "ds:whenNotToUse"]) {
216
+ const result = await rt.query.sparql(`ASK { ?s ${retired} ?o }`);
217
+ expect(
218
+ result.type === "ask" ? result.result : true,
219
+ `${retired} is asserted somewhere in the shipped graph`,
220
+ ).toBe(false);
221
+ }
222
+ });
223
+ });
224
+
225
+ describe("block lookup renders the usage narrative from the shipped graph (PROTECTED)", () => {
226
+ it("renders a Usage section carrying the graph's own literal", async () => {
227
+ const usage = await scalar(`SELECT ?u WHERE { <${BUTTON}> ds:usage ?u }`);
228
+ expect(usage?.trim().length ?? 0).toBeGreaterThan(0);
229
+
230
+ const llm = await renderLookup(BUTTON);
231
+ expect(llm).toContain("### Usage");
232
+ // The narrative itself, not just its heading: the first prose line of the
233
+ // literal is read FROM the graph rather than retyped here, so editing the
234
+ // content upstream cannot leave this asserting yesterday's copy.
235
+ const firstProseLine = (usage ?? "")
236
+ .split("\n")
237
+ .map((line) => line.trim())
238
+ .find((line) => line.length > 0 && !line.startsWith("#")) as string;
239
+ expect(llm).toContain(firstProseLine);
240
+ });
241
+
242
+ it("nests the literal's own headings UNDER the Usage heading", async () => {
243
+ // The live literals carry their own `### When to use` / `### When not to
244
+ // use` sub-sections. Left at `###` they would be siblings of the section
245
+ // heading, which reads as an empty Usage section; the renderer demotes them.
246
+ const heading = await scalar(
247
+ [
248
+ "SELECT ?h WHERE {",
249
+ ` <${BUTTON}> ds:usage ?u .`,
250
+ ' BIND(REPLACE(STR(?u), "(?s)^.*?\\\\n### ([^\\\\n]+)\\\\n.*$", "$1") AS ?h)',
251
+ "}",
252
+ ].join("\n"),
253
+ );
254
+ expect(
255
+ heading,
256
+ "the Button usage literal carries no `### ` heading",
257
+ ).toBeDefined();
258
+
259
+ const llm = await renderLookup(BUTTON);
260
+ expect(llm).toContain(`#### ${heading}`);
261
+ expect(llm).not.toContain(`\n### ${heading}\n`);
262
+ });
263
+
264
+ it("prints no Usage heading for a block whose usage literal is empty", async () => {
265
+ // Roughly half the shipped blocks assert `ds:usage ""`. An empty literal
266
+ // must render as no section at all, never as a heading with nothing below.
267
+ const uri = await scalar(
268
+ [
269
+ "SELECT ?b WHERE {",
270
+ ` VALUES ?class { ${TYPE_VALUES} }`,
271
+ " ?b a ?class ; ds:usage ?u .",
272
+ ' FILTER(STR(?u) = "")',
273
+ "}",
274
+ "ORDER BY ?b",
275
+ "LIMIT 1",
276
+ ].join("\n"),
277
+ );
278
+ expect(uri, "no shipped block carries an empty ds:usage").toBeDefined();
279
+
280
+ expect(await renderLookup(uri as string)).not.toContain("### Usage");
281
+ });
282
+ });
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Which block a SHARED name means, EXECUTED against the shipped pack
3
+ * (PROTECTED).
4
+ *
5
+ * 25 of the design system's block names are carried by two or three blocks
6
+ * apiece, across tiers. `block lookup` used to answer each of them with whichever
7
+ * IRI sorted first and say nothing about the rest, so `button` returned
8
+ * Launchpad's Button — `apps_launchpad…` sorts before `global…` — and an agent
9
+ * had no way to learn that the block everybody means also existed.
10
+ *
11
+ * Two properties, both asserted here: EVERY block the name reaches comes back,
12
+ * and they come back RANKED. Either alone leaves a caller stuck — a better
13
+ * arbitrary pick is still arbitrary, and an unordered list of two makes the
14
+ * reader do the ranking the graph already knows how to do.
15
+ *
16
+ * This suite is deliberately NOT fixture-backed, and that is the whole point of
17
+ * it. The two nearest specimens in this package show why: `journeys.livePack`
18
+ * is NAMED for ordering determinism and its fixture contains one Button, and
19
+ * the ambiguity fixture in `blockGraph.ts` pits two tiers that do not outrank
20
+ * each other, so neither graph can be wrong about the ranking in the way the
21
+ * live graph was. A fixture written alongside a ranking rule agrees with the
22
+ * rule by construction. The shipped pack does not.
23
+ *
24
+ * So every case here READS the collisions out of the store it is judging. There
25
+ * is no list of block names in this file to fall out of date — add a tier
26
+ * upstream, rename one, retire a duplicate, and these assertions follow.
27
+ */
28
+
29
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
30
+ import { verbKey } from "../kernel/packs/uniqueness.js";
31
+ import { bootRuntime } from "../kernel/runtime/boot.js";
32
+ import type { PragmaRuntime } from "../kernel/runtime/types.js";
33
+ import type { VerbSpec } from "../kernel/spec/types.js";
34
+ import { TEST_FLAGS } from "../testing/helpers/projectCli.js";
35
+ import { declaredStories, storyModules } from "./distribution.js";
36
+
37
+ const story = declaredStories.get("block");
38
+ if (!story?.lookup)
39
+ throw new Error('pragma.conf.ts declares no "block" lookup');
40
+ const blockLookup = story.lookup;
41
+
42
+ const blockModule = storyModules.get("block");
43
+ if (!blockModule) throw new Error('pragma.conf.ts declares no "block" story');
44
+
45
+ const verbFor = (verb: string): VerbSpec =>
46
+ blockModule.verbs.find(
47
+ (v) => verbKey(v.path) === `block ${verb}`,
48
+ ) as VerbSpec;
49
+ const lookupVerb = verbFor("lookup");
50
+ const listVerb = verbFor("list");
51
+
52
+ /** The block classes the story addresses, read from the story rather than retyped. */
53
+ const TYPE_VALUES = (blockLookup.types ?? []).join(" ");
54
+
55
+ /**
56
+ * The scope declaration under test, read from the config it lives in.
57
+ *
58
+ * Reading `falloff` here rather than writing `0.2` is what keeps this suite a
59
+ * check on the RULE instead of on one tuning of it: change the number in
60
+ * `pragma.conf.ts` and these cases still assert that a shallower tier outranks
61
+ * a deeper one, which is the judgement that was signed off.
62
+ */
63
+ const scope = blockLookup.scopeWeight;
64
+ if (!scope) throw new Error('the "block" lookup declares no scopeWeight');
65
+
66
+ let rt: PragmaRuntime;
67
+ beforeAll(async () => {
68
+ rt = bootRuntime(TEST_FLAGS);
69
+ await rt.store.get();
70
+ }, 60_000);
71
+ afterAll(async () => {
72
+ (await rt.store.get()).store.dispose();
73
+ });
74
+
75
+ /** Rows from the shipped store, as plain records. */
76
+ async function rows(query: string): Promise<Record<string, string>[]> {
77
+ const result = await rt.query.sparql(query);
78
+ return result.type === "select"
79
+ ? (result.bindings as Record<string, string>[])
80
+ : [];
81
+ }
82
+
83
+ /** One `block lookup <name>`: the IRI it answered with, and the ones it did not. */
84
+ async function lookup(
85
+ name: string,
86
+ ): Promise<{ chosen: string; others: string[] }> {
87
+ const out = (await lookupVerb.run({ name: [name] }, rt)) as {
88
+ results: { uri?: string }[];
89
+ };
90
+ // Every entity the name reached, best first. `chosen` is simply the head —
91
+ // the ranking orders the answer now, it does not select it.
92
+ const [chosen, ...others] = out.results.map((entity) => String(entity.uri));
93
+ return { chosen: String(chosen), others };
94
+ }
95
+
96
+ /**
97
+ * Every block name the shipped graph gives to more than one block, with each
98
+ * block's IRI, its tier's declared name and whether it is a subcomponent.
99
+ *
100
+ * Read with the SAME `LCASE` the resolve filters by, so a pair that collides
101
+ * only for a lookup (`Button` vs `button`) is in this set too.
102
+ */
103
+ async function collisions(): Promise<
104
+ Map<string, { uri: string; tier: string; part: boolean }[]>
105
+ > {
106
+ const found = await rows(
107
+ [
108
+ "SELECT ?uri ?key ?tierName ?part WHERE {",
109
+ ` VALUES ?class { ${TYPE_VALUES} }`,
110
+ " ?uri a ?class .",
111
+ ` ?uri ${blockLookup.by} ?name .`,
112
+ ` OPTIONAL { ?uri ${scope.via} ?tier . ?tier ${scope.by} ?tn . }`,
113
+ " BIND(LCASE(STR(?name)) AS ?key)",
114
+ ' BIND(COALESCE(?tn, "") AS ?tierName)',
115
+ " BIND(EXISTS { ?uri a ds:Subcomponent } AS ?part)",
116
+ "}",
117
+ ].join("\n"),
118
+ );
119
+ const byName = new Map<
120
+ string,
121
+ { uri: string; tier: string; part: boolean }[]
122
+ >();
123
+ for (const row of found) {
124
+ const key = String(row.key);
125
+ const entry = {
126
+ uri: String(row.uri),
127
+ tier: String(row.tierName ?? ""),
128
+ part: String(row.part) === "true",
129
+ };
130
+ const seen = byName.get(key);
131
+ if (seen) {
132
+ if (!seen.some((e) => e.uri === entry.uri)) seen.push(entry);
133
+ } else byName.set(key, [entry]);
134
+ }
135
+ return new Map([...byName].filter(([, entries]) => entries.length > 1));
136
+ }
137
+
138
+ /** A tier's depth: the separator count in its DECLARED name, +1. */
139
+ const depthOf = (tierName: string): number =>
140
+ tierName === "" ? 1 : tierName.split("/").length;
141
+
142
+ describe("a shared block name reaches every block that carries it (PROTECTED)", () => {
143
+ it("finds shared names in the shipped graph at all", async () => {
144
+ // The guard on every other case in this file: if the graph ever stops
145
+ // sharing names, these assertions become vacuous and must be told to go.
146
+ expect((await collisions()).size).toBeGreaterThan(1);
147
+ });
148
+
149
+ it("answers `button` with the GLOBAL Button, and names the other", async () => {
150
+ // The reported defect, exactly as reported. Two components of equal kind,
151
+ // separated by their tiers alone: Global is depth 1, Apps/Launchpad depth 2.
152
+ const out = await lookup("button");
153
+ expect(out.chosen).toBe("https://ds.canonical.com/global.component.button");
154
+ expect(out.others).toEqual([
155
+ "https://ds.canonical.com/apps_launchpad.component.button",
156
+ ]);
157
+ });
158
+
159
+ it("answers `TextInput` with the LAUNCHPAD one — a whole block beats a part", async () => {
160
+ // The case that stops the plausible wrong fix. A rule of "global wins", or
161
+ // a tier tiebreak ABOVE the type weight, would answer with the global
162
+ // SUBcomponent here. As a product it does not: 0.6 × 1 < 1 × 0.8. The
163
+ // editorial rule that a subcomponent is a PART of a block, never the block
164
+ // someone means, survives the tier ranking being added on top of it.
165
+ const out = await lookup("TextInput");
166
+ expect(out.chosen).toBe(
167
+ "https://ds.canonical.com/apps_launchpad.component.text_input",
168
+ );
169
+ expect(out.others).toEqual([
170
+ "https://ds.canonical.com/global.subcomponent.text_input",
171
+ ]);
172
+ });
173
+
174
+ it("answers a name shared by two EQUALLY deep tiers deterministically", async () => {
175
+ // `ThemeSwitcher` is Apps/Anbox against Apps/WorkplaceEngineering — same
176
+ // depth, so the ranking has nothing to say and the total order's final key
177
+ // decides. This is the residual the ranking does NOT resolve, asserted as
178
+ // such: the claim is not that the right one wins, it is that the same one
179
+ // wins every time and the other is never left unmentioned.
180
+ const runs = await Promise.all([
181
+ lookup("ThemeSwitcher"),
182
+ lookup("themeswitcher"),
183
+ lookup("ThemeSwitcher"),
184
+ ]);
185
+ for (const out of runs) {
186
+ expect(out.chosen).toBe(runs[0]?.chosen);
187
+ expect(out.others).toEqual(runs[0]?.others);
188
+ expect(out.others).toHaveLength(1);
189
+ // Equal rank, so the order's final key decides — the same key, the same
190
+ // way, as everywhere else in it.
191
+ expect([out.chosen, ...out.others].sort()).toEqual([
192
+ out.chosen,
193
+ ...out.others,
194
+ ]);
195
+ }
196
+ });
197
+
198
+ it("never leaves a block a shared name reaches unmentioned", async () => {
199
+ // Swept over the whole collision set, not a chosen few: the silent discard
200
+ // is the defect, and it was invisible precisely because it looked like a
201
+ // normal answer. Every block the name reaches is either the answer or named
202
+ // beside it — and named as an IRI, the one address that reaches it.
203
+ for (const [name, entries] of await collisions()) {
204
+ const out = await lookup(name);
205
+ expect(
206
+ [out.chosen, ...out.others].sort(),
207
+ `block lookup ${name}`,
208
+ ).toEqual(entries.map((entry) => entry.uri).sort());
209
+ }
210
+ });
211
+
212
+ it("ranks the winner's tier no worse than every loser's", async () => {
213
+ // The invariant, COMPUTED from the pack rather than listed. It holds for
214
+ // every shared name at once and cannot be satisfied by hard-coding an
215
+ // answer, so it survives a new tier, a renamed one, and a new collision.
216
+ //
217
+ // Stated over WHOLE BLOCKS only. A subcomponent is deliberately allowed to
218
+ // lose to a shallower-ranked component AND to a deeper-ranked one — that is
219
+ // the product, and `TextInput` above is the case that pins it.
220
+ for (const [name, entries] of await collisions()) {
221
+ const blocks = entries.filter((entry) => !entry.part);
222
+ if (blocks.length < 2) continue;
223
+ const { chosen } = await lookup(name);
224
+ const winner = blocks.find((entry) => entry.uri === chosen);
225
+ if (!winner) continue;
226
+ for (const loser of blocks) {
227
+ expect(
228
+ depthOf(winner.tier),
229
+ `block lookup ${name} led with ${winner.uri} (${winner.tier}) over ${loser.uri} (${loser.tier})`,
230
+ ).toBeLessThanOrEqual(depthOf(loser.tier));
231
+ }
232
+ }
233
+ });
234
+ });
235
+
236
+ describe("`block list` orders its ties totally (PROTECTED)", () => {
237
+ /** The entity IRIs of one `block list` run, in the order it printed them. */
238
+ async function listOrder(): Promise<string[]> {
239
+ const data = (await listVerb.run({}, rt)) as { uri?: string }[];
240
+ return data.map((row) => String(row.uri));
241
+ }
242
+
243
+ it("repeats the same order across runs", async () => {
244
+ // The half that looks like the whole defect and is not: across runs the
245
+ // store's scan order can change (a repack, another machine).
246
+ const [first, second] = await Promise.all([listOrder(), listOrder()]);
247
+ expect(first).toEqual(second);
248
+ expect(first.length).toBeGreaterThan(1);
249
+ });
250
+
251
+ it("orders EVERY tie the same way, within one run", async () => {
252
+ // The half that was actually broken. In ONE output `Button` came
253
+ // launchpad-first while `CheckboxInput` came global-first — not flakiness
254
+ // between runs, an inconsistency inside a single answer, because SPARQL
255
+ // says nothing about tied rows and the query's ORDER BY ended at `?name`.
256
+ //
257
+ // Asserted as a RULE over every shared name rather than on the two that
258
+ // happened to disagree: shallower tier leads, and equal depths fall back to
259
+ // a key that cannot tie.
260
+ const order = await listOrder();
261
+ const position = new Map(order.map((uri, index) => [uri, index]));
262
+ const shared = await collisions();
263
+ expect(shared.size).toBeGreaterThan(1);
264
+ for (const [name, entries] of shared) {
265
+ const group = [...entries].sort(
266
+ (a, b) => (position.get(a.uri) ?? -1) - (position.get(b.uri) ?? -1),
267
+ );
268
+ expect(
269
+ group.map((entry) => position.get(entry.uri)),
270
+ `block list rows for ${name}`,
271
+ ).not.toContain(undefined);
272
+ for (const [index, entry] of group.entries()) {
273
+ const next = group[index + 1];
274
+ if (!next) continue;
275
+ const keys = [
276
+ [depthOf(entry.tier), entry.uri],
277
+ [depthOf(next.tier), next.uri],
278
+ ] as const;
279
+ expect(
280
+ keys[0][0] < keys[1][0] ||
281
+ (keys[0][0] === keys[1][0] && keys[0][1] < keys[1][1]),
282
+ `block list printed ${entry.uri} (${entry.tier}) before ${next.uri} (${next.tier}) for "${name}"`,
283
+ ).toBe(true);
284
+ }
285
+ }
286
+ });
287
+
288
+ it("keeps every shared name's rows adjacent, one per block", async () => {
289
+ // The ordering above would also be satisfied by a list that lost a row, so
290
+ // the population is checked with it: the tie-break decides the order of the
291
+ // rows, never how many there are.
292
+ const order = await listOrder();
293
+ for (const [name, entries] of await collisions()) {
294
+ const present = entries.filter((entry) => order.includes(entry.uri));
295
+ expect(present, `block list rows for ${name}`).toHaveLength(
296
+ entries.length,
297
+ );
298
+ }
299
+ });
300
+ });
@@ -213,7 +213,7 @@ export const TOOL_HINTS: Record<string, ToolHint> = {
213
213
  create_application: {
214
214
  category: "write",
215
215
  use_when:
216
- "Scaffolding a new React application with SSR, routing, and optional Relay",
216
+ "Scaffolding a new React application with routing, optional Relay, and either server-side rendering or a client-only SPA",
217
217
  },
218
218
  setup: {
219
219
  category: "write",
@@ -66,6 +66,5 @@ export const colophonFormatters: Formatters<ColophonData> = {
66
66
 
67
67
  // Zero sections: the dispatcher routes this to stderr (exit 0) so plain
68
68
  // stdout stays empty; llm/json keep their own empty shapes.
69
- emptyNotice: (data) =>
70
- data.sections.length === 0 ? EMPTY_NOTICE : undefined,
69
+ notice: (data) => (data.sections.length === 0 ? EMPTY_NOTICE : undefined),
71
70
  };
@@ -146,14 +146,14 @@ describe("colophon — the configured domain's story, and only that", () => {
146
146
  // the empty state is pinned here — bare stdout, guidance on stderr.
147
147
  const empty: ColophonData = { sections: [] };
148
148
  expect(colophonFormatters.plain(empty)).toBe("");
149
- const notice = colophonFormatters.emptyNotice?.(empty);
149
+ const notice = colophonFormatters.notice?.(empty);
150
150
  expect(notice).toContain("No colophon declared");
151
151
  expect(notice).toContain("sources update");
152
152
  // llm returns before the dispatcher's stderr routing, so it says the same
153
153
  // thing in its own body rather than emitting nothing.
154
154
  expect(colophonFormatters.llm(empty)).toContain("No colophon declared");
155
155
  // A populated payload has nothing to be calm about.
156
- expect(colophonFormatters.emptyNotice?.(FIXTURE)).toBeUndefined();
156
+ expect(colophonFormatters.notice?.(FIXTURE)).toBeUndefined();
157
157
  });
158
158
  });
159
159
 
@@ -36,6 +36,7 @@ describe("config show payload", () => {
36
36
  ).toEqual([
37
37
  "@canonical/design-system",
38
38
  "@canonical/anatomy-dsl",
39
+ "@canonical/token-ontology",
39
40
  "@canonical/code-standards",
40
41
  "@canonical/ds-implementations",
41
42
  ]);
@@ -15,7 +15,8 @@
15
15
  * `create` verbs take. No new package edge: pragma already depends on
16
16
  * summon-core, and never on the summon bin.
17
17
  *
18
- * Five generators are covered: component × {react, svelte, lit}, package, and
18
+ * Five generators are covered across seven fixtures: component × {react, svelte, lit},
19
+ * package, and
19
20
  * application. A cross-binary subprocess guard lives in byteEquality.subprocess.test.ts.
20
21
  */
21
22
 
@@ -175,6 +176,32 @@ const cases: Case[] = [
175
176
  runInstall: false,
176
177
  },
177
178
  },
179
+ {
180
+ fixture: "application-spa",
181
+ kind: "application",
182
+ framework: "",
183
+ params: {
184
+ appPath: "my-app",
185
+ forms: true,
186
+ intl: false,
187
+ relay: false,
188
+ rendering: "spa",
189
+ runInstall: false,
190
+ },
191
+ },
192
+ {
193
+ fixture: "application-spa-all",
194
+ kind: "application",
195
+ framework: "",
196
+ params: {
197
+ appPath: "my-app",
198
+ forms: true,
199
+ intl: true,
200
+ relay: true,
201
+ rendering: "spa",
202
+ runInstall: false,
203
+ },
204
+ },
178
205
  ];
179
206
 
180
207
  describe("byte-equality goldens — pragma create \u2261 the conformance reference (PROTECTED)", () => {
@@ -688,6 +688,7 @@ describe("projection fidelity — the committed surface IS the live generators (
688
688
  "appPath",
689
689
  "forms",
690
690
  "intl",
691
+ "rendering",
691
692
  "relay",
692
693
  "runInstall",
693
694
  ]);