@canonical/pragma-cli 0.27.0 → 0.29.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 (169) hide show
  1. package/dist/pragma +0 -0
  2. package/package.json +19 -17
  3. package/src/config/readConfig.ts +38 -2
  4. package/src/config/types.ts +12 -1
  5. package/src/config/writeConfig.ts +12 -0
  6. package/src/constants.ts +12 -1
  7. package/src/domains/block/commands/index.ts +2 -1
  8. package/src/domains/block/commands/sample.ts +55 -0
  9. package/src/domains/block/formatters/index.ts +2 -1
  10. package/src/domains/block/formatters/sample.ts +21 -0
  11. package/src/domains/block/index.ts +3 -3
  12. package/src/domains/block/mcp/specs.ts +48 -0
  13. package/src/domains/block/operations/index.ts +2 -1
  14. package/src/domains/block/operations/lookup.ts +9 -0
  15. package/src/domains/block/operations/sample.ts +41 -0
  16. package/src/domains/block/orchestration/blockEmptyError.test.ts +26 -0
  17. package/src/domains/block/orchestration/resolveBlockLookup.ts +10 -3
  18. package/src/domains/config/commands/framework.test.ts +88 -0
  19. package/src/domains/config/commands/framework.ts +85 -0
  20. package/src/domains/config/commands/index.ts +2 -0
  21. package/src/domains/config/commands/trace.test.ts +148 -0
  22. package/src/domains/config/commands/trace.ts +80 -0
  23. package/src/domains/config/formatters/framework.ts +34 -0
  24. package/src/domains/config/formatters/index.ts +2 -0
  25. package/src/domains/config/formatters/trace.ts +28 -0
  26. package/src/domains/config/index.ts +15 -2
  27. package/src/domains/config/operations/index.ts +1 -0
  28. package/src/domains/config/operations/validateFramework.ts +24 -0
  29. package/src/domains/config/tasks/index.ts +2 -0
  30. package/src/domains/config/tasks/setChannel.ts +7 -1
  31. package/src/domains/config/tasks/setFramework.ts +40 -0
  32. package/src/domains/config/tasks/setTier.ts +7 -1
  33. package/src/domains/config/tasks/setTrace.ts +38 -0
  34. package/src/domains/doctor/operations/checks/checkKeStore.ts +5 -5
  35. package/src/domains/doctor/operations/checks/checkPackageRefs.ts +35 -41
  36. package/src/domains/doctor/operations/checks.test.ts +4 -1
  37. package/src/domains/doctor/operations/runChecks.test.ts +72 -0
  38. package/src/domains/doctor/operations/runChecks.ts +48 -12
  39. package/src/domains/graphql/commands/build.test.ts +194 -0
  40. package/src/domains/graphql/commands/build.ts +125 -0
  41. package/src/domains/graphql/commands/check.test.ts +139 -0
  42. package/src/domains/graphql/commands/check.ts +87 -0
  43. package/src/domains/graphql/commands/index.ts +4 -0
  44. package/src/domains/graphql/formatters/index.ts +3 -0
  45. package/src/domains/graphql/formatters/report.ts +78 -0
  46. package/src/domains/graphql/helpers/index.ts +4 -0
  47. package/src/domains/graphql/helpers/parsePrefixes.test.ts +44 -0
  48. package/src/domains/graphql/helpers/parsePrefixes.ts +38 -0
  49. package/src/domains/graphql/helpers/resolveSourceFiles.test.ts +56 -0
  50. package/src/domains/graphql/helpers/resolveSourceFiles.ts +45 -0
  51. package/src/domains/graphql/index.ts +21 -0
  52. package/src/domains/graphql/operations/compileSchema.test.ts +119 -0
  53. package/src/domains/graphql/operations/compileSchema.ts +109 -0
  54. package/src/domains/graphql/operations/index.ts +7 -0
  55. package/src/domains/graphql/types.ts +29 -0
  56. package/src/domains/info/operations/collectInfo.test.ts +2 -2
  57. package/src/domains/info/operations/collectInfo.ts +4 -4
  58. package/src/domains/llm/capabilities.test.ts +91 -0
  59. package/src/domains/llm/commands/capabilities.ts +28 -0
  60. package/src/domains/llm/commands/index.ts +1 -0
  61. package/src/domains/llm/data/commandReference.ts +90 -18
  62. package/src/domains/llm/data/decisionTrees.ts +15 -13
  63. package/src/domains/llm/data/index.ts +2 -0
  64. package/src/domains/llm/data/toolCatalog.ts +184 -0
  65. package/src/domains/llm/formatters/capabilities.ts +50 -0
  66. package/src/domains/llm/formatters/index.ts +1 -0
  67. package/src/domains/llm/index.ts +3 -2
  68. package/src/domains/llm/mcp/index.ts +1 -1
  69. package/src/domains/llm/mcp/specs.ts +59 -52
  70. package/src/domains/llm/types.ts +40 -0
  71. package/src/domains/modifier/commands/index.ts +2 -1
  72. package/src/domains/modifier/commands/sample.ts +53 -0
  73. package/src/domains/modifier/formatters/index.ts +2 -1
  74. package/src/domains/modifier/formatters/sample.ts +15 -0
  75. package/src/domains/modifier/index.ts +7 -3
  76. package/src/domains/modifier/mcp/specs.ts +47 -0
  77. package/src/domains/modifier/operations/index.ts +2 -1
  78. package/src/domains/modifier/operations/lookup.ts +9 -0
  79. package/src/domains/modifier/operations/sample.ts +32 -0
  80. package/src/domains/modifier/orchestration/modifierEmptyError.test.ts +26 -0
  81. package/src/domains/modifier/orchestration/modifierEmptyError.ts +7 -2
  82. package/src/domains/modifier/orchestration/resolveModifierLookup.ts +14 -2
  83. package/src/domains/refs/operations/index.ts +1 -1
  84. package/src/domains/refs/operations/paths.ts +29 -1
  85. package/src/domains/refs/operations/updateRefs.test.ts +6 -3
  86. package/src/domains/refs/operations/updateRefs.ts +3 -2
  87. package/src/domains/shared/bootStore.ts +88 -49
  88. package/src/domains/shared/contracts.ts +1 -0
  89. package/src/domains/shared/emptyErrorRecovery.test.ts +109 -0
  90. package/src/domains/shared/loaders/bundledLoader.ts +108 -0
  91. package/src/domains/shared/loaders/gitLoader.ts +31 -0
  92. package/src/domains/shared/loaders/index.ts +6 -0
  93. package/src/domains/shared/loaders/localLoader.ts +73 -0
  94. package/src/domains/shared/loaders/readPackageDir.ts +112 -0
  95. package/src/domains/shared/lookupMany.ts +9 -1
  96. package/src/domains/shared/packages.ts +21 -120
  97. package/src/domains/shared/pickRandom.ts +22 -0
  98. package/src/domains/shared/runtime.ts +6 -3
  99. package/src/domains/shared/sampleFormatters.ts +47 -0
  100. package/src/domains/shared/semanticPackage.ts +96 -0
  101. package/src/domains/shared/suggestions/detectCrossDomain.test.ts +43 -0
  102. package/src/domains/shared/suggestions/detectCrossDomain.ts +116 -0
  103. package/src/domains/shared/suggestions/expandGlob.test.ts +92 -0
  104. package/src/domains/shared/suggestions/expandGlob.ts +51 -0
  105. package/src/domains/shared/suggestions/expandLookupQueries.ts +57 -0
  106. package/src/domains/shared/suggestions/index.ts +7 -0
  107. package/src/domains/shared/suggestions/listDomainNames.ts +61 -0
  108. package/src/domains/shared/suggestions/suggestNames.test.ts +70 -0
  109. package/src/domains/shared/suggestions/suggestNames.ts +97 -0
  110. package/src/domains/shared/types/index.ts +1 -0
  111. package/src/domains/shared/types/runtime.ts +4 -1
  112. package/src/domains/shared/types/sample.ts +17 -0
  113. package/src/domains/skill/helpers/resolveSkillSources.ts +49 -16
  114. package/src/domains/standard/commands/index.ts +2 -1
  115. package/src/domains/standard/commands/sample.ts +57 -0
  116. package/src/domains/standard/formatters/index.ts +2 -1
  117. package/src/domains/standard/formatters/sample.ts +15 -0
  118. package/src/domains/standard/index.ts +8 -1
  119. package/src/domains/standard/mcp/specs.ts +48 -1
  120. package/src/domains/standard/operations/index.ts +2 -1
  121. package/src/domains/standard/operations/lookup.ts +9 -0
  122. package/src/domains/standard/operations/sample.ts +35 -0
  123. package/src/domains/standard/orchestration/resolveStandardLookup.ts +14 -2
  124. package/src/domains/standard/orchestration/standardEmptyError.test.ts +33 -0
  125. package/src/domains/standard/orchestration/standardEmptyError.ts +16 -6
  126. package/src/domains/token/commands/index.ts +2 -1
  127. package/src/domains/token/commands/sample.ts +53 -0
  128. package/src/domains/token/formatters/index.ts +2 -1
  129. package/src/domains/token/formatters/sample.ts +16 -0
  130. package/src/domains/token/index.ts +8 -1
  131. package/src/domains/token/mcp/specs.ts +47 -1
  132. package/src/domains/token/operations/index.ts +2 -1
  133. package/src/domains/token/operations/lookup.ts +9 -0
  134. package/src/domains/token/operations/sample.ts +32 -0
  135. package/src/domains/token/orchestration/resolveTokenLookup.ts +14 -2
  136. package/src/domains/token/orchestration/tokenEmptyError.test.ts +33 -0
  137. package/src/domains/token/orchestration/tokenEmptyError.ts +5 -2
  138. package/src/domains/trace/commands/clear.ts +71 -0
  139. package/src/domains/trace/commands/index.ts +5 -0
  140. package/src/domains/trace/commands/sessions.ts +32 -0
  141. package/src/domains/trace/commands/show.ts +145 -0
  142. package/src/domains/trace/formatters/index.ts +9 -0
  143. package/src/domains/trace/formatters/sessions.ts +80 -0
  144. package/src/domains/trace/formatters/show.ts +122 -0
  145. package/src/domains/trace/index.ts +17 -0
  146. package/src/domains/trace/operations/index.ts +8 -0
  147. package/src/domains/trace/operations/listSessions.test.ts +55 -0
  148. package/src/domains/trace/operations/listSessions.ts +52 -0
  149. package/src/domains/trace/operations/readTraceLog.test.ts +100 -0
  150. package/src/domains/trace/operations/readTraceLog.ts +104 -0
  151. package/src/domains/trace/tracePlugin.test.ts +171 -0
  152. package/src/domains/trace/tracePlugin.ts +159 -0
  153. package/src/domains/trace/types.ts +65 -0
  154. package/src/error/PragmaError.ts +15 -2
  155. package/src/error/types.ts +15 -1
  156. package/src/mcp/error/serializeErrorPayload.ts +7 -0
  157. package/src/mcp/error/types.ts +8 -0
  158. package/src/mcp/registerResources.test.ts +38 -16
  159. package/src/mcp/registerResources.ts +57 -8
  160. package/src/mcp/tools/registerTools.test.ts +22 -7
  161. package/src/mcp/utils/wrapTool.test.ts +1 -0
  162. package/src/pipeline/collectCommands.ts +7 -1
  163. package/src/pipeline/renderError.ts +15 -0
  164. package/src/pipeline/resolveCommandKind.ts +8 -1
  165. package/src/pipeline/runCli.ts +12 -1
  166. package/src/testing/createTestMcpClient.ts +1 -0
  167. package/src/testing/graphqlFixtures.ts +72 -0
  168. package/src/testing/index.ts +7 -0
  169. package/src/testing/integration/mcp-surface.test.ts +11 -5
@@ -1,19 +1,20 @@
1
1
  /**
2
2
  * Doctor check: report how each semantic package is resolved.
3
3
  *
4
- * Shows whether packages resolve from npm, file://, or git cache,
5
- * and warns if a configured ref is missing its cache.
4
+ * Uses the loader chain (local > git > bundled) to show where each
5
+ * package resolves from, its version, and graph/skill counts.
6
6
  */
7
7
 
8
- import { existsSync } from "node:fs";
9
8
  import { readConfig } from "#config";
10
- import {
11
- parsePackageEntry,
12
- type RawPackageEntry,
13
- } from "../../../refs/operations/parseRef.js";
14
- import { gitCacheDir } from "../../../refs/operations/paths.js";
9
+ import { parsePackageEntry } from "../../../refs/operations/parseRef.js";
15
10
  import readGlobalRefs from "../../../refs/operations/readGlobalRefs.js";
11
+ import {
12
+ createBundledLoader,
13
+ createGitLoader,
14
+ createLocalLoader,
15
+ } from "../../../shared/loaders/index.js";
16
16
  import { DEFAULT_PACKAGES } from "../../../shared/packages.js";
17
+ import { resolveSemanticPackages } from "../../../shared/semanticPackage.js";
17
18
  import type { CheckContext, CheckResult } from "../types.js";
18
19
 
19
20
  export default async function checkPackageRefs(
@@ -23,46 +24,39 @@ export default async function checkPackageRefs(
23
24
  const globalEntries = readGlobalRefs();
24
25
 
25
26
  const hasProjectRefs = config.packages && config.packages.length > 0;
26
- const hasGlobalRefs = globalEntries.length > 0;
27
27
 
28
- if (!hasProjectRefs && !hasGlobalRefs) {
29
- return {
30
- name: "package refs",
31
- status: "pass",
32
- detail: `${DEFAULT_PACKAGES.length} packages (all npm, defaults)`,
33
- };
34
- }
28
+ const rawEntries = hasProjectRefs
29
+ ? config.packages!
30
+ : globalEntries.length > 0
31
+ ? globalEntries
32
+ : DEFAULT_PACKAGES;
35
33
 
36
- const entries: ReadonlyArray<RawPackageEntry> =
37
- config.packages ?? globalEntries;
34
+ const refs = [...rawEntries].map(parsePackageEntry);
35
+ const loaders = [
36
+ createLocalLoader(),
37
+ createGitLoader(),
38
+ createBundledLoader(),
39
+ ];
40
+ const packages = await resolveSemanticPackages(refs, loaders);
38
41
 
39
42
  const details: string[] = [];
40
43
  let hasIssue = false;
41
44
 
42
- for (const entry of entries) {
43
- const ref = parsePackageEntry(entry);
45
+ for (const ref of refs) {
46
+ const resolved = packages.find((p) => p.name === ref.pkg);
44
47
 
45
- switch (ref.kind) {
46
- case "npm":
47
- details.push(`${ref.pkg}: npm`);
48
- break;
49
- case "file":
50
- if (existsSync(ref.path)) {
51
- details.push(`${ref.pkg}: file (${ref.path})`);
52
- } else {
53
- details.push(`${ref.pkg}: file MISSING (${ref.path})`);
54
- hasIssue = true;
55
- }
56
- break;
57
- case "git": {
58
- const cached = existsSync(gitCacheDir(ref.pkg, ref.ref));
59
- if (cached) {
60
- details.push(`${ref.pkg}: git #${ref.ref} (cached)`);
61
- } else {
62
- details.push(`${ref.pkg}: git #${ref.ref} NOT CACHED`);
63
- hasIssue = true;
64
- }
65
- break;
48
+ if (resolved) {
49
+ details.push(
50
+ `${ref.pkg}: ${resolved.source} v${resolved.version} (${resolved.graphs.length} graphs, ${resolved.skills.length} skills)`,
51
+ );
52
+ } else {
53
+ // Check if covered by bundled fallback
54
+ const bundled = packages.find((p) => p.name === "(bundled)");
55
+ if (bundled) {
56
+ details.push(`${ref.pkg}: bundled v${bundled.version}`);
57
+ } else {
58
+ details.push(`${ref.pkg}: NOT RESOLVED`);
59
+ hasIssue = true;
66
60
  }
67
61
  }
68
62
  }
@@ -75,7 +75,10 @@ describe("checkKeStore", () => {
75
75
  query: vi.fn(),
76
76
  dispose: vi.fn(),
77
77
  };
78
- vi.mocked(bootStore).mockResolvedValue(mockStore as never);
78
+ vi.mocked(bootStore).mockResolvedValue({
79
+ store: mockStore,
80
+ packages: [],
81
+ } as never);
79
82
  vi.mocked(collectStoreSummary).mockResolvedValue({
80
83
  tripleCount: 12847,
81
84
  graphNames: [],
@@ -22,6 +22,7 @@ import {
22
22
  checkSkillsSymlinked,
23
23
  } from "./checks/index.js";
24
24
  import runChecks from "./runChecks.js";
25
+ import type { CheckResult } from "./types.js";
25
26
 
26
27
  const pass = (name: string) => ({
27
28
  name,
@@ -92,4 +93,75 @@ describe("runChecks", () => {
92
93
  expect(data.checks[4].name).toBe("store");
93
94
  expect(data.checks[7].name).toBe("skills");
94
95
  });
96
+
97
+ it("preserves order even when checks resolve out of order", async () => {
98
+ const delayed = (result: CheckResult, ms: number) =>
99
+ new Promise<CheckResult>((resolve) =>
100
+ setTimeout(() => resolve(result), ms),
101
+ );
102
+
103
+ // Resolve the first check slowest and the last fastest to prove the report
104
+ // order follows declaration order, not completion order.
105
+ vi.mocked(checkNodeVersion).mockReturnValue(delayed(pass("Node"), 30));
106
+ vi.mocked(checkPragmaVersion).mockResolvedValue(pass("pragma"));
107
+ vi.mocked(checkConfigFile).mockResolvedValue(pass("config"));
108
+ vi.mocked(checkPackageRefs).mockResolvedValue(pass("refs"));
109
+ vi.mocked(checkKeStore).mockResolvedValue(pass("store"));
110
+ vi.mocked(checkShellCompletions).mockResolvedValue(pass("completions"));
111
+ vi.mocked(checkMcpConfigured).mockResolvedValue(pass("mcp"));
112
+ vi.mocked(checkSkillsSymlinked).mockReturnValue(delayed(pass("skills"), 0));
113
+
114
+ const data = await runChecks({ cwd: "/test" });
115
+ expect(data.checks.map((c) => c.name)).toEqual([
116
+ "Node",
117
+ "pragma",
118
+ "config",
119
+ "refs",
120
+ "store",
121
+ "completions",
122
+ "mcp",
123
+ "skills",
124
+ ]);
125
+ });
126
+
127
+ it("surfaces a thrown check as an attributable fail instead of aborting", async () => {
128
+ vi.mocked(checkNodeVersion).mockResolvedValue(pass("Node"));
129
+ vi.mocked(checkPragmaVersion).mockResolvedValue(pass("pragma"));
130
+ vi.mocked(checkConfigFile).mockRejectedValue(new Error("disk on fire"));
131
+ vi.mocked(checkPackageRefs).mockResolvedValue(pass("refs"));
132
+ vi.mocked(checkKeStore).mockResolvedValue(pass("store"));
133
+ vi.mocked(checkShellCompletions).mockResolvedValue(pass("completions"));
134
+ vi.mocked(checkMcpConfigured).mockResolvedValue(pass("mcp"));
135
+ vi.mocked(checkSkillsSymlinked).mockResolvedValue(pass("skills"));
136
+
137
+ const data = await runChecks({ cwd: "/test" });
138
+
139
+ // The whole run still completes; the thrown check becomes a fail in place.
140
+ expect(data.checks).toHaveLength(8);
141
+ expect(data.passed).toBe(7);
142
+ expect(data.failed).toBe(1);
143
+ const failed = data.checks[2];
144
+ expect(failed.status).toBe("fail");
145
+ // Fallback name matches checkConfigFile's own `name` so a thrown check
146
+ // reports the same label as a normal pass/fail.
147
+ expect(failed.name).toBe("pragma.config.json");
148
+ expect(failed.detail).toContain("disk on fire");
149
+ expect(failed.remedy).toBeDefined();
150
+ });
151
+
152
+ it("handles a non-Error rejection without crashing", async () => {
153
+ vi.mocked(checkNodeVersion).mockResolvedValue(pass("Node"));
154
+ vi.mocked(checkPragmaVersion).mockResolvedValue(pass("pragma"));
155
+ vi.mocked(checkConfigFile).mockResolvedValue(pass("config"));
156
+ vi.mocked(checkPackageRefs).mockResolvedValue(pass("refs"));
157
+ vi.mocked(checkKeStore).mockRejectedValue("string failure");
158
+ vi.mocked(checkShellCompletions).mockResolvedValue(pass("completions"));
159
+ vi.mocked(checkMcpConfigured).mockResolvedValue(pass("mcp"));
160
+ vi.mocked(checkSkillsSymlinked).mockResolvedValue(pass("skills"));
161
+
162
+ const data = await runChecks({ cwd: "/test" });
163
+ expect(data.failed).toBe(1);
164
+ expect(data.checks[4].name).toBe("ke store");
165
+ expect(data.checks[4].detail).toContain("string failure");
166
+ });
95
167
  });
@@ -11,8 +11,35 @@ import {
11
11
  import type { CheckContext, CheckResult, DoctorData } from "./types.js";
12
12
 
13
13
  /**
14
- * Orchestrate all environment health checks sequentially and return
15
- * a DoctorData summary with pass/fail/skip counts.
14
+ * Each check paired with the display name used if it rejects. The checks are
15
+ * mutually independent, so they run concurrently; this list also fixes the
16
+ * stable display order of the results. The fallback name must match the
17
+ * `name` each check returns on success, so a thrown check reports the same
18
+ * label as a normal pass/fail and stays correlatable.
19
+ */
20
+ function buildChecks(
21
+ ctx: CheckContext,
22
+ ): readonly [string, Promise<CheckResult>][] {
23
+ return [
24
+ ["Node version", checkNodeVersion()],
25
+ ["pragma version", checkPragmaVersion()],
26
+ ["pragma.config.json", checkConfigFile(ctx)],
27
+ ["package refs", checkPackageRefs(ctx)],
28
+ ["ke store", checkKeStore(ctx)],
29
+ ["Shell completions", checkShellCompletions()],
30
+ ["MCP configured", checkMcpConfigured(ctx)],
31
+ ["Skills symlinked", checkSkillsSymlinked(ctx)],
32
+ ];
33
+ }
34
+
35
+ /**
36
+ * Orchestrate all environment health checks concurrently and return a
37
+ * DoctorData summary with pass/fail/skip counts.
38
+ *
39
+ * Checks run concurrently; results are collected in declaration order so the
40
+ * report is deterministic. A check that throws is surfaced as a fail (rather
41
+ * than aborting `pragma doctor`), tagged with the check's name so the failure
42
+ * remains attributable.
16
43
  *
17
44
  * @param ctx - Check context containing the working directory.
18
45
  * @returns Aggregated check results.
@@ -21,16 +48,25 @@ import type { CheckContext, CheckResult, DoctorData } from "./types.js";
21
48
  export default async function runChecks(
22
49
  ctx: CheckContext,
23
50
  ): Promise<DoctorData> {
24
- const checks: CheckResult[] = [];
25
-
26
- checks.push(await checkNodeVersion());
27
- checks.push(await checkPragmaVersion());
28
- checks.push(await checkConfigFile(ctx));
29
- checks.push(await checkPackageRefs(ctx));
30
- checks.push(await checkKeStore(ctx));
31
- checks.push(await checkShellCompletions());
32
- checks.push(await checkMcpConfigured(ctx));
33
- checks.push(await checkSkillsSymlinked(ctx));
51
+ // buildChecks starts every check eagerly, so they run concurrently; each is
52
+ // guarded so a thrown check becomes an attributable fail rather than
53
+ // rejecting the whole run. Mapping preserves declaration order.
54
+ const checks: CheckResult[] = await Promise.all(
55
+ buildChecks(ctx).map(async ([name, promise]): Promise<CheckResult> => {
56
+ try {
57
+ return await promise;
58
+ } catch (error) {
59
+ const reason = error instanceof Error ? error.message : String(error);
60
+ return {
61
+ name,
62
+ status: "fail",
63
+ detail: `Check threw an unexpected error: ${reason}`,
64
+ remedy:
65
+ "Re-run `pragma doctor`; if it persists, report this as a bug.",
66
+ };
67
+ }
68
+ }),
69
+ );
34
70
 
35
71
  const passed = checks.filter((c) => c.status === "pass").length;
36
72
  const failed = checks.filter((c) => c.status === "fail").length;
@@ -0,0 +1,194 @@
1
+ import {
2
+ existsSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import type { CommandDefinition } from "@canonical/cli-core";
11
+ import { hashSources } from "@canonical/ke-graphql";
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+ import {
14
+ EX_PREFIX_ENTRY,
15
+ GRAPHQL_CLEAN_TTL,
16
+ GRAPHQL_ERROR_TTL,
17
+ GRAPHQL_FATAL_TTL,
18
+ } from "#testing";
19
+ import type { PragmaContext } from "../../shared/context.js";
20
+ import type { GraphqlCompileReport } from "../types.js";
21
+ import buildCommand from "./build.js";
22
+
23
+ let dir: string;
24
+ let stdoutSpy: ReturnType<typeof vi.spyOn>;
25
+
26
+ beforeEach(() => {
27
+ dir = mkdtempSync(join(tmpdir(), "pragma-graphql-build-"));
28
+ writeFileSync(join(dir, "clean.ttl"), GRAPHQL_CLEAN_TTL);
29
+ writeFileSync(join(dir, "error.ttl"), GRAPHQL_ERROR_TTL);
30
+ writeFileSync(join(dir, "fatal.ttl"), GRAPHQL_FATAL_TTL);
31
+ stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
32
+ });
33
+
34
+ afterEach(() => {
35
+ stdoutSpy.mockRestore();
36
+ rmSync(dir, { recursive: true, force: true });
37
+ });
38
+
39
+ function makeCtx(overrides: Partial<PragmaContext> = {}): PragmaContext {
40
+ return {
41
+ cwd: dir,
42
+ globalFlags: { llm: false, format: "text" as const, verbose: false },
43
+ store: {} as PragmaContext["store"],
44
+ config: { tier: undefined, channel: "normal" },
45
+ ...overrides,
46
+ } as PragmaContext;
47
+ }
48
+
49
+ async function executeOutput(
50
+ cmd: CommandDefinition,
51
+ params: Record<string, unknown>,
52
+ ctx: PragmaContext,
53
+ ): Promise<{ value: GraphqlCompileReport; text: string }> {
54
+ const result = await cmd.execute(params, ctx);
55
+ expect(result.tag).toBe("output");
56
+ if (result.tag !== "output") throw new Error("Expected output result");
57
+ return {
58
+ value: result.value as GraphqlCompileReport,
59
+ text: result.render.plain(result.value),
60
+ };
61
+ }
62
+
63
+ describe("graphql build command", () => {
64
+ it("writes the SDL and extraction artifacts to the default paths", async () => {
65
+ const ctx = makeCtx();
66
+ const { value } = await executeOutput(
67
+ buildCommand,
68
+ { sources: ["clean.ttl"], prefix: [EX_PREFIX_ENTRY] },
69
+ ctx,
70
+ );
71
+
72
+ const sdl = readFileSync(join(dir, "schema.graphql"), "utf-8");
73
+ expect(sdl).toContain("type Thing");
74
+
75
+ const artifact = JSON.parse(
76
+ readFileSync(join(dir, "extraction.json"), "utf-8"),
77
+ ) as { version: number; sourcesHash: string };
78
+ expect(artifact.version).toBe(1);
79
+
80
+ const raw = readFileSync(join(dir, "clean.ttl"), "utf-8");
81
+ expect(artifact.sourcesHash).toBe(hashSources([raw]));
82
+ expect(value.artifacts).toEqual({
83
+ sdl: join(dir, "schema.graphql"),
84
+ extraction: join(dir, "extraction.json"),
85
+ });
86
+ });
87
+
88
+ it("writes artifacts to custom --sdl and --extraction paths", async () => {
89
+ const ctx = makeCtx();
90
+ await executeOutput(
91
+ buildCommand,
92
+ {
93
+ sources: ["clean.ttl"],
94
+ prefix: [EX_PREFIX_ENTRY],
95
+ sdl: "out/api.graphql",
96
+ extraction: "out/api-extraction.json",
97
+ },
98
+ ctx,
99
+ );
100
+
101
+ expect(readFileSync(join(dir, "out", "api.graphql"), "utf-8")).toContain(
102
+ "type Thing",
103
+ );
104
+ expect(
105
+ JSON.parse(
106
+ readFileSync(join(dir, "out", "api-extraction.json"), "utf-8"),
107
+ ),
108
+ ).toMatchObject({ version: 1 });
109
+ });
110
+
111
+ it("renders plain output with a summary and artifact paths", async () => {
112
+ const ctx = makeCtx();
113
+ const { text } = await executeOutput(
114
+ buildCommand,
115
+ { sources: ["clean.ttl"], prefix: [EX_PREFIX_ENTRY] },
116
+ ctx,
117
+ );
118
+
119
+ expect(text).toContain("1 source file: 0 errors, 0 warnings, 0 info");
120
+ expect(text).toContain(`Wrote ${join(dir, "schema.graphql")}`);
121
+ expect(text).toContain(`Wrote ${join(dir, "extraction.json")}`);
122
+ });
123
+
124
+ it("prints error diagnostics but still writes artifacts", async () => {
125
+ const ctx = makeCtx();
126
+ const { text } = await executeOutput(
127
+ buildCommand,
128
+ { sources: ["error.ttl"], prefix: [EX_PREFIX_ENTRY] },
129
+ ctx,
130
+ );
131
+
132
+ expect(text).toContain("M001");
133
+ expect(text).toContain("1 error");
134
+ expect(existsSync(join(dir, "schema.graphql"))).toBe(true);
135
+ expect(existsSync(join(dir, "extraction.json"))).toBe(true);
136
+ });
137
+
138
+ it("renders llm output", async () => {
139
+ const ctx = makeCtx({
140
+ globalFlags: { llm: true, format: "text" as const, verbose: false },
141
+ });
142
+ const { text } = await executeOutput(
143
+ buildCommand,
144
+ { sources: ["clean.ttl"], prefix: [EX_PREFIX_ENTRY] },
145
+ ctx,
146
+ );
147
+
148
+ expect(text).toContain("## GraphQL Compile");
149
+ expect(text).toContain("**Summary:**");
150
+ });
151
+
152
+ it("renders json output", async () => {
153
+ const ctx = makeCtx({
154
+ globalFlags: { llm: false, format: "json" as const, verbose: false },
155
+ });
156
+ const { text } = await executeOutput(
157
+ buildCommand,
158
+ { sources: ["clean.ttl"], prefix: [EX_PREFIX_ENTRY] },
159
+ ctx,
160
+ );
161
+
162
+ const parsed = JSON.parse(text) as GraphqlCompileReport;
163
+ expect(parsed.diagnostics).toEqual([]);
164
+ expect(parsed.files).toEqual([join(dir, "clean.ttl")]);
165
+ });
166
+
167
+ it("exits non-zero and writes no artifacts when compilation fails", async () => {
168
+ const ctx = makeCtx();
169
+ const result = await buildCommand.execute(
170
+ { sources: ["fatal.ttl"], prefix: [EX_PREFIX_ENTRY] },
171
+ ctx,
172
+ );
173
+
174
+ expect(result.tag).toBe("exit");
175
+ if (result.tag !== "exit") throw new Error("Expected exit result");
176
+ expect(result.code).toBe(1);
177
+ expect(existsSync(join(dir, "schema.graphql"))).toBe(false);
178
+ expect(existsSync(join(dir, "extraction.json"))).toBe(false);
179
+
180
+ const printed = stdoutSpy.mock.calls.map((call) => call[0]).join("");
181
+ expect(printed).toContain("C003");
182
+ });
183
+
184
+ it("throws structured error when sources are missing", async () => {
185
+ const ctx = makeCtx();
186
+
187
+ await expect(buildCommand.execute({}, ctx)).rejects.toMatchObject({
188
+ code: "INVALID_INPUT",
189
+ recovery: {
190
+ message: "Provide at least one TTL file or glob pattern.",
191
+ },
192
+ });
193
+ });
194
+ });
@@ -0,0 +1,125 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import {
4
+ type CommandDefinition,
5
+ type CommandResult,
6
+ createExitResult,
7
+ createOutputResult,
8
+ } from "@canonical/cli-core";
9
+ import { serializeExtraction } from "@canonical/ke-graphql";
10
+ import { PragmaError } from "#error";
11
+ import { selectFormatter } from "../../shared/formatters.js";
12
+ import { reportFormatters } from "../formatters/index.js";
13
+ import { parsePrefixes } from "../helpers/index.js";
14
+ import { compileSchema } from "../operations/index.js";
15
+ import type { GraphqlCompileReport } from "../types.js";
16
+
17
+ const DEFAULT_SDL_PATH = "./schema.graphql";
18
+ const DEFAULT_EXTRACTION_PATH = "./extraction.json";
19
+
20
+ /** Narrow an unknown param to a string array. */
21
+ function readStringArray(value: unknown): string[] {
22
+ return Array.isArray(value)
23
+ ? value.filter((item): item is string => typeof item === "string")
24
+ : [];
25
+ }
26
+
27
+ /**
28
+ * Builds the `pragma graphql build` command definition.
29
+ *
30
+ * Compiles TTL sources into a GraphQL schema, writes the SDL and the
31
+ * extraction artifact, and prints all compiler diagnostics. Exits
32
+ * non-zero when schema composition fails.
33
+ */
34
+ const buildCommand: CommandDefinition = {
35
+ path: ["graphql", "build"],
36
+ description: "Compile TTL sources into GraphQL schema artifacts",
37
+ parameters: [
38
+ {
39
+ name: "sources",
40
+ description: "TTL source files or glob patterns",
41
+ type: "multiselect",
42
+ positional: true,
43
+ required: true,
44
+ },
45
+ {
46
+ name: "sdl",
47
+ description: "Output path for the GraphQL SDL",
48
+ type: "string",
49
+ default: DEFAULT_SDL_PATH,
50
+ },
51
+ {
52
+ name: "extraction",
53
+ description: "Output path for the extraction artifact",
54
+ type: "string",
55
+ default: DEFAULT_EXTRACTION_PATH,
56
+ },
57
+ {
58
+ name: "prefix",
59
+ description: "Ontology prefix as name=namespace (repeatable)",
60
+ type: "multiselect",
61
+ },
62
+ ],
63
+ meta: {
64
+ examples: [
65
+ "pragma graphql build ontology.ttl",
66
+ 'pragma graphql build "data/*.ttl" --sdl out/schema.graphql --extraction out/extraction.json',
67
+ "pragma graphql build ontology.ttl --prefix ds=https://ds.canonical.com/",
68
+ ],
69
+ },
70
+ async execute(params, ctx): Promise<CommandResult> {
71
+ const sources = readStringArray(params.sources);
72
+ if (sources.length === 0) {
73
+ throw PragmaError.invalidInput("sources", "(empty)", {
74
+ recovery: {
75
+ message: "Provide at least one TTL file or glob pattern.",
76
+ },
77
+ });
78
+ }
79
+
80
+ const prefixes = parsePrefixes(readStringArray(params.prefix));
81
+ const outcome = await compileSchema({ sources, prefixes, cwd: ctx.cwd });
82
+ const format = selectFormatter(ctx, reportFormatters);
83
+
84
+ if (outcome.status === "failed" || !outcome.compiled) {
85
+ const report: GraphqlCompileReport = {
86
+ diagnostics: outcome.diagnostics,
87
+ files: outcome.files,
88
+ sourcesHash: outcome.sourcesHash,
89
+ };
90
+ process.stdout.write(`${format(report)}\n`);
91
+ return createExitResult(1);
92
+ }
93
+
94
+ const sdlPath = resolve(
95
+ ctx.cwd,
96
+ typeof params.sdl === "string" ? params.sdl : DEFAULT_SDL_PATH,
97
+ );
98
+ const extractionPath = resolve(
99
+ ctx.cwd,
100
+ typeof params.extraction === "string"
101
+ ? params.extraction
102
+ : DEFAULT_EXTRACTION_PATH,
103
+ );
104
+
105
+ mkdirSync(dirname(sdlPath), { recursive: true });
106
+ writeFileSync(sdlPath, outcome.compiled.sdl, "utf-8");
107
+ mkdirSync(dirname(extractionPath), { recursive: true });
108
+ writeFileSync(
109
+ extractionPath,
110
+ serializeExtraction(outcome.compiled.extraction, outcome.sourcesHash),
111
+ "utf-8",
112
+ );
113
+
114
+ const report: GraphqlCompileReport = {
115
+ diagnostics: outcome.diagnostics,
116
+ files: outcome.files,
117
+ sourcesHash: outcome.sourcesHash,
118
+ artifacts: { sdl: sdlPath, extraction: extractionPath },
119
+ };
120
+
121
+ return createOutputResult(report, { plain: format });
122
+ },
123
+ };
124
+
125
+ export default buildCommand;