@canonical/pragma-cli 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/README.md +67 -15
  2. package/dist/pragma +0 -0
  3. package/package.json +13 -13
  4. package/src/config/defaultConfig.test.ts +33 -0
  5. package/src/config/defaultConfig.ts +59 -0
  6. package/src/config/defaults.json +17 -0
  7. package/src/config/findProjectConfigPath.test.ts +53 -0
  8. package/src/config/findProjectConfigPath.ts +36 -0
  9. package/src/config/index.ts +17 -2
  10. package/src/config/mergeConfigFileUpdate.ts +58 -0
  11. package/src/config/parseConfigValues.ts +215 -0
  12. package/src/config/readConfig.test.ts +104 -13
  13. package/src/config/readConfig.ts +12 -153
  14. package/src/config/readConfigLayers.test.ts +107 -0
  15. package/src/config/readConfigLayers.ts +115 -0
  16. package/src/config/resolveGlobalConfigPath.ts +13 -0
  17. package/src/config/resolveWriteConfigPath.test.ts +53 -0
  18. package/src/config/resolveWriteConfigPath.ts +31 -0
  19. package/src/config/types.ts +76 -1
  20. package/src/config/writeConfig.ts +25 -55
  21. package/src/domains/block/commands/list.ts +5 -90
  22. package/src/domains/block/commands/lookup.ts +8 -167
  23. package/src/domains/block/mcp/specs.ts +16 -196
  24. package/src/domains/block/orchestration/resolveBlockLookup.ts +2 -1
  25. package/src/domains/block/stories.test.ts +28 -0
  26. package/src/domains/block/stories.ts +254 -0
  27. package/src/domains/config/commands/channel.test.ts +14 -3
  28. package/src/domains/config/commands/channel.ts +11 -7
  29. package/src/domains/config/commands/configScope.ts +46 -0
  30. package/src/domains/config/commands/framework.test.ts +18 -1
  31. package/src/domains/config/commands/framework.ts +13 -7
  32. package/src/domains/config/commands/show.ts +5 -45
  33. package/src/domains/config/commands/tier.test.ts +13 -2
  34. package/src/domains/config/commands/tier.ts +11 -7
  35. package/src/domains/config/commands/trace.test.ts +19 -2
  36. package/src/domains/config/commands/trace.ts +6 -3
  37. package/src/domains/config/formatters/channel.test.ts +20 -6
  38. package/src/domains/config/formatters/channel.ts +14 -11
  39. package/src/domains/config/formatters/framework.ts +14 -11
  40. package/src/domains/config/formatters/show.test.ts +27 -0
  41. package/src/domains/config/formatters/show.ts +27 -4
  42. package/src/domains/config/formatters/tier.test.ts +26 -10
  43. package/src/domains/config/formatters/tier.ts +19 -11
  44. package/src/domains/config/formatters/trace.ts +7 -7
  45. package/src/domains/config/mcp/specs.ts +33 -53
  46. package/src/domains/config/operations/show.test.ts +18 -0
  47. package/src/domains/config/operations/show.ts +7 -1
  48. package/src/domains/config/operations/types.ts +7 -0
  49. package/src/domains/config/stories.ts +42 -0
  50. package/src/domains/config/tasks/index.ts +1 -0
  51. package/src/domains/config/tasks/setChannel.ts +13 -28
  52. package/src/domains/config/tasks/setFramework.ts +11 -30
  53. package/src/domains/config/tasks/setTier.ts +10 -29
  54. package/src/domains/config/tasks/setTrace.ts +10 -28
  55. package/src/domains/config/tasks/writeConfigField.ts +41 -0
  56. package/src/domains/create/commands/component.ts +5 -5
  57. package/src/domains/doctor/operations/checks/checkConfigFile.ts +19 -5
  58. package/src/domains/doctor/operations/checks/checkMcpCommands.ts +130 -0
  59. package/src/domains/doctor/operations/checks/checkMcpConfigured.ts +1 -1
  60. package/src/domains/doctor/operations/checks/checkPackageRefs.ts +2 -14
  61. package/src/domains/doctor/operations/checks/checkSkillsSymlinked.ts +1 -1
  62. package/src/domains/doctor/operations/checks/index.ts +1 -0
  63. package/src/domains/doctor/operations/checks.test.ts +158 -7
  64. package/src/domains/doctor/operations/runChecks.test.ts +15 -6
  65. package/src/domains/doctor/operations/runChecks.ts +2 -0
  66. package/src/domains/graph/helpers/resolveUri.test.ts +50 -0
  67. package/src/domains/graph/helpers/resolveUri.ts +28 -21
  68. package/src/domains/graphql/commands/build.test.ts +5 -3
  69. package/src/domains/graphql/commands/build.ts +8 -16
  70. package/src/domains/graphql/commands/check.test.ts +5 -3
  71. package/src/domains/graphql/commands/check.ts +7 -13
  72. package/src/domains/graphql/commands/index.ts +1 -0
  73. package/src/domains/graphql/commands/serve.test.ts +40 -0
  74. package/src/domains/graphql/commands/serve.ts +131 -0
  75. package/src/domains/graphql/constants.ts +10 -0
  76. package/src/domains/graphql/helpers/gatherSources.test.ts +92 -0
  77. package/src/domains/graphql/helpers/gatherSources.ts +58 -0
  78. package/src/domains/graphql/helpers/index.ts +2 -0
  79. package/src/domains/graphql/helpers/routeGraphql.test.ts +43 -0
  80. package/src/domains/graphql/helpers/routeGraphql.ts +28 -0
  81. package/src/domains/graphql/index.ts +9 -6
  82. package/src/domains/graphql/operations/compileSchema.test.ts +21 -45
  83. package/src/domains/graphql/operations/compileSchema.ts +23 -25
  84. package/src/domains/graphql/operations/createSchemaServer.test.ts +50 -0
  85. package/src/domains/graphql/operations/createSchemaServer.ts +75 -0
  86. package/src/domains/graphql/operations/index.ts +5 -0
  87. package/src/domains/info/operations/collectInfo.test.ts +14 -3
  88. package/src/domains/info/operations/collectInfo.ts +12 -15
  89. package/src/domains/llm/capabilities.test.ts +4 -4
  90. package/src/domains/llm/data/toolCatalog.ts +6 -0
  91. package/src/domains/modifier/commands/list.ts +5 -38
  92. package/src/domains/modifier/commands/lookup.ts +8 -79
  93. package/src/domains/modifier/mcp/specs.ts +15 -94
  94. package/src/domains/modifier/orchestration/resolveModifierLookup.ts +2 -1
  95. package/src/domains/modifier/stories.test.ts +20 -0
  96. package/src/domains/modifier/stories.ts +77 -0
  97. package/src/domains/ontology/commands/list.ts +5 -58
  98. package/src/domains/ontology/commands/show.ts +6 -58
  99. package/src/domains/ontology/formatters/show.ts +7 -1
  100. package/src/domains/ontology/mcp/specs.ts +7 -84
  101. package/src/domains/ontology/stories.test.ts +36 -0
  102. package/src/domains/ontology/stories.ts +108 -0
  103. package/src/domains/refs/operations/updateRefs.ts +2 -37
  104. package/src/domains/setup/commands/skills.test.ts +6 -2
  105. package/src/domains/setup/commands/skills.ts +6 -2
  106. package/src/domains/setup/helpers/runSetupTask.test.ts +148 -150
  107. package/src/domains/setup/helpers/runSetupTask.ts +62 -70
  108. package/src/domains/setup/operations/setupMcp.test.ts +6 -1
  109. package/src/domains/shared/bootStore.ts +6 -3
  110. package/src/domains/shared/compactUri.test.ts +11 -0
  111. package/src/domains/shared/compactUri.ts +10 -5
  112. package/src/domains/shared/context.ts +2 -2
  113. package/src/domains/shared/contracts.ts +11 -0
  114. package/src/domains/shared/createInteractivePromptSession.test.ts +439 -0
  115. package/src/domains/shared/createInteractivePromptSession.ts +236 -0
  116. package/src/domains/shared/loaders/bundledLoader.test.ts +248 -0
  117. package/src/domains/shared/loaders/bundledLoader.ts +110 -12
  118. package/src/domains/shared/loaders/gitLoader.ts +2 -2
  119. package/src/domains/shared/loaders/index.ts +4 -1
  120. package/src/domains/shared/loaders/localLoader.ts +18 -4
  121. package/src/domains/shared/loaders/readPackageDir.ts +36 -1
  122. package/src/domains/shared/loaders/storyAssetsPlugin.test.ts +84 -0
  123. package/src/domains/shared/loaders/storyAssetsPlugin.ts +46 -0
  124. package/src/domains/shared/lookupMany.test.ts +120 -0
  125. package/src/domains/shared/lookupMany.ts +34 -2
  126. package/src/domains/shared/mergeAndParseRefs.test.ts +89 -0
  127. package/src/domains/shared/mergeAndParseRefs.ts +44 -0
  128. package/src/domains/shared/packages.ts +10 -17
  129. package/src/domains/shared/resolveConfiguredGraphs.test.ts +27 -0
  130. package/src/domains/shared/resolveConfiguredGraphs.ts +41 -0
  131. package/src/domains/shared/runtime.ts +4 -63
  132. package/src/domains/shared/semanticPackage.ts +17 -1
  133. package/src/domains/shared/stories/compileLookupCommand.test.ts +145 -0
  134. package/src/domains/shared/stories/compileLookupCommand.ts +100 -0
  135. package/src/domains/shared/stories/compileLookupTool.test.ts +105 -0
  136. package/src/domains/shared/stories/compileLookupTool.ts +96 -0
  137. package/src/domains/shared/stories/compileReadCommand.test.ts +121 -0
  138. package/src/domains/shared/stories/compileReadCommand.ts +48 -0
  139. package/src/domains/shared/stories/compileReadTool.test.ts +64 -0
  140. package/src/domains/shared/stories/compileReadTool.ts +38 -0
  141. package/src/domains/shared/stories/condense.test.ts +18 -0
  142. package/src/domains/shared/stories/condense.ts +15 -0
  143. package/src/domains/shared/stories/index.ts +20 -0
  144. package/src/domains/shared/stories/normalizeNames.test.ts +20 -0
  145. package/src/domains/shared/stories/normalizeNames.ts +22 -0
  146. package/src/domains/shared/stories/pack/applyPackFilters.test.ts +102 -0
  147. package/src/domains/shared/stories/pack/applyPackFilters.ts +76 -0
  148. package/src/domains/shared/stories/pack/buildLookupQuery.test.ts +51 -0
  149. package/src/domains/shared/stories/pack/buildLookupQuery.ts +80 -0
  150. package/src/domains/shared/stories/pack/collectPackStories.test.ts +108 -0
  151. package/src/domains/shared/stories/pack/collectPackStories.ts +76 -0
  152. package/src/domains/shared/stories/pack/compilePackCommands.ts +40 -0
  153. package/src/domains/shared/stories/pack/compilePackStories.test.ts +146 -0
  154. package/src/domains/shared/stories/pack/compilePackStories.ts +253 -0
  155. package/src/domains/shared/stories/pack/compilePackToolSpecs.ts +43 -0
  156. package/src/domains/shared/stories/pack/index.ts +28 -0
  157. package/src/domains/shared/stories/pack/runSelectQuery.ts +31 -0
  158. package/src/domains/shared/stories/pack/standardParity.test.ts +308 -0
  159. package/src/domains/shared/stories/pack/types.ts +104 -0
  160. package/src/domains/shared/stories/pack/validateStoryPackDefinition.test.ts +240 -0
  161. package/src/domains/shared/stories/pack/validateStoryPackDefinition.ts +408 -0
  162. package/src/domains/shared/stories/projectCliParameters.test.ts +59 -0
  163. package/src/domains/shared/stories/projectCliParameters.ts +57 -0
  164. package/src/domains/shared/stories/projectToolParams.test.ts +50 -0
  165. package/src/domains/shared/stories/projectToolParams.ts +32 -0
  166. package/src/domains/shared/stories/requirePragmaContext.test.ts +23 -0
  167. package/src/domains/shared/stories/requirePragmaContext.ts +23 -0
  168. package/src/domains/shared/stories/resolveLookupDetailed.test.ts +57 -0
  169. package/src/domains/shared/stories/resolveLookupDetailed.ts +27 -0
  170. package/src/domains/shared/stories/types.ts +171 -0
  171. package/src/domains/shared/suggestions/expandGlob.ts +1 -0
  172. package/src/domains/shared/suggestions/suggestNames.ts +43 -10
  173. package/src/domains/skill/commands/index.ts +3 -1
  174. package/src/domains/skill/commands/list.test.ts +6 -2
  175. package/src/domains/skill/commands/list.ts +5 -63
  176. package/src/domains/skill/commands/lookup.test.ts +118 -0
  177. package/src/domains/skill/commands/lookup.ts +14 -0
  178. package/src/domains/skill/formatters/index.ts +1 -0
  179. package/src/domains/skill/formatters/lookup.test.ts +46 -0
  180. package/src/domains/skill/formatters/lookup.ts +57 -0
  181. package/src/domains/skill/helpers/resolveSkillSources.ts +0 -1
  182. package/src/domains/skill/index.ts +7 -3
  183. package/src/domains/skill/mcp/specs.ts +10 -29
  184. package/src/domains/skill/operations/discover.test.ts +61 -30
  185. package/src/domains/skill/operations/discover.ts +28 -37
  186. package/src/domains/skill/operations/index.ts +2 -1
  187. package/src/domains/skill/operations/list.test.ts +15 -7
  188. package/src/domains/skill/operations/lookup.test.ts +91 -0
  189. package/src/domains/skill/operations/lookup.ts +70 -0
  190. package/src/domains/skill/stories.test.ts +43 -0
  191. package/src/domains/skill/stories.ts +103 -0
  192. package/src/domains/skill/types.ts +8 -0
  193. package/src/domains/standard/commands/list.ts +5 -84
  194. package/src/domains/standard/commands/lookup.ts +8 -93
  195. package/src/domains/standard/mcp/specs.ts +16 -171
  196. package/src/domains/standard/orchestration/resolveStandardLookup.ts +2 -1
  197. package/src/domains/standard/stories.test.ts +24 -0
  198. package/src/domains/standard/stories.ts +151 -0
  199. package/src/domains/tier/commands/list.ts +5 -55
  200. package/src/domains/tier/mcp/specs.ts +5 -31
  201. package/src/domains/tier/stories.test.ts +22 -0
  202. package/src/domains/tier/stories.ts +52 -0
  203. package/src/domains/token/mcp/specs.ts +5 -1
  204. package/src/domains/token/orchestration/resolveTokenLookup.ts +2 -1
  205. package/src/domains/trace/tracePlugin.test.ts +1 -1
  206. package/src/mcp/registerResources.test.ts +382 -74
  207. package/src/mcp/registerResources.ts +5 -348
  208. package/src/mcp/resources/buildGraphIndex.ts +167 -0
  209. package/src/mcp/resources/buildResourceList.test.ts +97 -0
  210. package/src/mcp/resources/buildResourceList.ts +176 -0
  211. package/src/mcp/resources/classifyEntity.test.ts +40 -0
  212. package/src/mcp/resources/classifyEntity.ts +27 -0
  213. package/src/mcp/resources/constants.ts +105 -0
  214. package/src/mcp/resources/displayPredicates.ts +62 -0
  215. package/src/mcp/resources/pickFirstValue.test.ts +29 -0
  216. package/src/mcp/resources/pickFirstValue.ts +26 -0
  217. package/src/mcp/resources/pickPrimaryType.test.ts +41 -0
  218. package/src/mcp/resources/pickPrimaryType.ts +26 -0
  219. package/src/mcp/resources/rankUriCompletions.test.ts +52 -0
  220. package/src/mcp/resources/rankUriCompletions.ts +64 -0
  221. package/src/mcp/resources/readEntity.ts +169 -0
  222. package/src/mcp/resources/registerResources.ts +126 -0
  223. package/src/mcp/resources/types.ts +128 -0
  224. package/src/mcp/tools/index.ts +9 -0
  225. package/src/mcp/tools/registerTools.test.ts +65 -8
  226. package/src/mcp/types.ts +4 -0
  227. package/src/pipeline/collectCommands.ts +8 -1
  228. package/src/pipeline/runCli.ts +40 -6
  229. package/src/testing/createTestMcpClient.ts +4 -1
  230. package/src/testing/index.ts +10 -0
  231. package/src/testing/integration/__snapshots__/executor-golden.test.ts.snap +1207 -0
  232. package/src/testing/integration/cross-binary-byte-equality.test.ts +129 -0
  233. package/src/testing/integration/executor-execution.test.ts +101 -0
  234. package/src/testing/integration/executor-golden.test.ts +145 -0
  235. package/src/testing/integration/mcp-surface.test.ts +1 -1
  236. package/src/testing/integration/parity.test.ts +17 -12
  237. package/src/testing/regression/0001-skill-discovery-granularity.test.ts +71 -0
  238. package/src/testing/setupXdgIsolation.ts +15 -0
  239. package/src/testing/standardParityFixtures.ts +92 -0
  240. package/src/testing/storyFixtures.ts +63 -0
  241. package/src/pipeline/runInteractiveCommand.ts +0 -198
@@ -0,0 +1,248 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { PackageRef } from "../../refs/operations/parseRef.js";
3
+ import createBundledLoader, {
4
+ resetBundledLoaderCache,
5
+ } from "./bundledLoader.js";
6
+
7
+ const ref: PackageRef = { kind: "npm", pkg: "@canonical/design-system" };
8
+
9
+ /** Minimal stand-in for a Bun embedded-file blob. */
10
+ interface FakeBlob {
11
+ readonly name: string;
12
+ text(): Promise<string>;
13
+ }
14
+
15
+ const blob = (name: string, content: string): FakeBlob => ({
16
+ name,
17
+ text: async () => content,
18
+ });
19
+
20
+ const corruptBlob = (name: string): FakeBlob => ({
21
+ name,
22
+ text: async () => {
23
+ throw new Error("embedded blob unreadable");
24
+ },
25
+ });
26
+
27
+ const TTL = "@prefix ds: <https://example.test/ds#> .";
28
+
29
+ function stubEmbeddedFiles(blobs: readonly FakeBlob[]): void {
30
+ (globalThis as { Bun?: unknown }).Bun = { embeddedFiles: blobs };
31
+ }
32
+
33
+ const hadBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
34
+ const originalBun = (globalThis as { Bun?: unknown }).Bun;
35
+
36
+ let stderrSpy: ReturnType<typeof vi.spyOn>;
37
+
38
+ beforeEach(() => {
39
+ resetBundledLoaderCache();
40
+ stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
41
+ });
42
+
43
+ afterEach(() => {
44
+ resetBundledLoaderCache();
45
+ if (hadBun) {
46
+ (globalThis as { Bun?: unknown }).Bun = originalBun;
47
+ } else {
48
+ delete (globalThis as { Bun?: unknown }).Bun;
49
+ }
50
+ stderrSpy.mockRestore();
51
+ });
52
+
53
+ const stderrText = (): string =>
54
+ stderrSpy.mock.calls.map((call) => String(call[0])).join("");
55
+
56
+ describe("createBundledLoader", () => {
57
+ it("returns undefined outside a compiled Bun binary", async () => {
58
+ if (!hadBun) {
59
+ delete (globalThis as { Bun?: unknown }).Bun;
60
+ const resolved = await createBundledLoader().resolve(ref);
61
+ expect(resolved).toBeUndefined();
62
+ }
63
+ });
64
+
65
+ it("bundles all embedded TTL and reads the manifest version", async () => {
66
+ stubEmbeddedFiles([
67
+ blob("button-a1b2c3d4.ttl", TTL),
68
+ blob("package-a1b2c3d4.json", JSON.stringify({ version: "1.2.3" })),
69
+ ]);
70
+
71
+ const resolved = await createBundledLoader().resolve(ref);
72
+
73
+ expect(resolved).toBeDefined();
74
+ expect(resolved?.source).toBe("bundled");
75
+ expect(resolved?.version).toBe("1.2.3");
76
+ expect(resolved?.graphs).toEqual([
77
+ {
78
+ path: "(bundled)/button-a1b2c3d4.ttl",
79
+ content: TTL,
80
+ format: "turtle",
81
+ },
82
+ ]);
83
+ });
84
+
85
+ it("accepts a plain package.json basename", async () => {
86
+ stubEmbeddedFiles([
87
+ blob("button.ttl", TTL),
88
+ blob("package.json", JSON.stringify({ version: "2.0.0" })),
89
+ ]);
90
+
91
+ const resolved = await createBundledLoader().resolve(ref);
92
+ expect(resolved?.version).toBe("2.0.0");
93
+ });
94
+
95
+ it("warns and skips a corrupt TTL blob instead of silently dropping it", async () => {
96
+ stubEmbeddedFiles([
97
+ blob("good-a1b2c3d4.ttl", TTL),
98
+ corruptBlob("bad-a1b2c3d4.ttl"),
99
+ ]);
100
+
101
+ const resolved = await createBundledLoader().resolve(ref);
102
+
103
+ expect(resolved?.graphs).toHaveLength(1);
104
+ expect(stderrText()).toContain("bad-a1b2c3d4.ttl");
105
+ expect(stderrText()).toContain("embedded blob unreadable");
106
+ });
107
+
108
+ it("serves embedded story JSON as story-file entries", async () => {
109
+ stubEmbeddedFiles([
110
+ blob("button-a1b2c3d4.ttl", TTL),
111
+ blob("package-a1b2c3d4.json", JSON.stringify({ version: "1.2.3" })),
112
+ blob("colors-fyskc7pf.json", JSON.stringify({ noun: "color" })),
113
+ ]);
114
+
115
+ const resolved = await createBundledLoader().resolve(ref);
116
+
117
+ expect(resolved?.stories).toEqual([
118
+ {
119
+ path: "(bundled)/colors-fyskc7pf.json",
120
+ definition: { noun: "color" },
121
+ },
122
+ ]);
123
+ expect(resolved?.version).toBe("1.2.3");
124
+ });
125
+
126
+ it("does not serve package manifests as stories", async () => {
127
+ stubEmbeddedFiles([
128
+ blob("button.ttl", TTL),
129
+ blob("package.json", JSON.stringify({ version: "2.0.0" })),
130
+ blob("package-deadbeef.json", JSON.stringify({ version: "3.0.0" })),
131
+ ]);
132
+
133
+ const resolved = await createBundledLoader().resolve(ref);
134
+
135
+ expect(resolved?.stories).toEqual([]);
136
+ });
137
+
138
+ it("warns and skips a story blob with invalid JSON instead of crashing", async () => {
139
+ stubEmbeddedFiles([
140
+ blob("button.ttl", TTL),
141
+ blob("good-a1b2c3d4.json", JSON.stringify({ noun: "color" })),
142
+ blob("broken-a1b2c3d4.json", "{ not json"),
143
+ ]);
144
+
145
+ const resolved = await createBundledLoader().resolve(ref);
146
+
147
+ expect(resolved?.stories).toHaveLength(1);
148
+ expect(resolved?.stories.at(0)?.definition).toEqual({ noun: "color" });
149
+ expect(stderrText()).toContain("broken-a1b2c3d4.json");
150
+ });
151
+
152
+ it("warns and skips an unreadable story blob", async () => {
153
+ stubEmbeddedFiles([
154
+ blob("button.ttl", TTL),
155
+ corruptBlob("story-a1b2c3d4.json"),
156
+ ]);
157
+
158
+ const resolved = await createBundledLoader().resolve(ref);
159
+
160
+ expect(resolved?.stories).toEqual([]);
161
+ expect(stderrText()).toContain("story-a1b2c3d4.json");
162
+ expect(stderrText()).toContain("embedded blob unreadable");
163
+ });
164
+
165
+ it("resolves a stories-only bundle with no TTL graphs", async () => {
166
+ stubEmbeddedFiles([
167
+ blob("colors-fyskc7pf.json", JSON.stringify({ noun: "color" })),
168
+ ]);
169
+
170
+ const resolved = await createBundledLoader().resolve(ref);
171
+
172
+ expect(resolved).toBeDefined();
173
+ expect(resolved?.graphs).toEqual([]);
174
+ expect(resolved?.stories).toHaveLength(1);
175
+ });
176
+
177
+ it("returns undefined when no TTL or story content is embedded", async () => {
178
+ stubEmbeddedFiles([
179
+ blob("template-a1b2c3d4.ejs", "<%= name %>"),
180
+ blob("skill-a1b2c3d4.md", "# Skill"),
181
+ ]);
182
+
183
+ const resolved = await createBundledLoader().resolve(ref);
184
+
185
+ expect(resolved).toBeUndefined();
186
+ });
187
+
188
+ it("warns on a corrupt package manifest and falls back to 0.0.0", async () => {
189
+ stubEmbeddedFiles([
190
+ blob("button.ttl", TTL),
191
+ blob("package-deadbeef.json", "{ not json"),
192
+ ]);
193
+
194
+ const resolved = await createBundledLoader().resolve(ref);
195
+
196
+ expect(resolved?.version).toBe("0.0.0");
197
+ expect(stderrText()).toContain("package-deadbeef.json");
198
+ });
199
+
200
+ it("rejects JSON blobs whose basename is not a package manifest", async () => {
201
+ stubEmbeddedFiles([
202
+ blob("button.ttl", TTL),
203
+ blob("mypackage.json", JSON.stringify({ version: "9.9.9" })),
204
+ blob("package-lock.json", JSON.stringify({ version: "8.8.8" })),
205
+ blob("packages.json", JSON.stringify({ version: "7.7.7" })),
206
+ ]);
207
+
208
+ const resolved = await createBundledLoader().resolve(ref);
209
+ expect(resolved?.version).toBe("0.0.0");
210
+ });
211
+
212
+ it("warns on an invalid semver version and keeps looking", async () => {
213
+ stubEmbeddedFiles([
214
+ blob("button.ttl", TTL),
215
+ blob("package-aaaaaaaa.json", JSON.stringify({ version: "not-semver" })),
216
+ blob("package-bbbbbbbb.json", JSON.stringify({ version: "3.4.5" })),
217
+ ]);
218
+
219
+ const resolved = await createBundledLoader().resolve(ref);
220
+
221
+ expect(resolved?.version).toBe("3.4.5");
222
+ expect(stderrText()).toContain('invalid semver "not-semver"');
223
+ });
224
+
225
+ it("caches the resolved package until the cache is reset", async () => {
226
+ stubEmbeddedFiles([
227
+ blob("button.ttl", TTL),
228
+ blob("package.json", JSON.stringify({ version: "1.0.0" })),
229
+ ]);
230
+
231
+ const first = await createBundledLoader().resolve(ref);
232
+ expect(first?.version).toBe("1.0.0");
233
+
234
+ // New fixture without reset — cached result still returned.
235
+ stubEmbeddedFiles([
236
+ blob("other.ttl", TTL),
237
+ blob("package.json", JSON.stringify({ version: "5.0.0" })),
238
+ ]);
239
+ const second = await createBundledLoader().resolve(ref);
240
+ expect(second).toBe(first);
241
+
242
+ // After reset the loader re-reads the embedded files.
243
+ resetBundledLoaderCache();
244
+ const third = await createBundledLoader().resolve(ref);
245
+ expect(third?.version).toBe("5.0.0");
246
+ expect(third).not.toBe(first);
247
+ });
248
+ });
@@ -1,23 +1,32 @@
1
1
  /**
2
2
  * Bundled package loader for compiled binaries.
3
3
  *
4
- * Resolves semantic content from `Bun.embeddedFiles` — TTL and skill
5
- * files embedded into the compiled binary via the build command.
4
+ * Resolves semantic content from `Bun.embeddedFiles` — the TTL graphs
5
+ * and story-pack JSON embedded into the compiled binary via the build
6
+ * command. Skills are not embedded; this loader reports `skills: []`.
6
7
  *
7
8
  * Blob names are hashed by bun (e.g. `button-a1b2c3d4.ttl`), losing
8
9
  * directory and package association. The loader therefore returns a
9
- * single SemanticPackage containing ALL embedded TTL content. RDF
10
- * stores deduplicate triples, so this is safe when combined with
11
- * higher-precedence loaders that resolve individual packages.
10
+ * single SemanticPackage containing ALL embedded TTL and story content,
11
+ * and the same cached package is returned for every ref this loader
12
+ * resolves.
13
+ * Note that repeated loads are NOT fully deduplicated by the store:
14
+ * Oxigraph applies set semantics to ground triples, but blank nodes are
15
+ * re-minted on every parse, so content loaded both here and by a
16
+ * higher-precedence loader can produce duplicated blank-node subgraphs.
17
+ * The orchestrator's first-loader-wins precedence (local > git >
18
+ * bundled) is what keeps individual packages from double-loading.
12
19
  *
13
20
  * @note Impure — reads embedded blobs.
14
21
  */
15
22
 
23
+ import { basename } from "node:path";
16
24
  import type { PackageRef } from "../../refs/operations/parseRef.js";
17
25
  import type {
18
26
  GraphContent,
19
27
  PackageLoader,
20
28
  SemanticPackage,
29
+ StoryFileEntry,
21
30
  } from "../semanticPackage.js";
22
31
 
23
32
  // ---------------------------------------------------------------------------
@@ -27,6 +36,45 @@ import type {
27
36
  /** Cached result — embedded content is immutable, no need to re-read. */
28
37
  let cached: SemanticPackage | undefined;
29
38
 
39
+ /**
40
+ * Reset the module-level cache. Test-only: lets suites exercise the loader
41
+ * against different `Bun.embeddedFiles` fixtures without module isolation.
42
+ */
43
+ export function resetBundledLoaderCache(): void {
44
+ cached = undefined;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Validation
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Embedded package manifest basename: exactly `package.json`, or
53
+ * `package-<hash>.json` where `<hash>` is bun's content hash suffix.
54
+ */
55
+ const PACKAGE_MANIFEST_PATTERN = /^package(?:-[0-9a-f]{6,64})?\.json$/i;
56
+
57
+ /**
58
+ * Semver 2.0.0 validation (semver.org's suggested pattern).
59
+ */
60
+ const SEMVER_PATTERN =
61
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
62
+
63
+ /**
64
+ * Whether a blob name is an embedded package manifest — strict basename
65
+ * match, so unrelated JSON blobs (e.g. `mypackage.json`) are not misread.
66
+ */
67
+ function isPackageManifest(name: string): boolean {
68
+ return PACKAGE_MANIFEST_PATTERN.test(basename(name));
69
+ }
70
+
71
+ /**
72
+ * Emit a loader warning on stderr (safe for both CLI and MCP stdio use).
73
+ */
74
+ function warn(message: string): void {
75
+ process.stderr.write(`Warning: bundled loader: ${message}\n`);
76
+ }
77
+
30
78
  // ---------------------------------------------------------------------------
31
79
  // Public API
32
80
  // ---------------------------------------------------------------------------
@@ -44,7 +92,8 @@ export default function createBundledLoader(): PackageLoader {
44
92
  if (!blobs?.length) return undefined;
45
93
 
46
94
  const graphs = await readEmbeddedGraphs(blobs);
47
- if (graphs.length === 0) return undefined;
95
+ const stories = await readEmbeddedStories(blobs);
96
+ if (graphs.length === 0 && stories.length === 0) return undefined;
48
97
 
49
98
  const version = await readEmbeddedVersion(blobs);
50
99
 
@@ -54,6 +103,7 @@ export default function createBundledLoader(): PackageLoader {
54
103
  source: "bundled",
55
104
  graphs,
56
105
  skills: [],
106
+ stories,
57
107
  };
58
108
 
59
109
  return cached;
@@ -80,12 +130,51 @@ async function readEmbeddedGraphs(
80
130
  content,
81
131
  format: "turtle",
82
132
  });
83
- } catch {}
133
+ } catch (error) {
134
+ const reason = error instanceof Error ? error.message : String(error);
135
+ warn(`skipping unreadable embedded graph "${blob.name}": ${reason}`);
136
+ }
84
137
  }
85
138
 
86
139
  return graphs;
87
140
  }
88
141
 
142
+ // ---------------------------------------------------------------------------
143
+ // Story reading
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /**
147
+ * Read story-pack definitions from embedded `.json` blobs.
148
+ *
149
+ * The build's story-assets plugin makes `stories/*.json` the only JSON
150
+ * embedded as file assets (see storyAssetsPlugin.ts), so every `.json`
151
+ * blob not named like a package manifest is a story file. Mirrors the
152
+ * local loader: a blob that cannot be read or parsed warns on stderr
153
+ * and is skipped — one bad file cannot break boot. Definitions are
154
+ * shape-validated later by the story-pack compiler.
155
+ */
156
+ async function readEmbeddedStories(
157
+ blobs: ReadonlyArray<Blob & { name: string }>,
158
+ ): Promise<StoryFileEntry[]> {
159
+ const stories: StoryFileEntry[] = [];
160
+
161
+ for (const blob of blobs) {
162
+ if (!blob.name.endsWith(".json") || isPackageManifest(blob.name)) continue;
163
+
164
+ try {
165
+ stories.push({
166
+ path: `(bundled)/${blob.name}`,
167
+ definition: JSON.parse(await blob.text()),
168
+ });
169
+ } catch (error) {
170
+ const reason = error instanceof Error ? error.message : String(error);
171
+ warn(`skipping invalid embedded story "${blob.name}": ${reason}`);
172
+ }
173
+ }
174
+
175
+ return stories;
176
+ }
177
+
89
178
  // ---------------------------------------------------------------------------
90
179
  // Version reading
91
180
  // ---------------------------------------------------------------------------
@@ -94,14 +183,23 @@ async function readEmbeddedVersion(
94
183
  blobs: ReadonlyArray<Blob & { name: string }>,
95
184
  ): Promise<string> {
96
185
  for (const blob of blobs) {
97
- if (!blob.name.includes("package") || !blob.name.endsWith(".json"))
98
- continue;
186
+ if (!isPackageManifest(blob.name)) continue;
99
187
 
100
188
  try {
101
189
  const text = await blob.text();
102
- const parsed = JSON.parse(text) as { version?: string };
103
- if (parsed.version) return parsed.version;
104
- } catch {}
190
+ const parsed = JSON.parse(text) as { version?: unknown };
191
+ if (typeof parsed.version !== "string") continue;
192
+ if (!SEMVER_PATTERN.test(parsed.version)) {
193
+ warn(
194
+ `ignoring invalid semver "${parsed.version}" in embedded manifest "${blob.name}"`,
195
+ );
196
+ continue;
197
+ }
198
+ return parsed.version;
199
+ } catch (error) {
200
+ const reason = error instanceof Error ? error.message : String(error);
201
+ warn(`skipping corrupt embedded manifest "${blob.name}": ${reason}`);
202
+ }
105
203
  }
106
204
 
107
205
  return "0.0.0";
@@ -24,8 +24,8 @@ export default function createGitLoader(): PackageLoader {
24
24
  const dir = gitCacheDir(ref.pkg, ref.ref);
25
25
  if (!existsSync(dir)) return undefined;
26
26
 
27
- const { version, graphs, skills } = readPackageDir(dir);
28
- return { name: ref.pkg, version, source: "git", graphs, skills };
27
+ const { version, graphs, skills, stories } = readPackageDir(dir);
28
+ return { name: ref.pkg, version, source: "git", graphs, skills, stories };
29
29
  },
30
30
  };
31
31
  }
@@ -1,6 +1,9 @@
1
1
  /** @module Semantic package loaders — local, git, and bundled resolution. */
2
2
 
3
- export { default as createBundledLoader } from "./bundledLoader.js";
3
+ export {
4
+ default as createBundledLoader,
5
+ resetBundledLoaderCache,
6
+ } from "./bundledLoader.js";
4
7
  export { default as createGitLoader } from "./gitLoader.js";
5
8
  export { default as createLocalLoader } from "./localLoader.js";
6
9
  export { default as readPackageDir } from "./readPackageDir.js";
@@ -28,16 +28,30 @@ export default function createLocalLoader(): PackageLoader {
28
28
  // file:// refs → direct path check first
29
29
  if (ref.kind === "file") {
30
30
  if (!existsSync(ref.path)) return undefined;
31
- const { version, graphs, skills } = readPackageDir(ref.path);
32
- return { name: ref.pkg, version, source: "local", graphs, skills };
31
+ const { version, graphs, skills, stories } = readPackageDir(ref.path);
32
+ return {
33
+ name: ref.pkg,
34
+ version,
35
+ source: "local",
36
+ graphs,
37
+ skills,
38
+ stories,
39
+ };
33
40
  }
34
41
 
35
42
  // All ref kinds → try node_modules walk
36
43
  const dir = findPackageDir(ref.pkg);
37
44
  if (!dir) return undefined;
38
45
 
39
- const { version, graphs, skills } = readPackageDir(dir);
40
- return { name: ref.pkg, version, source: "local", graphs, skills };
46
+ const { version, graphs, skills, stories } = readPackageDir(dir);
47
+ return {
48
+ name: ref.pkg,
49
+ version,
50
+ source: "local",
51
+ graphs,
52
+ skills,
53
+ stories,
54
+ };
41
55
  },
42
56
  };
43
57
  }
@@ -16,7 +16,11 @@ import {
16
16
  statSync,
17
17
  } from "node:fs";
18
18
  import { join } from "node:path";
19
- import type { GraphContent, SkillEntry } from "../semanticPackage.js";
19
+ import type {
20
+ GraphContent,
21
+ SkillEntry,
22
+ StoryFileEntry,
23
+ } from "../semanticPackage.js";
20
24
 
21
25
  // ---------------------------------------------------------------------------
22
26
  // TTL discovery convention
@@ -35,6 +39,7 @@ export interface PackageDirContents {
35
39
  readonly version: string;
36
40
  readonly graphs: GraphContent[];
37
41
  readonly skills: SkillEntry[];
42
+ readonly stories: StoryFileEntry[];
38
43
  }
39
44
 
40
45
  /**
@@ -48,6 +53,7 @@ export default function readPackageDir(dir: string): PackageDirContents {
48
53
  version: readVersion(dir),
49
54
  graphs: readGraphs(dir),
50
55
  skills: readSkills(dir),
56
+ stories: readStories(dir),
51
57
  };
52
58
  }
53
59
 
@@ -87,6 +93,35 @@ function readGraphs(dir: string): GraphContent[] {
87
93
  return graphs;
88
94
  }
89
95
 
96
+ function readStories(dir: string): StoryFileEntry[] {
97
+ const storiesDir = join(dir, "stories");
98
+ if (!existsSync(storiesDir)) return [];
99
+
100
+ const stories: StoryFileEntry[] = [];
101
+ try {
102
+ // Sorted: readdir order is filesystem-dependent, and story collision
103
+ // resolution must be deterministic across platforms.
104
+ for (const entry of [...readdirSync(storiesDir)].sort()) {
105
+ if (!entry.endsWith(".json")) continue;
106
+ const entryPath = join(storiesDir, entry);
107
+ try {
108
+ stories.push({
109
+ path: entryPath,
110
+ definition: JSON.parse(readFileSync(entryPath, "utf-8")),
111
+ });
112
+ } catch {
113
+ process.stderr.write(
114
+ `Warning: invalid JSON in story file ${entryPath}\n`,
115
+ );
116
+ }
117
+ }
118
+ } catch {
119
+ // readdir failed — no stories
120
+ }
121
+
122
+ return stories;
123
+ }
124
+
90
125
  function readSkills(dir: string): SkillEntry[] {
91
126
  const skillsDir = join(dir, "skills");
92
127
  if (!existsSync(skillsDir)) return [];
@@ -0,0 +1,84 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { BunPlugin } from "bun";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import createStoryAssetsPlugin, {
7
+ STORY_ASSET_PATH_PATTERN,
8
+ } from "./storyAssetsPlugin.js";
9
+
10
+ interface OnLoadRegistration {
11
+ readonly filter: RegExp;
12
+ readonly callback: (args: { path: string }) => unknown;
13
+ }
14
+
15
+ /** Run the plugin's setup against a stub builder and capture its onLoad. */
16
+ function captureOnLoadRegistration(): OnLoadRegistration {
17
+ let captured: OnLoadRegistration | undefined;
18
+ const builder = {
19
+ onLoad(
20
+ constraints: { filter: RegExp },
21
+ callback: OnLoadRegistration["callback"],
22
+ ): void {
23
+ captured = { filter: constraints.filter, callback };
24
+ },
25
+ };
26
+
27
+ createStoryAssetsPlugin().setup(
28
+ builder as unknown as Parameters<BunPlugin["setup"]>[0],
29
+ );
30
+
31
+ if (!captured) throw new Error("plugin did not register an onLoad handler");
32
+ return captured;
33
+ }
34
+
35
+ describe("STORY_ASSET_PATH_PATTERN", () => {
36
+ it("matches JSON directly inside a stories directory", () => {
37
+ expect(
38
+ STORY_ASSET_PATH_PATTERN.test(
39
+ "/repo/node_modules/@canonical/design-system/stories/color.json",
40
+ ),
41
+ ).toBe(true);
42
+ });
43
+
44
+ it("rejects manifests, nested files, near-miss directories, and non-JSON", () => {
45
+ const nonStories = [
46
+ "/repo/node_modules/@canonical/design-system/package.json",
47
+ "/repo/node_modules/@canonical/design-system/stories/nested/color.json",
48
+ "/repo/node_modules/@canonical/design-system/histories/color.json",
49
+ "/repo/node_modules/@canonical/design-system/stories/color.jsonc",
50
+ "/repo/node_modules/@canonical/design-system/stories/color.ttl",
51
+ ];
52
+ for (const path of nonStories) {
53
+ expect(STORY_ASSET_PATH_PATTERN.test(path), path).toBe(false);
54
+ }
55
+ });
56
+ });
57
+
58
+ describe("createStoryAssetsPlugin", () => {
59
+ let tmpDir: string | undefined;
60
+
61
+ afterEach(() => {
62
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
63
+ tmpDir = undefined;
64
+ });
65
+
66
+ it("registers its load hook under the story path filter", () => {
67
+ const { filter } = captureOnLoadRegistration();
68
+ expect(filter).toBe(STORY_ASSET_PATH_PATTERN);
69
+ });
70
+
71
+ it("loads story JSON contents under the file loader", () => {
72
+ tmpDir = mkdtempSync(join(tmpdir(), "pragma-story-assets-"));
73
+ const storyPath = join(tmpDir, "color.json");
74
+ const content = JSON.stringify({ noun: "color" });
75
+ writeFileSync(storyPath, content);
76
+
77
+ const { callback } = captureOnLoadRegistration();
78
+
79
+ expect(callback({ path: storyPath })).toEqual({
80
+ contents: content,
81
+ loader: "file",
82
+ });
83
+ });
84
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Build-time bundler plugin: embed story-pack JSON as file assets.
3
+ *
4
+ * Bun compiles `.json` build inputs into JSON modules, which never
5
+ * surface in `Bun.embeddedFiles`. Story-pack files must instead be
6
+ * embedded as file assets — like TTL graphs — so the bundled loader can
7
+ * serve them inside the compiled binary. This plugin scopes the `file`
8
+ * loader to story paths only, leaving every other JSON input (e.g.
9
+ * `package.json` imported for the CLI version) compiled as a module.
10
+ *
11
+ * Contract with the bundled loader: story files are the only `.json`
12
+ * content embedded as file assets, so every `.json` blob in
13
+ * `Bun.embeddedFiles` that is not named like a package manifest is a
14
+ * story definition. Consequence: no module in the bundle may import a
15
+ * `stories/*.json` file as a JSON module — the plugin would rewrite
16
+ * such an import into a file-path string.
17
+ *
18
+ * Consumed by `scripts/build.ts` only; never bundled into the binary.
19
+ */
20
+
21
+ import { readFileSync } from "node:fs";
22
+ import type { BunPlugin } from "bun";
23
+
24
+ /**
25
+ * Matches story-pack files: a `.json` directly inside a `stories/`
26
+ * directory — the same non-recursive scope the local loader scans.
27
+ */
28
+ export const STORY_ASSET_PATH_PATTERN = /[\\/]stories[\\/][^\\/]+\.json$/;
29
+
30
+ /**
31
+ * Create the bundler plugin that embeds `stories/*.json` as file assets.
32
+ *
33
+ * @returns A `Bun.build` plugin for the compiled-binary build.
34
+ * @note Impure — the registered load callback reads the filesystem.
35
+ */
36
+ export default function createStoryAssetsPlugin(): BunPlugin {
37
+ return {
38
+ name: "story-assets",
39
+ setup(build) {
40
+ build.onLoad({ filter: STORY_ASSET_PATH_PATTERN }, (args) => ({
41
+ contents: readFileSync(args.path, "utf-8"),
42
+ loader: "file",
43
+ }));
44
+ },
45
+ };
46
+ }