@canonical/pragma-cli 0.20.0 → 0.21.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 (199) hide show
  1. package/dist/pragma +0 -0
  2. package/package.json +30 -18
  3. package/src/constants.ts +1 -1
  4. package/src/domains/block/blockConfig.ts +47 -3
  5. package/src/domains/block/commands/list.test.ts +1 -1
  6. package/src/domains/block/commands/list.ts +27 -0
  7. package/src/domains/block/commands/lookup.ts +26 -52
  8. package/src/domains/block/formatters/index.ts +1 -1
  9. package/src/domains/block/formatters/list.test.ts +1 -1
  10. package/src/domains/block/formatters/list.ts +28 -42
  11. package/src/domains/block/formatters/lookup.test.ts +3 -2
  12. package/src/domains/block/formatters/lookup.ts +91 -296
  13. package/src/domains/block/formatters/types.ts +1 -1
  14. package/src/domains/block/operations/list.test.ts +1 -1
  15. package/src/domains/block/operations/list.ts +1 -1
  16. package/src/domains/block/operations/lookup.test.ts +29 -8
  17. package/src/domains/block/operations/lookup.ts +94 -16
  18. package/src/domains/block/orchestration/blockEmptyError.ts +1 -1
  19. package/src/domains/block/orchestration/buildBlockFilters.ts +1 -1
  20. package/src/domains/block/orchestration/enrichBlocks.ts +6 -2
  21. package/src/domains/block/orchestration/resolveBlockList.ts +1 -1
  22. package/src/domains/block/orchestration/resolveBlockLookup.ts +8 -2
  23. package/src/domains/block/types.ts +1 -1
  24. package/src/domains/config/commands/channel.ts +6 -4
  25. package/src/domains/config/commands/tier.ts +6 -4
  26. package/src/domains/config/mcp/specs.ts +18 -6
  27. package/src/domains/config/operations/validateTier.ts +1 -1
  28. package/src/domains/config/tasks/index.ts +3 -0
  29. package/src/domains/config/tasks/setChannel.ts +30 -0
  30. package/src/domains/config/tasks/setTier.ts +33 -0
  31. package/src/domains/create/commands/package.test.ts +2 -2
  32. package/src/domains/doctor/mcp/index.ts +1 -1
  33. package/src/domains/doctor/mcp/specs.ts +2 -49
  34. package/src/domains/graph/commands/inspect.test.ts +1 -1
  35. package/src/domains/graph/formatters/inspect.ts +12 -7
  36. package/src/domains/graph/operations/inspectUri.ts +4 -1
  37. package/src/domains/info/commands/upgrade.ts +6 -3
  38. package/src/domains/info/index.ts +3 -1
  39. package/src/domains/info/mcp/index.ts +2 -0
  40. package/src/domains/info/mcp/specs.ts +65 -0
  41. package/src/domains/llm/data/commandReference.ts +4 -4
  42. package/src/domains/llm/data/decisionTrees.ts +14 -12
  43. package/src/domains/llm/operations/collectContext.ts +1 -1
  44. package/src/domains/modifier/commands/list.ts +9 -0
  45. package/src/domains/modifier/commands/lookup.ts +22 -42
  46. package/src/domains/modifier/formatters/index.ts +4 -1
  47. package/src/domains/modifier/formatters/list.test.ts +1 -1
  48. package/src/domains/modifier/formatters/list.ts +13 -26
  49. package/src/domains/modifier/formatters/lookup.test.ts +1 -1
  50. package/src/domains/modifier/formatters/lookup.ts +41 -29
  51. package/src/domains/modifier/index.ts +1 -0
  52. package/src/domains/modifier/modifierConfig.ts +36 -0
  53. package/src/domains/modifier/operations/list.ts +1 -1
  54. package/src/domains/modifier/operations/lookup.ts +4 -3
  55. package/src/domains/modifier/orchestration/resolveModifierLookup.ts +1 -1
  56. package/src/domains/modifier/types.ts +1 -1
  57. package/src/domains/ontology/commands/list.test.ts +1 -1
  58. package/src/domains/ontology/commands/list.ts +18 -0
  59. package/src/domains/ontology/commands/show.test.ts +1 -1
  60. package/src/domains/ontology/formatters/list.ts +5 -3
  61. package/src/domains/ontology/formatters/show.ts +38 -13
  62. package/src/domains/ontology/helpers/queryClasses.ts +1 -1
  63. package/src/domains/ontology/helpers/queryProperties.ts +1 -1
  64. package/src/domains/ontology/operations/listOntologies.ts +1 -1
  65. package/src/domains/ontology/operations/showOntology.ts +1 -1
  66. package/src/domains/setup/helpers/completionScripts.test.ts +3 -3
  67. package/src/domains/setup/helpers/completionScripts.ts +4 -4
  68. package/src/domains/shared/ToolSpec.ts +1 -1
  69. package/src/domains/shared/context.ts +3 -2
  70. package/src/domains/shared/contracts.ts +40 -1
  71. package/src/domains/shared/filters/buildFilterConfig.ts +1 -2
  72. package/src/domains/shared/filters/buildFilters.ts +1 -1
  73. package/src/domains/shared/filters/describeFilters.ts +1 -1
  74. package/src/domains/shared/formatters.ts +47 -0
  75. package/src/domains/shared/renderers.ts +410 -0
  76. package/src/domains/shared/runtime.ts +7 -22
  77. package/src/domains/shared/types/block.ts +107 -0
  78. package/src/domains/shared/types/common.ts +33 -0
  79. package/src/domains/shared/types/graph.ts +17 -0
  80. package/src/domains/shared/types/index.ts +49 -0
  81. package/src/domains/shared/types/modifier.ts +22 -0
  82. package/src/domains/shared/types/ontology.ts +50 -0
  83. package/src/domains/shared/types/runtime.ts +21 -0
  84. package/src/domains/shared/types/standard.ts +64 -0
  85. package/src/domains/shared/types/tier.ts +15 -0
  86. package/src/domains/shared/types/token.ts +30 -0
  87. package/src/domains/standard/commands/list.ts +9 -0
  88. package/src/domains/standard/commands/lookup.ts +22 -50
  89. package/src/domains/standard/formatters/categories.test.ts +1 -1
  90. package/src/domains/standard/formatters/categories.ts +1 -1
  91. package/src/domains/standard/formatters/index.ts +4 -1
  92. package/src/domains/standard/formatters/list.test.ts +4 -1
  93. package/src/domains/standard/formatters/list.ts +112 -84
  94. package/src/domains/standard/formatters/lookup.test.ts +1 -1
  95. package/src/domains/standard/formatters/lookup.ts +85 -70
  96. package/src/domains/standard/formatters/types.ts +1 -1
  97. package/src/domains/standard/index.ts +1 -0
  98. package/src/domains/standard/operations/categories.ts +1 -1
  99. package/src/domains/standard/operations/list.ts +1 -1
  100. package/src/domains/standard/operations/lookup.ts +1 -1
  101. package/src/domains/standard/orchestration/buildStandardFilters.ts +1 -1
  102. package/src/domains/standard/orchestration/resolveStandardList.ts +4 -1
  103. package/src/domains/standard/orchestration/resolveStandardLookup.ts +1 -1
  104. package/src/domains/standard/orchestration/standardEmptyError.ts +1 -1
  105. package/src/domains/standard/standardConfig.ts +42 -0
  106. package/src/domains/standard/types.ts +1 -1
  107. package/src/domains/tier/commands/list.ts +16 -0
  108. package/src/domains/tier/formatters/list.test.ts +1 -1
  109. package/src/domains/tier/formatters/list.ts +11 -6
  110. package/src/domains/tier/operations/list.ts +1 -1
  111. package/src/domains/token/commands/list.ts +9 -0
  112. package/src/domains/token/commands/lookup.ts +22 -48
  113. package/src/domains/token/formatters/index.ts +4 -1
  114. package/src/domains/token/formatters/list.test.ts +1 -1
  115. package/src/domains/token/formatters/list.ts +15 -28
  116. package/src/domains/token/formatters/lookup.test.ts +2 -2
  117. package/src/domains/token/formatters/lookup.ts +80 -45
  118. package/src/domains/token/index.ts +1 -0
  119. package/src/domains/token/operations/list.ts +1 -1
  120. package/src/domains/token/operations/lookup.ts +1 -1
  121. package/src/domains/token/orchestration/resolveTokenLookup.ts +1 -1
  122. package/src/domains/token/tokenConfig.ts +38 -0
  123. package/src/domains/token/types.ts +1 -1
  124. package/src/index.ts +1 -1
  125. package/src/mcp/runMcpServer.test.ts +2 -2
  126. package/src/mcp/tools/index.ts +2 -0
  127. package/src/mcp/tools/registerTools.test.ts +8 -3
  128. package/src/package-manager/types.ts +1 -3
  129. package/src/pipeline/createProgram.ts +20 -1
  130. package/src/pipeline/index.ts +4 -1
  131. package/src/pipeline/parseGlobalFlags.test.ts +92 -0
  132. package/src/pipeline/parseGlobalFlags.ts +25 -0
  133. package/src/pipeline/runCli.orchestration.test.ts +1 -0
  134. package/src/pipeline/runCli.ts +11 -4
  135. package/src/pipeline/runInteractiveCommand.ts +198 -0
  136. package/src/testing/cli.ts +2 -2
  137. package/src/testing/fixtures.ts +2 -2
  138. package/src/testing/integration/boot.test.ts +2 -2
  139. package/src/testing/integration/cli-user-stories.test.ts +198 -0
  140. package/src/testing/integration/edge-cases.test.ts +263 -0
  141. package/src/testing/integration/parity.test.ts +377 -27
  142. package/src/testing/store.ts +1 -1
  143. package/src/tui/components/BulletList/BulletList.tsx +46 -0
  144. package/src/tui/components/BulletList/index.ts +2 -0
  145. package/src/tui/components/BulletList/types.ts +6 -0
  146. package/src/tui/components/Card/Card.tsx +73 -0
  147. package/src/tui/components/Card/index.ts +1 -0
  148. package/src/tui/components/Card/types.ts +12 -0
  149. package/src/tui/components/CodeBlock/CodeBlock.tsx +37 -0
  150. package/src/tui/components/CodeBlock/index.ts +2 -0
  151. package/src/tui/components/CodeBlock/types.ts +6 -0
  152. package/src/tui/components/DataRow/DataRow.tsx +80 -0
  153. package/src/tui/components/DataRow/index.ts +2 -0
  154. package/src/tui/components/DataRow/types.ts +14 -0
  155. package/src/tui/components/FieldRow/FieldRow.tsx +24 -0
  156. package/src/tui/components/FieldRow/index.ts +2 -0
  157. package/src/tui/components/FieldRow/types.ts +10 -0
  158. package/src/tui/components/HeaderRow/HeaderRow.tsx +32 -0
  159. package/src/tui/components/HeaderRow/index.ts +2 -0
  160. package/src/tui/components/HeaderRow/types.ts +8 -0
  161. package/src/tui/components/MarkdownText/MarkdownText.tsx +19 -0
  162. package/src/tui/components/MarkdownText/index.ts +2 -0
  163. package/src/tui/components/MarkdownText/types.ts +4 -0
  164. package/src/tui/components/MiniTable/MiniTable.tsx +82 -0
  165. package/src/tui/components/MiniTable/index.ts +2 -0
  166. package/src/tui/components/MiniTable/types.ts +6 -0
  167. package/src/tui/components/SectionRenderer/SectionRenderer.tsx +91 -0
  168. package/src/tui/components/SectionRenderer/index.ts +2 -0
  169. package/src/tui/components/SectionRenderer/types.ts +15 -0
  170. package/src/tui/components/TreeView/TreeView.tsx +84 -0
  171. package/src/tui/components/TreeView/index.ts +2 -0
  172. package/src/tui/components/TreeView/types.ts +6 -0
  173. package/src/tui/components/index.ts +19 -0
  174. package/src/tui/constants.ts +39 -0
  175. package/src/tui/createListView.ts +23 -0
  176. package/src/tui/createLookupView.ts +26 -0
  177. package/src/tui/helpers/computeWidths.ts +53 -0
  178. package/src/tui/helpers/fitColumns.ts +33 -0
  179. package/src/tui/helpers/formatMarkdown.ts +19 -0
  180. package/src/tui/helpers/getTerminalSize.ts +16 -0
  181. package/src/tui/helpers/index.ts +6 -0
  182. package/src/tui/helpers/syntaxColor.ts +67 -0
  183. package/src/tui/helpers/truncateText.ts +12 -0
  184. package/src/tui/hooks/index.ts +3 -0
  185. package/src/tui/hooks/types.ts +7 -0
  186. package/src/tui/hooks/useAutoExit.ts +17 -0
  187. package/src/tui/hooks/useTerminalSize.ts +19 -0
  188. package/src/tui/index.ts +15 -0
  189. package/src/tui/renderInk.tsx +18 -0
  190. package/src/tui/types.ts +12 -0
  191. package/src/tui/views/ListView/ListView.tsx +104 -0
  192. package/src/tui/views/ListView/index.ts +2 -0
  193. package/src/tui/views/ListView/types.ts +14 -0
  194. package/src/tui/views/LookupView/LookupView.tsx +244 -0
  195. package/src/tui/views/LookupView/index.ts +2 -0
  196. package/src/tui/views/LookupView/types.ts +16 -0
  197. package/src/tui/views/index.ts +4 -0
  198. package/src/domains/shared/types.test-d.ts +0 -127
  199. package/src/domains/shared/types.ts +0 -348
@@ -0,0 +1,198 @@
1
+ import { stdout as processStdout } from "node:process";
2
+ import { createInterface } from "node:readline/promises";
3
+ import {
4
+ createExitResult,
5
+ type InteractiveHandler,
6
+ type InteractiveSpec,
7
+ } from "@canonical/cli-core";
8
+
9
+ type PromptWithWhen = InteractiveSpec["generator"]["prompts"][number] & {
10
+ readonly when?: ((answers: Record<string, unknown>) => boolean) | undefined;
11
+ };
12
+
13
+ const isInteractiveTerminal = (): boolean =>
14
+ process.stdin.isTTY === true && process.stdout.isTTY === true;
15
+
16
+ const getPromptDefault = (
17
+ prompt: PromptWithWhen,
18
+ answers: Record<string, unknown>,
19
+ ): unknown => {
20
+ if (prompt.name in answers) {
21
+ return answers[prompt.name];
22
+ }
23
+ return prompt.default;
24
+ };
25
+
26
+ async function promptForText(
27
+ rl: ReturnType<typeof createInterface>,
28
+ prompt: PromptWithWhen,
29
+ answers: Record<string, unknown>,
30
+ ): Promise<string> {
31
+ const defaultValue = getPromptDefault(prompt, answers);
32
+ const defaultSuffix =
33
+ defaultValue !== undefined ? ` (${String(defaultValue)})` : "";
34
+
35
+ while (true) {
36
+ const response = (
37
+ await rl.question(`${prompt.message}${defaultSuffix}: `)
38
+ ).trim();
39
+ if (response.length > 0) {
40
+ return response;
41
+ }
42
+ if (defaultValue !== undefined) {
43
+ return String(defaultValue);
44
+ }
45
+ }
46
+ }
47
+
48
+ async function promptForConfirm(
49
+ rl: ReturnType<typeof createInterface>,
50
+ prompt: PromptWithWhen,
51
+ answers: Record<string, unknown>,
52
+ ): Promise<boolean> {
53
+ const defaultValue = getPromptDefault(prompt, answers);
54
+ const hint =
55
+ defaultValue === true ? "Y/n" : defaultValue === false ? "y/N" : "y/n";
56
+
57
+ while (true) {
58
+ const response = (await rl.question(`${prompt.message} [${hint}]: `))
59
+ .trim()
60
+ .toLowerCase();
61
+
62
+ if (response.length === 0 && defaultValue !== undefined) {
63
+ return Boolean(defaultValue);
64
+ }
65
+ if (["y", "yes"].includes(response)) {
66
+ return true;
67
+ }
68
+ if (["n", "no"].includes(response)) {
69
+ return false;
70
+ }
71
+ }
72
+ }
73
+
74
+ async function promptForSelect(
75
+ rl: ReturnType<typeof createInterface>,
76
+ prompt: PromptWithWhen,
77
+ answers: Record<string, unknown>,
78
+ multiple: boolean,
79
+ ): Promise<string | string[]> {
80
+ const choices = prompt.choices ?? [];
81
+ const defaultValue = getPromptDefault(prompt, answers);
82
+
83
+ if (choices.length === 0) {
84
+ return multiple
85
+ ? Array.isArray(defaultValue)
86
+ ? defaultValue.map(String)
87
+ : defaultValue !== undefined
88
+ ? [String(defaultValue)]
89
+ : []
90
+ : String(defaultValue ?? "");
91
+ }
92
+
93
+ processStdout.write(`${prompt.message}\n`);
94
+ choices.forEach((choice, index) => {
95
+ processStdout.write(` ${index + 1}) ${choice.label}\n`);
96
+ });
97
+
98
+ const defaultSuffix =
99
+ defaultValue === undefined
100
+ ? ""
101
+ : ` (${Array.isArray(defaultValue) ? defaultValue.join(",") : String(defaultValue)})`;
102
+
103
+ while (true) {
104
+ const response = (
105
+ await rl.question(
106
+ multiple
107
+ ? `Choose one or more values${defaultSuffix}: `
108
+ : `Choose a value${defaultSuffix}: `,
109
+ )
110
+ ).trim();
111
+
112
+ if (response.length === 0 && defaultValue !== undefined) {
113
+ return multiple
114
+ ? Array.isArray(defaultValue)
115
+ ? defaultValue.map(String)
116
+ : [String(defaultValue)]
117
+ : String(defaultValue);
118
+ }
119
+
120
+ const parsed = response
121
+ .split(",")
122
+ .map((part) => part.trim())
123
+ .filter((part) => part.length > 0)
124
+ .map((part) => {
125
+ const index = Number(part);
126
+ if (Number.isInteger(index) && index >= 1 && index <= choices.length) {
127
+ return choices[index - 1]?.value;
128
+ }
129
+
130
+ const matched = choices.find(
131
+ (choice) => choice.value === part || choice.label === part,
132
+ );
133
+ return matched?.value;
134
+ })
135
+ .filter((value): value is string => typeof value === "string");
136
+
137
+ if (parsed.length === 0) {
138
+ continue;
139
+ }
140
+
141
+ return multiple ? parsed : (parsed[0] ?? String(defaultValue ?? ""));
142
+ }
143
+ }
144
+
145
+ async function promptForValue(
146
+ rl: ReturnType<typeof createInterface>,
147
+ prompt: PromptWithWhen,
148
+ answers: Record<string, unknown>,
149
+ ): Promise<unknown> {
150
+ switch (prompt.type) {
151
+ case "confirm":
152
+ return promptForConfirm(rl, prompt, answers);
153
+ case "select":
154
+ return promptForSelect(rl, prompt, answers, false);
155
+ case "multiselect":
156
+ return promptForSelect(rl, prompt, answers, true);
157
+ default:
158
+ return promptForText(rl, prompt, answers);
159
+ }
160
+ }
161
+
162
+ const runInteractiveCommand: InteractiveHandler = async ({
163
+ spec,
164
+ command,
165
+ params,
166
+ ctx,
167
+ }) => {
168
+ if (!isInteractiveTerminal()) {
169
+ return null;
170
+ }
171
+
172
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
173
+ const answers: Record<string, unknown> = { ...spec.partialAnswers };
174
+
175
+ try {
176
+ processStdout.write("\n");
177
+
178
+ for (const prompt of spec.generator.prompts as readonly PromptWithWhen[]) {
179
+ if (prompt.when && prompt.when(answers) !== true) {
180
+ continue;
181
+ }
182
+
183
+ answers[prompt.name] = await promptForValue(rl, prompt, answers);
184
+ }
185
+ } catch (error) {
186
+ if (error instanceof Error && error.name === "AbortError") {
187
+ process.exitCode = 130;
188
+ return createExitResult(130);
189
+ }
190
+ throw error;
191
+ } finally {
192
+ rl.close();
193
+ }
194
+
195
+ return command.execute({ ...params, ...answers, yes: true }, ctx);
196
+ };
197
+
198
+ export default runInteractiveCommand;
@@ -29,7 +29,7 @@ function runCommand(args: string[], cwd?: string): CommandResult {
29
29
  const result = spawnSync("bun", ["run", binPath, ...args], {
30
30
  cwd,
31
31
  encoding: "utf-8",
32
- timeout: 10_000,
32
+ timeout: 20_000,
33
33
  });
34
34
 
35
35
  return {
@@ -39,5 +39,5 @@ function runCommand(args: string[], cwd?: string): CommandResult {
39
39
  };
40
40
  }
41
41
 
42
- export { runCommand };
43
42
  export type { CommandResult };
43
+ export { runCommand };
@@ -27,8 +27,8 @@ const MOCK_BIN_PATHS: Record<string, string> = {
27
27
  };
28
28
 
29
29
  export {
30
+ MOCK_BIN_PATHS,
31
+ SAMPLE_CONFIG_EMPTY,
30
32
  SAMPLE_CONFIG_FULL,
31
33
  SAMPLE_CONFIG_TIER_ONLY,
32
- SAMPLE_CONFIG_EMPTY,
33
- MOCK_BIN_PATHS,
34
34
  };
@@ -26,13 +26,13 @@ describe("PragmaRuntime boot", () => {
26
26
  expect(rt.config.channel).toBe("normal");
27
27
  });
28
28
 
29
- it("boots in under 100ms", async () => {
29
+ it("boots in under 250ms", async () => {
30
30
  const start = performance.now();
31
31
  const rt = await createTestRuntime();
32
32
  const elapsed = performance.now() - start;
33
33
  runtimes.push(rt);
34
34
 
35
- expect(elapsed).toBeLessThan(100);
35
+ expect(elapsed).toBeLessThan(250);
36
36
  });
37
37
 
38
38
  it("respects filtered config for tier", async () => {
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Layer 4b: CLI user-story end-to-end tests.
3
+ *
4
+ * Runs the real `pragma` binary through subprocesses and validates
5
+ * realistic user journeys against its JSON output.
6
+ */
7
+
8
+ import {
9
+ existsSync,
10
+ mkdtempSync,
11
+ readFileSync,
12
+ rmSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { afterEach, describe, expect, it } from "vitest";
18
+ import { type CommandResult, runCommand } from "../cli.js";
19
+
20
+ const tempDirs = new Set<string>();
21
+
22
+ afterEach(() => {
23
+ for (const dir of tempDirs) {
24
+ rmSync(dir, { recursive: true, force: true });
25
+ }
26
+ tempDirs.clear();
27
+ });
28
+
29
+ function createWorkspace(config?: Record<string, unknown>): string {
30
+ const dir = mkdtempSync(join(tmpdir(), "pragma-cli-e2e-"));
31
+ tempDirs.add(dir);
32
+
33
+ if (config !== undefined) {
34
+ writeFileSync(
35
+ join(dir, "pragma.config.json"),
36
+ `${JSON.stringify(config, null, 2)}\n`,
37
+ "utf-8",
38
+ );
39
+ }
40
+
41
+ return dir;
42
+ }
43
+
44
+ function expectSuccess(result: CommandResult): void {
45
+ expect(result.exitCode).toBe(0);
46
+ expect(result.stderr.trim()).toBe("");
47
+ }
48
+
49
+ function parseJson<T>(result: CommandResult): T {
50
+ expectSuccess(result);
51
+ return JSON.parse(result.stdout) as T;
52
+ }
53
+
54
+ describe("CLI user stories", () => {
55
+ it("story: a new contributor verifies the local installation with info", () => {
56
+ const workspace = createWorkspace();
57
+
58
+ const result = runCommand(["info", "--format", "json"], workspace);
59
+ const data = parseJson<{
60
+ version: string;
61
+ pm: string;
62
+ installSource: string;
63
+ channel: string;
64
+ store: { tripleCount: number };
65
+ }>(result);
66
+
67
+ expect(data.version).toMatch(/^\d+\.\d+\.\d+/);
68
+ expect(data.pm).toBe("bun");
69
+ expect(data.installSource).toContain("local");
70
+ expect(data.channel).toBe("normal");
71
+ expect(data.store.tripleCount).toBeGreaterThan(0);
72
+ }, 20_000);
73
+
74
+ it("story: an explorer sees duplicate Button matches before scoping the workspace", () => {
75
+ const workspace = createWorkspace();
76
+
77
+ const result = runCommand(
78
+ ["block", "lookup", "button", "--format", "json"],
79
+ workspace,
80
+ );
81
+ const data = parseJson<{
82
+ results: { name: string; tier: string }[];
83
+ errors: unknown[];
84
+ }>(result);
85
+
86
+ expect(data.errors).toEqual([]);
87
+ expect(data.results.length).toBeGreaterThan(1);
88
+ expect(data.results.every((item) => item.name === "Button")).toBe(true);
89
+ expect(data.results.some((item) => item.tier === "global")).toBe(true);
90
+ }, 20_000);
91
+
92
+ it("story: an app engineer scopes to global and resolves Button case-insensitively", () => {
93
+ const workspace = createWorkspace({ tier: "global", channel: "normal" });
94
+
95
+ const configResult = runCommand(
96
+ ["config", "show", "--format", "json"],
97
+ workspace,
98
+ );
99
+ const config = parseJson<{
100
+ tier: string;
101
+ channel: string;
102
+ configFileExists: boolean;
103
+ configFilePath: string;
104
+ }>(configResult);
105
+
106
+ expect(config.tier).toBe("global");
107
+ expect(config.channel).toBe("normal");
108
+ expect(config.configFileExists).toBe(true);
109
+ expect(config.configFilePath).toContain("pragma.config.json");
110
+
111
+ const lookupResult = runCommand(
112
+ ["block", "lookup", "button", "--format", "json"],
113
+ workspace,
114
+ );
115
+ const block = parseJson<{
116
+ name: string;
117
+ tier: string;
118
+ uri: string;
119
+ }>(lookupResult);
120
+
121
+ expect(block.name).toBe("Button");
122
+ expect(block.tier).toBe("global");
123
+ expect(block.uri).toContain("global.component.button");
124
+ }, 20_000);
125
+
126
+ it("story: a standards auditor inspects a code rule in structured output", () => {
127
+ const workspace = createWorkspace();
128
+
129
+ const result = runCommand(
130
+ ["standard", "lookup", "code/function/purity", "--format", "json"],
131
+ workspace,
132
+ );
133
+ const standard = parseJson<{
134
+ name: string;
135
+ category: string;
136
+ description: string;
137
+ }>(result);
138
+
139
+ expect(standard.name).toBe("code/function/purity");
140
+ expect(standard.category).toBe("code");
141
+ expect(standard.description.toLowerCase()).toContain("pure");
142
+ }, 20_000);
143
+
144
+ it("story: a component author previews a React scaffold before writing files", () => {
145
+ const workspace = createWorkspace();
146
+
147
+ const result = runCommand(
148
+ [
149
+ "create",
150
+ "component",
151
+ "react",
152
+ "src/components/Button",
153
+ "--dry-run",
154
+ "--yes",
155
+ "--format",
156
+ "json",
157
+ ],
158
+ workspace,
159
+ );
160
+ const plan = parseJson<{
161
+ generator: { name: string };
162
+ answers: { componentPath: string };
163
+ plan: { action: string; path: string }[];
164
+ files: Record<string, string>;
165
+ }>(result);
166
+
167
+ expect(plan.generator.name).toBe("component/react");
168
+ expect(plan.answers.componentPath).toBe("src/components/Button");
169
+ expect(
170
+ plan.plan.some(
171
+ (step) =>
172
+ step.action === "create" &&
173
+ step.path === "src/components/Button/Button.tsx",
174
+ ),
175
+ ).toBe(true);
176
+ expect(plan.files).toHaveProperty("src/components/Button/styles.css");
177
+ expect(plan.files).toHaveProperty("src/components/index.ts");
178
+ }, 20_000);
179
+
180
+ it("story: a component author can create a React scaffold from positional args", () => {
181
+ const workspace = createWorkspace();
182
+
183
+ const result = runCommand(
184
+ ["create", "component", "react", "MySth"],
185
+ workspace,
186
+ );
187
+
188
+ expectSuccess(result);
189
+ expect(result.stdout).toContain("Generation complete.");
190
+
191
+ const componentFile = join(workspace, "MySth", "MySth.tsx");
192
+ const indexFile = join(workspace, "MySth", "index.ts");
193
+
194
+ expect(existsSync(componentFile)).toBe(true);
195
+ expect(existsSync(indexFile)).toBe(true);
196
+ expect(readFileSync(indexFile, "utf-8")).toContain("./MySth");
197
+ }, 20_000);
198
+ });
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Layer 3b: Edge-case and error-path tests.
3
+ *
4
+ * Covers error conditions that are not exercised by the happy-path
5
+ * parity tests or the basic MCP surface tests: unknown prefixes,
6
+ * all-fail batches, empty result sets, and condensed-mode error
7
+ * rendering.
8
+ */
9
+
10
+ import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
11
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
12
+ import type { PragmaRuntime } from "../../domains/shared/runtime.js";
13
+ import createTestMcpClient from "../helpers/createTestMcpClient.js";
14
+ import createTestRuntime from "../helpers/createTestRuntime.js";
15
+
16
+ let rt: PragmaRuntime;
17
+ let client: Client;
18
+ let cleanup: () => Promise<void>;
19
+
20
+ beforeAll(async () => {
21
+ rt = await createTestRuntime();
22
+ const mcp = await createTestMcpClient(rt);
23
+ client = mcp.client;
24
+ cleanup = mcp.cleanup;
25
+ });
26
+
27
+ afterAll(async () => {
28
+ await cleanup();
29
+ rt.dispose();
30
+ });
31
+
32
+ /** Parse MCP response envelope. */
33
+ function parseEnvelope(
34
+ mcpResponse: Record<string, unknown>,
35
+ ): Record<string, unknown> {
36
+ const content = mcpResponse.content as unknown[];
37
+ const first = content[0] as { text: string };
38
+ return JSON.parse(first.text) as Record<string, unknown>;
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Unknown prefix IRI resolution
43
+ // ---------------------------------------------------------------------------
44
+
45
+ describe("IRI resolution edge cases", () => {
46
+ it("block_lookup with unknown prefix returns error", async () => {
47
+ const res = await client.callTool({
48
+ name: "block_lookup",
49
+ arguments: { names: ["zz:nonexistent.block"] },
50
+ });
51
+ const body = parseEnvelope(res);
52
+ expect(body.ok).toBe(true);
53
+ const data = body.data as { results: unknown[]; errors: unknown[] };
54
+ expect(data.results).toHaveLength(0);
55
+ expect(data.errors).toHaveLength(1);
56
+ });
57
+
58
+ it("standard_lookup with unknown prefix returns error", async () => {
59
+ const res = await client.callTool({
60
+ name: "standard_lookup",
61
+ arguments: { names: ["zz:nonexistent.standard"] },
62
+ });
63
+ const body = parseEnvelope(res);
64
+ expect(body.ok).toBe(true);
65
+ const data = body.data as { results: unknown[]; errors: unknown[] };
66
+ expect(data.results).toHaveLength(0);
67
+ expect(data.errors).toHaveLength(1);
68
+ });
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // All-fail batch lookups
73
+ // ---------------------------------------------------------------------------
74
+
75
+ describe("all-fail batch lookups", () => {
76
+ it("block_lookup: all names unknown → empty results, all errors", async () => {
77
+ const res = await client.callTool({
78
+ name: "block_lookup",
79
+ arguments: { names: ["Unknown1", "Unknown2", "Unknown3"] },
80
+ });
81
+ const body = parseEnvelope(res);
82
+ expect(body.ok).toBe(true);
83
+ const data = body.data as {
84
+ results: unknown[];
85
+ errors: { query: string; code: string }[];
86
+ };
87
+ expect(data.results).toHaveLength(0);
88
+ expect(data.errors).toHaveLength(3);
89
+ for (const err of data.errors) {
90
+ expect(err.code).toBe("ENTITY_NOT_FOUND");
91
+ }
92
+ });
93
+
94
+ it("standard_lookup: all names unknown → empty results, all errors", async () => {
95
+ const res = await client.callTool({
96
+ name: "standard_lookup",
97
+ arguments: { names: ["no/such/standard", "also/missing"] },
98
+ });
99
+ const body = parseEnvelope(res);
100
+ expect(body.ok).toBe(true);
101
+ const data = body.data as {
102
+ results: unknown[];
103
+ errors: { query: string }[];
104
+ };
105
+ expect(data.results).toHaveLength(0);
106
+ expect(data.errors).toHaveLength(2);
107
+ });
108
+
109
+ it("modifier_lookup: unknown family → error", async () => {
110
+ const res = await client.callTool({
111
+ name: "modifier_lookup",
112
+ arguments: { names: ["nonexistent_family"] },
113
+ });
114
+ const body = parseEnvelope(res);
115
+ expect(body.ok).toBe(true);
116
+ const data = body.data as {
117
+ results: unknown[];
118
+ errors: { query: string; code: string }[];
119
+ };
120
+ expect(data.results).toHaveLength(0);
121
+ expect(data.errors).toHaveLength(1);
122
+ expect(data.errors[0]?.code).toBe("ENTITY_NOT_FOUND");
123
+ });
124
+
125
+ it("token_lookup: unknown token → error", async () => {
126
+ const res = await client.callTool({
127
+ name: "token_lookup",
128
+ arguments: { names: ["color.nonexistent"] },
129
+ });
130
+ const body = parseEnvelope(res);
131
+ expect(body.ok).toBe(true);
132
+ const data = body.data as {
133
+ results: unknown[];
134
+ errors: { query: string; code: string }[];
135
+ };
136
+ expect(data.results).toHaveLength(0);
137
+ expect(data.errors).toHaveLength(1);
138
+ expect(data.errors[0]?.code).toBe("ENTITY_NOT_FOUND");
139
+ });
140
+ });
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Empty result sets (filtered lists)
144
+ // ---------------------------------------------------------------------------
145
+
146
+ describe("empty result sets", () => {
147
+ it("standard_list with nonexistent category → error envelope", async () => {
148
+ const res = await client.callTool({
149
+ name: "standard_list",
150
+ arguments: { category: "nonexistent_category" },
151
+ });
152
+ const body = parseEnvelope(res);
153
+ // resolveStandardList throws when no results found
154
+ expect(body.ok).toBe(false);
155
+ const error = body.error as { code: string };
156
+ expect(error.code).toBeDefined();
157
+ });
158
+
159
+ it("token_list with nonexistent category → empty or error", async () => {
160
+ const res = await client.callTool({
161
+ name: "token_list",
162
+ arguments: { category: "NonexistentType" },
163
+ });
164
+ const body = parseEnvelope(res);
165
+ if (body.ok) {
166
+ const data = body.data as unknown[];
167
+ expect(data).toHaveLength(0);
168
+ } else {
169
+ // Token list may throw on empty results
170
+ const error = body.error as { code: string };
171
+ expect(error.code).toBeDefined();
172
+ }
173
+ });
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // ontology_show with unknown prefix
178
+ // ---------------------------------------------------------------------------
179
+
180
+ describe("ontology_show edge cases", () => {
181
+ it("unknown prefix → error envelope", async () => {
182
+ const res = await client.callTool({
183
+ name: "ontology_show",
184
+ arguments: { prefix: "zzz" },
185
+ });
186
+ const body = parseEnvelope(res);
187
+ expect(body.ok).toBe(false);
188
+ const error = body.error as { code: string };
189
+ expect(error.code).toBeDefined();
190
+ });
191
+ });
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Condensed mode with errors
195
+ // ---------------------------------------------------------------------------
196
+
197
+ describe("condensed mode error rendering", () => {
198
+ it("block_lookup condensed: includes error section for unknown blocks", async () => {
199
+ const res = await client.callTool({
200
+ name: "block_lookup",
201
+ arguments: {
202
+ names: ["Button", "Nonexistent"],
203
+ condensed: true,
204
+ },
205
+ });
206
+ const body = parseEnvelope(res);
207
+ expect(body.condensed).toBe(true);
208
+ const text = body.text as string;
209
+ // Should contain the successful block
210
+ expect(text).toContain("Button");
211
+ // Should contain the errors section
212
+ expect(text).toContain("Errors");
213
+ expect(text).toContain("Nonexistent");
214
+ });
215
+
216
+ it("standard_lookup condensed: includes error section for unknown standards", async () => {
217
+ const res = await client.callTool({
218
+ name: "standard_lookup",
219
+ arguments: {
220
+ names: ["react/component/folder-structure", "no/such/standard"],
221
+ condensed: true,
222
+ },
223
+ });
224
+ const body = parseEnvelope(res);
225
+ expect(body.condensed).toBe(true);
226
+ const text = body.text as string;
227
+ expect(text).toContain("folder-structure");
228
+ expect(text).toContain("Errors");
229
+ expect(text).toContain("no/such/standard");
230
+ });
231
+
232
+ it("modifier_lookup condensed: includes error for unknown family", async () => {
233
+ const res = await client.callTool({
234
+ name: "modifier_lookup",
235
+ arguments: {
236
+ names: ["importance", "nonexistent"],
237
+ condensed: true,
238
+ },
239
+ });
240
+ const body = parseEnvelope(res);
241
+ expect(body.condensed).toBe(true);
242
+ const text = body.text as string;
243
+ expect(text).toContain("importance");
244
+ expect(text).toContain("Errors");
245
+ expect(text).toContain("nonexistent");
246
+ });
247
+
248
+ it("token_lookup condensed: includes error for unknown token", async () => {
249
+ const res = await client.callTool({
250
+ name: "token_lookup",
251
+ arguments: {
252
+ names: ["color.primary", "color.nonexistent"],
253
+ condensed: true,
254
+ },
255
+ });
256
+ const body = parseEnvelope(res);
257
+ expect(body.condensed).toBe(true);
258
+ const text = body.text as string;
259
+ expect(text).toContain("color.primary");
260
+ expect(text).toContain("Errors");
261
+ expect(text).toContain("color.nonexistent");
262
+ });
263
+ });