@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.
Files changed (140) hide show
  1. package/README.md +72 -0
  2. package/dist/pragma +0 -0
  3. package/package.json +14 -13
  4. package/src/bin.ts +2 -2
  5. package/src/capabilities/block/blockList.test.ts +79 -0
  6. package/src/capabilities/block/blockList.verb.ts +18 -5
  7. package/src/capabilities/block/pack.ts +12 -1
  8. package/src/capabilities/block/parity.test.ts +9 -2
  9. package/src/capabilities/capabilities/capabilities.test.ts +10 -1
  10. package/src/capabilities/capabilities/capabilities.verb.ts +1 -1
  11. package/src/capabilities/capabilities/catalog.ts +7 -3
  12. package/src/capabilities/capabilities/hints.ts +8 -17
  13. package/src/capabilities/capabilities/types.ts +2 -0
  14. package/src/capabilities/colophon/colophon.render.test.ts +31 -4
  15. package/src/capabilities/colophon/colophon.render.ts +13 -4
  16. package/src/capabilities/colophon/colophon.test.ts +3 -3
  17. package/src/capabilities/colophon/colophon.verb.ts +4 -1
  18. package/src/capabilities/colophon/markdownTerminal.ts +33 -14
  19. package/src/capabilities/colophon/pragmaColophon.ts +4 -4
  20. package/src/capabilities/colophon/types.ts +1 -1
  21. package/src/capabilities/config/fields.ts +21 -92
  22. package/src/capabilities/config/set.test.ts +6 -6
  23. package/src/capabilities/config/set.verb.ts +5 -5
  24. package/src/capabilities/config/show.render.test.ts +65 -0
  25. package/src/capabilities/config/show.render.ts +56 -10
  26. package/src/capabilities/config/show.verb.ts +5 -5
  27. package/src/capabilities/create/byteEquality.subprocess.test.ts +1 -1
  28. package/src/capabilities/create/byteEquality.test.ts +7 -4
  29. package/src/capabilities/create/compiledCreate.subprocess.test.ts +1 -1
  30. package/src/capabilities/create/create.test.ts +32 -3
  31. package/src/capabilities/create/create.verb.ts +69 -11
  32. package/src/capabilities/doctor/checks/checkConfigFile.ts +1 -1
  33. package/src/capabilities/doctor/checks/checkMcpCommands.ts +6 -0
  34. package/src/capabilities/doctor/checks/checkMcpConfigured.ts +10 -4
  35. package/src/capabilities/doctor/checks/deriveBand.test.ts +42 -0
  36. package/src/capabilities/doctor/checks/deriveBand.ts +28 -0
  37. package/src/capabilities/doctor/doctor.render.test.ts +170 -0
  38. package/src/capabilities/doctor/doctor.render.ts +116 -34
  39. package/src/capabilities/doctor/doctor.test.ts +31 -1
  40. package/src/capabilities/doctor/runChecks.ts +28 -4
  41. package/src/capabilities/doctor/types.ts +15 -0
  42. package/src/capabilities/ontology/index.ts +10 -3
  43. package/src/capabilities/ontology/ontology.test.ts +157 -5
  44. package/src/capabilities/ontology/verbs.ts +192 -93
  45. package/src/capabilities/prompt/prompt.render.test.ts +94 -0
  46. package/src/capabilities/prompt/prompt.render.ts +23 -6
  47. package/src/capabilities/prompt/prompt.test.ts +61 -0
  48. package/src/capabilities/reference.test.ts +32 -0
  49. package/src/capabilities/resources/provider.ts +28 -14
  50. package/src/capabilities/resources/resources.test.ts +99 -0
  51. package/src/capabilities/scopeTypeSync.test.ts +37 -0
  52. package/src/capabilities/setup/index.ts +7 -1
  53. package/src/capabilities/setup/operations/setupCompletions.ts +49 -16
  54. package/src/capabilities/setup/operations/setupGenerator.ts +157 -61
  55. package/src/capabilities/setup/operations/setupLsp.ts +56 -12
  56. package/src/capabilities/setup/operations/setupMcp.ts +216 -39
  57. package/src/capabilities/setup/operations/setupSkills.ts +7 -1
  58. package/src/capabilities/setup/setup.render.test.ts +109 -0
  59. package/src/capabilities/setup/setup.render.ts +61 -14
  60. package/src/capabilities/setup/setup.test.ts +352 -1
  61. package/src/capabilities/setup/setup.verb.ts +57 -5
  62. package/src/capabilities/setup/types.ts +59 -2
  63. package/src/capabilities/shared/bands.ts +16 -0
  64. package/src/capabilities/sources/collectStatus.ts +19 -4
  65. package/src/capabilities/sources/runUpdate.ts +58 -1
  66. package/src/capabilities/sources/sources.test.ts +187 -1
  67. package/src/capabilities/sources/status.render.test.ts +65 -0
  68. package/src/capabilities/sources/status.render.ts +50 -23
  69. package/src/capabilities/surface.test.ts +34 -9
  70. package/src/capabilities/tier/lookup.render.test.ts +72 -0
  71. package/src/capabilities/tier/lookup.render.ts +33 -8
  72. package/src/capabilities/tier/lookup.test.ts +15 -0
  73. package/src/capabilities/tier/runLookup.ts +28 -0
  74. package/src/constants.test.ts +1 -1
  75. package/src/constants.ts +2 -2
  76. package/src/kernel/completion/__snapshots__/emitScripts.test.ts.snap +13 -14
  77. package/src/kernel/completion/bashDrive.test.ts +3 -3
  78. package/src/kernel/completion/complete.test.ts +3 -5
  79. package/src/kernel/completion/entitySource.ts +14 -6
  80. package/src/kernel/completion/model.test.ts +2 -2
  81. package/src/kernel/completion/model.ts +0 -1
  82. package/src/kernel/completion/parse.test.ts +2 -4
  83. package/src/kernel/error/PragmaError.test.ts +4 -4
  84. package/src/kernel/error/renderError.ts +1 -1
  85. package/src/kernel/packs/compile.ts +1 -1
  86. package/src/kernel/project/cli/buildProgram.test.ts +51 -7
  87. package/src/kernel/project/cli/buildProgram.ts +11 -0
  88. package/src/kernel/project/cli/globalFlags.test.ts +12 -4
  89. package/src/kernel/project/cli/globalFlags.ts +14 -14
  90. package/src/kernel/project/cli/rootHelp.ts +4 -2
  91. package/src/kernel/project/mcp/instructions.ts +1 -1
  92. package/src/kernel/project/mcp/mcpError.ts +52 -0
  93. package/src/kernel/project/mcp/prompts/prompts.test.ts +8 -0
  94. package/src/kernel/project/mcp/prompts/provider.ts +36 -6
  95. package/src/kernel/render/formatters.ts +5 -4
  96. package/src/kernel/render/renderers.test.ts +34 -0
  97. package/src/kernel/render/renderers.ts +25 -7
  98. package/src/kernel/render/style.test.ts +31 -0
  99. package/src/kernel/render/style.ts +74 -0
  100. package/src/kernel/runtime/graphpack/build.ts +28 -0
  101. package/src/kernel/runtime/graphpack/buildIndex.test.ts +103 -0
  102. package/src/kernel/runtime/graphpack/buildIndex.ts +124 -36
  103. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +3 -6
  104. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -2
  105. package/src/kernel/runtime/graphpack/graphpack.test.ts +69 -1
  106. package/src/kernel/runtime/graphpack/read.ts +28 -13
  107. package/src/kernel/runtime/graphpack/types.ts +14 -0
  108. package/src/kernel/runtime/loadSession.ts +4 -12
  109. package/src/kernel/runtime/refs/resolve.ts +63 -3
  110. package/src/kernel/runtime/storeReadiness.ts +61 -0
  111. package/src/kernel/runtime/types.ts +1 -1
  112. package/src/kernel/runtime/wasmEmbed.test.ts +3 -1
  113. package/src/kernel/spec/emitReference.test.ts +196 -0
  114. package/src/kernel/spec/emitReference.ts +491 -0
  115. package/src/kernel/spec/emitSurface.ts +4 -2
  116. package/src/kernel/spec/index.ts +2 -0
  117. package/src/kernel/spec/surfaceConformance.test.ts +2 -2
  118. package/src/testing/PARITY_GAPS.ts +1 -1
  119. package/src/testing/behavioral/autoLlm.e2e.test.ts +12 -2
  120. package/src/testing/behavioral/completion.test.ts +2 -2
  121. package/src/testing/behavioral/disclosure.test.ts +12 -0
  122. package/src/testing/behavioral/docExamples.test.ts +283 -0
  123. package/src/testing/behavioral/errorMatrix.storeless.test.ts +2 -1
  124. package/src/testing/behavioral/journeys.cli.test.ts +3 -1
  125. package/src/testing/behavioral/journeys.defaultPack.test.ts +571 -0
  126. package/src/testing/behavioral/journeys.interactiveTty.e2e.test.ts +66 -0
  127. package/src/testing/behavioral/journeys.livePack.e2e.test.ts +129 -0
  128. package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +176 -0
  129. package/src/testing/behavioral/mcpSurface.test.ts +1 -1
  130. package/src/testing/behavioral/staleCommands.test.ts +76 -0
  131. package/src/testing/eval/__snapshots__/eval.test.ts.snap +2 -2
  132. package/src/testing/eval/cases/assembly.ts +3 -3
  133. package/src/testing/eval/cases/effectful.ts +6 -3
  134. package/src/testing/eval/cases/readNouns.ts +4 -1
  135. package/src/testing/fixtures/fixtureCapability.ts +156 -0
  136. package/src/testing/fixtures/graph/defaultPack.ts +178 -0
  137. package/src/testing/fixtures/graph/owlExport.ts +95 -0
  138. package/src/testing/helpers/extractSnippets.ts +29 -0
  139. package/src/testing/helpers/golden.ts +1 -1
  140. 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: "ssr", type: "confirm", message: "Include SSR?", default: true },
147
+ { name: "withSsr", type: "confirm", message: "Include SSR?", default: true },
141
148
  {
142
- name: "router",
149
+ name: "withRouter",
143
150
  type: "confirm",
144
151
  message: "Include router?",
145
152
  default: true,
146
153
  },
147
154
  {
148
- name: "forms",
155
+ name: "withForms",
149
156
  type: "confirm",
150
157
  message: "Include form components?",
151
158
  default: true,
152
159
  },
153
160
  {
154
- name: "relay",
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
- const generator = pickGenerator(kind, params);
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, params[pathParam], rt.cwd);
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, params);
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" ? summon.mcpPrompt(params) : summon.autoPrompt(params);
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
  );
@@ -36,6 +36,6 @@ export async function checkConfigFile(rt: PragmaRuntime): Promise<CheckResult> {
36
36
  name: "pragma config",
37
37
  status: "fail",
38
38
  detail: "not found",
39
- remedy: "pragma config tier <path>",
39
+ remedy: "pragma config set tier <path>",
40
40
  };
41
41
  }
@@ -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: string[] = [];
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.harness.name);
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
+ });