@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
@@ -8,9 +8,11 @@
8
8
  * that capability, and the moment something here has a single consumer again
9
9
  * it should move back. What survives is the environment the CLI reports on but
10
10
  * does not own — how it was installed, what the registry says about it,
11
- * whether a subprocess it ran actually succeeded — plus the one piece of
12
- * vocabulary (`BAND_LABELS`) that keeps `setup` and `doctor` calling the two
13
- * config bands by the same names users meet in the flags.
11
+ * whether a subprocess it ran actually succeeded. (The scope labels that keep
12
+ * `setup` and `doctor` calling the two config scopes by the same names used
13
+ * to live here too; they are part of the rendering vocabulary now —
14
+ * `kernel/render/vocabulary.ts` — beside the statuses and glyphs they are
15
+ * printed with.)
14
16
  *
15
17
  * The exec guard is exported as the pair a caller needs together: `checkExecOk`
16
18
  * to classify a result inside a Task and `assertExecOk` to throw on one
@@ -25,8 +27,17 @@ export {
25
27
  checkExecOk,
26
28
  guardMissingBinary,
27
29
  } from "./assertExecOk.js";
28
- export { BAND_LABELS } from "./bands.js";
29
- export type { InstallSource } from "./packageManager.js";
30
- export { detectInstallSource, pmUpdateCommand } from "./packageManager.js";
30
+ export type {
31
+ EphemeralRunner,
32
+ GlobalInstall,
33
+ InstallProbe,
34
+ InstallSource,
35
+ PackageManager,
36
+ } from "./packageManager.js";
37
+ export {
38
+ detectInstallSource,
39
+ pmUpdateCommand,
40
+ updateGuidance,
41
+ } from "./packageManager.js";
31
42
  export type { RegistryCheckResult } from "./registry.js";
32
43
  export { checkRegistryVersion, PRAGMA_PACKAGE } from "./registry.js";
@@ -1,14 +1,421 @@
1
1
  /**
2
- * The install-scope heuristic's two path questions.
2
+ * The install-source detector, driven entirely by fixtures.
3
3
  *
4
- * `detectInstallSource` itself reads `import.meta.url` and the real working
5
- * directory, so the decision it makes is exercised here through the two pure
6
- * helpers it composes — which is where every case that has ever been wrong
7
- * lives.
4
+ * `detectInstallSource` itself captures the live process; the decision lives
5
+ * in the pure `classifyInstall`, which takes an {@link InstallProbe} — paths,
6
+ * environment, and injected filesystem readers — so every layout that has
7
+ * ever been wrong is a deterministic case here, independent of the machine
8
+ * the suite runs on (the `PlatformEnv` fixture pattern from
9
+ * `@canonical/harnesses`).
10
+ *
11
+ * The headline case is the LINKED development install (`npm link`/`bun link`):
12
+ * the old env-var heuristic reported it as `node (global)` and would have
13
+ * offered `npm i -g`, which overwrites the link to the worktree being
14
+ * developed. Here it must be identified as linked — and the type of
15
+ * `pmUpdateCommand` (global arm only) makes producing a command for it a
16
+ * compile error, which is the strongest "no upgrade command" assertion there
17
+ * is.
8
18
  */
9
19
 
10
20
  import { describe, expect, it } from "vitest";
11
- import { containsPath, findInstallRoot } from "./packageManager.js";
21
+ import {
22
+ classifyInstall,
23
+ containsPath,
24
+ ephemeralRunner,
25
+ findInstallRoot,
26
+ type InstallProbe,
27
+ managerFromPath,
28
+ pmUpdateCommand,
29
+ } from "./packageManager.js";
30
+ import { PRAGMA_PACKAGE } from "./registry.js";
31
+
32
+ /** The module-relative suffix the running entry carries under `dist`. */
33
+ const ENTRY = "dist/src/capabilities/shared/packageManager.js";
34
+
35
+ /** Build a probe whose filesystem is the given symlink map + file list. */
36
+ function probeOf(
37
+ over: Partial<InstallProbe>,
38
+ fs: { links?: Record<string, string>; files?: string[] } = {},
39
+ ): InstallProbe {
40
+ const links = fs.links ?? {};
41
+ const resolveLink = (path: string): string | undefined => {
42
+ const target = links[path];
43
+ if (target === undefined) return undefined;
44
+ const base = path.slice(0, path.lastIndexOf("/"));
45
+ return target.startsWith("/") ? target : `${base}/${target}`;
46
+ };
47
+ return {
48
+ entry: "/unset",
49
+ invoked: undefined,
50
+ cwd: "/somewhere/else",
51
+ userAgent: undefined,
52
+ runtime: "node",
53
+ readLink: (path) => links[path],
54
+ realPath: (path) => {
55
+ // Follow the final component's link chain, normalizing `..` segments —
56
+ // enough realpath for these fixtures.
57
+ let current = path;
58
+ for (let hop = 0; hop < 10; hop++) {
59
+ const next = resolveLink(current);
60
+ if (next === undefined) break;
61
+ current = next;
62
+ }
63
+ const segments: string[] = [];
64
+ for (const part of current.split("/")) {
65
+ if (part === "..") segments.pop();
66
+ else if (part !== "." && part !== "") segments.push(part);
67
+ }
68
+ return `/${segments.join("/")}`;
69
+ },
70
+ exists: (path) => fs.files?.includes(path) ?? false,
71
+ ...over,
72
+ };
73
+ }
74
+
75
+ const G = "/home/u/.npm-global";
76
+ const NPM_PKG = `${G}/lib/node_modules/@canonical/pragma-cli`;
77
+
78
+ describe("classifyInstall — global installs", () => {
79
+ it("npm global: bin symlink into an unmarked global tree", () => {
80
+ const install = classifyInstall(
81
+ probeOf(
82
+ { entry: `${NPM_PKG}/${ENTRY}`, invoked: `${G}/bin/pragma` },
83
+ {
84
+ links: {
85
+ [`${G}/bin/pragma`]:
86
+ "../lib/node_modules/@canonical/pragma-cli/dist/src/bin.js",
87
+ },
88
+ },
89
+ ),
90
+ );
91
+ expect(install).toMatchObject({ kind: "global", pm: "npm" });
92
+ expect(install.label).toBe("npm (global)");
93
+ if (install.kind === "global") {
94
+ expect(pmUpdateCommand(install, PRAGMA_PACKAGE)).toBe(
95
+ `npm i -g ${PRAGMA_PACKAGE}`,
96
+ );
97
+ }
98
+ });
99
+
100
+ it("the user-agent NEVER overrides the path shape (corroboration only)", () => {
101
+ // `bun run some-script` invoking a globally npm-installed pragma sets a
102
+ // bun user-agent — the install is still npm's.
103
+ const install = classifyInstall(
104
+ probeOf({
105
+ entry: `${NPM_PKG}/${ENTRY}`,
106
+ userAgent: "bun/1.2.19 npm/? node/v24.3.0 linux x64",
107
+ }),
108
+ );
109
+ expect(install).toMatchObject({ kind: "global", pm: "npm" });
110
+ });
111
+
112
+ it("bun global: ~/.bun/install/global shape", () => {
113
+ const pkg =
114
+ "/home/u/.bun/install/global/node_modules/@canonical/pragma-cli";
115
+ const install = classifyInstall(
116
+ probeOf(
117
+ { entry: `${pkg}/${ENTRY}`, invoked: "/home/u/.bun/bin/pragma" },
118
+ {
119
+ links: {
120
+ "/home/u/.bun/bin/pragma":
121
+ "../install/global/node_modules/@canonical/pragma-cli/dist/src/bin.js",
122
+ },
123
+ },
124
+ ),
125
+ );
126
+ expect(install).toMatchObject({ kind: "global", pm: "bun" });
127
+ if (install.kind === "global") {
128
+ expect(pmUpdateCommand(install, PRAGMA_PACKAGE)).toBe(
129
+ `bun add -g ${PRAGMA_PACKAGE}`,
130
+ );
131
+ }
132
+ });
133
+
134
+ it("pnpm global: the .pnpm store segment names the manager", () => {
135
+ const store =
136
+ "/home/u/.local/share/pnpm/global/5/.pnpm/@canonical+pragma-cli@0.35.0/node_modules/@canonical/pragma-cli";
137
+ const install = classifyInstall(probeOf({ entry: `${store}/${ENTRY}` }));
138
+ expect(install).toMatchObject({ kind: "global", pm: "pnpm" });
139
+ });
140
+
141
+ it("volta: ~/.volta/tools/image/packages shape, with volta's own command", () => {
142
+ const pkg =
143
+ "/home/u/.volta/tools/image/packages/pragma-cli/lib/node_modules/@canonical/pragma-cli";
144
+ // Volta's bin shim is a real executable, not a symlink — the chain walk
145
+ // yields nothing and the entry path decides.
146
+ const install = classifyInstall(
147
+ probeOf({
148
+ entry: `${pkg}/${ENTRY}`,
149
+ invoked: "/home/u/.volta/bin/pragma",
150
+ }),
151
+ );
152
+ expect(install).toMatchObject({ kind: "global", pm: "volta" });
153
+ if (install.kind === "global") {
154
+ expect(pmUpdateCommand(install, PRAGMA_PACKAGE)).toBe(
155
+ `volta install ${PRAGMA_PACKAGE}`,
156
+ );
157
+ }
158
+ });
159
+
160
+ it("asdf hosts an ordinary npm global tree", () => {
161
+ const pkg =
162
+ "/home/u/.asdf/installs/nodejs/22.5.0/lib/node_modules/@canonical/pragma-cli";
163
+ const install = classifyInstall(probeOf({ entry: `${pkg}/${ENTRY}` }));
164
+ expect(install).toMatchObject({ kind: "global", pm: "npm" });
165
+ });
166
+ });
167
+
168
+ describe("classifyInstall — the LINKED development install (the headline)", () => {
169
+ // The layout measured on a real machine: `which pragma` →
170
+ // ~/.npm-global/bin/pragma → node_modules/@canonical/pragma-cli, which is
171
+ // itself a symlink OUT of the global root into a development worktree. The
172
+ // module loader realpath-resolves `import.meta.url`, so the entry has
173
+ // already escaped to the worktree — only the argv[1] chain can see the link.
174
+ const worktree =
175
+ "/home/u/code/cn/pragma/.claude/worktrees/try-main/packages/cli/pragma";
176
+
177
+ const linkedProbe = (over: Partial<InstallProbe> = {}): InstallProbe =>
178
+ probeOf(
179
+ {
180
+ entry: `${worktree}/${ENTRY}`,
181
+ invoked: `${G}/bin/pragma`,
182
+ ...over,
183
+ },
184
+ {
185
+ links: {
186
+ [`${G}/bin/pragma`]:
187
+ "../lib/node_modules/@canonical/pragma-cli/dist/src/bin.js",
188
+ [NPM_PKG]:
189
+ "../../../../code/cn/pragma/.claude/worktrees/try-main/packages/cli/pragma",
190
+ },
191
+ },
192
+ );
193
+
194
+ it("identifies npm link — never 'global'", () => {
195
+ const install = classifyInstall(linkedProbe());
196
+ expect(install.kind).toBe("linked");
197
+ expect(install.label).toBe("linked (development checkout)");
198
+ if (install.kind === "linked") {
199
+ expect(install.target).toBe(worktree);
200
+ }
201
+ // No upgrade command exists for it: `pmUpdateCommand` only accepts the
202
+ // global arm, so the following would not compile —
203
+ // pmUpdateCommand(install, PRAGMA_PACKAGE)
204
+ // — and the runtime data carries guidance instead (see upgrade.test.ts).
205
+ });
206
+
207
+ it("identifies bun link, naming the hosting manager", () => {
208
+ const pkg =
209
+ "/home/u/.bun/install/global/node_modules/@canonical/pragma-cli";
210
+ const checkout =
211
+ "/home/u/code/cn/pragma/.claude/worktrees/fix-tier-rank/packages/cli/pragma";
212
+ const install = classifyInstall(
213
+ probeOf(
214
+ {
215
+ entry: `${checkout}/${ENTRY}`,
216
+ invoked: "/home/u/.bun/bin/pragma",
217
+ runtime: "bun",
218
+ },
219
+ {
220
+ links: {
221
+ "/home/u/.bun/bin/pragma":
222
+ "../install/global/node_modules/@canonical/pragma-cli/dist/src/bin.js",
223
+ [pkg]: checkout,
224
+ },
225
+ },
226
+ ),
227
+ );
228
+ expect(install).toMatchObject({ kind: "linked", pm: "bun" });
229
+ expect(install.label).toBe("bun link (development checkout)");
230
+ });
231
+
232
+ it("a workspace-internal package symlink is NOT a link — it stays inside the root", () => {
233
+ // A monorepo's own `node_modules/<pkg>` → `packages/<pkg>` symlink points
234
+ // outside node_modules but INSIDE the project — a workspace install.
235
+ const root = "/home/u/proj";
236
+ const install = classifyInstall(
237
+ probeOf(
238
+ {
239
+ entry: `${root}/packages/cli/pragma/${ENTRY}`,
240
+ invoked: `${root}/node_modules/.bin/pragma`,
241
+ cwd: `${root}/apps/site`,
242
+ },
243
+ {
244
+ links: {
245
+ [`${root}/node_modules/.bin/pragma`]:
246
+ "../@canonical/pragma-cli/dist/src/bin.js",
247
+ [`${root}/node_modules/@canonical/pragma-cli`]:
248
+ "../../packages/cli/pragma",
249
+ },
250
+ files: [`${root}/bun.lock`],
251
+ },
252
+ ),
253
+ );
254
+ expect(install).toMatchObject({ kind: "workspace", pm: "bun", root });
255
+ expect(install.label).toBe("bun (local project)");
256
+ });
257
+ });
258
+
259
+ describe("classifyInstall — ephemeral runners (no upgrade command)", () => {
260
+ it("npx resolves into the ~/.npm/_npx cache", () => {
261
+ const pkg =
262
+ "/home/u/.npm/_npx/beb367dfa21eb3f5/node_modules/@canonical/pragma-cli";
263
+ const install = classifyInstall(probeOf({ entry: `${pkg}/${ENTRY}` }));
264
+ expect(install).toMatchObject({ kind: "ephemeral", runner: "npx" });
265
+ expect(install.label).toBe("npx (ephemeral)");
266
+ });
267
+
268
+ it("bunx resolves into a bunx-* temp dir", () => {
269
+ const pkg =
270
+ "/tmp/bunx-1000-@canonical/pragma-cli@latest/node_modules/@canonical/pragma-cli";
271
+ const install = classifyInstall(
272
+ probeOf({ entry: `${pkg}/${ENTRY}`, runtime: "bun" }),
273
+ );
274
+ expect(install).toMatchObject({ kind: "ephemeral", runner: "bunx" });
275
+ });
276
+ });
277
+
278
+ describe("classifyInstall — workspace installs", () => {
279
+ const root = "/home/u/proj";
280
+ const pkg = `${root}/node_modules/@canonical/pragma-cli`;
281
+
282
+ it("names the manager from the lockfile beside the install root", () => {
283
+ const install = classifyInstall(
284
+ probeOf(
285
+ { entry: `${pkg}/${ENTRY}`, cwd: `${root}/packages/site` },
286
+ { files: [`${root}/package-lock.json`] },
287
+ ),
288
+ );
289
+ expect(install).toMatchObject({ kind: "workspace", pm: "npm", root });
290
+ expect(install.label).toBe("npm (local project)");
291
+ });
292
+
293
+ it("falls back to the user-agent when no lockfile marks the tree", () => {
294
+ const install = classifyInstall(
295
+ probeOf({
296
+ entry: `${pkg}/${ENTRY}`,
297
+ cwd: root,
298
+ userAgent: "pnpm/9.1.0 npm/? node/v20.11.0 linux x64",
299
+ }),
300
+ );
301
+ expect(install).toMatchObject({ kind: "workspace", pm: "pnpm" });
302
+ });
303
+
304
+ it("stays honest with no marker at all", () => {
305
+ const install = classifyInstall(
306
+ probeOf({ entry: `${pkg}/${ENTRY}`, cwd: root }),
307
+ );
308
+ expect(install.kind).toBe("workspace");
309
+ expect(install.label).toBe("local project dependency");
310
+ });
311
+ });
312
+
313
+ describe("classifyInstall — unknown (the honest fallback)", () => {
314
+ it("a source checkout run directly is unknown, not global", () => {
315
+ const entry =
316
+ "/work/pragma/packages/cli/pragma/src/capabilities/shared/packageManager.ts";
317
+ const install = classifyInstall(
318
+ probeOf({
319
+ entry,
320
+ invoked: "/work/pragma/packages/cli/pragma/src/bin.ts",
321
+ }),
322
+ );
323
+ expect(install.kind).toBe("unknown");
324
+ expect(install.label).toBe("unknown (node runtime)");
325
+ });
326
+
327
+ it("a FOREIGN bin on argv[1] (a test runner) never misattributes the install", () => {
328
+ const repo = "/repo";
329
+ const install = classifyInstall(
330
+ probeOf(
331
+ {
332
+ entry: `${repo}/packages/cli/pragma/src/capabilities/shared/packageManager.ts`,
333
+ invoked: `${repo}/node_modules/.bin/vitest`,
334
+ },
335
+ {
336
+ links: {
337
+ [`${repo}/node_modules/.bin/vitest`]: "../vitest/vitest.mjs",
338
+ },
339
+ },
340
+ ),
341
+ );
342
+ expect(install.kind).toBe("unknown");
343
+ });
344
+
345
+ it("a symlink cycle on the invoked path is bounded, not spun", () => {
346
+ const install = classifyInstall(
347
+ probeOf(
348
+ { entry: "/src/tree/file.ts", invoked: "/a" },
349
+ { links: { "/a": "/b", "/b": "/a" } },
350
+ ),
351
+ );
352
+ expect(install.kind).toBe("unknown");
353
+ });
354
+ });
355
+
356
+ describe("path-shape helpers (win32 shapes exercised, not host-validated)", () => {
357
+ it("managerFromPath reads win32 volta and posix shapes alike", () => {
358
+ expect(
359
+ managerFromPath(
360
+ "C:\\Users\\u\\AppData\\Local\\Volta\\tools\\image\\packages\\pragma-cli\\node_modules\\@canonical\\pragma-cli\\dist\\src\\bin.js",
361
+ ),
362
+ ).toBe("volta");
363
+ expect(
364
+ managerFromPath(
365
+ "C:\\Users\\u\\AppData\\Roaming\\npm\\node_modules\\@canonical\\pragma-cli\\dist\\src\\bin.js",
366
+ ),
367
+ ).toBeUndefined();
368
+ expect(
369
+ managerFromPath("/home/u/.config/yarn/global/node_modules/x/bin.js"),
370
+ ).toBe("yarn");
371
+ expect(
372
+ managerFromPath("/home/u/.nvm/versions/node/v20/lib/node_modules/x"),
373
+ ).toBe("npm");
374
+ // A project merely NAMED volta must not read as the volta layout.
375
+ expect(
376
+ managerFromPath("/home/u/volta/node_modules/x/bin.js"),
377
+ ).toBeUndefined();
378
+ });
379
+
380
+ it("ephemeralRunner ignores ordinary directories that merely share a name (REGRESSION)", () => {
381
+ // The patterns are anchored to each runner's real cache layout. Matching a
382
+ // bare segment anywhere would reclassify a project living under a directory
383
+ // called `dlx`, `_npx` or `bunx-something` as ephemeral — and since that
384
+ // verdict is reached before the workspace check, it wins even with a
385
+ // lockfile beside the root, leaving the user with no upgrade command and no
386
+ // error explaining why.
387
+ expect(
388
+ ephemeralRunner("/work/dlx/app/node_modules/@canonical/pragma-cli"),
389
+ ).toBeUndefined();
390
+ expect(
391
+ ephemeralRunner("/srv/_npx/site/node_modules/@canonical/pragma-cli"),
392
+ ).toBeUndefined();
393
+ expect(
394
+ ephemeralRunner("/home/u/bunx-tools/node_modules/@canonical/pragma-cli"),
395
+ ).toBeUndefined();
396
+ // `dlx` outside a pnpm cache, and `_npx` outside npm's, stay ordinary.
397
+ expect(
398
+ ephemeralRunner("/home/u/.cache/yarn/dlx/abc/node_modules/x/bin.js"),
399
+ ).toBeUndefined();
400
+ });
401
+
402
+ it("ephemeralRunner reads npx/bunx/dlx cache shapes on both separator styles", () => {
403
+ expect(
404
+ ephemeralRunner(
405
+ "C:\\Users\\u\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\x\\bin.js",
406
+ ),
407
+ ).toBe("npx");
408
+ expect(
409
+ ephemeralRunner("/tmp/bunx-1000-x@latest/node_modules/x/bin.js"),
410
+ ).toBe("bunx");
411
+ expect(
412
+ ephemeralRunner("/home/u/.cache/pnpm/dlx/abc/node_modules/x/bin.js"),
413
+ ).toBe("pnpm dlx");
414
+ expect(
415
+ ephemeralRunner("/home/u/proj/node_modules/x/bin.js"),
416
+ ).toBeUndefined();
417
+ });
418
+ });
12
419
 
13
420
  describe("findInstallRoot", () => {
14
421
  it("yields the directory the package was installed into", () => {
@@ -25,6 +432,14 @@ describe("findInstallRoot", () => {
25
432
  ).toBe("/proj/node_modules/a");
26
433
  });
27
434
 
435
+ it("reads a win32 path with its own separator", () => {
436
+ expect(
437
+ findInstallRoot(
438
+ "C:\\Users\\u\\AppData\\Roaming\\npm\\node_modules\\@canonical\\pragma-cli\\dist\\src\\bin.js",
439
+ ),
440
+ ).toBe("C:\\Users\\u\\AppData\\Roaming\\npm");
441
+ });
442
+
28
443
  it("reports no install root for a source checkout", () => {
29
444
  expect(findInstallRoot("/work/pragma/packages/cli/pragma/src/bin.ts")).toBe(
30
445
  undefined,