@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.
- package/dist/pragma +0 -0
- package/package.json +30 -18
- package/src/constants.ts +1 -1
- package/src/domains/block/blockConfig.ts +47 -3
- package/src/domains/block/commands/list.test.ts +1 -1
- package/src/domains/block/commands/list.ts +27 -0
- package/src/domains/block/commands/lookup.ts +26 -52
- package/src/domains/block/formatters/index.ts +1 -1
- package/src/domains/block/formatters/list.test.ts +1 -1
- package/src/domains/block/formatters/list.ts +28 -42
- package/src/domains/block/formatters/lookup.test.ts +3 -2
- package/src/domains/block/formatters/lookup.ts +91 -296
- package/src/domains/block/formatters/types.ts +1 -1
- package/src/domains/block/operations/list.test.ts +1 -1
- package/src/domains/block/operations/list.ts +1 -1
- package/src/domains/block/operations/lookup.test.ts +29 -8
- package/src/domains/block/operations/lookup.ts +94 -16
- package/src/domains/block/orchestration/blockEmptyError.ts +1 -1
- package/src/domains/block/orchestration/buildBlockFilters.ts +1 -1
- package/src/domains/block/orchestration/enrichBlocks.ts +6 -2
- package/src/domains/block/orchestration/resolveBlockList.ts +1 -1
- package/src/domains/block/orchestration/resolveBlockLookup.ts +8 -2
- package/src/domains/block/types.ts +1 -1
- package/src/domains/config/commands/channel.ts +6 -4
- package/src/domains/config/commands/tier.ts +6 -4
- package/src/domains/config/mcp/specs.ts +18 -6
- package/src/domains/config/operations/validateTier.ts +1 -1
- package/src/domains/config/tasks/index.ts +3 -0
- package/src/domains/config/tasks/setChannel.ts +30 -0
- package/src/domains/config/tasks/setTier.ts +33 -0
- package/src/domains/create/commands/package.test.ts +2 -2
- package/src/domains/doctor/mcp/index.ts +1 -1
- package/src/domains/doctor/mcp/specs.ts +2 -49
- package/src/domains/graph/commands/inspect.test.ts +1 -1
- package/src/domains/graph/formatters/inspect.ts +12 -7
- package/src/domains/graph/operations/inspectUri.ts +4 -1
- package/src/domains/info/commands/upgrade.ts +6 -3
- package/src/domains/info/index.ts +3 -1
- package/src/domains/info/mcp/index.ts +2 -0
- package/src/domains/info/mcp/specs.ts +65 -0
- package/src/domains/llm/data/commandReference.ts +4 -4
- package/src/domains/llm/data/decisionTrees.ts +14 -12
- package/src/domains/llm/operations/collectContext.ts +1 -1
- package/src/domains/modifier/commands/list.ts +9 -0
- package/src/domains/modifier/commands/lookup.ts +22 -42
- package/src/domains/modifier/formatters/index.ts +4 -1
- package/src/domains/modifier/formatters/list.test.ts +1 -1
- package/src/domains/modifier/formatters/list.ts +13 -26
- package/src/domains/modifier/formatters/lookup.test.ts +1 -1
- package/src/domains/modifier/formatters/lookup.ts +41 -29
- package/src/domains/modifier/index.ts +1 -0
- package/src/domains/modifier/modifierConfig.ts +36 -0
- package/src/domains/modifier/operations/list.ts +1 -1
- package/src/domains/modifier/operations/lookup.ts +4 -3
- package/src/domains/modifier/orchestration/resolveModifierLookup.ts +1 -1
- package/src/domains/modifier/types.ts +1 -1
- package/src/domains/ontology/commands/list.test.ts +1 -1
- package/src/domains/ontology/commands/list.ts +18 -0
- package/src/domains/ontology/commands/show.test.ts +1 -1
- package/src/domains/ontology/formatters/list.ts +5 -3
- package/src/domains/ontology/formatters/show.ts +38 -13
- package/src/domains/ontology/helpers/queryClasses.ts +1 -1
- package/src/domains/ontology/helpers/queryProperties.ts +1 -1
- package/src/domains/ontology/operations/listOntologies.ts +1 -1
- package/src/domains/ontology/operations/showOntology.ts +1 -1
- package/src/domains/setup/helpers/completionScripts.test.ts +3 -3
- package/src/domains/setup/helpers/completionScripts.ts +4 -4
- package/src/domains/shared/ToolSpec.ts +1 -1
- package/src/domains/shared/context.ts +3 -2
- package/src/domains/shared/contracts.ts +40 -1
- package/src/domains/shared/filters/buildFilterConfig.ts +1 -2
- package/src/domains/shared/filters/buildFilters.ts +1 -1
- package/src/domains/shared/filters/describeFilters.ts +1 -1
- package/src/domains/shared/formatters.ts +47 -0
- package/src/domains/shared/renderers.ts +410 -0
- package/src/domains/shared/runtime.ts +7 -22
- package/src/domains/shared/types/block.ts +107 -0
- package/src/domains/shared/types/common.ts +33 -0
- package/src/domains/shared/types/graph.ts +17 -0
- package/src/domains/shared/types/index.ts +49 -0
- package/src/domains/shared/types/modifier.ts +22 -0
- package/src/domains/shared/types/ontology.ts +50 -0
- package/src/domains/shared/types/runtime.ts +21 -0
- package/src/domains/shared/types/standard.ts +64 -0
- package/src/domains/shared/types/tier.ts +15 -0
- package/src/domains/shared/types/token.ts +30 -0
- package/src/domains/standard/commands/list.ts +9 -0
- package/src/domains/standard/commands/lookup.ts +22 -50
- package/src/domains/standard/formatters/categories.test.ts +1 -1
- package/src/domains/standard/formatters/categories.ts +1 -1
- package/src/domains/standard/formatters/index.ts +4 -1
- package/src/domains/standard/formatters/list.test.ts +4 -1
- package/src/domains/standard/formatters/list.ts +112 -84
- package/src/domains/standard/formatters/lookup.test.ts +1 -1
- package/src/domains/standard/formatters/lookup.ts +85 -70
- package/src/domains/standard/formatters/types.ts +1 -1
- package/src/domains/standard/index.ts +1 -0
- package/src/domains/standard/operations/categories.ts +1 -1
- package/src/domains/standard/operations/list.ts +1 -1
- package/src/domains/standard/operations/lookup.ts +1 -1
- package/src/domains/standard/orchestration/buildStandardFilters.ts +1 -1
- package/src/domains/standard/orchestration/resolveStandardList.ts +4 -1
- package/src/domains/standard/orchestration/resolveStandardLookup.ts +1 -1
- package/src/domains/standard/orchestration/standardEmptyError.ts +1 -1
- package/src/domains/standard/standardConfig.ts +42 -0
- package/src/domains/standard/types.ts +1 -1
- package/src/domains/tier/commands/list.ts +16 -0
- package/src/domains/tier/formatters/list.test.ts +1 -1
- package/src/domains/tier/formatters/list.ts +11 -6
- package/src/domains/tier/operations/list.ts +1 -1
- package/src/domains/token/commands/list.ts +9 -0
- package/src/domains/token/commands/lookup.ts +22 -48
- package/src/domains/token/formatters/index.ts +4 -1
- package/src/domains/token/formatters/list.test.ts +1 -1
- package/src/domains/token/formatters/list.ts +15 -28
- package/src/domains/token/formatters/lookup.test.ts +2 -2
- package/src/domains/token/formatters/lookup.ts +80 -45
- package/src/domains/token/index.ts +1 -0
- package/src/domains/token/operations/list.ts +1 -1
- package/src/domains/token/operations/lookup.ts +1 -1
- package/src/domains/token/orchestration/resolveTokenLookup.ts +1 -1
- package/src/domains/token/tokenConfig.ts +38 -0
- package/src/domains/token/types.ts +1 -1
- package/src/index.ts +1 -1
- package/src/mcp/runMcpServer.test.ts +2 -2
- package/src/mcp/tools/index.ts +2 -0
- package/src/mcp/tools/registerTools.test.ts +8 -3
- package/src/package-manager/types.ts +1 -3
- package/src/pipeline/createProgram.ts +20 -1
- package/src/pipeline/index.ts +4 -1
- package/src/pipeline/parseGlobalFlags.test.ts +92 -0
- package/src/pipeline/parseGlobalFlags.ts +25 -0
- package/src/pipeline/runCli.orchestration.test.ts +1 -0
- package/src/pipeline/runCli.ts +11 -4
- package/src/pipeline/runInteractiveCommand.ts +198 -0
- package/src/testing/cli.ts +2 -2
- package/src/testing/fixtures.ts +2 -2
- package/src/testing/integration/boot.test.ts +2 -2
- package/src/testing/integration/cli-user-stories.test.ts +198 -0
- package/src/testing/integration/edge-cases.test.ts +263 -0
- package/src/testing/integration/parity.test.ts +377 -27
- package/src/testing/store.ts +1 -1
- package/src/tui/components/BulletList/BulletList.tsx +46 -0
- package/src/tui/components/BulletList/index.ts +2 -0
- package/src/tui/components/BulletList/types.ts +6 -0
- package/src/tui/components/Card/Card.tsx +73 -0
- package/src/tui/components/Card/index.ts +1 -0
- package/src/tui/components/Card/types.ts +12 -0
- package/src/tui/components/CodeBlock/CodeBlock.tsx +37 -0
- package/src/tui/components/CodeBlock/index.ts +2 -0
- package/src/tui/components/CodeBlock/types.ts +6 -0
- package/src/tui/components/DataRow/DataRow.tsx +80 -0
- package/src/tui/components/DataRow/index.ts +2 -0
- package/src/tui/components/DataRow/types.ts +14 -0
- package/src/tui/components/FieldRow/FieldRow.tsx +24 -0
- package/src/tui/components/FieldRow/index.ts +2 -0
- package/src/tui/components/FieldRow/types.ts +10 -0
- package/src/tui/components/HeaderRow/HeaderRow.tsx +32 -0
- package/src/tui/components/HeaderRow/index.ts +2 -0
- package/src/tui/components/HeaderRow/types.ts +8 -0
- package/src/tui/components/MarkdownText/MarkdownText.tsx +19 -0
- package/src/tui/components/MarkdownText/index.ts +2 -0
- package/src/tui/components/MarkdownText/types.ts +4 -0
- package/src/tui/components/MiniTable/MiniTable.tsx +82 -0
- package/src/tui/components/MiniTable/index.ts +2 -0
- package/src/tui/components/MiniTable/types.ts +6 -0
- package/src/tui/components/SectionRenderer/SectionRenderer.tsx +91 -0
- package/src/tui/components/SectionRenderer/index.ts +2 -0
- package/src/tui/components/SectionRenderer/types.ts +15 -0
- package/src/tui/components/TreeView/TreeView.tsx +84 -0
- package/src/tui/components/TreeView/index.ts +2 -0
- package/src/tui/components/TreeView/types.ts +6 -0
- package/src/tui/components/index.ts +19 -0
- package/src/tui/constants.ts +39 -0
- package/src/tui/createListView.ts +23 -0
- package/src/tui/createLookupView.ts +26 -0
- package/src/tui/helpers/computeWidths.ts +53 -0
- package/src/tui/helpers/fitColumns.ts +33 -0
- package/src/tui/helpers/formatMarkdown.ts +19 -0
- package/src/tui/helpers/getTerminalSize.ts +16 -0
- package/src/tui/helpers/index.ts +6 -0
- package/src/tui/helpers/syntaxColor.ts +67 -0
- package/src/tui/helpers/truncateText.ts +12 -0
- package/src/tui/hooks/index.ts +3 -0
- package/src/tui/hooks/types.ts +7 -0
- package/src/tui/hooks/useAutoExit.ts +17 -0
- package/src/tui/hooks/useTerminalSize.ts +19 -0
- package/src/tui/index.ts +15 -0
- package/src/tui/renderInk.tsx +18 -0
- package/src/tui/types.ts +12 -0
- package/src/tui/views/ListView/ListView.tsx +104 -0
- package/src/tui/views/ListView/index.ts +2 -0
- package/src/tui/views/ListView/types.ts +14 -0
- package/src/tui/views/LookupView/LookupView.tsx +244 -0
- package/src/tui/views/LookupView/index.ts +2 -0
- package/src/tui/views/LookupView/types.ts +16 -0
- package/src/tui/views/index.ts +4 -0
- package/src/domains/shared/types.test-d.ts +0 -127
- 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;
|
package/src/testing/cli.ts
CHANGED
|
@@ -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:
|
|
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 };
|
package/src/testing/fixtures.ts
CHANGED
|
@@ -26,13 +26,13 @@ describe("PragmaRuntime boot", () => {
|
|
|
26
26
|
expect(rt.config.channel).toBe("normal");
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
it("boots in under
|
|
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(
|
|
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
|
+
});
|