@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
@@ -2,7 +2,7 @@ import type { Store } from "@canonical/ke";
2
2
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
3
3
  import { PragmaError } from "#error";
4
4
  import { createTestStore, DS_ALL_TTL } from "#testing";
5
- import { P, PREFIX_MAP } from "../../shared/prefixes.js";
5
+ import { DEFAULT_PREFIX_MAP, P } from "../../shared/prefixes.js";
6
6
  import inspectUri from "./inspectUri.js";
7
7
 
8
8
  let store: Store;
@@ -19,7 +19,7 @@ afterAll(() => cleanup());
19
19
  describe("inspectUri", () => {
20
20
  it("returns grouped triples for a known URI", async () => {
21
21
  const result = await inspectUri(store, `${P.ds}global.component.button`);
22
- expect(result.uri).toBe(`${PREFIX_MAP.ds}global.component.button`);
22
+ expect(result.uri).toBe(`${DEFAULT_PREFIX_MAP.ds}global.component.button`);
23
23
  expect(result.groups.length).toBeGreaterThan(0);
24
24
 
25
25
  const predicates = result.groups.map((g) => g.predicate);
@@ -30,15 +30,15 @@ describe("inspectUri", () => {
30
30
 
31
31
  it("resolves prefixed URIs", async () => {
32
32
  const result = await inspectUri(store, `${P.ds}global.component.button`);
33
- expect(result.uri).toBe(`${PREFIX_MAP.ds}global.component.button`);
33
+ expect(result.uri).toBe(`${DEFAULT_PREFIX_MAP.ds}global.component.button`);
34
34
  });
35
35
 
36
36
  it("accepts full URIs", async () => {
37
37
  const result = await inspectUri(
38
38
  store,
39
- `${PREFIX_MAP.ds}global.component.button`,
39
+ `${DEFAULT_PREFIX_MAP.ds}global.component.button`,
40
40
  );
41
- expect(result.uri).toBe(`${PREFIX_MAP.ds}global.component.button`);
41
+ expect(result.uri).toBe(`${DEFAULT_PREFIX_MAP.ds}global.component.button`);
42
42
  expect(result.groups.length).toBeGreaterThan(0);
43
43
  });
44
44
 
@@ -1,4 +1,5 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
+ import { PragmaError } from "#error";
2
3
  import type { PragmaContext } from "../../shared/context.js";
3
4
  import serveCommand, { readPort } from "./serve.js";
4
5
 
@@ -7,15 +8,20 @@ describe("readPort", () => {
7
8
  expect(readPort("4001")).toBe(4001);
8
9
  });
9
10
 
10
- it("falls back to the default for non-string values", () => {
11
+ it("uses the default when the flag is absent", () => {
11
12
  expect(readPort(undefined)).toBe(4000);
12
- expect(readPort(4001)).toBe(4000);
13
+ expect(readPort("")).toBe(4000);
13
14
  });
14
15
 
15
- it("falls back to the default for out-of-range or unparseable values", () => {
16
- expect(readPort("0")).toBe(4000);
17
- expect(readPort("70000")).toBe(4000);
18
- expect(readPort("not-a-port")).toBe(4000);
16
+ it("rejects an out-of-range or unparseable provided port", () => {
17
+ for (const value of ["0", "70000", "not-a-port"]) {
18
+ expect(() => readPort(value)).toThrowError(PragmaError);
19
+ try {
20
+ readPort(value);
21
+ } catch (error) {
22
+ expect((error as PragmaError).code).toBe("INVALID_INPUT");
23
+ }
24
+ }
19
25
  });
20
26
  });
21
27
 
@@ -18,15 +18,25 @@ function readStringArray(value: unknown): string[] {
18
18
  : [];
19
19
  }
20
20
 
21
- /** Parse the --port flag, falling back to the default on anything invalid. */
21
+ /**
22
+ * Parse the `--port` flag. A missing flag uses the default; a provided value
23
+ * must be a valid port (1–65535), otherwise it is rejected rather than
24
+ * silently falling back and masking the user's mistake.
25
+ *
26
+ * @throws PragmaError INVALID_INPUT when a provided `--port` is not a valid port.
27
+ */
22
28
  export function readPort(value: unknown): number {
23
- if (typeof value !== "string") {
29
+ if (value === undefined || value === null || value === "") {
24
30
  return DEFAULT_PORT;
25
31
  }
26
- const port = Number.parseInt(value, 10);
27
- return Number.isInteger(port) && port > 0 && port < 65536
28
- ? port
29
- : DEFAULT_PORT;
32
+ const text = String(value).trim();
33
+ const port = Number(text);
34
+ if (!/^\d+$/.test(text) || port < 1 || port > 65535) {
35
+ throw PragmaError.invalidInput("port", String(value), {
36
+ recovery: { message: "Provide a port between 1 and 65535." },
37
+ });
38
+ }
39
+ return port;
30
40
  }
31
41
 
32
42
  /**
@@ -1,17 +1,17 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { PREFIX_MAP } from "../../shared/prefixes.js";
2
+ import { DEFAULT_PREFIX_MAP } from "../../shared/prefixes.js";
3
3
  import parsePrefixes from "./parsePrefixes.js";
4
4
 
5
5
  describe("parsePrefixes", () => {
6
6
  it("returns the default prefix map for no entries", () => {
7
7
  const prefixes = parsePrefixes([]);
8
- expect(prefixes).toEqual(PREFIX_MAP);
8
+ expect(prefixes).toEqual(DEFAULT_PREFIX_MAP);
9
9
  });
10
10
 
11
11
  it("merges entries on top of the defaults", () => {
12
12
  const prefixes = parsePrefixes(["ex=http://example.org/"]);
13
13
  expect(prefixes.ex).toBe("http://example.org/");
14
- expect(prefixes.ds).toBe(PREFIX_MAP.ds);
14
+ expect(prefixes.ds).toBe(DEFAULT_PREFIX_MAP.ds);
15
15
  });
16
16
 
17
17
  it("lets entries override default prefixes", () => {
@@ -1,11 +1,11 @@
1
1
  import { PragmaError } from "#error";
2
- import { PREFIX_MAP } from "../../shared/prefixes.js";
2
+ import { DEFAULT_PREFIX_MAP } from "../../shared/prefixes.js";
3
3
 
4
4
  /**
5
5
  * Parse repeated `--prefix name=namespace` entries into a prefix map.
6
6
  *
7
7
  * User-supplied prefixes are merged on top of pragma's default
8
- * {@link PREFIX_MAP}, so `ds:` and the W3C vocabularies stay available
8
+ * {@link DEFAULT_PREFIX_MAP}, so `ds:` and the W3C vocabularies stay available
9
9
  * unless explicitly overridden.
10
10
  *
11
11
  * @param entries - Raw `name=namespace` strings from the CLI.
@@ -15,7 +15,7 @@ import { PREFIX_MAP } from "../../shared/prefixes.js";
15
15
  export default function parsePrefixes(
16
16
  entries: readonly string[],
17
17
  ): Record<string, string> {
18
- const prefixes: Record<string, string> = { ...PREFIX_MAP };
18
+ const prefixes: Record<string, string> = { ...DEFAULT_PREFIX_MAP };
19
19
 
20
20
  for (const entry of entries) {
21
21
  const separator = entry.indexOf("=");
@@ -53,4 +53,13 @@ describe("resolveSourceFiles", () => {
53
53
  const files = resolveSourceFiles(["b.ttl", "nested/*.ttl"], dir);
54
54
  expect(files).toEqual([join(dir, "b.ttl"), join(dir, "nested", "c.ttl")]);
55
55
  });
56
+
57
+ it("skips a directory path instead of treating it as a file", () => {
58
+ // A directory would later crash readFileSync with EISDIR.
59
+ expect(resolveSourceFiles(["nested"], dir)).toEqual([]);
60
+ });
61
+
62
+ it("resolves the empty string (the cwd directory) to no files", () => {
63
+ expect(resolveSourceFiles([""], dir)).toEqual([]);
64
+ });
56
65
  });
@@ -8,9 +8,27 @@
8
8
  * @note Impure — reads filesystem to expand globs and check existence.
9
9
  */
10
10
 
11
- import { existsSync, globSync } from "node:fs";
11
+ import { globSync, statSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
13
 
14
+ /**
15
+ * Whether a path points at an existing regular file.
16
+ *
17
+ * Guards against a directory (or the empty-string path, which resolves to the
18
+ * cwd directory) slipping through as a "source" and later crashing readFileSync
19
+ * with EISDIR.
20
+ *
21
+ * @param absPath - Absolute path to check.
22
+ * @returns True when the path exists and is a regular file.
23
+ */
24
+ function isRegularFile(absPath: string): boolean {
25
+ try {
26
+ return statSync(absPath).isFile();
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
14
32
  /**
15
33
  * Expand file paths and glob patterns into absolute file paths.
16
34
  *
@@ -31,11 +49,14 @@ export default function resolveSourceFiles(
31
49
  pattern.includes("[")
32
50
  ) {
33
51
  for (const file of globSync(pattern, { cwd })) {
34
- files.push(resolve(cwd, file));
52
+ const absPath = resolve(cwd, file);
53
+ if (isRegularFile(absPath)) {
54
+ files.push(absPath);
55
+ }
35
56
  }
36
57
  } else {
37
58
  const absPath = resolve(cwd, pattern);
38
- if (existsSync(absPath)) {
59
+ if (isRegularFile(absPath)) {
39
60
  files.push(absPath);
40
61
  }
41
62
  }
@@ -6,10 +6,10 @@ import {
6
6
  GRAPHQL_ERROR_TTL,
7
7
  GRAPHQL_FATAL_TTL,
8
8
  } from "#testing";
9
- import { PREFIX_MAP } from "../../shared/prefixes.js";
9
+ import { DEFAULT_PREFIX_MAP } from "../../shared/prefixes.js";
10
10
  import compileSchema from "./compileSchema.js";
11
11
 
12
- const PREFIXES = { ...PREFIX_MAP, ex: EX_NAMESPACE };
12
+ const PREFIXES = { ...DEFAULT_PREFIX_MAP, ex: EX_NAMESPACE };
13
13
 
14
14
  const inline = (content: string, path: string) => ({
15
15
  content,
@@ -1,9 +1,9 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
2
  import { EX_NAMESPACE, GRAPHQL_CLEAN_TTL } from "#testing";
3
- import { PREFIX_MAP } from "../../shared/prefixes.js";
3
+ import { DEFAULT_PREFIX_MAP } from "../../shared/prefixes.js";
4
4
  import createSchemaServer, { type SchemaServer } from "./createSchemaServer.js";
5
5
 
6
- const PREFIXES = { ...PREFIX_MAP, ex: EX_NAMESPACE };
6
+ const PREFIXES = { ...DEFAULT_PREFIX_MAP, ex: EX_NAMESPACE };
7
7
 
8
8
  describe("createSchemaServer", () => {
9
9
  let server: SchemaServer | undefined;
@@ -0,0 +1,167 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
12
+ import { EX_NAMESPACE, GRAPHQL_CLEAN_TTL, GRAPHQL_FATAL_TTL } from "#testing";
13
+ import { DEFAULT_PREFIX_MAP } from "../../shared/prefixes.js";
14
+ import type { SemanticPackage } from "../../shared/semanticPackage.js";
15
+ import ensureSchemaArtifact, {
16
+ resolveSchemaArtifactPath,
17
+ } from "./ensureSchemaArtifact.js";
18
+
19
+ const PREFIXES = { ...DEFAULT_PREFIX_MAP, ex: EX_NAMESPACE };
20
+
21
+ let dataDir: string;
22
+ let cwd: string;
23
+ let originalXdgData: string | undefined;
24
+
25
+ beforeAll(() => {
26
+ dataDir = mkdtempSync(join(tmpdir(), "pragma-schema-artifact-data-"));
27
+ cwd = mkdtempSync(join(tmpdir(), "pragma-schema-artifact-cwd-"));
28
+ originalXdgData = process.env.XDG_DATA_HOME;
29
+ process.env.XDG_DATA_HOME = dataDir;
30
+ // The global test setup disables artifact emission; this suite tests it.
31
+ delete process.env.PRAGMA_SCHEMA_ARTIFACT;
32
+ });
33
+
34
+ afterAll(() => {
35
+ if (originalXdgData === undefined) {
36
+ delete process.env.XDG_DATA_HOME;
37
+ } else {
38
+ process.env.XDG_DATA_HOME = originalXdgData;
39
+ }
40
+ process.env.PRAGMA_SCHEMA_ARTIFACT = "off";
41
+ rmSync(dataDir, { recursive: true, force: true });
42
+ rmSync(cwd, { recursive: true, force: true });
43
+ });
44
+
45
+ afterEach(() => {
46
+ delete process.env.PRAGMA_SCHEMA_ARTIFACT;
47
+ rmSync(join(dataDir, "pragma"), { recursive: true, force: true });
48
+ });
49
+
50
+ function makeRuntime(ttl: string): Parameters<typeof ensureSchemaArtifact>[0] {
51
+ const pkg: SemanticPackage = {
52
+ name: "@test/ontology",
53
+ version: "1.0.0",
54
+ source: "local",
55
+ graphs: [{ path: "ontology.ttl", content: ttl, format: "turtle" }],
56
+ skills: [],
57
+ stories: [],
58
+ };
59
+ return {
60
+ cwd,
61
+ packages: [pkg],
62
+ store: { prefixes: PREFIXES } as never,
63
+ };
64
+ }
65
+
66
+ describe("resolveSchemaArtifactPath", () => {
67
+ it("uses the XDG data dir when no project config is in tree", () => {
68
+ expect(resolveSchemaArtifactPath(cwd)).toBe(
69
+ join(dataDir, "pragma", "schema.graphql"),
70
+ );
71
+ });
72
+
73
+ it("uses .pragma/ beside the project config when one is in tree", () => {
74
+ const project = mkdtempSync(join(tmpdir(), "pragma-schema-project-"));
75
+ try {
76
+ writeFileSync(join(project, "pragma.config.json"), "{}", "utf-8");
77
+ const nested = join(project, "src");
78
+ mkdirSync(nested);
79
+ expect(resolveSchemaArtifactPath(nested)).toBe(
80
+ join(project, ".pragma", "schema.graphql"),
81
+ );
82
+ } finally {
83
+ rmSync(project, { recursive: true, force: true });
84
+ }
85
+ });
86
+ });
87
+
88
+ describe("ensureSchemaArtifact", () => {
89
+ it("writes the artifact with a source-hash header on first use", async () => {
90
+ const result = await ensureSchemaArtifact(makeRuntime(GRAPHQL_CLEAN_TTL));
91
+
92
+ expect(result.status).toBe("written");
93
+ expect(existsSync(result.path)).toBe(true);
94
+
95
+ const content = readFileSync(result.path, "utf-8");
96
+ expect(content.startsWith("# pragma:sources ")).toBe(true);
97
+ expect(content).toContain("type Thing");
98
+ });
99
+
100
+ it("is fresh on a second run with unchanged sources", async () => {
101
+ const runtime = makeRuntime(GRAPHQL_CLEAN_TTL);
102
+ await ensureSchemaArtifact(runtime);
103
+
104
+ const second = await ensureSchemaArtifact(runtime);
105
+ expect(second.status).toBe("fresh");
106
+ });
107
+
108
+ it("rewrites when the sources change", async () => {
109
+ await ensureSchemaArtifact(makeRuntime(GRAPHQL_CLEAN_TTL));
110
+
111
+ const changed = await ensureSchemaArtifact(
112
+ makeRuntime(`${GRAPHQL_CLEAN_TTL}\n# changed`),
113
+ );
114
+ expect(changed.status).toBe("written");
115
+ });
116
+
117
+ it("leaves a stale artifact in place when compilation fails", async () => {
118
+ const first = await ensureSchemaArtifact(makeRuntime(GRAPHQL_CLEAN_TTL));
119
+ const before = readFileSync(first.path, "utf-8");
120
+
121
+ const failed = await ensureSchemaArtifact(makeRuntime(GRAPHQL_FATAL_TTL));
122
+ expect(failed.status).toBe("failed");
123
+ expect(readFileSync(first.path, "utf-8")).toBe(before);
124
+ });
125
+
126
+ it("compiles from the parseable sources when one is malformed", async () => {
127
+ const pkg: SemanticPackage = {
128
+ name: "@test/ontology",
129
+ version: "1.0.0",
130
+ source: "local",
131
+ graphs: [
132
+ { path: "clean.ttl", content: GRAPHQL_CLEAN_TTL, format: "turtle" },
133
+ {
134
+ path: "broken.ttl",
135
+ content: "this is not turtle .",
136
+ format: "turtle",
137
+ },
138
+ ],
139
+ skills: [],
140
+ stories: [],
141
+ };
142
+ const result = await ensureSchemaArtifact({
143
+ cwd,
144
+ packages: [pkg],
145
+ store: { prefixes: PREFIXES } as never,
146
+ });
147
+
148
+ expect(result.status).toBe("written");
149
+ expect(readFileSync(result.path, "utf-8")).toContain("type Thing");
150
+ });
151
+
152
+ it("skips when no sources resolve", async () => {
153
+ const result = await ensureSchemaArtifact({
154
+ cwd,
155
+ packages: [],
156
+ store: { prefixes: PREFIXES } as never,
157
+ });
158
+ expect(result.status).toBe("skipped");
159
+ });
160
+
161
+ it("skips when disabled via PRAGMA_SCHEMA_ARTIFACT=off", async () => {
162
+ process.env.PRAGMA_SCHEMA_ARTIFACT = "off";
163
+ const result = await ensureSchemaArtifact(makeRuntime(GRAPHQL_CLEAN_TTL));
164
+ expect(result.status).toBe("skipped");
165
+ expect(existsSync(result.path)).toBe(false);
166
+ });
167
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Hash-gated `schema.graphql` artifact.
3
+ *
4
+ * pragma keeps one compiled GraphQL schema per data set so downstream
5
+ * tooling (codegen, IDEs, `graphql serve` clients) always has a current
6
+ * SDL without anyone running `pragma graphql build` by hand:
7
+ *
8
+ * - **Location** — next to the project's `pragma.config.json`
9
+ * (`<projectRoot>/.pragma/schema.graphql`) when one is in tree, else the
10
+ * XDG data dir (`$XDG_DATA_HOME/pragma/schema.graphql`).
11
+ * - **Emission** — on first use of pragma (any command that boots the
12
+ * runtime) the artifact is generated if missing.
13
+ * - **Renewal** — the artifact's first line records the hash of the TTL
14
+ * sources it was compiled from (`# pragma:sources <hash>`); it is
15
+ * recompiled only when the resolved sources no longer match that hash.
16
+ *
17
+ * Failure is never fatal: a schema that does not compile leaves the
18
+ * previous artifact in place and the boot proceeds with a warning.
19
+ */
20
+
21
+ import {
22
+ closeSync,
23
+ existsSync,
24
+ mkdirSync,
25
+ openSync,
26
+ readSync,
27
+ writeFileSync,
28
+ } from "node:fs";
29
+ import { dirname, join } from "node:path";
30
+ import { createStore, definePlugin, type InlineSource } from "@canonical/ke";
31
+ import { hashSources } from "@canonical/ke-graphql";
32
+ import { findProjectConfigPath } from "#config";
33
+ import { dataRoot } from "../../refs/operations/paths.js";
34
+ import type { PragmaRuntime } from "../../shared/runtime.js";
35
+ import compileSchema from "./compileSchema.js";
36
+
37
+ /** First-line marker carrying the source fingerprint. */
38
+ const HASH_HEADER = "# pragma:sources ";
39
+
40
+ /** Environment kill-switch (CI, tests, air-gapped boots). */
41
+ const DISABLE_ENV = "PRAGMA_SCHEMA_ARTIFACT";
42
+
43
+ export interface SchemaArtifactResult {
44
+ /** Absolute path of the artifact. */
45
+ readonly path: string;
46
+ /**
47
+ * What happened: `fresh` (hash matched, nothing to do), `written`
48
+ * (compiled and saved), `failed` (compilation failed, artifact left
49
+ * as-is), `skipped` (no sources, or disabled via env).
50
+ */
51
+ readonly status: "fresh" | "written" | "failed" | "skipped";
52
+ }
53
+
54
+ /**
55
+ * Where the schema artifact lives for a working directory: project-local
56
+ * (`.pragma/` beside the nearest `pragma.config.json`) when the project is
57
+ * configured, global XDG data dir otherwise.
58
+ */
59
+ export function resolveSchemaArtifactPath(cwd: string): string {
60
+ const configPath = findProjectConfigPath(cwd);
61
+ return configPath
62
+ ? join(dirname(configPath), ".pragma", "schema.graphql")
63
+ : join(dataRoot(), "schema.graphql");
64
+ }
65
+
66
+ /**
67
+ * Ensure the schema artifact exists and matches the runtime's resolved
68
+ * TTL sources, recompiling only on hash mismatch.
69
+ *
70
+ * @note Impure — reads and writes the artifact; compiles via a throwaway
71
+ * ke store when stale.
72
+ */
73
+ export default async function ensureSchemaArtifact(
74
+ runtime: Pick<PragmaRuntime, "cwd" | "packages" | "store">,
75
+ ): Promise<SchemaArtifactResult> {
76
+ const path = resolveSchemaArtifactPath(runtime.cwd);
77
+
78
+ if (process.env[DISABLE_ENV] === "off") {
79
+ return { path, status: "skipped" };
80
+ }
81
+
82
+ // Sort by provenance path so the fingerprint is stable across boots
83
+ // regardless of package resolution order.
84
+ const sources: InlineSource[] = runtime.packages
85
+ .flatMap((pkg) =>
86
+ pkg.graphs.map((graph) => ({
87
+ content: graph.content,
88
+ format: "turtle" as const,
89
+ path: graph.path,
90
+ })),
91
+ )
92
+ .sort((a, b) => a.path.localeCompare(b.path));
93
+
94
+ if (sources.length === 0) {
95
+ return { path, status: "skipped" };
96
+ }
97
+
98
+ const hash = hashSources(sources.map((source) => source.content));
99
+
100
+ if (existsSync(path) && readFirstLine(path) === `${HASH_HEADER}${hash}`) {
101
+ return { path, status: "fresh" };
102
+ }
103
+
104
+ // Mirror boot's "one bad file cannot break boot": compile from the
105
+ // parseable sources only. The runtime already warned about any malformed
106
+ // graph while booting the main store, so the filter here is silent.
107
+ // The fingerprint above covers ALL sources, so fixing a malformed file
108
+ // changes the hash and triggers recompilation.
109
+ const parseable = await filterParseableSources(sources);
110
+ if (parseable.length === 0) {
111
+ return { path, status: "failed" };
112
+ }
113
+
114
+ const outcome = await compileSchema({
115
+ sources: parseable,
116
+ prefixes: runtime.store.prefixes,
117
+ cwd: runtime.cwd,
118
+ });
119
+
120
+ if (outcome.status === "failed" || !outcome.compiled) {
121
+ return { path, status: "failed" };
122
+ }
123
+
124
+ mkdirSync(dirname(path), { recursive: true });
125
+ writeFileSync(
126
+ path,
127
+ `${HASH_HEADER}${hash}\n${outcome.compiled.sdl}`,
128
+ "utf-8",
129
+ );
130
+
131
+ return { path, status: "written" };
132
+ }
133
+
134
+ /**
135
+ * Read just the artifact's header line — the freshness probe runs on every
136
+ * booted invocation, so it must not scale with the SDL's size.
137
+ */
138
+ function readFirstLine(path: string, max = 96): string {
139
+ const fd = openSync(path, "r");
140
+ try {
141
+ const buffer = Buffer.alloc(max);
142
+ const bytes = readSync(fd, buffer, 0, max, 0);
143
+ const text = buffer.subarray(0, bytes).toString("utf-8");
144
+ const newline = text.indexOf("\n");
145
+ return newline === -1 ? text : text.slice(0, newline);
146
+ } finally {
147
+ closeSync(fd);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Drop sources the Turtle parser rejects, keeping the rest — the same
153
+ * tolerance the main store's graph loader applies at boot. Probes with a
154
+ * throwaway store so the strict `graphql check` semantics of
155
+ * {@link compileSchema} stay untouched.
156
+ */
157
+ async function filterParseableSources(
158
+ sources: readonly InlineSource[],
159
+ ): Promise<InlineSource[]> {
160
+ const parseable: InlineSource[] = [];
161
+ const probe = definePlugin({
162
+ name: "pragma-schema-artifact-probe",
163
+ onReady(ctx) {
164
+ for (const source of sources) {
165
+ try {
166
+ ctx.load(source.content, { format: source.format });
167
+ parseable.push(source);
168
+ } catch {
169
+ // Already warned at boot by the main store's graph loader.
170
+ }
171
+ }
172
+ },
173
+ });
174
+
175
+ const store = await createStore({ sources: [], plugins: [probe] });
176
+ store.dispose();
177
+ return parseable;
178
+ }
@@ -10,3 +10,8 @@ export type {
10
10
  SchemaServer,
11
11
  } from "./createSchemaServer.js";
12
12
  export { default as createSchemaServer } from "./createSchemaServer.js";
13
+ export type { SchemaArtifactResult } from "./ensureSchemaArtifact.js";
14
+ export {
15
+ default as ensureSchemaArtifact,
16
+ resolveSchemaArtifactPath,
17
+ } from "./ensureSchemaArtifact.js";
@@ -8,7 +8,9 @@ import {
8
8
 
9
9
  function createUpgradeData(overrides: Partial<UpgradeData> = {}): UpgradeData {
10
10
  return {
11
- pm: "bun",
11
+ // `pm` is the install label, which already carries the scope (as the
12
+ // command populates it from source.label).
13
+ pm: "bun (global)",
12
14
  current: "0.18.0",
13
15
  latest: "0.19.0",
14
16
  command: "bun update -g @canonical/pragma-cli",
@@ -21,9 +23,10 @@ function createUpgradeData(overrides: Partial<UpgradeData> = {}): UpgradeData {
21
23
  }
22
24
 
23
25
  describe("renderUpgradePlain", () => {
24
- it("shows PM info", () => {
26
+ it("shows PM info without doubling the scope suffix", () => {
25
27
  const output = renderUpgradePlain(createUpgradeData());
26
- expect(output).toContain("bun (global)");
28
+ expect(output).toContain("Installed via: bun (global)");
29
+ expect(output).not.toContain("(global) (global)");
27
30
  });
28
31
 
29
32
  it("shows version transition", () => {
@@ -107,7 +110,7 @@ describe("renderUpgradeJson", () => {
107
110
  it("produces valid JSON", () => {
108
111
  const data = createUpgradeData();
109
112
  const parsed = JSON.parse(renderUpgradeJson(data));
110
- expect(parsed.pm).toBe("bun");
113
+ expect(parsed.pm).toBe("bun (global)");
111
114
  expect(parsed.current).toBe("0.18.0");
112
115
  expect(parsed.latest).toBe("0.19.0");
113
116
  });
@@ -12,7 +12,9 @@ import type { UpgradeData } from "../types.js";
12
12
  function renderUpgradePlain(data: UpgradeData): string {
13
13
  const lines: string[] = [];
14
14
 
15
- lines.push(`Installed via: ${data.pm} (global)`);
15
+ // `data.pm` is the install label (e.g. "bun (global)"), which already carries
16
+ // the scope — do not append another "(global)".
17
+ lines.push(`Installed via: ${data.pm}`);
16
18
 
17
19
  if (data.offline) {
18
20
  lines.push("Could not reach registry");