@canonical/pragma-cli 0.32.0 → 0.33.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/README.md +72 -0
- package/dist/pragma +0 -0
- package/package.json +14 -13
- package/src/bin.ts +2 -2
- package/src/capabilities/block/blockList.test.ts +79 -0
- package/src/capabilities/block/blockList.verb.ts +18 -5
- package/src/capabilities/block/pack.ts +12 -1
- package/src/capabilities/block/parity.test.ts +9 -2
- package/src/capabilities/capabilities/capabilities.test.ts +10 -1
- package/src/capabilities/capabilities/capabilities.verb.ts +1 -1
- package/src/capabilities/capabilities/catalog.ts +7 -3
- package/src/capabilities/capabilities/hints.ts +8 -17
- package/src/capabilities/capabilities/types.ts +2 -0
- package/src/capabilities/colophon/colophon.render.test.ts +31 -4
- package/src/capabilities/colophon/colophon.render.ts +13 -4
- package/src/capabilities/colophon/colophon.test.ts +3 -3
- package/src/capabilities/colophon/colophon.verb.ts +4 -1
- package/src/capabilities/colophon/markdownTerminal.ts +33 -14
- package/src/capabilities/colophon/pragmaColophon.ts +4 -4
- package/src/capabilities/colophon/types.ts +1 -1
- package/src/capabilities/config/fields.ts +21 -92
- package/src/capabilities/config/set.test.ts +6 -6
- package/src/capabilities/config/set.verb.ts +5 -5
- package/src/capabilities/config/show.render.test.ts +65 -0
- package/src/capabilities/config/show.render.ts +56 -10
- package/src/capabilities/config/show.verb.ts +5 -5
- package/src/capabilities/create/byteEquality.subprocess.test.ts +1 -1
- package/src/capabilities/create/byteEquality.test.ts +7 -4
- package/src/capabilities/create/compiledCreate.subprocess.test.ts +1 -1
- package/src/capabilities/create/create.test.ts +32 -3
- package/src/capabilities/create/create.verb.ts +69 -11
- package/src/capabilities/doctor/checks/checkConfigFile.ts +1 -1
- package/src/capabilities/doctor/checks/checkMcpCommands.ts +6 -0
- package/src/capabilities/doctor/checks/checkMcpConfigured.ts +10 -4
- package/src/capabilities/doctor/checks/deriveBand.test.ts +42 -0
- package/src/capabilities/doctor/checks/deriveBand.ts +28 -0
- package/src/capabilities/doctor/doctor.render.test.ts +170 -0
- package/src/capabilities/doctor/doctor.render.ts +116 -34
- package/src/capabilities/doctor/doctor.test.ts +31 -1
- package/src/capabilities/doctor/runChecks.ts +28 -4
- package/src/capabilities/doctor/types.ts +15 -0
- package/src/capabilities/ontology/index.ts +10 -3
- package/src/capabilities/ontology/ontology.test.ts +157 -5
- package/src/capabilities/ontology/verbs.ts +192 -93
- package/src/capabilities/prompt/prompt.render.test.ts +94 -0
- package/src/capabilities/prompt/prompt.render.ts +23 -6
- package/src/capabilities/prompt/prompt.test.ts +61 -0
- package/src/capabilities/reference.test.ts +32 -0
- package/src/capabilities/resources/provider.ts +28 -14
- package/src/capabilities/resources/resources.test.ts +99 -0
- package/src/capabilities/scopeTypeSync.test.ts +37 -0
- package/src/capabilities/setup/index.ts +7 -1
- package/src/capabilities/setup/operations/setupCompletions.ts +49 -16
- package/src/capabilities/setup/operations/setupGenerator.ts +157 -61
- package/src/capabilities/setup/operations/setupLsp.ts +56 -12
- package/src/capabilities/setup/operations/setupMcp.ts +216 -39
- package/src/capabilities/setup/operations/setupSkills.ts +7 -1
- package/src/capabilities/setup/setup.render.test.ts +109 -0
- package/src/capabilities/setup/setup.render.ts +61 -14
- package/src/capabilities/setup/setup.test.ts +352 -1
- package/src/capabilities/setup/setup.verb.ts +57 -5
- package/src/capabilities/setup/types.ts +59 -2
- package/src/capabilities/shared/bands.ts +16 -0
- package/src/capabilities/sources/collectStatus.ts +19 -4
- package/src/capabilities/sources/runUpdate.ts +58 -1
- package/src/capabilities/sources/sources.test.ts +187 -1
- package/src/capabilities/sources/status.render.test.ts +65 -0
- package/src/capabilities/sources/status.render.ts +50 -23
- package/src/capabilities/surface.test.ts +34 -9
- package/src/capabilities/tier/lookup.render.test.ts +72 -0
- package/src/capabilities/tier/lookup.render.ts +33 -8
- package/src/capabilities/tier/lookup.test.ts +15 -0
- package/src/capabilities/tier/runLookup.ts +28 -0
- package/src/constants.test.ts +1 -1
- package/src/constants.ts +2 -2
- package/src/kernel/completion/__snapshots__/emitScripts.test.ts.snap +13 -14
- package/src/kernel/completion/bashDrive.test.ts +3 -3
- package/src/kernel/completion/complete.test.ts +3 -5
- package/src/kernel/completion/entitySource.ts +14 -6
- package/src/kernel/completion/model.test.ts +2 -2
- package/src/kernel/completion/model.ts +0 -1
- package/src/kernel/completion/parse.test.ts +2 -4
- package/src/kernel/error/PragmaError.test.ts +4 -4
- package/src/kernel/error/renderError.ts +1 -1
- package/src/kernel/packs/compile.ts +1 -1
- package/src/kernel/project/cli/buildProgram.test.ts +51 -7
- package/src/kernel/project/cli/buildProgram.ts +11 -0
- package/src/kernel/project/cli/globalFlags.test.ts +12 -4
- package/src/kernel/project/cli/globalFlags.ts +14 -14
- package/src/kernel/project/cli/rootHelp.ts +4 -2
- package/src/kernel/project/mcp/instructions.ts +1 -1
- package/src/kernel/project/mcp/mcpError.ts +52 -0
- package/src/kernel/project/mcp/prompts/prompts.test.ts +8 -0
- package/src/kernel/project/mcp/prompts/provider.ts +36 -6
- package/src/kernel/render/formatters.ts +5 -4
- package/src/kernel/render/renderers.test.ts +34 -0
- package/src/kernel/render/renderers.ts +25 -7
- package/src/kernel/render/style.test.ts +31 -0
- package/src/kernel/render/style.ts +74 -0
- package/src/kernel/runtime/graphpack/build.ts +28 -0
- package/src/kernel/runtime/graphpack/buildIndex.test.ts +103 -0
- package/src/kernel/runtime/graphpack/buildIndex.ts +124 -36
- package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +3 -6
- package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -2
- package/src/kernel/runtime/graphpack/graphpack.test.ts +69 -1
- package/src/kernel/runtime/graphpack/read.ts +28 -13
- package/src/kernel/runtime/graphpack/types.ts +14 -0
- package/src/kernel/runtime/loadSession.ts +4 -12
- package/src/kernel/runtime/refs/resolve.ts +63 -3
- package/src/kernel/runtime/storeReadiness.ts +61 -0
- package/src/kernel/runtime/types.ts +1 -1
- package/src/kernel/runtime/wasmEmbed.test.ts +3 -1
- package/src/kernel/spec/emitReference.test.ts +196 -0
- package/src/kernel/spec/emitReference.ts +491 -0
- package/src/kernel/spec/emitSurface.ts +4 -2
- package/src/kernel/spec/index.ts +2 -0
- package/src/kernel/spec/surfaceConformance.test.ts +2 -2
- package/src/testing/PARITY_GAPS.ts +1 -1
- package/src/testing/behavioral/autoLlm.e2e.test.ts +12 -2
- package/src/testing/behavioral/completion.test.ts +2 -2
- package/src/testing/behavioral/disclosure.test.ts +12 -0
- package/src/testing/behavioral/docExamples.test.ts +283 -0
- package/src/testing/behavioral/errorMatrix.storeless.test.ts +2 -1
- package/src/testing/behavioral/journeys.cli.test.ts +3 -1
- package/src/testing/behavioral/journeys.defaultPack.test.ts +571 -0
- package/src/testing/behavioral/journeys.interactiveTty.e2e.test.ts +66 -0
- package/src/testing/behavioral/journeys.livePack.e2e.test.ts +129 -0
- package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +176 -0
- package/src/testing/behavioral/mcpSurface.test.ts +1 -1
- package/src/testing/behavioral/staleCommands.test.ts +76 -0
- package/src/testing/eval/__snapshots__/eval.test.ts.snap +2 -2
- package/src/testing/eval/cases/assembly.ts +3 -3
- package/src/testing/eval/cases/effectful.ts +6 -3
- package/src/testing/eval/cases/readNouns.ts +4 -1
- package/src/testing/fixtures/fixtureCapability.ts +156 -0
- package/src/testing/fixtures/graph/defaultPack.ts +178 -0
- package/src/testing/fixtures/graph/owlExport.ts +95 -0
- package/src/testing/helpers/extractSnippets.ts +29 -0
- package/src/testing/helpers/golden.ts +1 -1
- package/src/capabilities/config/field.test.ts +0 -188
|
@@ -129,6 +129,13 @@ const PACKAGE_MIRROR: PromptDefinition[] = [
|
|
|
129
129
|
},
|
|
130
130
|
];
|
|
131
131
|
|
|
132
|
+
// The include-flag prompts carry the CLI grammar's `--with-X` names
|
|
133
|
+
// (`withSsr`/`withRouter`/`withForms`/`withRelay`, AV-228 B8) rather than the
|
|
134
|
+
// summon generator's bare prompt names (`ssr`/`router`/`forms`/`relay`). {@link
|
|
135
|
+
// INCLUDE_FLAG_ALIASES} maps them back at the CLI↔generator boundary so the
|
|
136
|
+
// generator prompt names — and their embedded templates + byte-equality
|
|
137
|
+
// goldens — stay stable. `--run-install` (default false) is an action flag, not
|
|
138
|
+
// an include, so it keeps its spelling.
|
|
132
139
|
const APPLICATION_MIRROR: PromptDefinition[] = [
|
|
133
140
|
{
|
|
134
141
|
name: "appPath",
|
|
@@ -137,21 +144,21 @@ const APPLICATION_MIRROR: PromptDefinition[] = [
|
|
|
137
144
|
default: "my-app",
|
|
138
145
|
positional: true,
|
|
139
146
|
},
|
|
140
|
-
{ name: "
|
|
147
|
+
{ name: "withSsr", type: "confirm", message: "Include SSR?", default: true },
|
|
141
148
|
{
|
|
142
|
-
name: "
|
|
149
|
+
name: "withRouter",
|
|
143
150
|
type: "confirm",
|
|
144
151
|
message: "Include router?",
|
|
145
152
|
default: true,
|
|
146
153
|
},
|
|
147
154
|
{
|
|
148
|
-
name: "
|
|
155
|
+
name: "withForms",
|
|
149
156
|
type: "confirm",
|
|
150
157
|
message: "Include form components?",
|
|
151
158
|
default: true,
|
|
152
159
|
},
|
|
153
160
|
{
|
|
154
|
-
name: "
|
|
161
|
+
name: "withRelay",
|
|
155
162
|
type: "confirm",
|
|
156
163
|
message: "Include a Relay (GraphQL) data layer?",
|
|
157
164
|
default: false,
|
|
@@ -164,6 +171,50 @@ const APPLICATION_MIRROR: PromptDefinition[] = [
|
|
|
164
171
|
},
|
|
165
172
|
];
|
|
166
173
|
|
|
174
|
+
/**
|
|
175
|
+
* CLI include-flag names → summon generator prompt names, per create kind
|
|
176
|
+
* (AV-228 B8). All four `create application` include-flags are exposed on the
|
|
177
|
+
* unified `--with-X` convention, so their params arrive keyed `withSsr`/
|
|
178
|
+
* `withRouter`/`withForms`/`withRelay`; the summon generator reads the bare
|
|
179
|
+
* `ssr`/`router`/`forms`/`relay`. We normalize at the ONE CLI↔generator seam
|
|
180
|
+
* ({@link toGeneratorAnswers}), keeping the generator prompt names (and their
|
|
181
|
+
* templates/goldens) untouched. Component and package already use `--with-X`
|
|
182
|
+
* names that match their prompts, so their maps are empty.
|
|
183
|
+
*/
|
|
184
|
+
export const INCLUDE_FLAG_ALIASES: Record<
|
|
185
|
+
CreateKind,
|
|
186
|
+
Readonly<Record<string, string>>
|
|
187
|
+
> = {
|
|
188
|
+
component: {},
|
|
189
|
+
package: {},
|
|
190
|
+
application: {
|
|
191
|
+
withSsr: "ssr",
|
|
192
|
+
withRouter: "router",
|
|
193
|
+
withForms: "forms",
|
|
194
|
+
withRelay: "relay",
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Re-key a param bag's CLI include-flag aliases to the generator prompt names,
|
|
200
|
+
* so the summon generator reads `ssr` where the CLI grammar exposes `--with-ssr`.
|
|
201
|
+
*
|
|
202
|
+
* @param kind - The create noun (selects the alias map).
|
|
203
|
+
* @param params - The coerced CLI/MCP param bag.
|
|
204
|
+
* @returns A new bag with aliased keys renamed to the generator prompt names.
|
|
205
|
+
*/
|
|
206
|
+
export function toGeneratorAnswers(
|
|
207
|
+
kind: CreateKind,
|
|
208
|
+
params: Readonly<Record<string, unknown>>,
|
|
209
|
+
): Record<string, unknown> {
|
|
210
|
+
const aliases = INCLUDE_FLAG_ALIASES[kind];
|
|
211
|
+
const answers: Record<string, unknown> = {};
|
|
212
|
+
for (const [key, value] of Object.entries(params)) {
|
|
213
|
+
answers[aliases[key] ?? key] = value;
|
|
214
|
+
}
|
|
215
|
+
return answers;
|
|
216
|
+
}
|
|
217
|
+
|
|
167
218
|
const componentParams: ParamSpec[] = [
|
|
168
219
|
FRAMEWORK_PARAM,
|
|
169
220
|
COMPONENT_PATH_PARAM,
|
|
@@ -291,15 +342,20 @@ async function runCreate(
|
|
|
291
342
|
// injected here. `package`/`application` stay a source-run feature in the binary.
|
|
292
343
|
const { pickGenerator, summon } = await loadCreateRuntime(kind);
|
|
293
344
|
|
|
294
|
-
|
|
345
|
+
// Normalize the CLI/MCP `--with-X` include-flags to the generator prompt names
|
|
346
|
+
// (AV-228 B8) once, at this seam; every summon interaction below reads the
|
|
347
|
+
// generator-facing `answers` bag so the generator prompt names stay stable.
|
|
348
|
+
const answers = toGeneratorAnswers(kind, params);
|
|
349
|
+
|
|
350
|
+
const generator = pickGenerator(kind, answers);
|
|
295
351
|
|
|
296
352
|
// SEC-2: reject a path escaping the workspace BEFORE any effect runs.
|
|
297
353
|
const pathParam = PATH_PARAM[kind];
|
|
298
|
-
if (pathParam) assertInsideWorkspace(pathParam,
|
|
354
|
+
if (pathParam) assertInsideWorkspace(pathParam, answers[pathParam], rt.cwd);
|
|
299
355
|
|
|
300
356
|
// Reject a flag/arg-provided answer that fails its prompt's own constraint,
|
|
301
357
|
// with a clean INVALID_INPUT (execute re-validates as a backstop).
|
|
302
|
-
const invalid = summon.validateAnswers(generator.prompts,
|
|
358
|
+
const invalid = summon.validateAnswers(generator.prompts, answers);
|
|
303
359
|
if (invalid !== null) {
|
|
304
360
|
throw new PragmaError({ code: "INVALID_INPUT", message: invalid });
|
|
305
361
|
}
|
|
@@ -336,14 +392,16 @@ async function runCreate(
|
|
|
336
392
|
};
|
|
337
393
|
return summon.execute(generator, {
|
|
338
394
|
prompt: session.promptHandler,
|
|
339
|
-
params,
|
|
395
|
+
params: answers,
|
|
340
396
|
signal,
|
|
341
397
|
});
|
|
342
398
|
}
|
|
343
399
|
|
|
344
400
|
// Non-interactive: MCP → params-or-error; CLI/--yes/CI → flags+defaults.
|
|
345
401
|
const prompt =
|
|
346
|
-
transport === "mcp"
|
|
402
|
+
transport === "mcp"
|
|
403
|
+
? summon.mcpPrompt(answers)
|
|
404
|
+
: summon.autoPrompt(answers);
|
|
347
405
|
// Same per-call write root as the Ink branch: `rt.cwd` feeds both the SEC-2
|
|
348
406
|
// jail and the interpreter's effect-path base, atomically.
|
|
349
407
|
rt.exec = {
|
|
@@ -353,7 +411,7 @@ async function runCreate(
|
|
|
353
411
|
onLog: (_level, message) => process.stderr.write(`${message}\n`),
|
|
354
412
|
signal,
|
|
355
413
|
};
|
|
356
|
-
return summon.execute(generator, { prompt, params, signal });
|
|
414
|
+
return summon.execute(generator, { prompt, params: answers, signal });
|
|
357
415
|
}
|
|
358
416
|
|
|
359
417
|
/**
|
|
@@ -430,6 +488,6 @@ export const createApplicationVerb = createVerb(
|
|
|
430
488
|
applicationParams,
|
|
431
489
|
[
|
|
432
490
|
{ cmd: "pragma create application my-app" },
|
|
433
|
-
{ cmd: "pragma create application my-app --relay" },
|
|
491
|
+
{ cmd: "pragma create application my-app --with-relay" },
|
|
434
492
|
],
|
|
435
493
|
);
|
|
@@ -4,6 +4,7 @@ import type { DetectedHarness } from "@canonical/harnesses";
|
|
|
4
4
|
import { detectHarnesses, readMcpConfig } from "@canonical/harnesses";
|
|
5
5
|
import { runTask } from "@canonical/task/node";
|
|
6
6
|
import type { CheckItem, CheckResult } from "../types.js";
|
|
7
|
+
import { deriveBand } from "./deriveBand.js";
|
|
7
8
|
|
|
8
9
|
const NAME = "MCP commands";
|
|
9
10
|
|
|
@@ -56,6 +57,9 @@ export async function checkMcpCommands(cwd: string): Promise<CheckResult> {
|
|
|
56
57
|
return { name: NAME, status: "skip", detail: "no MCP configs found" };
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
// Band the finding by the harnesses whose configs were actually examined, so
|
|
61
|
+
// a global-scope harness's broken command is reported under the global band.
|
|
62
|
+
const band = deriveBand(withConfig);
|
|
59
63
|
const broken: CheckItem[] = [];
|
|
60
64
|
let checked = 0;
|
|
61
65
|
|
|
@@ -94,6 +98,7 @@ export async function checkMcpCommands(cwd: string): Promise<CheckResult> {
|
|
|
94
98
|
name: NAME,
|
|
95
99
|
status: "pass",
|
|
96
100
|
detail: `${checked} command${checked === 1 ? "" : "s"} resolve on PATH`,
|
|
101
|
+
band,
|
|
97
102
|
};
|
|
98
103
|
}
|
|
99
104
|
|
|
@@ -104,5 +109,6 @@ export async function checkMcpCommands(cwd: string): Promise<CheckResult> {
|
|
|
104
109
|
items: broken,
|
|
105
110
|
remedy:
|
|
106
111
|
"Install the missing command or remove the entry — every agent session tries and fails to boot it.",
|
|
112
|
+
band,
|
|
107
113
|
};
|
|
108
114
|
}
|
|
@@ -2,9 +2,13 @@ import type { DetectedHarness } from "@canonical/harnesses";
|
|
|
2
2
|
import { detectHarnesses, readMcpConfig } from "@canonical/harnesses";
|
|
3
3
|
import { runTask } from "@canonical/task/node";
|
|
4
4
|
import type { CheckResult } from "../types.js";
|
|
5
|
+
import { deriveBand } from "./deriveBand.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* Check that at least one AI harness has pragma configured as an MCP server.
|
|
8
|
+
* Check that at least one AI harness has pragma configured as an MCP server. The
|
|
9
|
+
* result's band is derived from the harnesses actually found (Windsurf ⇒ global,
|
|
10
|
+
* Cursor ⇒ project), not the check name — so a global-scope harness is not
|
|
11
|
+
* mislabeled PROJECT.
|
|
8
12
|
*
|
|
9
13
|
* @param cwd - The project root to detect harnesses against.
|
|
10
14
|
* @returns A CheckResult listing configured harnesses, or fail with a remedy.
|
|
@@ -32,12 +36,12 @@ export async function checkMcpConfigured(cwd: string): Promise<CheckResult> {
|
|
|
32
36
|
};
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
const configured:
|
|
39
|
+
const configured: DetectedHarness[] = [];
|
|
36
40
|
for (const d of detected) {
|
|
37
41
|
if (!d.configExists) continue;
|
|
38
42
|
try {
|
|
39
43
|
const servers = await runTask(readMcpConfig(d.harness, cwd));
|
|
40
|
-
if ("pragma" in servers) configured.push(d
|
|
44
|
+
if ("pragma" in servers) configured.push(d);
|
|
41
45
|
} catch {
|
|
42
46
|
// Config unreadable — skip.
|
|
43
47
|
}
|
|
@@ -47,7 +51,8 @@ export async function checkMcpConfigured(cwd: string): Promise<CheckResult> {
|
|
|
47
51
|
return {
|
|
48
52
|
name: "MCP configured",
|
|
49
53
|
status: "pass",
|
|
50
|
-
detail: configured.join(", "),
|
|
54
|
+
detail: configured.map((d) => d.harness.name).join(", "),
|
|
55
|
+
band: deriveBand(configured),
|
|
51
56
|
};
|
|
52
57
|
}
|
|
53
58
|
|
|
@@ -57,5 +62,6 @@ export async function checkMcpConfigured(cwd: string): Promise<CheckResult> {
|
|
|
57
62
|
status: "fail",
|
|
58
63
|
detail: `detected ${names} but pragma not configured`,
|
|
59
64
|
remedy: "pragma setup mcp",
|
|
65
|
+
band: deriveBand(detected),
|
|
60
66
|
};
|
|
61
67
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `deriveBand` — band a doctor check by the harnesses it actually found.
|
|
3
|
+
*
|
|
4
|
+
* Fixtures wrap REAL harness definitions (via `findHarnessById`, no casts), so
|
|
5
|
+
* the scope→band mapping is exercised against the true `scope` fields: the
|
|
6
|
+
* regression this guards is a global-scope harness (Windsurf) being mislabeled
|
|
7
|
+
* PROJECT by a static per-name guess.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { DetectedHarness } from "@canonical/harnesses";
|
|
11
|
+
import { findHarnessById } from "@canonical/harnesses";
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import { deriveBand } from "./deriveBand.js";
|
|
14
|
+
|
|
15
|
+
/** A minimal {@link DetectedHarness} over a real definition (no `as` casts). */
|
|
16
|
+
function detect(id: string): DetectedHarness {
|
|
17
|
+
const harness = findHarnessById(id);
|
|
18
|
+
if (!harness) throw new Error(`unknown harness fixture: ${id}`);
|
|
19
|
+
return { harness, confidence: "high", configExists: true, configPath: "x" };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("deriveBand", () => {
|
|
23
|
+
it("bands a project-scope harness (Cursor) as project", () => {
|
|
24
|
+
expect(deriveBand([detect("cursor")])).toBe("project");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("bands a global-scope harness (Windsurf) as global — the fixed mislabel", () => {
|
|
28
|
+
expect(deriveBand([detect("windsurf")])).toBe("global");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("bands a dual-scope harness (Claude Code) by its default band (project)", () => {
|
|
32
|
+
expect(deriveBand([detect("claude-code")])).toBe("project");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("leaves a set spanning BOTH bands unbanded (general, not mislabeled)", () => {
|
|
36
|
+
expect(deriveBand([detect("cursor"), detect("windsurf")])).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("leaves an empty set unbanded", () => {
|
|
40
|
+
expect(deriveBand([])).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive a doctor check's config band from the harnesses it actually found,
|
|
3
|
+
* rather than a static per-check-name guess. The aggregate MCP checks read each
|
|
4
|
+
* harness's DEFAULT-band file (`defaultBandOf` — the home config for a
|
|
5
|
+
* global-only harness like Windsurf, the project file otherwise), so the band a
|
|
6
|
+
* finding belongs to is exactly that default band.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { DetectedHarness } from "@canonical/harnesses";
|
|
10
|
+
import { defaultBandOf } from "@canonical/harnesses";
|
|
11
|
+
import type { ScopeBand } from "../types.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The single config band a set of detected harnesses shares, or `undefined` when
|
|
15
|
+
* the set is empty or genuinely spans BOTH bands. A two-band finding stays
|
|
16
|
+
* unbanded (rendered in the general section) instead of being mislabeled as one
|
|
17
|
+
* band — faithful to the two-band model: band by what was found, never a guess.
|
|
18
|
+
*
|
|
19
|
+
* @param harnesses - The detected harnesses the check reports on.
|
|
20
|
+
* @returns The shared {@link ScopeBand}, or `undefined` when none/mixed.
|
|
21
|
+
*/
|
|
22
|
+
export function deriveBand(
|
|
23
|
+
harnesses: readonly DetectedHarness[],
|
|
24
|
+
): ScopeBand | undefined {
|
|
25
|
+
const bands = new Set<ScopeBand>();
|
|
26
|
+
for (const detected of harnesses) bands.add(defaultBandOf(detected.harness));
|
|
27
|
+
return bands.size === 1 ? bands.values().next().value : undefined;
|
|
28
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render goldens for `pragma doctor` — the band-grouped report AND the plain-path
|
|
3
|
+
* color gate (F1).
|
|
4
|
+
*
|
|
5
|
+
* The banded Global/Project grouping (`partitionByBand` → sections) is pinned for
|
|
6
|
+
* both the plain and llm formatters, locking the unified Global/Project
|
|
7
|
+
* vocabulary (the report no longer says MACHINE/PROJECT). Separately, the plain
|
|
8
|
+
* formatter tints ONLY on a color-capable TTY: `supports-color` reports a
|
|
9
|
+
* non-zero `chalk.level` off a TTY under `GITHUB_ACTIONS` / `FORCE_COLOR`, so the
|
|
10
|
+
* `isTTY` gate (via the shared style seam) keeps ANSI out of
|
|
11
|
+
* `doctor --format plain | tee`. `beforeEach` forces color OFF so the banded
|
|
12
|
+
* goldens stay deterministic; the color tests opt a level back in explicitly.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import chalk from "chalk";
|
|
16
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
17
|
+
import { doctorFormatters } from "./doctor.render.js";
|
|
18
|
+
import type { DoctorData } from "./types.js";
|
|
19
|
+
|
|
20
|
+
/** A fixture spanning all three groups: environment (no band), global, project. */
|
|
21
|
+
const BANDED_DATA: DoctorData = {
|
|
22
|
+
checks: [
|
|
23
|
+
{ name: "Node version", status: "pass", detail: "v20 detected" },
|
|
24
|
+
{
|
|
25
|
+
name: "Shell completions",
|
|
26
|
+
status: "pass",
|
|
27
|
+
detail: "zsh installed and resolving",
|
|
28
|
+
band: "global",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "MCP configured",
|
|
32
|
+
status: "fail",
|
|
33
|
+
detail: "detected Windsurf but pragma not configured",
|
|
34
|
+
remedy: "pragma setup mcp",
|
|
35
|
+
band: "global",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "Skills symlinked",
|
|
39
|
+
status: "skip",
|
|
40
|
+
detail: "no AI harnesses detected",
|
|
41
|
+
band: "project",
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
passed: 2,
|
|
45
|
+
failed: 1,
|
|
46
|
+
skipped: 1,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** A fixture spanning pass/fail/skip, sub-items, and a remedy — every color path. */
|
|
50
|
+
const COLOR_DATA: DoctorData = {
|
|
51
|
+
checks: [
|
|
52
|
+
{ name: "Node version", status: "pass", detail: "v24" },
|
|
53
|
+
{
|
|
54
|
+
name: "package refs",
|
|
55
|
+
status: "fail",
|
|
56
|
+
detail: "3 configured, 0 locked",
|
|
57
|
+
items: [
|
|
58
|
+
{ label: "core", status: "fail", detail: "unlocked" },
|
|
59
|
+
{ label: "ui", status: "fail", detail: "unlocked" },
|
|
60
|
+
],
|
|
61
|
+
remedy: "pragma sources update",
|
|
62
|
+
},
|
|
63
|
+
{ name: "Skills symlinked", status: "skip", detail: "no harness" },
|
|
64
|
+
],
|
|
65
|
+
passed: 1,
|
|
66
|
+
failed: 1,
|
|
67
|
+
skipped: 1,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Run `body` with stdout's `isTTY` forced to `value`, then restore it. */
|
|
71
|
+
function withStdoutTty(value: boolean | undefined, body: () => void): void {
|
|
72
|
+
const stream = process.stdout as { isTTY?: boolean };
|
|
73
|
+
const saved = stream.isTTY;
|
|
74
|
+
stream.isTTY = value;
|
|
75
|
+
try {
|
|
76
|
+
body();
|
|
77
|
+
} finally {
|
|
78
|
+
stream.isTTY = saved;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let prevLevel: number;
|
|
83
|
+
beforeAll(() => {
|
|
84
|
+
prevLevel = chalk.level;
|
|
85
|
+
});
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
chalk.level = 0; // color-free ⇒ deterministic text (color tests opt back in)
|
|
88
|
+
});
|
|
89
|
+
afterAll(() => {
|
|
90
|
+
chalk.level = prevLevel;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("doctor render — banded plain report", () => {
|
|
94
|
+
it("groups checks under Global then Project headers (unified labels)", () => {
|
|
95
|
+
const out = doctorFormatters.plain(BANDED_DATA);
|
|
96
|
+
const lines = out.split("\n");
|
|
97
|
+
// The two banded section headers use the unified Global/Project vocabulary.
|
|
98
|
+
expect(lines).toContain("Global");
|
|
99
|
+
expect(lines).toContain("Project");
|
|
100
|
+
// The superseded MACHINE/PROJECT labels are gone.
|
|
101
|
+
expect(out).not.toContain("MACHINE");
|
|
102
|
+
expect(out).not.toMatch(/^PROJECT$/m);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("orders environment → Global → Project, banding each check correctly", () => {
|
|
106
|
+
const out = doctorFormatters.plain(BANDED_DATA);
|
|
107
|
+
// Environment check leads with no header; the two global-band checks sit
|
|
108
|
+
// under Global; the project-band check sits under Project.
|
|
109
|
+
const at = (needle: string): number => out.indexOf(needle);
|
|
110
|
+
expect(at("Node version")).toBeGreaterThanOrEqual(0);
|
|
111
|
+
expect(at("Node version")).toBeLessThan(at("Global"));
|
|
112
|
+
expect(at("Global")).toBeLessThan(at("Shell completions"));
|
|
113
|
+
expect(at("MCP configured")).toBeLessThan(at("Project"));
|
|
114
|
+
expect(at("Project")).toBeLessThan(at("Skills symlinked"));
|
|
115
|
+
// A failing banded check keeps its inline remedy under its band.
|
|
116
|
+
expect(out).toContain("fix: pragma setup mcp");
|
|
117
|
+
// The tally closes the report.
|
|
118
|
+
expect(out).toContain("2 passed");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("doctor render — banded llm report", () => {
|
|
123
|
+
it("groups checks under ### Global then ### Project headers", () => {
|
|
124
|
+
const out = doctorFormatters.llm(BANDED_DATA);
|
|
125
|
+
expect(out).toContain("### Global");
|
|
126
|
+
expect(out).toContain("### Project");
|
|
127
|
+
expect(out).not.toContain("### Machine");
|
|
128
|
+
const at = (needle: string): number => out.indexOf(needle);
|
|
129
|
+
// Environment leads (no section header), then the two banded sections.
|
|
130
|
+
expect(at("Node version")).toBeLessThan(at("### Global"));
|
|
131
|
+
expect(at("### Global")).toBeLessThan(at("Shell completions"));
|
|
132
|
+
expect(at("Shell completions")).toBeLessThan(at("### Project"));
|
|
133
|
+
expect(at("### Project")).toBeLessThan(at("Skills symlinked"));
|
|
134
|
+
expect(out).toContain("_2 passed, 1 failed, 1 skipped_");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("doctor render — json", () => {
|
|
139
|
+
it("is the exact DoctorData round-trip", () => {
|
|
140
|
+
expect(JSON.parse(doctorFormatters.json(BANDED_DATA))).toEqual(BANDED_DATA);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("doctor render — piped output is ANSI-free (F1)", () => {
|
|
145
|
+
it("plain emits ZERO ANSI off a TTY even when chalk reports color (CI/FORCE_COLOR)", () => {
|
|
146
|
+
chalk.level = 3;
|
|
147
|
+
withStdoutTty(undefined, () => {
|
|
148
|
+
const out = doctorFormatters.plain(COLOR_DATA);
|
|
149
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: asserting NO ESC byte survives
|
|
150
|
+
expect(out).not.toMatch(/\x1b\[/);
|
|
151
|
+
// The structural, color-free content still renders (glyphs, names, remedy).
|
|
152
|
+
expect(out).toContain("pragma doctor");
|
|
153
|
+
expect(out).toContain("✓ Node version");
|
|
154
|
+
expect(out).toContain("✗ package refs");
|
|
155
|
+
expect(out).toContain("○ Skills symlinked");
|
|
156
|
+
expect(out).toContain("↳ fix: pragma sources update");
|
|
157
|
+
expect(out).toContain(" 1 passed · 1 failed · 1 skipped");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("doctor render — color ON (attended TTY)", () => {
|
|
163
|
+
it("plain tints the output on a color-capable TTY", () => {
|
|
164
|
+
chalk.level = 1;
|
|
165
|
+
withStdoutTty(true, () => {
|
|
166
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: asserting the literal ESC byte is the point
|
|
167
|
+
expect(doctorFormatters.plain(COLOR_DATA)).toMatch(/\x1b\[/);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|