@canonical/pragma-cli 0.35.0 → 0.36.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 (230) hide show
  1. package/README.md +2 -2
  2. package/bundled-skills/add-standard/SKILL.md +338 -0
  3. package/bundled-skills/adoption-a1-styles/SKILL.md +269 -0
  4. package/bundled-skills/adoption-a2-components/SKILL.md +291 -0
  5. package/bundled-skills/adoption-a3-forms/SKILL.md +260 -0
  6. package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +406 -0
  7. package/bundled-skills/anatomy-author/SKILL.md +1226 -0
  8. package/bundled-skills/design-auditor/SKILL.md +455 -0
  9. package/bundled-skills/implement-component/SKILL.md +333 -0
  10. package/bundled-skills/specify-component/SKILL.md +331 -0
  11. package/bundled-skills/specify-pattern/SKILL.md +292 -0
  12. package/dist/package.json +16 -15
  13. package/dist/pragma.conf.js +236 -34
  14. package/dist/pragma.conf.js.map +1 -1
  15. package/dist/src/bin.js +1 -1
  16. package/dist/src/bin.js.map +1 -1
  17. package/dist/src/capabilities/colophon/colophon.render.js +1 -1
  18. package/dist/src/capabilities/colophon/colophon.render.js.map +1 -1
  19. package/dist/src/capabilities/create/mount.js +4 -3
  20. package/dist/src/capabilities/create/mount.js.map +1 -1
  21. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js +60 -4
  22. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js.map +1 -1
  23. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js +8 -8
  24. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js.map +1 -1
  25. package/dist/src/capabilities/doctor/checks/harnessInventory.js +195 -0
  26. package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -0
  27. package/dist/src/capabilities/doctor/checks/index.js +6 -6
  28. package/dist/src/capabilities/doctor/checks/targetHealth.js +194 -50
  29. package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
  30. package/dist/src/capabilities/doctor/doctor.render.js +43 -64
  31. package/dist/src/capabilities/doctor/doctor.render.js.map +1 -1
  32. package/dist/src/capabilities/doctor/doctor.verb.js +6 -2
  33. package/dist/src/capabilities/doctor/doctor.verb.js.map +1 -1
  34. package/dist/src/capabilities/doctor/runChecks.js +14 -11
  35. package/dist/src/capabilities/doctor/runChecks.js.map +1 -1
  36. package/dist/src/capabilities/graph/query.render.js +2 -2
  37. package/dist/src/capabilities/graph/query.render.js.map +1 -1
  38. package/dist/src/capabilities/info/collectInfo.js +8 -2
  39. package/dist/src/capabilities/info/collectInfo.js.map +1 -1
  40. package/dist/src/capabilities/info/info.render.js +6 -2
  41. package/dist/src/capabilities/info/info.render.js.map +1 -1
  42. package/dist/src/capabilities/prompt/prompt.render.js +2 -2
  43. package/dist/src/capabilities/prompt/prompt.render.js.map +1 -1
  44. package/dist/src/capabilities/setup/buildPlan.js +43 -39
  45. package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
  46. package/dist/src/capabilities/setup/mode.js +2 -2
  47. package/dist/src/capabilities/setup/operations/index.js +4 -4
  48. package/dist/src/capabilities/setup/operations/index.js.map +1 -1
  49. package/dist/src/capabilities/setup/operations/setupGenerator.js +92 -45
  50. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  51. package/dist/src/capabilities/setup/operations/setupLsp.js +88 -36
  52. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  53. package/dist/src/capabilities/setup/operations/setupMcp.js +97 -51
  54. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  55. package/dist/src/capabilities/setup/operations/setupSkills.js +325 -47
  56. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  57. package/dist/src/capabilities/setup/plan.js.map +1 -1
  58. package/dist/src/capabilities/setup/plan.render.js +144 -66
  59. package/dist/src/capabilities/setup/plan.render.js.map +1 -1
  60. package/dist/src/capabilities/setup/setup.render.js +1 -1
  61. package/dist/src/capabilities/setup/setup.verb.js +86 -33
  62. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  63. package/dist/src/capabilities/setup/targets.js +110 -45
  64. package/dist/src/capabilities/setup/targets.js.map +1 -1
  65. package/dist/src/capabilities/shared/index.js +6 -5
  66. package/dist/src/capabilities/shared/index.js.map +1 -1
  67. package/dist/src/capabilities/shared/packageManager.js +300 -54
  68. package/dist/src/capabilities/shared/packageManager.js.map +1 -1
  69. package/dist/src/capabilities/skill/discover.js +97 -11
  70. package/dist/src/capabilities/skill/discover.js.map +1 -1
  71. package/dist/src/capabilities/skill/render.js +2 -2
  72. package/dist/src/capabilities/skill/render.js.map +1 -1
  73. package/dist/src/capabilities/sources/installSkills.js +145 -3
  74. package/dist/src/capabilities/sources/installSkills.js.map +1 -1
  75. package/dist/src/capabilities/sources/runUpdate.js +68 -2
  76. package/dist/src/capabilities/sources/runUpdate.js.map +1 -1
  77. package/dist/src/capabilities/sources/update.verb.js +3 -3
  78. package/dist/src/capabilities/sources/update.verb.js.map +1 -1
  79. package/dist/src/capabilities/upgrade/runUpgrade.js +43 -7
  80. package/dist/src/capabilities/upgrade/runUpgrade.js.map +1 -1
  81. package/dist/src/capabilities/upgrade/upgrade.render.js +10 -2
  82. package/dist/src/capabilities/upgrade/upgrade.render.js.map +1 -1
  83. package/dist/src/constants.js +6 -1
  84. package/dist/src/constants.js.map +1 -1
  85. package/dist/src/kernel/index.js +11 -2
  86. package/dist/src/kernel/index.js.map +1 -1
  87. package/dist/src/kernel/interactivity.js +68 -0
  88. package/dist/src/kernel/interactivity.js.map +1 -0
  89. package/dist/src/kernel/packs/renderPack.js +3 -3
  90. package/dist/src/kernel/packs/renderPack.js.map +1 -1
  91. package/dist/src/kernel/packs/resolveEntity.js +63 -12
  92. package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
  93. package/dist/src/kernel/packs/runBodies.js +57 -4
  94. package/dist/src/kernel/packs/runBodies.js.map +1 -1
  95. package/dist/src/kernel/packs/schema.js +54 -2
  96. package/dist/src/kernel/packs/schema.js.map +1 -1
  97. package/dist/src/kernel/packs/sparql/applyFilters.js +104 -5
  98. package/dist/src/kernel/packs/sparql/applyFilters.js.map +1 -1
  99. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +306 -43
  100. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  101. package/dist/src/kernel/packs/types.js.map +1 -1
  102. package/dist/src/kernel/project/cli/buildProgram.js +1 -1
  103. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
  104. package/dist/src/kernel/project/cli/dispatch.js +23 -23
  105. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  106. package/dist/src/kernel/project/cli/globalFlags.js +4 -1
  107. package/dist/src/kernel/project/cli/globalFlags.js.map +1 -1
  108. package/dist/src/kernel/project/cli/helpFormat.js +1 -2
  109. package/dist/src/kernel/project/cli/helpFormat.js.map +1 -1
  110. package/dist/src/kernel/project/cli/rootHelp.js +34 -4
  111. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  112. package/dist/src/kernel/project/mcp/registerVerb.js +16 -1
  113. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
  114. package/dist/src/kernel/render/renderers.js +1 -1
  115. package/dist/src/kernel/render/style.js +29 -9
  116. package/dist/src/kernel/render/style.js.map +1 -1
  117. package/dist/src/kernel/render/vocabulary.js +174 -0
  118. package/dist/src/kernel/render/vocabulary.js.map +1 -0
  119. package/dist/src/kernel/spec/emitReference.js +1 -1
  120. package/dist/src/kernel/spec/emitReference.js.map +1 -1
  121. package/package.json +16 -15
  122. package/pragma.conf.ts +241 -34
  123. package/src/bin.ts +8 -1
  124. package/src/capabilities/block.parity.test.ts +25 -11
  125. package/src/capabilities/block.shipped.exec.test.ts +282 -0
  126. package/src/capabilities/block.tierRank.exec.test.ts +300 -0
  127. package/src/capabilities/colophon/colophon.render.ts +1 -2
  128. package/src/capabilities/colophon/colophon.test.ts +2 -2
  129. package/src/capabilities/create/createGrammar.test.ts +1 -1
  130. package/src/capabilities/create/mount.ts +3 -3
  131. package/src/capabilities/distribution.parity.test.ts +4 -0
  132. package/src/capabilities/doctor/checks/checkPackageRefs.ts +69 -4
  133. package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +6 -4
  134. package/src/capabilities/doctor/checks/checkShellCompletions.ts +8 -8
  135. package/src/capabilities/doctor/checks/harnessInventory.test.ts +219 -0
  136. package/src/capabilities/doctor/checks/harnessInventory.ts +298 -0
  137. package/src/capabilities/doctor/checks/index.ts +6 -6
  138. package/src/capabilities/doctor/checks/targetHealth.ts +229 -59
  139. package/src/capabilities/doctor/doctor.render.test.ts +108 -32
  140. package/src/capabilities/doctor/doctor.render.ts +56 -80
  141. package/src/capabilities/doctor/doctor.test.ts +247 -35
  142. package/src/capabilities/doctor/doctor.verb.ts +7 -2
  143. package/src/capabilities/doctor/runChecks.ts +14 -11
  144. package/src/capabilities/doctor/types.ts +15 -24
  145. package/src/capabilities/emptyRecovery.test.ts +10 -2
  146. package/src/capabilities/graph/query.render.ts +2 -2
  147. package/src/capabilities/info/collectInfo.ts +8 -1
  148. package/src/capabilities/info/info.render.ts +6 -2
  149. package/src/capabilities/info/info.test.ts +37 -1
  150. package/src/capabilities/info/types.ts +10 -2
  151. package/src/capabilities/prompt/prompt.render.ts +2 -2
  152. package/src/capabilities/renderVocabularySync.test.ts +82 -0
  153. package/src/capabilities/scopeTypeSync.test.ts +29 -15
  154. package/src/capabilities/setup/buildPlan.ts +45 -41
  155. package/src/capabilities/setup/index.ts +1 -1
  156. package/src/capabilities/setup/mode.ts +2 -2
  157. package/src/capabilities/setup/operations/index.ts +6 -1
  158. package/src/capabilities/setup/operations/setupGenerator.ts +144 -70
  159. package/src/capabilities/setup/operations/setupLsp.ts +129 -37
  160. package/src/capabilities/setup/operations/setupMcp.ts +132 -61
  161. package/src/capabilities/setup/operations/setupSkills.ts +392 -58
  162. package/src/capabilities/setup/plan.render.ts +171 -76
  163. package/src/capabilities/setup/plan.ts +17 -12
  164. package/src/capabilities/setup/setup.mcp.correctness.test.ts +9 -9
  165. package/src/capabilities/setup/setup.render.test.ts +50 -36
  166. package/src/capabilities/setup/setup.render.ts +1 -1
  167. package/src/capabilities/setup/setup.test.ts +881 -50
  168. package/src/capabilities/setup/setup.verb.ts +91 -33
  169. package/src/capabilities/setup/targets.ts +120 -52
  170. package/src/capabilities/setup/types.ts +10 -18
  171. package/src/capabilities/shared/index.ts +17 -6
  172. package/src/capabilities/shared/packageManager.test.ts +421 -6
  173. package/src/capabilities/shared/packageManager.ts +394 -59
  174. package/src/capabilities/skill/bundledSkills.test.ts +347 -0
  175. package/src/capabilities/skill/discover.ts +100 -11
  176. package/src/capabilities/skill/render.ts +2 -2
  177. package/src/capabilities/skill/skill.test.ts +17 -4
  178. package/src/capabilities/sources/installSkills.ts +181 -3
  179. package/src/capabilities/sources/runUpdate.ts +78 -2
  180. package/src/capabilities/sources/sources.test.ts +228 -0
  181. package/src/capabilities/sources/update.verb.ts +3 -3
  182. package/src/capabilities/surface.test.ts +2 -2
  183. package/src/capabilities/upgrade/runUpgrade.ts +45 -6
  184. package/src/capabilities/upgrade/types.ts +10 -2
  185. package/src/capabilities/upgrade/upgrade.render.ts +9 -2
  186. package/src/capabilities/upgrade/upgrade.test.ts +72 -0
  187. package/src/constants.ts +6 -1
  188. package/src/identity.test.ts +18 -5
  189. package/src/kernel/completion/entitySource.test.ts +15 -2
  190. package/src/kernel/completion/safety.test.ts +16 -9
  191. package/src/kernel/config/defaults.test.ts +2 -1
  192. package/src/kernel/index.ts +11 -2
  193. package/src/kernel/interactivity.test.ts +76 -0
  194. package/src/kernel/interactivity.ts +69 -0
  195. package/src/kernel/packs/compile.test.ts +92 -0
  196. package/src/kernel/packs/renderPack.ts +3 -3
  197. package/src/kernel/packs/resolveEntity.test.ts +75 -14
  198. package/src/kernel/packs/resolveEntity.ts +88 -24
  199. package/src/kernel/packs/runBodies.ts +84 -19
  200. package/src/kernel/packs/schema.ts +60 -4
  201. package/src/kernel/packs/sparql/applyFilters.ts +125 -4
  202. package/src/kernel/packs/sparql/buildLookupQuery.test.ts +290 -0
  203. package/src/kernel/packs/sparql/buildLookupQuery.ts +323 -45
  204. package/src/kernel/packs/toolDescription.test.ts +35 -1
  205. package/src/kernel/packs/types.ts +125 -2
  206. package/src/kernel/project/cli/buildProgram.test.ts +73 -2
  207. package/src/kernel/project/cli/buildProgram.ts +8 -1
  208. package/src/kernel/project/cli/dispatch.test.ts +5 -4
  209. package/src/kernel/project/cli/dispatch.ts +25 -24
  210. package/src/kernel/project/cli/globalFlags.ts +4 -1
  211. package/src/kernel/project/cli/helpFormat.ts +1 -2
  212. package/src/kernel/project/cli/rootHelp.ts +36 -3
  213. package/src/kernel/project/mcp/registerVerb.ts +20 -1
  214. package/src/kernel/render/renderers.ts +1 -1
  215. package/src/kernel/render/style.ts +32 -9
  216. package/src/kernel/render/vocabulary.ts +276 -0
  217. package/src/kernel/spec/emitReference.ts +1 -1
  218. package/src/kernel/spec/types.ts +35 -6
  219. package/src/testing/PARITY_GAPS.ts +9 -3
  220. package/src/testing/behavioral/docExamples.test.ts +4 -2
  221. package/src/testing/behavioral/errorMatrix.mcp.test.ts +110 -7
  222. package/src/testing/behavioral/journeys.firstInstall.test.ts +7 -2
  223. package/src/testing/behavioral/journeys.packageStories.test.ts +6 -3
  224. package/src/testing/eval/__snapshots__/eval.test.ts.snap +26 -1
  225. package/src/testing/eval/cases/readNouns.ts +245 -9
  226. package/src/testing/fixtures/blockGraph.ts +28 -13
  227. package/src/testing/fixtures/graph/canonical.ts +95 -12
  228. package/dist/src/capabilities/shared/bands.js +0 -16
  229. package/dist/src/capabilities/shared/bands.js.map +0 -1
  230. package/src/capabilities/shared/bands.ts +0 -16
@@ -60,6 +60,51 @@ function ignoredSuffix(items: readonly CheckItem[]): string {
60
60
  return ` · ${items.length} ${items.length === 1 ? "story" : "stories"} ignored`;
61
61
  }
62
62
 
63
+ /**
64
+ * The answering pack's provenance, one item per pack it was built from.
65
+ *
66
+ * `sourceRef` is a single comma-joined string — `@canonical/design-system@git:
67
+ * d6d8a6c8268cf2bd103e956a2540d6e36bd08d72, @canonical/anatomy-dsl@npm:0.2.2,
68
+ * …` — and printing it whole put four packs and two forty-character SHAs on one
69
+ * line, in a report whose every other multi-part check (`mcp`, `harnesses`)
70
+ * uses the sub-item mechanism. Provenance is the thing a reader is here for
71
+ * when they read this line at all: which revision of which pack answered.
72
+ *
73
+ * The scheme (`git`/`npm`/`self`/`file`) is kept because it says how to move
74
+ * the pin, and a git hash is cut to the seven characters every other tool in
75
+ * this workflow shows. A ref that does not parse is passed through whole rather
76
+ * than dropped — an unreadable provenance is still provenance, and hiding it
77
+ * would be the one failure this check exists to prevent.
78
+ *
79
+ * @param sourceRef - The manifest's comma-joined provenance string.
80
+ * @returns One passing item per pack, in the order the pack records them.
81
+ */
82
+ function packItems(sourceRef: string): CheckItem[] {
83
+ return sourceRef
84
+ .split(",")
85
+ .map((ref) => ref.trim())
86
+ .filter((ref) => ref !== "")
87
+ .map((ref) => {
88
+ const parsed = /^(.+)@(git|npm|self|file|link):(.+)$/.exec(ref);
89
+ if (parsed === null) return { label: ref, status: "pass" as const };
90
+ const [, pack, scheme, revision] = parsed as unknown as [
91
+ string,
92
+ string,
93
+ string,
94
+ string,
95
+ ];
96
+ const short =
97
+ scheme === "git" && /^[0-9a-f]{40}$/.test(revision)
98
+ ? revision.slice(0, 7)
99
+ : revision;
100
+ return {
101
+ label: pack,
102
+ detail: `${scheme} ${short}`,
103
+ status: "pass" as const,
104
+ };
105
+ });
106
+ }
107
+
63
108
  /**
64
109
  * Report which pack answers reads, its provenance, and its entity total.
65
110
  *
@@ -89,13 +134,30 @@ export async function checkPackageRefs(
89
134
  // The pack DOES answer reads, so the check itself passes; the ignored stories
90
135
  // are failing sub-items under it. (A `warn` status would ripple through the
91
136
  // renderer and the DoctorData counts to buy an icon.)
92
- const items = await ignoredStoryItems(decision);
93
- const suffix = ignoredSuffix(items);
137
+ const ignored = await ignoredStoryItems(decision);
138
+ const suffix = ignoredSuffix(ignored);
139
+ const sourceRef =
140
+ decision.kind === "embedded"
141
+ ? embeddedManifest().sourceRef
142
+ : (readManifest(decision.dir)?.sourceRef ?? "");
143
+ // Packs first, then anything wrong with them: the composition is the answer to
144
+ // "which revision am I reading", and a failing story is a note about one of
145
+ // the rows above it.
146
+ const packs = packItems(sourceRef);
147
+ const items = [...packs, ...ignored];
148
+ const composition = `${packs.length} ${packs.length === 1 ? "pack" : "packs"}, ${entities} entities${suffix}`;
149
+
94
150
  if (decision.kind === "embedded") {
95
151
  return {
96
152
  name,
97
153
  status: "pass",
98
- detail: `embedded snapshot @ ${embeddedManifest().sourceRef} — ${entities} entities${suffix} · \`${BIN_NAME} sources update\` to build from the configured packs`,
154
+ // The update hint stays in the DETAIL rather than moving to `remedy`:
155
+ // the renderer prints a remedy only where there is something to fix or
156
+ // to finish, and a snapshot that is answering reads correctly is
157
+ // neither. "shipped with the CLI" rather than "embedded snapshot" — the
158
+ // reader needs to know where these packs came from, not what the build
159
+ // calls the artefact.
160
+ detail: `shipped with the CLI — ${composition} · run \`${BIN_NAME} sources update\` to build from your own configured packs instead`,
99
161
  ...(items.length > 0 ? { items } : {}),
100
162
  };
101
163
  }
@@ -103,7 +165,10 @@ export async function checkPackageRefs(
103
165
  return {
104
166
  name,
105
167
  status: "pass",
106
- detail: `${manifest?.sourceRef ?? decision.contentHash.slice(0, 12)} — ${entities} entities, built ${manifest?.createdAt ?? "?"}${suffix}`,
168
+ detail:
169
+ packs.length > 0
170
+ ? `built ${manifest?.createdAt ?? "?"} — ${composition}`
171
+ : `${decision.contentHash.slice(0, 12)} — ${composition}, built ${manifest?.createdAt ?? "?"}`,
107
172
  ...(items.length > 0 ? { items } : {}),
108
173
  };
109
174
  }
@@ -94,10 +94,12 @@ function wireZfunc(): void {
94
94
  describe("checkShellCompletions — effect test (gate 1)", () => {
95
95
  it("passes the resolver gate: `pragma <TAB>` resolves nouns", async () => {
96
96
  // No shell → skip, but only AFTER the resolver answered — the detail proves
97
- // the effect test ran and succeeded.
97
+ // the effect test ran and succeeded. The proof is the same fact worded for
98
+ // a reader ("pragma answers `<TAB>`") rather than as "resolver OK": the
99
+ // gate it pins is unchanged, only the sentence that reports it.
98
100
  const result = await check(tmp(), null);
99
101
  expect(result.status).toBe("skip");
100
- expect(result.detail).toMatch(/resolver OK/);
102
+ expect(result.detail).toMatch(/answers `<TAB>`/);
101
103
  });
102
104
  });
103
105
 
@@ -137,7 +139,7 @@ describe("checkShellCompletions — install probe (gate 2)", () => {
137
139
  const result = await check(tmp(), "bash");
138
140
  expect(result.status).toBe("pass");
139
141
  expect(result.detail).toMatch(
140
- /bash \(the shell in use\) up to date and resolving/,
142
+ /bash \(the shell you are in\) — installed, up to date, and completing/,
141
143
  );
142
144
  });
143
145
 
@@ -200,7 +202,7 @@ describe("checkShellCompletions — zsh fpath activation (gate 3)", () => {
200
202
  const result = await check(tmp(), "zsh");
201
203
  expect(result.status).toBe("pass");
202
204
  expect(result.detail).toMatch(
203
- /zsh \(the shell in use\) up to date and resolving/,
205
+ /zsh \(the shell you are in\) — installed, up to date, and completing/,
204
206
  );
205
207
  });
206
208
  });
@@ -126,7 +126,7 @@ export async function checkShellCompletions(
126
126
  return {
127
127
  name: NAME,
128
128
  status: "fail",
129
- detail: `completion resolver failed: ${reason}`,
129
+ detail: `${BIN_NAME} cannot answer \`<TAB>\`: ${reason}`,
130
130
  remedy: `Report this as a bug — \`${BIN_NAME} __complete\` should never throw.`,
131
131
  };
132
132
  }
@@ -134,7 +134,7 @@ export async function checkShellCompletions(
134
134
  return {
135
135
  name: NAME,
136
136
  status: "fail",
137
- detail: `completion resolver returned no candidates for \`${BIN_NAME} <TAB>\``,
137
+ detail: `${BIN_NAME} answered \`<TAB>\` with nothing to complete`,
138
138
  remedy: "Report this as a bug — the noun context is always non-empty.",
139
139
  };
140
140
  }
@@ -147,7 +147,7 @@ export async function checkShellCompletions(
147
147
  return {
148
148
  name: NAME,
149
149
  status: "skip",
150
- detail: `resolver OK; the running shell cannot be identified (SHELL names ${d.detection.login}, the login shell)`,
150
+ detail: `${BIN_NAME} answers \`<TAB>\`, but the running shell cannot be identified — SHELL names ${d.detection.login}, which is your login shell`,
151
151
  remedy: `Run \`${INSTALL_REMEDY}\` from the shell you want completions in.`,
152
152
  };
153
153
  }
@@ -155,7 +155,7 @@ export async function checkShellCompletions(
155
155
  return {
156
156
  name: NAME,
157
157
  status: "skip",
158
- detail: "resolver OK; no bash, zsh, or fish in this process tree",
158
+ detail: `${BIN_NAME} answers \`<TAB>\`, but no bash, zsh, or fish was found in this process tree`,
159
159
  };
160
160
  }
161
161
  // The installed script delegates every name context to the binary. A script
@@ -174,7 +174,7 @@ export async function checkShellCompletions(
174
174
  status: installed ? "fail" : "available",
175
175
  detail: installed
176
176
  ? `${shell} script installed, but \`${BIN_NAME}\` is not on PATH — the script cannot run it`
177
- : `resolver OK; \`${BIN_NAME}\` is not on PATH — an installed script cannot run it`,
177
+ : `${BIN_NAME} answers \`<TAB>\`, but is not on PATH — an installed script cannot run it`,
178
178
  remedy: `Put \`${BIN_NAME}\` on your PATH.`,
179
179
  };
180
180
  }
@@ -186,7 +186,7 @@ export async function checkShellCompletions(
186
186
  return {
187
187
  name: NAME,
188
188
  status: "available",
189
- detail: `resolver OK; ${shell} script not installed`,
189
+ detail: `${BIN_NAME} answers \`<TAB>\`; the ${shell} script is not installed`,
190
190
  remedy: INSTALL_REMEDY,
191
191
  };
192
192
  }
@@ -194,7 +194,7 @@ export async function checkShellCompletions(
194
194
  return {
195
195
  name: NAME,
196
196
  status: "fail",
197
- detail: `resolver OK; ${shell} script at ${path} is out of date`,
197
+ detail: `${BIN_NAME} answers \`<TAB>\`; the ${shell} script at ${path} is out of date`,
198
198
  remedy: INSTALL_REMEDY,
199
199
  };
200
200
  }
@@ -212,6 +212,6 @@ export async function checkShellCompletions(
212
212
  return {
213
213
  name: NAME,
214
214
  status: "pass",
215
- detail: `${shell} (the shell in use) up to date and resolving (${candidates} nouns)`,
215
+ detail: `${shell} (the shell you are in) — installed, up to date, and completing ${candidates} commands`,
216
216
  };
217
217
  }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * The harness inventory roll-up — the pure half, driven by fixtures.
3
+ *
4
+ * `doctor.test.ts` covers the wiring (both scopes, `--verbose`, the real
5
+ * registry). What is unit-tested here is the classification itself: the
6
+ * registry supplies the universe so the NEGATIVE is representable, the groups
7
+ * supply the state, and no combination of the two may ever produce a `fail`.
8
+ */
9
+
10
+ import { describe, expect, it } from "vitest";
11
+ import {
12
+ harnessInventory,
13
+ type InventoryGroup,
14
+ type InventoryHarness,
15
+ type InventoryMember,
16
+ type InventoryRow,
17
+ inventoryHealth,
18
+ inventoryItems,
19
+ isDetectedState,
20
+ type WriteState,
21
+ } from "./harnessInventory.js";
22
+
23
+ const REGISTRY: InventoryHarness[] = [
24
+ { id: "claude-code", name: "Claude Code", inScope: true },
25
+ { id: "cursor", name: "Cursor", inScope: true },
26
+ { id: "vscode", name: "VS Code", inScope: false },
27
+ ];
28
+
29
+ /** One harness's share of a file — its OWN write's state, never the file's. */
30
+ const named = (name: string, state: WriteState): InventoryMember => ({
31
+ name,
32
+ state,
33
+ });
34
+
35
+ const roll = (groups: InventoryGroup[]): InventoryRow[] =>
36
+ harnessInventory(REGISTRY, groups, "global");
37
+
38
+ describe("harnessInventory — the roll-up", () => {
39
+ it("classifies each registry harness against the scope's groups", () => {
40
+ const rows = roll([
41
+ {
42
+ path: "~/.claude.json",
43
+ harnesses: [named("Claude Code", "registered")],
44
+ },
45
+ { path: "~/.cursor/mcp.json", harnesses: [named("Cursor", "absent")] },
46
+ ]);
47
+
48
+ expect(rows.map((r) => `${r.harnessId}:${r.state}`)).toEqual([
49
+ "claude-code:registered",
50
+ "cursor:detected",
51
+ // Not detected AND has no location in this scope at all — the two are
52
+ // different facts and the row says which one applies.
53
+ "vscode:unscoped",
54
+ ]);
55
+ expect(rows[0]?.locations).toEqual(["~/.claude.json"]);
56
+ expect(rows[2]?.locations).toEqual([]);
57
+ });
58
+
59
+ it("keeps 'undetected' and 'unscoped' apart", () => {
60
+ // Same absence, two different reasons: Cursor could be here and is not,
61
+ // VS Code could never be here at all.
62
+ const rows = roll([]);
63
+ expect(rows.find((r) => r.harnessId === "cursor")?.state).toBe(
64
+ "undetected",
65
+ );
66
+ expect(rows.find((r) => r.harnessId === "vscode")?.state).toBe("unscoped");
67
+ });
68
+
69
+ it("reports a drifted group as drifted, not as registered", () => {
70
+ const rows = roll([
71
+ { path: "~/.claude.json", harnesses: [named("Claude Code", "drifted")] },
72
+ ]);
73
+ expect(rows[0]?.state).toBe("drifted");
74
+ });
75
+
76
+ it("a harness sharing two files is registered only when BOTH carry the entry", () => {
77
+ const both = harnessInventory(
78
+ REGISTRY,
79
+ [
80
+ { path: "a.json", harnesses: [named("Cursor", "registered")] },
81
+ { path: "b.json", harnesses: [named("Cursor", "registered")] },
82
+ ],
83
+ "global",
84
+ );
85
+ expect(both[1]?.state).toBe("registered");
86
+ expect(both[1]?.locations).toEqual(["a.json", "b.json"]);
87
+
88
+ const mixed = harnessInventory(
89
+ REGISTRY,
90
+ [
91
+ { path: "a.json", harnesses: [named("Cursor", "registered")] },
92
+ { path: "b.json", harnesses: [named("Cursor", "absent")] },
93
+ ],
94
+ "global",
95
+ );
96
+ expect(mixed[1]?.state).toBe("drifted");
97
+ });
98
+
99
+ it("gives each harness sharing ONE file its own state, not the file's", () => {
100
+ // The co-detection case, and the reason a group carries per-harness state:
101
+ // `.vscode/mcp.json` holds VS Code's `servers` and Cline's `mcpServers` as
102
+ // two independent entries. Register pragma for VS Code alone and the FILE
103
+ // classifies as drifted — one key current, one absent — but neither
104
+ // harness is drifted: VS Code is registered and Cline is merely detected.
105
+ // A fixture with one harness per file cannot see this.
106
+ const shared: InventoryHarness[] = [
107
+ { id: "cline", name: "Cline", inScope: true },
108
+ { id: "vscode", name: "VS Code", inScope: true },
109
+ ];
110
+ const rows = harnessInventory(
111
+ shared,
112
+ [
113
+ {
114
+ path: ".vscode/mcp.json",
115
+ harnesses: [named("Cline", "absent"), named("VS Code", "registered")],
116
+ },
117
+ ],
118
+ "project",
119
+ );
120
+
121
+ expect(rows.map((r) => `${r.harnessId}:${r.state}`)).toEqual([
122
+ "cline:detected",
123
+ "vscode:registered",
124
+ ]);
125
+ // Both are located in the same file — the sharing itself is unchanged.
126
+ for (const row of rows) expect(row.locations).toEqual([".vscode/mcp.json"]);
127
+ });
128
+
129
+ it("ignores a group naming a harness the registry does not know", () => {
130
+ // The inventory reports on the REGISTRY; a group is evidence, not a source
131
+ // of rows, so an unknown name adds nothing rather than inventing an entry.
132
+ const rows = roll([
133
+ { path: "x.json", harnesses: [named("Ghost Editor", "registered")] },
134
+ ]);
135
+ expect(rows).toHaveLength(REGISTRY.length);
136
+ expect(rows.map((r) => r.harnessName)).not.toContain("Ghost Editor");
137
+ });
138
+
139
+ it("names every row with the scope it was rolled up for", () => {
140
+ const rows = harnessInventory(REGISTRY, [], "project");
141
+ expect(rows.every((r) => r.scope === "project")).toBe(true);
142
+ });
143
+ });
144
+
145
+ describe("harnessInventory — the listing", () => {
146
+ const rows = roll([
147
+ { path: "~/.claude.json", harnesses: [named("Claude Code", "registered")] },
148
+ ]);
149
+
150
+ it("shows detected harnesses only by default", () => {
151
+ const items = inventoryItems(rows, false);
152
+ expect(items.map((i) => i.label)).toEqual(["Claude Code"]);
153
+ expect(items[0]?.detail).toBe("registered — ~/.claude.json");
154
+ });
155
+
156
+ it("--verbose widens the same rows to the whole registry", () => {
157
+ const items = inventoryItems(rows, true);
158
+ expect(items.map((i) => i.label)).toEqual([
159
+ "Claude Code",
160
+ "Cursor",
161
+ "VS Code",
162
+ ]);
163
+ expect(items[1]?.detail).toBe("not detected");
164
+ // Scope-aware, and it states the FACT rather than naming a partition:
165
+ // VS Code keeps its MCP config per project, so there is no global file for
166
+ // pragma to be in. `no Global band` named an internal word for that.
167
+ expect(items[2]?.detail).toBe(
168
+ "keeps no global config — it is per-project only",
169
+ );
170
+ });
171
+
172
+ it("never renders any state as a failure", () => {
173
+ for (const item of inventoryItems(rows, true)) {
174
+ expect(item.status).not.toBe("fail");
175
+ }
176
+ const every = harnessInventory(REGISTRY, [], "project");
177
+ for (const item of inventoryItems(every, true)) {
178
+ expect(item.status).not.toBe("fail");
179
+ }
180
+ });
181
+
182
+ it("is a listing, not a verdict: pass when the scope holds harnesses, skip when it does not", () => {
183
+ expect(inventoryHealth(rows, false).status).toBe("pass");
184
+ expect(inventoryHealth(rows, false).detail).toBe(
185
+ "1 detected · 1 registered",
186
+ );
187
+ expect(inventoryHealth(rows, true).detail).toBe(
188
+ "1 detected · 1 registered · 3 known",
189
+ );
190
+
191
+ const empty = roll([]);
192
+ expect(inventoryHealth(empty, false).status).toBe("skip");
193
+ expect(inventoryHealth(empty, false).detail).toBe("no harnesses detected");
194
+ expect(inventoryHealth(empty, false).items).toEqual([]);
195
+ expect(inventoryHealth(empty, true).detail).toBe(
196
+ "none of 3 known harnesses detected",
197
+ );
198
+ });
199
+
200
+ it("counts a drifted harness as detected but not as registered", () => {
201
+ const drifted = roll([
202
+ { path: "~/.claude.json", harnesses: [named("Claude Code", "drifted")] },
203
+ ]);
204
+ expect(inventoryHealth(drifted, false).detail).toBe(
205
+ "1 detected · 0 registered",
206
+ );
207
+ expect(inventoryItems(drifted, false)[0]?.status).toBe("available");
208
+ });
209
+ });
210
+
211
+ describe("isDetectedState", () => {
212
+ it("counts every state that means the harness is on the machine", () => {
213
+ expect(isDetectedState("registered")).toBe(true);
214
+ expect(isDetectedState("drifted")).toBe(true);
215
+ expect(isDetectedState("detected")).toBe(true);
216
+ expect(isDetectedState("undetected")).toBe(false);
217
+ expect(isDetectedState("unscoped")).toBe(false);
218
+ });
219
+ });