@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
@@ -1,35 +1,74 @@
1
1
  /**
2
- * Formatters for `pragma doctor` — plain (chalk), llm (Markdown), json.
2
+ * Formatters for `pragma doctor` — plain (chalk on a TTY), llm (Markdown), json.
3
3
  *
4
4
  * Ported from the old shell's `doctor/formatters/doctor.ts`, retargeted at the
5
- * kernel `Formatters` contract.
5
+ * kernel `Formatters` contract. The plain path is colored ONLY on a color-capable
6
+ * TTY: it consults the shared {@link defaultStyle} seam (stdout `isTTY` AND a
7
+ * non-zero chalk level), so a piped / redirected / CI run — where `supports-color`
8
+ * can report a level with no TTY (`GITHUB_ACTIONS`, `FORCE_COLOR`) — renders the
9
+ * plain form byte-for-byte instead of leaking ANSI into `doctor --format plain`.
10
+ * Glyphs are plain constants tinted at render time; nothing is baked at load.
6
11
  */
7
12
 
8
13
  import chalk from "chalk";
14
+ import { defaultStyle, type RenderStyle } from "../../kernel/render/style.js";
9
15
  import type { Formatters } from "../../kernel/spec/types.js";
16
+ import { BAND_LABELS } from "../shared/bands.js";
10
17
  import type { CheckResult, CheckStatus, DoctorData } from "./types.js";
11
18
 
12
- /** Status icons for pass/fail/skip results (checks and sub-items). */
13
- const STATUS_ICONS: Record<CheckStatus, string> = {
14
- pass: chalk.green("✓"),
15
- fail: chalk.red("✗"),
16
- skip: chalk.yellow("○"),
19
+ /** Uncolored status glyphs — tinted at render time (never baked at module load). */
20
+ const STATUS_GLYPHS: Record<CheckStatus, string> = {
21
+ pass: "✓",
22
+ fail: "✗",
23
+ skip: "○",
17
24
  };
18
25
 
19
- const SUB_BULLET = chalk.dim("·");
20
- const FIX_ARROW = chalk.cyan("↳");
26
+ const SUB_BULLET = "·";
27
+ const FIX_ARROW = "↳";
21
28
  const INDENT = " "; // aligns sub-lines under the check name
22
29
 
30
+ /** The TTY styler plus `red` — the one tint {@link RenderStyle} does not carry. */
31
+ interface DoctorStyle extends RenderStyle {
32
+ red(text: string): string;
33
+ }
34
+
35
+ /**
36
+ * Build the doctor styler for this process's stdout: the shared TTY seam, plus a
37
+ * `red` gated on the SAME decision, so a fail tint appears only on a color TTY.
38
+ *
39
+ * @returns A colorizing styler on a color-capable TTY, else the identity styler.
40
+ * @note Impure — {@link defaultStyle} reads `process.stdout.isTTY` + chalk level.
41
+ */
42
+ function doctorStyle(): DoctorStyle {
43
+ const style = defaultStyle();
44
+ return {
45
+ ...style,
46
+ red: style.enabled ? (text) => chalk.red(text) : (text) => text,
47
+ };
48
+ }
49
+
50
+ /** Tint a status glyph by meaning — green pass, red fail, yellow skip. */
51
+ function paintGlyph(status: CheckStatus, style: DoctorStyle): string {
52
+ const glyph = STATUS_GLYPHS[status];
53
+ if (status === "pass") return style.green(glyph);
54
+ if (status === "fail") return style.red(glyph);
55
+ return style.yellow(glyph);
56
+ }
57
+
23
58
  /**
24
59
  * Render one check as terminal lines: a headline row (icon, aligned name,
25
60
  * detail), an optional indented breakdown of sub-items, and — for failures — an
26
61
  * inline remedial instruction.
27
62
  */
28
- function formatCheckPlain(check: CheckResult, nameWidth: number): string[] {
63
+ function formatCheckPlain(
64
+ check: CheckResult,
65
+ nameWidth: number,
66
+ style: DoctorStyle,
67
+ ): string[] {
29
68
  const name = check.name.padEnd(nameWidth);
30
- const label = check.status === "fail" ? chalk.red(name) : chalk.bold(name);
69
+ const label = check.status === "fail" ? style.red(name) : style.bold(name);
31
70
  const lines = [
32
- ` ${STATUS_ICONS[check.status]} ${label} ${chalk.dim(check.detail)}`,
71
+ ` ${paintGlyph(check.status, style)} ${label} ${style.dim(check.detail)}`,
33
72
  ];
34
73
 
35
74
  if (check.items && check.items.length > 0) {
@@ -38,55 +77,86 @@ function formatCheckPlain(check: CheckResult, nameWidth: number): string[] {
38
77
  for (const item of check.items) {
39
78
  const icon = anyStatus
40
79
  ? item.status
41
- ? `${STATUS_ICONS[item.status]} `
80
+ ? `${paintGlyph(item.status, style)} `
42
81
  : " "
43
82
  : "";
44
83
  const itemLabel = item.detail ? item.label.padEnd(itemWidth) : item.label;
45
- const detail = item.detail ? ` ${chalk.dim(item.detail)}` : "";
46
- lines.push(`${INDENT}${SUB_BULLET} ${icon}${itemLabel}${detail}`);
84
+ const detail = item.detail ? ` ${style.dim(item.detail)}` : "";
85
+ lines.push(
86
+ `${INDENT}${style.dim(SUB_BULLET)} ${icon}${itemLabel}${detail}`,
87
+ );
47
88
  }
48
89
  }
49
90
 
50
91
  if (check.status === "fail" && check.remedy) {
51
- lines.push(`${INDENT}${FIX_ARROW} ${chalk.cyan("fix:")} ${check.remedy}`);
92
+ lines.push(
93
+ `${INDENT}${style.cyan(FIX_ARROW)} ${style.cyan("fix:")} ${check.remedy}`,
94
+ );
52
95
  }
53
96
 
54
97
  return lines;
55
98
  }
56
99
 
57
100
  /** Render the pass/fail/skip tally, coloring non-zero fail/skip. */
58
- function formatSummary(data: DoctorData): string {
59
- const parts = [chalk.green(`${data.passed} passed`)];
101
+ function formatSummary(data: DoctorData, style: DoctorStyle): string {
102
+ const parts = [style.green(`${data.passed} passed`)];
60
103
  parts.push(
61
104
  data.failed > 0
62
- ? chalk.red(`${data.failed} failed`)
63
- : chalk.dim(`${data.failed} failed`),
105
+ ? style.red(`${data.failed} failed`)
106
+ : style.dim(`${data.failed} failed`),
64
107
  );
65
108
  parts.push(
66
109
  data.skipped > 0
67
- ? chalk.yellow(`${data.skipped} skipped`)
68
- : chalk.dim(`${data.skipped} skipped`),
110
+ ? style.yellow(`${data.skipped} skipped`)
111
+ : style.dim(`${data.skipped} skipped`),
69
112
  );
70
- return ` ${parts.join(chalk.dim(" · "))}`;
113
+ return ` ${parts.join(style.dim(" · "))}`;
114
+ }
115
+
116
+ /**
117
+ * Partition checks into ordered bands: environment (no band), then the global
118
+ * and project bands. Declaration order is preserved within each band, so the
119
+ * report stays deterministic.
120
+ */
121
+ function partitionByBand(checks: readonly CheckResult[]): {
122
+ environment: CheckResult[];
123
+ machine: CheckResult[];
124
+ project: CheckResult[];
125
+ } {
126
+ return {
127
+ environment: checks.filter((c) => c.band === undefined),
128
+ machine: checks.filter((c) => c.band === "global"),
129
+ project: checks.filter((c) => c.band === "project"),
130
+ };
71
131
  }
72
132
 
73
133
  export const doctorFormatters: Formatters<DoctorData> = {
74
134
  plain(data) {
135
+ const style = doctorStyle();
75
136
  const nameWidth = Math.max(...data.checks.map((c) => c.name.length), 0);
76
- const lines: string[] = [chalk.bold("pragma doctor"), ""];
77
- for (const check of data.checks) {
78
- lines.push(...formatCheckPlain(check, nameWidth));
79
- }
80
- lines.push("", formatSummary(data));
137
+ const lines: string[] = [style.bold("pragma doctor"), ""];
138
+ const { environment, machine, project } = partitionByBand(data.checks);
139
+ const section = (heading: string, checks: CheckResult[]): void => {
140
+ if (checks.length === 0) return;
141
+ if (heading) lines.push(style.bold(heading));
142
+ for (const check of checks)
143
+ lines.push(...formatCheckPlain(check, nameWidth, style));
144
+ lines.push("");
145
+ };
146
+ // Environment checks lead (no header); the global then project bands are the
147
+ // two banded sections before the tally.
148
+ section("", environment);
149
+ section(BAND_LABELS.global, machine);
150
+ section(BAND_LABELS.project, project);
151
+ lines.push(formatSummary(data, style));
81
152
  return lines.join("\n");
82
153
  },
83
154
 
84
155
  llm(data) {
85
- const lines: string[] = ["## Doctor", ""];
86
- for (const check of data.checks) {
156
+ const renderCheck = (check: CheckResult): string[] => {
87
157
  const icon =
88
158
  check.status === "pass" ? "✓" : check.status === "fail" ? "✗" : "○";
89
- lines.push(`- ${icon} **${check.name}**: ${check.detail}`);
159
+ const out = [`- ${icon} **${check.name}**: ${check.detail}`];
90
160
  for (const item of check.items ?? []) {
91
161
  const itemIcon =
92
162
  item.status === "pass"
@@ -97,12 +167,24 @@ export const doctorFormatters: Formatters<DoctorData> = {
97
167
  ? "○ "
98
168
  : "";
99
169
  const detail = item.detail ? `: ${item.detail}` : "";
100
- lines.push(` - ${itemIcon}${item.label}${detail}`);
170
+ out.push(` - ${itemIcon}${item.label}${detail}`);
101
171
  }
102
172
  if (check.status === "fail" && check.remedy) {
103
- lines.push(` - _fix:_ \`${check.remedy}\``);
173
+ out.push(` - _fix:_ \`${check.remedy}\``);
104
174
  }
105
- }
175
+ return out;
176
+ };
177
+
178
+ const lines: string[] = ["## Doctor", ""];
179
+ const { environment, machine, project } = partitionByBand(data.checks);
180
+ const section = (heading: string, checks: CheckResult[]): void => {
181
+ if (checks.length === 0) return;
182
+ if (heading) lines.push(`### ${heading}`, "");
183
+ for (const check of checks) lines.push(...renderCheck(check));
184
+ };
185
+ section("", environment);
186
+ section(BAND_LABELS.global, machine);
187
+ section(BAND_LABELS.project, project);
106
188
  lines.push(
107
189
  "",
108
190
  `_${data.passed} passed, ${data.failed} failed, ${data.skipped} skipped_`,
@@ -8,7 +8,7 @@
8
8
  * the MCP read-only envelope.
9
9
  */
10
10
 
11
- import { mkdtempSync, rmSync } from "node:fs";
11
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
14
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -28,6 +28,7 @@ import {
28
28
  } from "../../testing/fixtures/graph/canonical.js";
29
29
  import { bootFixtureRuntime } from "../../testing/helpers/fixtureGraph.js";
30
30
  import { projectMcp } from "../../testing/helpers/projectMcp.js";
31
+ import { checkMcpConfigured } from "./checks/checkMcpConfigured.js";
31
32
  import { doctorModule } from "./index.js";
32
33
  import { runChecks } from "./runChecks.js";
33
34
  import type { DoctorData } from "./types.js";
@@ -50,17 +51,24 @@ const tmp = (prefix: string): string => {
50
51
 
51
52
  let prevHome: string | undefined;
52
53
  let prevXdg: string | undefined;
54
+ let prevPath: string | undefined;
53
55
  beforeEach(() => {
54
56
  prevHome = process.env.HOME;
55
57
  prevXdg = process.env.XDG_CONFIG_HOME;
58
+ prevPath = process.env.PATH;
56
59
  // Empty HOME/XDG so `~`-based harness signals, rc files, and the global config
57
60
  // are all absent — the harness/config/completion checks become deterministic.
58
61
  process.env.HOME = tmp("pragma-doctor-home-");
59
62
  process.env.XDG_CONFIG_HOME = tmp("pragma-doctor-xdg-");
63
+ // Point PATH at an empty dir so no harness `process` signal fires off the
64
+ // ambient PATH (e.g. a `claude`/`codex` binary on the CI/dev host) — keeping
65
+ // "no harnesses detected" deterministic regardless of what is installed.
66
+ process.env.PATH = tmp("pragma-doctor-path-");
60
67
  });
61
68
  afterEach(() => {
62
69
  process.env.HOME = prevHome;
63
70
  process.env.XDG_CONFIG_HOME = prevXdg;
71
+ process.env.PATH = prevPath;
64
72
  for (const dir of roots) rmSync(dir, { recursive: true, force: true });
65
73
  roots.length = 0;
66
74
  });
@@ -177,3 +185,25 @@ describe("doctor — dispatch & MCP", () => {
177
185
  expect((envelope.data as DoctorData).checks).toHaveLength(9);
178
186
  });
179
187
  });
188
+
189
+ describe("doctor — MCP checks band by detected harness scope, not check name", () => {
190
+ it("bands a configured GLOBAL-scope harness (Windsurf) as global", async () => {
191
+ // Windsurf is a global-only harness whose MCP config lives in the home band.
192
+ // The old static map tagged every "MCP configured" result PROJECT; the fix
193
+ // derives the band from the harness's real scope, so this reports `global`.
194
+ const cwd = tmp("pragma-doctor-proj-");
195
+ mkdirSync(join(cwd, ".windsurf"), { recursive: true }); // ⇒ Windsurf detected
196
+ const home = process.env.HOME ?? "";
197
+ const wsDir = join(home, ".codeium", "windsurf");
198
+ mkdirSync(wsDir, { recursive: true });
199
+ writeFileSync(
200
+ join(wsDir, "mcp_config.json"),
201
+ JSON.stringify({ mcpServers: { pragma: { command: "pragma" } } }),
202
+ );
203
+
204
+ const check = await checkMcpConfigured(cwd);
205
+ expect(check.status).toBe("pass");
206
+ expect(check.detail).toContain("Windsurf");
207
+ expect(check.band).toBe("global"); // NOT the old static "project"
208
+ });
209
+ });
@@ -18,7 +18,31 @@ import { checkPackageRefs } from "./checks/checkPackageRefs.js";
18
18
  import { checkPragmaVersion } from "./checks/checkPragmaVersion.js";
19
19
  import { checkShellCompletions } from "./checks/checkShellCompletions.js";
20
20
  import { checkSkillsSymlinked } from "./checks/checkSkillsSymlinked.js";
21
- import type { CheckResult, DoctorData } from "./types.js";
21
+ import type { CheckResult, DoctorData, ScopeBand } from "./types.js";
22
+
23
+ /**
24
+ * The band a check statically concerns, by name — for checks whose band is
25
+ * fixed by construction: skills always symlink into the per-repo project dir
26
+ * (`skillsPath` is project-rooted), and completions install into the user/home
27
+ * shell dir. The MCP checks are NOT here: they detect harnesses across BOTH
28
+ * bands, so each derives its own band from what it found (see `deriveBand`) and
29
+ * sets it on its own result — {@link attachBand} preserves that.
30
+ */
31
+ const CHECK_BANDS: Record<string, ScopeBand> = {
32
+ "Skills symlinked": "project",
33
+ "Shell completions": "global",
34
+ };
35
+
36
+ /**
37
+ * Attach a band tag to a check result. A check that already set its own band
38
+ * (the harness-derived MCP checks) keeps it; otherwise the static
39
+ * {@link CHECK_BANDS} map fills one in by name, if defined.
40
+ */
41
+ function attachBand(result: CheckResult): CheckResult {
42
+ if (result.band !== undefined) return result;
43
+ const band = CHECK_BANDS[result.name];
44
+ return band ? { ...result, band } : result;
45
+ }
22
46
 
23
47
  /**
24
48
  * Each check paired with the display name used if it rejects. The fallback name
@@ -53,16 +77,16 @@ export async function runChecks(rt: PragmaRuntime): Promise<DoctorData> {
53
77
  const checks: CheckResult[] = await Promise.all(
54
78
  buildChecks(rt).map(async ([name, promise]): Promise<CheckResult> => {
55
79
  try {
56
- return await promise;
80
+ return attachBand(await promise);
57
81
  } catch (error) {
58
82
  const reason = error instanceof Error ? error.message : String(error);
59
- return {
83
+ return attachBand({
60
84
  name,
61
85
  status: "fail",
62
86
  detail: `Check threw an unexpected error: ${reason}`,
63
87
  remedy:
64
88
  "Re-run `pragma doctor`; if it persists, report this as a bug.",
65
- };
89
+ });
66
90
  }
67
91
  }),
68
92
  );
@@ -2,6 +2,13 @@
2
2
  * Data shapes for `pragma doctor` (ported verbatim from the old shell).
3
3
  */
4
4
 
5
+ /**
6
+ * One of the two config bands a check can concern. Mirrors the harnesses
7
+ * `ScopeBand` structurally but is redeclared here so this statically-reachable
8
+ * type module never pulls the harnesses runtime into the fast-path module graph.
9
+ */
10
+ export type ScopeBand = "project" | "global";
11
+
5
12
  /** Status of a doctor check or one of its sub-items. */
6
13
  export type CheckStatus = "pass" | "fail" | "skip";
7
14
 
@@ -30,6 +37,14 @@ export interface CheckResult {
30
37
  readonly items?: readonly CheckItem[];
31
38
  /** Remedial instruction shown inline under a failing check. */
32
39
  readonly remedy?: string;
40
+ /**
41
+ * Which config band the check concerns, if any: `global` for the user/home
42
+ * level (shell completions), `project` for per-repo config (skills). The MCP
43
+ * checks derive their band from the harnesses they found. The renderer groups
44
+ * banded checks into Global/Project sections; environment checks (Node,
45
+ * versions, store) carry no band.
46
+ */
47
+ readonly band?: ScopeBand;
33
48
  }
34
49
 
35
50
  /** Aggregated results from all doctor checks. */
@@ -3,10 +3,17 @@
3
3
  */
4
4
 
5
5
  import type { CapabilityModule } from "../../kernel/spec/types.js";
6
- import { ontologyListVerb, ontologyShowVerb } from "./verbs.js";
6
+ import {
7
+ ontologyListVerb,
8
+ ontologyLookupVerb,
9
+ ontologyShowVerb,
10
+ } from "./verbs.js";
7
11
 
8
- /** The `ontology` capability module (`list`, `show`). */
12
+ /**
13
+ * The `ontology` capability module (`list`, `lookup`, and the deprecated `show`
14
+ * alias — AV-228 B1).
15
+ */
9
16
  export const ontologyModule: CapabilityModule = {
10
17
  name: "ontology",
11
- verbs: [ontologyListVerb, ontologyShowVerb],
18
+ verbs: [ontologyListVerb, ontologyLookupVerb, ontologyShowVerb],
12
19
  };
@@ -15,13 +15,23 @@ import {
15
15
  BLOCK_PREFIXES,
16
16
  BLOCK_TTL,
17
17
  } from "../../testing/fixtures/blockGraph.js";
18
+ import { CANONICAL_TTL } from "../../testing/fixtures/graph/canonical.js";
19
+ import {
20
+ bootFixtureRuntime,
21
+ type FixtureGraph,
22
+ } from "../../testing/helpers/fixtureGraph.js";
18
23
  import { buildFixtureRuntime } from "../../testing/helpers/packRuntime.js";
24
+ import { projectMcp } from "../../testing/helpers/projectMcp.js";
25
+ import { capabilities } from "../index.js";
19
26
  import { ontologyModule } from "./index.js";
20
27
  import type { OntologyShowData, OntologySummary } from "./queries.js";
21
28
 
22
29
  const listVerb = ontologyModule.verbs.find(
23
30
  (v) => verbKey(v.path) === "ontology list",
24
31
  ) as VerbSpec;
32
+ const lookupVerb = ontologyModule.verbs.find(
33
+ (v) => verbKey(v.path) === "ontology lookup",
34
+ ) as VerbSpec;
25
35
  const showVerb = ontologyModule.verbs.find(
26
36
  (v) => verbKey(v.path) === "ontology show",
27
37
  ) as VerbSpec;
@@ -48,9 +58,12 @@ describe("ontology list", () => {
48
58
  });
49
59
  });
50
60
 
51
- describe("ontology show", () => {
61
+ describe("ontology lookup (primary by-name read)", () => {
52
62
  it("renders the class hierarchy with per-class instance counts from the index", async () => {
53
- const data = (await showVerb.run({ prefix: "ds" }, rt)) as OntologyShowData;
63
+ const data = (await lookupVerb.run(
64
+ { prefix: "ds" },
65
+ rt,
66
+ )) as OntologyShowData;
54
67
  const component = data.classes.find((c) => c.uri.endsWith("Component"));
55
68
  expect(component?.superclass).toBe("https://ds.canonical.com/UIBlock");
56
69
  // Two Component individuals (Button, Modal) in the fixture.
@@ -58,8 +71,34 @@ describe("ontology show", () => {
58
71
  expect(data.properties).toEqual([]);
59
72
  });
60
73
 
74
+ it("--detail detailed folds onto disclosure and includes properties (B5)", async () => {
75
+ // Ontology honours the canonical `--detail` instead of only its bespoke
76
+ // `--properties`: standard/detailed add the properties section.
77
+ const detailed = {
78
+ ...rt,
79
+ globalFlags: { ...rt.globalFlags, detail: "detailed" as const },
80
+ };
81
+ const data = (await lookupVerb.run(
82
+ { prefix: "ds" },
83
+ detailed,
84
+ )) as OntologyShowData;
85
+ expect(data.properties.length).toBeGreaterThan(0);
86
+ });
87
+
88
+ it("--detail summary keeps classes only (no properties)", async () => {
89
+ const summary = {
90
+ ...rt,
91
+ globalFlags: { ...rt.globalFlags, detail: "summary" as const },
92
+ };
93
+ const data = (await lookupVerb.run(
94
+ { prefix: "ds" },
95
+ summary,
96
+ )) as OntologyShowData;
97
+ expect(data.properties).toEqual([]);
98
+ });
99
+
61
100
  it("--class focuses on one class and its properties", async () => {
62
- const data = (await showVerb.run(
101
+ const data = (await lookupVerb.run(
63
102
  { prefix: "ds", class: "BlockProperty" },
64
103
  rt,
65
104
  )) as OntologyShowData;
@@ -77,18 +116,131 @@ describe("ontology show", () => {
77
116
  });
78
117
 
79
118
  it("--full-uris flag rides on the payload for the renderer", async () => {
80
- const data = (await showVerb.run(
119
+ const data = (await lookupVerb.run(
81
120
  { prefix: "ds", fullUris: true },
82
121
  rt,
83
122
  )) as OntologyShowData;
84
123
  expect(data.fullUris).toBe(true);
85
- const llm = showVerb.output.formatters.llm(data);
124
+ const llm = lookupVerb.output.formatters.llm(data);
86
125
  expect(llm).toContain("https://ds.canonical.com/Component");
87
126
  });
88
127
 
89
128
  it("rejects an unknown prefix with recovery", async () => {
129
+ await expect(lookupVerb.run({ prefix: "nope" }, rt)).rejects.toThrow(
130
+ /prefix/i,
131
+ );
132
+ });
133
+ });
134
+
135
+ describe("ontology show — deprecated alias of lookup (AV-228 B1)", () => {
136
+ it("is marked deprecated in its summary and steers callers to `ontology lookup`", () => {
137
+ expect(showVerb.summary.toLowerCase()).toContain("deprecated");
138
+ expect(showVerb.summary).toContain("ontology lookup");
139
+ });
140
+
141
+ it("shares the lookup handler — same payload for the same input", async () => {
142
+ const viaLookup = (await lookupVerb.run(
143
+ { prefix: "ds", properties: true },
144
+ rt,
145
+ )) as OntologyShowData;
146
+ const viaShow = (await showVerb.run(
147
+ { prefix: "ds", properties: true },
148
+ rt,
149
+ )) as OntologyShowData;
150
+ expect(viaShow).toEqual(viaLookup);
151
+ });
152
+
153
+ it("still rejects an unknown prefix (the alias is a full, callable verb)", async () => {
90
154
  await expect(showVerb.run({ prefix: "nope" }, rt)).rejects.toThrow(
91
155
  /prefix/i,
92
156
  );
93
157
  });
94
158
  });
159
+
160
+ describe("ontology_show honours detail over MCP (B5)", () => {
161
+ // The CLI B5 cases above seed `globalFlags.detail` and call `showVerb.run`
162
+ // directly. Over MCP there are NO global flags — the ONLY detail channel is
163
+ // the per-tool `detail` param the projector injects from the VerbSpec
164
+ // disclosure. Under an AMBIENT config `detail: standard`, an `ontology_show`
165
+ // with no declared disclosure would force the properties section with no
166
+ // per-call escape (the AV-228 MCP-opt-out asymmetry `block`/`standard` lack);
167
+ // declaring disclosure lights the param up, so an agent can ask for
168
+ // classes-only per call and can equally override upward.
169
+ let fixture: FixtureGraph;
170
+ let mcp: Awaited<ReturnType<typeof projectMcp>>;
171
+
172
+ beforeAll(async () => {
173
+ // Ambient config detail=standard → origins.detail === "project", so it
174
+ // outranks the verb's `summary` default inside resolvePackDetail.
175
+ fixture = await bootFixtureRuntime({
176
+ ttl: CANONICAL_TTL,
177
+ config: { detail: "standard" },
178
+ });
179
+ mcp = await projectMcp(capabilities, fixture.cwd);
180
+ });
181
+
182
+ afterAll(async () => {
183
+ await mcp.cleanup();
184
+ await fixture.dispose();
185
+ });
186
+
187
+ it("injects a detail param onto the ontology_show tool (symmetric with block/standard)", async () => {
188
+ const tools = await mcp.listTools();
189
+ const show = tools.find((t) => t.name === "ontology_show");
190
+ const schema = show?.inputSchema as {
191
+ properties?: Record<string, unknown>;
192
+ };
193
+ expect(schema.properties?.detail).toBeDefined();
194
+ });
195
+
196
+ it("exposes the primary ontology_lookup tool with the same injected detail param (AV-228 B1)", async () => {
197
+ const tools = await mcp.listTools();
198
+ const lookup = tools.find((t) => t.name === "ontology_lookup");
199
+ expect(lookup).toBeDefined();
200
+ const schema = lookup?.inputSchema as {
201
+ properties?: Record<string, unknown>;
202
+ };
203
+ expect(schema.properties?.detail).toBeDefined();
204
+ });
205
+
206
+ it("ontology_lookup honours a per-call detail=summary despite the ambient standard", async () => {
207
+ const result = await mcp.callTool("ontology_lookup", {
208
+ prefix: "ds",
209
+ detail: "summary",
210
+ });
211
+ expect(result.ok).toBe(true);
212
+ const data = result.data as { classes: unknown[]; properties: unknown[] };
213
+ expect(data.classes.length).toBeGreaterThan(0);
214
+ expect(data.properties).toEqual([]);
215
+ });
216
+
217
+ it("honours the ambient detail=standard config when no per-call detail is set", async () => {
218
+ // Proves the ambient level really is standard, so the summary case below is
219
+ // a genuine per-call override rather than merely the spec default.
220
+ const result = await mcp.callTool("ontology_show", { prefix: "ds" });
221
+ expect(result.ok).toBe(true);
222
+ const data = result.data as { properties: unknown[] };
223
+ expect(data.properties.length).toBeGreaterThan(0);
224
+ });
225
+
226
+ it("a per-call detail=summary returns classes-only despite the ambient standard", async () => {
227
+ const result = await mcp.callTool("ontology_show", {
228
+ prefix: "ds",
229
+ detail: "summary",
230
+ });
231
+ expect(result.ok).toBe(true);
232
+ const data = result.data as { classes: unknown[]; properties: unknown[] };
233
+ expect(data.classes.length).toBeGreaterThan(0);
234
+ expect(data.properties).toEqual([]);
235
+ });
236
+
237
+ it("an explicit detail=detailed overrides upward to include properties", async () => {
238
+ const result = await mcp.callTool("ontology_show", {
239
+ prefix: "ds",
240
+ detail: "detailed",
241
+ });
242
+ expect(result.ok).toBe(true);
243
+ const data = result.data as { properties: unknown[] };
244
+ expect(data.properties.length).toBeGreaterThan(0);
245
+ });
246
+ });