@canonical/pragma-cli 0.36.0 → 0.38.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 (312) hide show
  1. package/README.md +3 -2
  2. package/bundled-skills/adoption-a1-styles/SKILL.md +24 -0
  3. package/bundled-skills/adoption-a2-components/SKILL.md +22 -6
  4. package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +1397 -327
  5. package/bundled-skills/anatomy-author/SKILL.md +840 -137
  6. package/bundled-skills/design-auditor/SKILL.md +133 -9
  7. package/bundled-skills/implement-component/SKILL.md +192 -23
  8. package/bundled-skills/specify-component/SKILL.md +124 -12
  9. package/bundled-skills/specify-pattern/SKILL.md +47 -8
  10. package/dist/package.json +23 -20
  11. package/dist/pragma.conf.js +963 -40
  12. package/dist/pragma.conf.js.map +1 -1
  13. package/dist/src/capabilities/capabilities/catalog.js +14 -5
  14. package/dist/src/capabilities/capabilities/catalog.js.map +1 -1
  15. package/dist/src/capabilities/capabilities/hints.js +33 -5
  16. package/dist/src/capabilities/capabilities/hints.js.map +1 -1
  17. package/dist/src/capabilities/config/show.render.js +30 -2
  18. package/dist/src/capabilities/config/show.render.js.map +1 -1
  19. package/dist/src/capabilities/create/create.verb.js +2 -1
  20. package/dist/src/capabilities/create/create.verb.js.map +1 -1
  21. package/dist/src/capabilities/create/createSurface.generated.js +23 -1
  22. package/dist/src/capabilities/create/createSurface.generated.js.map +1 -1
  23. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js +11 -6
  24. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -1
  25. package/dist/src/capabilities/doctor/checks/harnessInventory.js +1 -1
  26. package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -1
  27. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js +38 -0
  28. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js.map +1 -0
  29. package/dist/src/capabilities/doctor/checks/targetHealth.js +104 -14
  30. package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
  31. package/dist/src/capabilities/graph/connect.render.js +101 -0
  32. package/dist/src/capabilities/graph/connect.render.js.map +1 -0
  33. package/dist/src/capabilities/graph/connect.types.js +21 -0
  34. package/dist/src/capabilities/graph/connect.types.js.map +1 -0
  35. package/dist/src/capabilities/graph/connect.verb.js +147 -0
  36. package/dist/src/capabilities/graph/connect.verb.js.map +1 -0
  37. package/dist/src/capabilities/graph/index.js +5 -3
  38. package/dist/src/capabilities/graph/index.js.map +1 -1
  39. package/dist/src/capabilities/graph/queryText.js +206 -0
  40. package/dist/src/capabilities/graph/queryText.js.map +1 -0
  41. package/dist/src/capabilities/graph/relationIndex.js +302 -0
  42. package/dist/src/capabilities/graph/relationIndex.js.map +1 -0
  43. package/dist/src/capabilities/graph/runConnect.js +456 -0
  44. package/dist/src/capabilities/graph/runConnect.js.map +1 -0
  45. package/dist/src/capabilities/graph/runQuery.js +21 -4
  46. package/dist/src/capabilities/graph/runQuery.js.map +1 -1
  47. package/dist/src/capabilities/setup/buildPlan.js +6 -2
  48. package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
  49. package/dist/src/capabilities/setup/operations/index.js +7 -2
  50. package/dist/src/capabilities/setup/operations/index.js.map +1 -1
  51. package/dist/src/capabilities/setup/operations/setupCompletions.js +6 -2
  52. package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -1
  53. package/dist/src/capabilities/setup/operations/setupConfig.js +4 -2
  54. package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -1
  55. package/dist/src/capabilities/setup/operations/setupGenerator.js +72 -14
  56. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  57. package/dist/src/capabilities/setup/operations/setupLsp.js +372 -62
  58. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  59. package/dist/src/capabilities/setup/operations/setupMcp.js +150 -5
  60. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  61. package/dist/src/capabilities/setup/operations/setupSkills.js +4 -2
  62. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  63. package/dist/src/capabilities/setup/operations/writability.js +114 -0
  64. package/dist/src/capabilities/setup/operations/writability.js.map +1 -0
  65. package/dist/src/capabilities/setup/plan.js.map +1 -1
  66. package/dist/src/capabilities/setup/setup.verb.js +80 -31
  67. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  68. package/dist/src/capabilities/setup/targets.js +124 -42
  69. package/dist/src/capabilities/setup/targets.js.map +1 -1
  70. package/dist/src/capabilities/shared/index.js +1 -1
  71. package/dist/src/capabilities/shared/index.js.map +1 -1
  72. package/dist/src/constants.js +7 -1
  73. package/dist/src/constants.js.map +1 -1
  74. package/dist/src/kernel/error/renderError.js +3 -2
  75. package/dist/src/kernel/error/renderError.js.map +1 -1
  76. package/dist/src/kernel/error/validOptions.js +41 -0
  77. package/dist/src/kernel/error/validOptions.js.map +1 -0
  78. package/dist/src/kernel/packs/compile.js +90 -6
  79. package/dist/src/kernel/packs/compile.js.map +1 -1
  80. package/dist/src/kernel/packs/cursor.js +135 -0
  81. package/dist/src/kernel/packs/cursor.js.map +1 -0
  82. package/dist/src/kernel/packs/graphql/buildLookupDocument.js +9 -2
  83. package/dist/src/kernel/packs/graphql/buildLookupDocument.js.map +1 -1
  84. package/dist/src/kernel/packs/paging.js +108 -0
  85. package/dist/src/kernel/packs/paging.js.map +1 -0
  86. package/dist/src/kernel/packs/renderPack.js +154 -5
  87. package/dist/src/kernel/packs/renderPack.js.map +1 -1
  88. package/dist/src/kernel/packs/resolveEntity.js +118 -19
  89. package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
  90. package/dist/src/kernel/packs/runBodies.js +156 -18
  91. package/dist/src/kernel/packs/runBodies.js.map +1 -1
  92. package/dist/src/kernel/packs/schema.js +160 -12
  93. package/dist/src/kernel/packs/schema.js.map +1 -1
  94. package/dist/src/kernel/packs/sparql/authorQuery.js +227 -0
  95. package/dist/src/kernel/packs/sparql/authorQuery.js.map +1 -0
  96. package/dist/src/kernel/packs/sparql/buildListQuery.js +251 -0
  97. package/dist/src/kernel/packs/sparql/buildListQuery.js.map +1 -0
  98. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +125 -22
  99. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  100. package/dist/src/kernel/packs/sparql/filterValues.js +145 -0
  101. package/dist/src/kernel/packs/sparql/filterValues.js.map +1 -0
  102. package/dist/src/kernel/packs/sparql/index.js +24 -14
  103. package/dist/src/kernel/packs/sparql/index.js.map +1 -1
  104. package/dist/src/kernel/packs/sparql/runSelect.js +10 -4
  105. package/dist/src/kernel/packs/sparql/runSelect.js.map +1 -1
  106. package/dist/src/kernel/packs/sparql/searchTerm.js +25 -0
  107. package/dist/src/kernel/packs/sparql/searchTerm.js.map +1 -0
  108. package/dist/src/kernel/packs/storyRules.js +151 -0
  109. package/dist/src/kernel/packs/storyRules.js.map +1 -0
  110. package/dist/src/kernel/packs/tierScope.js +310 -0
  111. package/dist/src/kernel/packs/tierScope.js.map +1 -0
  112. package/dist/src/kernel/packs/types.js +80 -0
  113. package/dist/src/kernel/packs/types.js.map +1 -1
  114. package/dist/src/kernel/project/cli/buildProgram.js +10 -1
  115. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
  116. package/dist/src/kernel/project/cli/dispatch.js +104 -3
  117. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  118. package/dist/src/kernel/project/cli/rootHelp.js +8 -5
  119. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  120. package/dist/src/kernel/project/cli/suggestNames.js +31 -7
  121. package/dist/src/kernel/project/cli/suggestNames.js.map +1 -1
  122. package/dist/src/kernel/project/mcp/registerVerb.js +5 -1
  123. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
  124. package/dist/src/kernel/render/renderers.js +34 -2
  125. package/dist/src/kernel/render/renderers.js.map +1 -1
  126. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js +2 -3
  127. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -1
  128. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +1 -1
  129. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -1
  130. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js +4 -0
  131. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js.map +1 -0
  132. package/dist/src/kernel/runtime/graphpack/embedded.js +13 -6
  133. package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -1
  134. package/dist/src/kernel/runtime/graphpack/read.js +17 -10
  135. package/dist/src/kernel/runtime/graphpack/read.js.map +1 -1
  136. package/dist/src/kernel/runtime/loadSession.js +3 -1
  137. package/dist/src/kernel/runtime/loadSession.js.map +1 -1
  138. package/dist/src/kernel/runtime/probe.js +1 -1
  139. package/dist/src/kernel/runtime/probe.js.map +1 -1
  140. package/dist/src/kernel/runtime/readEntity.js +19 -4
  141. package/dist/src/kernel/runtime/readEntity.js.map +1 -1
  142. package/dist/src/kernel/runtime/refs/parseRef.js +17 -1
  143. package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -1
  144. package/dist/src/kernel/runtime/refs/resolve.js +38 -5
  145. package/dist/src/kernel/runtime/refs/resolve.js.map +1 -1
  146. package/dist/src/kernel/spec/emitReference.js +1 -1
  147. package/dist/src/kernel/spec/emitReference.js.map +1 -1
  148. package/package.json +23 -20
  149. package/pragma.conf.ts +983 -40
  150. package/src/capabilities/block.parity.test.ts +207 -27
  151. package/src/capabilities/block.shipped.exec.test.ts +156 -14
  152. package/src/capabilities/block.tierRank.exec.test.ts +46 -9
  153. package/src/capabilities/capabilities/catalog.ts +14 -5
  154. package/src/capabilities/capabilities/hints.ts +40 -5
  155. package/src/capabilities/config/collectConfigShow.test.ts +1 -0
  156. package/src/capabilities/config/set.test.ts +24 -25
  157. package/src/capabilities/config/show.render.test.ts +9 -4
  158. package/src/capabilities/config/show.render.ts +30 -2
  159. package/src/capabilities/create/byteEquality.test.ts +28 -1
  160. package/src/capabilities/create/create.test.ts +1 -0
  161. package/src/capabilities/create/create.verb.ts +2 -1
  162. package/src/capabilities/create/createGrammar.test.ts +4 -1
  163. package/src/capabilities/create/createSurface.generated.ts +23 -1
  164. package/src/capabilities/create/crossCli.subprocess.test.ts +10 -3
  165. package/src/capabilities/distribution.parity.test.ts +12 -11
  166. package/src/capabilities/distribution.test.ts +77 -7
  167. package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
  168. package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
  169. package/src/capabilities/doctor/checks/harnessInventory.test.ts +13 -6
  170. package/src/capabilities/doctor/checks/harnessInventory.ts +8 -4
  171. package/src/capabilities/doctor/checks/isSupportedNodeVersion.test.ts +111 -0
  172. package/src/capabilities/doctor/checks/isSupportedNodeVersion.ts +34 -0
  173. package/src/capabilities/doctor/checks/mcpCommand.test.ts +7 -1
  174. package/src/capabilities/doctor/checks/targetHealth.ts +114 -13
  175. package/src/capabilities/doctor/doctor.test.ts +327 -36
  176. package/src/capabilities/emptyRecovery.test.ts +84 -28
  177. package/src/capabilities/graph/connect.render.ts +141 -0
  178. package/src/capabilities/graph/connect.shipped.exec.test.ts +370 -0
  179. package/src/capabilities/graph/connect.test.ts +361 -0
  180. package/src/capabilities/graph/connect.types.ts +136 -0
  181. package/src/capabilities/graph/connect.verb.ts +165 -0
  182. package/src/capabilities/graph/index.ts +5 -3
  183. package/src/capabilities/graph/query.shipped.exec.test.ts +66 -0
  184. package/src/capabilities/graph/query.test.ts +17 -0
  185. package/src/capabilities/graph/queryText.test.ts +121 -0
  186. package/src/capabilities/graph/queryText.ts +240 -0
  187. package/src/capabilities/graph/relationIndex.ts +372 -0
  188. package/src/capabilities/graph/runConnect.ts +577 -0
  189. package/src/capabilities/graph/runQuery.ts +21 -6
  190. package/src/capabilities/lazy.test.ts +32 -2
  191. package/src/capabilities/listBudget.shipped.exec.test.ts +239 -0
  192. package/src/capabilities/listLookup.shipped.exec.test.ts +387 -0
  193. package/src/capabilities/listQuery.shipped.exec.test.ts +400 -0
  194. package/src/capabilities/resources/resources.test.ts +38 -2
  195. package/src/capabilities/setup/buildPlan.ts +7 -1
  196. package/src/capabilities/setup/operations/index.ts +14 -0
  197. package/src/capabilities/setup/operations/setupCompletions.ts +9 -2
  198. package/src/capabilities/setup/operations/setupConfig.ts +7 -2
  199. package/src/capabilities/setup/operations/setupGenerator.ts +93 -15
  200. package/src/capabilities/setup/operations/setupLsp.ts +544 -85
  201. package/src/capabilities/setup/operations/setupMcp.ts +212 -4
  202. package/src/capabilities/setup/operations/setupSkills.ts +7 -2
  203. package/src/capabilities/setup/operations/writability.test.ts +151 -0
  204. package/src/capabilities/setup/operations/writability.ts +135 -0
  205. package/src/capabilities/setup/plan.ts +11 -0
  206. package/src/capabilities/setup/setup.test.ts +992 -16
  207. package/src/capabilities/setup/setup.verb.ts +84 -36
  208. package/src/capabilities/setup/targets.ts +152 -48
  209. package/src/capabilities/setup/types.ts +41 -5
  210. package/src/capabilities/shared/index.ts +1 -0
  211. package/src/capabilities/sources/sources.test.ts +55 -0
  212. package/src/capabilities/surface.test.ts +59 -11
  213. package/src/capabilities/tier.parity.test.ts +2 -2
  214. package/src/capabilities/token.parity.test.ts +784 -0
  215. package/src/capabilities/tokenGraph.cli.test.ts +279 -0
  216. package/src/capabilities/tokenGraph.shipped.exec.test.ts +653 -0
  217. package/src/capabilities/variable.parity.test.ts +448 -0
  218. package/src/constants.ts +8 -0
  219. package/src/kernel/completion/shellDrive.test.ts +32 -30
  220. package/src/kernel/config/defaults.test.ts +21 -2
  221. package/src/kernel/config/readConfig.test.ts +1 -1
  222. package/src/kernel/error/renderError.ts +3 -2
  223. package/src/kernel/error/validOptions.test.ts +57 -0
  224. package/src/kernel/error/validOptions.ts +45 -0
  225. package/src/kernel/packs/collect.test.ts +2 -0
  226. package/src/kernel/packs/compile.test.ts +188 -15
  227. package/src/kernel/packs/compile.ts +109 -14
  228. package/src/kernel/packs/cursor.ts +135 -0
  229. package/src/kernel/packs/filteredEmpty.test.ts +213 -0
  230. package/src/kernel/packs/graphql/buildLookupDocument.ts +16 -9
  231. package/src/kernel/packs/graphqlSource.test.ts +152 -0
  232. package/src/kernel/packs/listNotice.test.ts +156 -0
  233. package/src/kernel/packs/paging.ts +110 -0
  234. package/src/kernel/packs/renderPack.ts +186 -13
  235. package/src/kernel/packs/resolveEntity.test.ts +62 -3
  236. package/src/kernel/packs/resolveEntity.ts +186 -26
  237. package/src/kernel/packs/runBodies.ts +196 -26
  238. package/src/kernel/packs/schema.ts +180 -13
  239. package/src/kernel/packs/sparql/authorQuery.test.ts +179 -0
  240. package/src/kernel/packs/sparql/authorQuery.ts +257 -0
  241. package/src/kernel/packs/sparql/buildListQuery.test.ts +344 -0
  242. package/src/kernel/packs/sparql/buildListQuery.ts +344 -0
  243. package/src/kernel/packs/sparql/buildLookupQuery.test.ts +138 -0
  244. package/src/kernel/packs/sparql/buildLookupQuery.ts +140 -20
  245. package/src/kernel/packs/sparql/filterValues.test.ts +86 -0
  246. package/src/kernel/packs/sparql/filterValues.ts +182 -0
  247. package/src/kernel/packs/sparql/index.ts +30 -14
  248. package/src/kernel/packs/sparql/runSelect.test.ts +63 -0
  249. package/src/kernel/packs/sparql/runSelect.ts +42 -2
  250. package/src/kernel/packs/sparql/searchTerm.ts +29 -0
  251. package/src/kernel/packs/storyRules.ts +180 -0
  252. package/src/kernel/packs/tierScope.test.ts +487 -0
  253. package/src/kernel/packs/tierScope.ts +409 -0
  254. package/src/kernel/packs/toolDescription.test.ts +13 -10
  255. package/src/kernel/packs/types.ts +311 -9
  256. package/src/kernel/project/cli/buildProgram.ts +17 -1
  257. package/src/kernel/project/cli/dispatch.test.ts +87 -0
  258. package/src/kernel/project/cli/dispatch.ts +114 -4
  259. package/src/kernel/project/cli/rootHelp.ts +8 -5
  260. package/src/kernel/project/cli/suggest.test.ts +35 -0
  261. package/src/kernel/project/cli/suggestNames.ts +34 -7
  262. package/src/kernel/project/mcp/registerVerb.ts +5 -1
  263. package/src/kernel/render/contracts.ts +2 -0
  264. package/src/kernel/render/prefixes.test.ts +2 -0
  265. package/src/kernel/render/renderers.test.ts +43 -0
  266. package/src/kernel/render/renderers.ts +34 -1
  267. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +2 -3
  268. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
  269. package/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.ts +3 -0
  270. package/src/kernel/runtime/graphpack/embedded.ts +14 -6
  271. package/src/kernel/runtime/graphpack/graphpack.test.ts +31 -5
  272. package/src/kernel/runtime/graphpack/read.ts +17 -13
  273. package/src/kernel/runtime/loadSession.ts +3 -1
  274. package/src/kernel/runtime/probe.ts +1 -1
  275. package/src/kernel/runtime/readEntity.ts +19 -4
  276. package/src/kernel/runtime/refs/parseRef.test.ts +49 -1
  277. package/src/kernel/runtime/refs/parseRef.ts +25 -1
  278. package/src/kernel/runtime/refs/resolve.ts +39 -5
  279. package/src/kernel/runtime/types.ts +30 -0
  280. package/src/kernel/spec/emitReference.ts +1 -1
  281. package/src/kernel/spec/surfaceConformance.test.ts +2 -2
  282. package/src/kernel/spec/types.ts +17 -0
  283. package/src/testing/PARITY_GAPS.ts +4 -4
  284. package/src/testing/behavioral/agentSession.mcp.test.ts +43 -39
  285. package/src/testing/behavioral/completion.test.ts +7 -6
  286. package/src/testing/behavioral/disclosure.test.ts +39 -37
  287. package/src/testing/behavioral/errorMatrix.mcp.test.ts +82 -64
  288. package/src/testing/behavioral/errorMatrix.storeless.test.ts +11 -10
  289. package/src/testing/behavioral/journeys.cli.test.ts +71 -32
  290. package/src/testing/behavioral/journeys.defaultPack.test.ts +6 -4
  291. package/src/testing/behavioral/journeys.firstInstall.test.ts +70 -5
  292. package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +4 -3
  293. package/src/testing/behavioral/journeys.packageStories.test.ts +2 -1
  294. package/src/testing/behavioral/mcpSurface.test.ts +28 -25
  295. package/src/testing/behavioral/parity.test.ts +25 -23
  296. package/src/testing/eval/__snapshots__/eval.test.ts.snap +20 -0
  297. package/src/testing/eval/cases/effectful.ts +7 -1
  298. package/src/testing/eval/cases/readNouns.ts +100 -11
  299. package/src/testing/fixtures/blockGraph.ts +89 -5
  300. package/src/testing/fixtures/connectGraph.ts +159 -0
  301. package/src/testing/fixtures/graph/canonical.ts +128 -32
  302. package/src/testing/helpers/fsProbe.ts +88 -0
  303. package/src/testing/helpers/runCli.test.ts +44 -0
  304. package/src/testing/helpers/runCli.ts +27 -3
  305. package/src/testing/perf/budgets.test.ts +3 -1
  306. package/src/testing/perf/budgets.ts +138 -54
  307. package/dist/src/kernel/packs/sparql/applyFilters.js +0 -167
  308. package/dist/src/kernel/packs/sparql/applyFilters.js.map +0 -1
  309. package/dist/src/kernel/packs/sparql/applySearch.js +0 -25
  310. package/dist/src/kernel/packs/sparql/applySearch.js.map +0 -1
  311. package/src/kernel/packs/sparql/applyFilters.ts +0 -203
  312. package/src/kernel/packs/sparql/applySearch.ts +0 -33
@@ -209,6 +209,31 @@ async function runSetup(
209
209
  // to know which of the two it is.
210
210
  const previewing = interactionMode === "batch-dry-run";
211
211
 
212
+ // The undo truth channel. A real `--undo` interprets this verb's task ONLY
213
+ // as the undo interpreter's mocked collection walk — every exec exits 0,
214
+ // every prompt answers its default, and the OutcomeSink is never written —
215
+ // so nothing the task body could report describes what the collected
216
+ // reversals later did. The body therefore says NOTHING on the undo path
217
+ // (see the guard in the gen body below); the kernel calls this seam after
218
+ // the reversals actually ran, handing over the interpreter's per-undo
219
+ // outcomes, and `appliedUndo` projects them onto the rows through the SAME
220
+ // sink + `applied` path the forward run reports through. One outcome
221
+ // model, both directions.
222
+ if (!previewing && input.undo) {
223
+ rt.undoReport = (outcomes) => {
224
+ const applied = run.appliedUndo(outcomes);
225
+ const width = Math.max(...applied.rows.map((row) => row.target.length));
226
+ for (const row of applied.rows) {
227
+ if (row.outcome === undefined) continue;
228
+ rt.report?.(renderProgressLine(row, width));
229
+ }
230
+ if (planExitFailed(applied)) {
231
+ rt.report?.(renderRecap(applied, "Removed"));
232
+ raiseFailedRows(applied, true);
233
+ }
234
+ };
235
+ }
236
+
212
237
  // A CONVERGED machine has no question to ask. Every row is `none` or `skip`,
213
238
  // so nothing composes an effect — and the wizard's confirm gate then mounted
214
239
  // Ink to render summon-core's "No operations planned." above a "Proceed?"
@@ -313,6 +338,16 @@ async function runSetup(
313
338
  const result = yield* $(task);
314
339
  const applied = run.applied(result.answers);
315
340
 
341
+ // A real `--undo` only ever drives this body under the undo
342
+ // interpreter's COLLECTION walk, with every effect mocked. `applied` is
343
+ // then a projection of the mocked walk (`removed` rows for reversals
344
+ // that have not run yet), so reporting it here printed green checks
345
+ // before anything happened. Say nothing; the kernel hands the executed
346
+ // reversals' outcomes to `rt.undoReport` afterwards, and `appliedUndo`
347
+ // grades the rows from those. (`--undo --dry-run` stays on the shared
348
+ // preview path below, where the same suppressions already apply.)
349
+ if (input.undo && !previewing) return applied;
350
+
316
351
  // Progress: one line per row on stderr for an unattended run (the Ink
317
352
  // session already draws them for the wizard). stdout stays the data stream.
318
353
  //
@@ -333,45 +368,58 @@ async function runSetup(
333
368
  // stdout on a failing run.
334
369
  if (planExitFailed(applied)) {
335
370
  rt.report?.(renderRecap(applied));
336
- const failed = applied.rows.filter(
337
- (row) => row.outcome?.status === "failed",
338
- );
339
-
340
- // ONE failure reports itself. The row already carries the cause it was
341
- // given ("`bun` is not found on your PATH") and a remedy that runs on
342
- // this machine; replacing them with a count would throw away the only
343
- // two sentences that say what is wrong and what to do about it.
344
- const only = failed.length === 1 ? failed[0] : undefined;
345
- if (only?.outcome !== undefined) {
346
- throw new PragmaError({
347
- code: "UNSUPPORTED",
348
- message: `${only.target}: ${only.outcome.note ?? "the step did not complete"}`,
349
- ...(only.outcome.remedy === undefined
350
- ? {}
351
- : { recovery: { message: only.outcome.remedy } }),
352
- });
353
- }
371
+ raiseFailedRows(applied);
372
+ }
373
+ return applied;
374
+ });
375
+ }
354
376
 
355
- // Several failures: name every one with its own cause, then point at the
356
- // recap above. A bare list of target names would make the reader re-run
357
- // each one to find out why it failed.
358
- const named = failed
377
+ /**
378
+ * Raise the run's failure from its rows — shared by the forward run's recap
379
+ * and the post-undo truth report, so the two paths name a shortfall in the
380
+ * same words. `undo` only re-words the multi-failure recovery line: the
381
+ * command that retries a removal is `setup <target> --undo`, not the
382
+ * installer.
383
+ *
384
+ * @param applied - The plan with outcomes filled in (at least one `failed`).
385
+ * @param undo - Whether the failed run was a removal.
386
+ * @throws PragmaError naming the failed row(s), always.
387
+ */
388
+ function raiseFailedRows(applied: SetupPlan, undo = false): never {
389
+ const failed = applied.rows.filter((row) => row.outcome?.status === "failed");
390
+
391
+ // ONE failure reports itself. The row already carries the cause it was
392
+ // given ("`bun` is not found on your PATH") and a remedy that runs on
393
+ // this machine; replacing them with a count would throw away the only
394
+ // two sentences that say what is wrong and what to do about it.
395
+ const only = failed.length === 1 ? failed[0] : undefined;
396
+ if (only?.outcome !== undefined) {
397
+ throw new PragmaError({
398
+ code: "UNSUPPORTED",
399
+ message: `${only.target}: ${only.outcome.note ?? "the step did not complete"}`,
400
+ ...(only.outcome.remedy === undefined
401
+ ? {}
402
+ : { recovery: { message: only.outcome.remedy } }),
403
+ });
404
+ }
405
+
406
+ // Several failures: name every one with its own cause, then point at the
407
+ // recap above. A bare list of target names would make the reader re-run
408
+ // each one to find out why it failed.
409
+ const named = failed
410
+ .map((row) => `${row.target} (${row.outcome?.note ?? "no cause recorded"})`)
411
+ .join("; ");
412
+ throw new PragmaError({
413
+ code: "UNSUPPORTED",
414
+ message: `${failed.length} of ${planTally(applied).accountable} targets did not complete: ${named}.`,
415
+ recovery: {
416
+ message: `The other targets are ${undo ? "removed" : "configured"}. Re-run the ones that did not complete: ${failed
359
417
  .map(
360
418
  (row) =>
361
- `${row.target} (${row.outcome?.note ?? "no cause recorded"})`,
419
+ `\`${BIN_NAME} setup ${row.target}${undo ? " --undo" : ""}\``,
362
420
  )
363
- .join("; ");
364
- throw new PragmaError({
365
- code: "UNSUPPORTED",
366
- message: `${failed.length} of ${planTally(applied).accountable} targets did not complete: ${named}.`,
367
- recovery: {
368
- message: `The other targets are configured. Re-run the ones that did not complete: ${failed
369
- .map((row) => `\`${BIN_NAME} setup ${row.target}\``)
370
- .join(", ")}.`,
371
- },
372
- });
373
- }
374
- return applied;
421
+ .join(", ")}.`,
422
+ },
375
423
  });
376
424
  }
377
425
 
@@ -456,7 +504,7 @@ const skillsVerb = setupVerb(
456
504
 
457
505
  const lspVerb = setupVerb(
458
506
  ["setup", "lsp"],
459
- "Install the Terrazzo design-token extension into your VS Code-family editors.",
507
+ "Install the Terrazzo design-token extension into the VS Code-family editors on this machine (on PATH, under /Applications or ~/Applications, or by its user directory).",
460
508
  "lsp",
461
509
  SUB_CAPABILITY,
462
510
  );
@@ -24,6 +24,7 @@
24
24
  * doctor's), so nothing here lands on the `--help`/`__complete` fast path.
25
25
  */
26
26
 
27
+ import { dirname } from "node:path";
27
28
  import type { Task } from "@canonical/task";
28
29
  import { BIN_NAME } from "../../constants.js";
29
30
  import type { PragmaRuntime } from "../../kernel/runtime/index.js";
@@ -42,9 +43,13 @@ import {
42
43
  import {
43
44
  composeLsp,
44
45
  composeLspRemoval,
46
+ describeEditorSource,
45
47
  detectLsp,
48
+ firstLspBlock,
49
+ installableEditors,
46
50
  LSP_SKIP_REMEDY,
47
51
  type LspDetection,
52
+ lspBlockReason,
48
53
  lspEditorNames,
49
54
  lspSkipReason,
50
55
  ownedLspEditors,
@@ -53,7 +58,10 @@ import {
53
58
  composeMcp,
54
59
  composeMcpRemoval,
55
60
  detectMcp,
61
+ firstMcpBlock,
56
62
  type McpDetection,
63
+ mcpBlockReason,
64
+ mcpGroupBlock,
57
65
  mcpGroupState,
58
66
  ownedMcpGroups,
59
67
  selectedGroups,
@@ -68,10 +76,11 @@ import {
68
76
  skillsSkipRemedy,
69
77
  staleSkillLinks,
70
78
  } from "./operations/setupSkills.js";
79
+ import type { FsProbe } from "./operations/writability.js";
71
80
  import {
72
81
  type PlanAction,
73
82
  type PlanChildRow,
74
- type SetupPlan,
83
+ type Roots,
75
84
  shortenPath,
76
85
  type TargetId,
77
86
  } from "./plan.js";
@@ -88,9 +97,6 @@ export interface TargetDraft {
88
97
  readonly remedy?: string;
89
98
  }
90
99
 
91
- /** The context a draft is rendered against — the plan's two named roots. */
92
- export type Roots = SetupPlan["roots"];
93
-
94
100
  /**
95
101
  * One row of the table, generic over its own detection type. Every consumer
96
102
  * treats the detection as opaque and hands it straight back to the same row,
@@ -101,8 +107,15 @@ export interface TargetDefinition<D> {
101
107
  /** Human title, used where a sentence needs one; the id is the row name. */
102
108
  readonly title: string;
103
109
  readonly scopes: readonly Scope[];
104
- /** Real reads, up front, once per (target, scope) per invocation. */
105
- detect(rt: PragmaRuntime, scope: Scope): Promise<D>;
110
+ /**
111
+ * Real reads, up front, once per (target, scope) per invocation.
112
+ *
113
+ * `probe` is the writability filesystem seam, threaded from `detectTargets`
114
+ * so a test can drive the two arms no CI host has — a `/nix/store` path, and
115
+ * (as root) a directory that refuses `W_OK` — through the SAME entry points
116
+ * production uses. Rows that write nothing ignore it.
117
+ */
118
+ detect(rt: PragmaRuntime, scope: Scope, probe?: FsProbe): Promise<D>;
106
119
  /** What a forward run would do. */
107
120
  plan(detection: D, scope: Scope, roots: Roots): TargetDraft;
108
121
  /** What a removal would do — composed from what detection says we own. */
@@ -112,8 +125,12 @@ export interface TargetDefinition<D> {
112
125
  * paths); a row without children ignores it.
113
126
  */
114
127
  compose(detection: D, chosen?: readonly string[]): Task<void>;
115
- /** The removal effects: forward re-assertion carrying the reversal as `undo`. */
116
- composeRemoval(detection: D): Task<void>;
128
+ /**
129
+ * The removal effects: forward re-assertion carrying the reversal as
130
+ * `undo`. `undoKey` (the row's identity) is stamped on every reversal so
131
+ * the undo interpreter's outcomes can be read back per row.
132
+ */
133
+ composeRemoval(detection: D, undoKey?: string): Task<void>;
117
134
  }
118
135
 
119
136
  /** A table row with its detection type erased — how every consumer holds one. */
@@ -150,7 +167,7 @@ const configTarget = defineTarget<ConfigDetection>({
150
167
  return { action: "remove", detail: path };
151
168
  },
152
169
  compose: (d) => composeConfigFile(d),
153
- composeRemoval: (d) => composeConfigRemoval(d),
170
+ composeRemoval: (d, undoKey) => composeConfigRemoval(d, undoKey),
154
171
  });
155
172
 
156
173
  const completionsTarget = defineTarget<CompletionsDetection>({
@@ -207,66 +224,123 @@ const completionsTarget = defineTarget<CompletionsDetection>({
207
224
  return { action: "remove", detail: shortenPath(d.path, roots) };
208
225
  },
209
226
  compose: (d) => composeCompletions(d),
210
- composeRemoval: (d) => composeCompletionsRemoval(d),
227
+ composeRemoval: (d, undoKey) => composeCompletionsRemoval(d, undoKey),
211
228
  });
212
229
 
230
+ /**
231
+ * The named skip for a machine with no VS Code-family editor at all — the one
232
+ * finding the forward plan and the removal share verbatim.
233
+ */
234
+ const draftLspNothingFound = (d: LspDetection): TargetDraft => ({
235
+ action: "skip",
236
+ detail: lspSkipReason(d),
237
+ reason: lspSkipReason(d),
238
+ remedy: LSP_SKIP_REMEDY,
239
+ });
240
+
241
+ /**
242
+ * One child per detected editor, exactly like the mcp row's files: a machine
243
+ * with several VS Code forks should not have the extension pushed into all of
244
+ * them because they happen to be installed.
245
+ *
246
+ * Each label carries HOW the editor was found, because that is the fact a user
247
+ * whose run did nothing needs — whether pragma missed their editor or found it
248
+ * and could not act.
249
+ *
250
+ * An INSTALLED editor is `unchanged` even when its folder is blocked: the copy
251
+ * really is there, and a child reading `skip — make the folder writable` told
252
+ * a user to chmod and reinstall an extension they already have. A block only
253
+ * decides the child when there is nothing installed for it to be about.
254
+ */
255
+ const lspChildren = (d: LspDetection, roots: Roots): PlanChildRow[] =>
256
+ d.editors.map((e): PlanChildRow => {
257
+ const label = `${e.editor.cli} — ${e.editor.name} · ${describeEditorSource(e, roots)}`;
258
+ if (e.installed) {
259
+ return { key: e.editor.cli, label, action: "unchanged" };
260
+ }
261
+ const reason = lspBlockReason(e);
262
+ if (reason !== undefined) {
263
+ return { key: e.editor.cli, label, action: "skip", reason };
264
+ }
265
+ return { key: e.editor.cli, label, action: "add" };
266
+ });
267
+
268
+ /**
269
+ * The row-level skip for a machine where nothing can be done at all —
270
+ * `undefined` when there is still something the row can say for itself.
271
+ *
272
+ * `actionable` is the row's own count of editors it could act on (pending
273
+ * installs going forward, owned copies on a removal) PLUS the ones already
274
+ * installed: a machine that is fully installed everywhere it can be is a `no
275
+ * change` row, not a permanent skip telling its owner to fix a folder whose
276
+ * extension is in place. Zero of those AND at least one block means the row's
277
+ * whole answer is the block, so it carries the FIRST one's reason and the
278
+ * remedy that matches it: a Nix declaration, a `chmod`, or the palette
279
+ * command, never one sentence standing for all three.
280
+ */
281
+ const draftLspRowBlock = (
282
+ d: LspDetection,
283
+ actionable: number,
284
+ ): TargetDraft | undefined => {
285
+ if (actionable > 0) return undefined;
286
+ const block = firstLspBlock(d);
287
+ if (block === undefined) return undefined;
288
+ return {
289
+ action: "skip",
290
+ detail: block.headline,
291
+ reason: block.headline,
292
+ remedy: block.remedy,
293
+ };
294
+ };
295
+
213
296
  const lspTarget = defineTarget<LspDetection>({
214
297
  id: "lsp",
215
298
  title: "Terrazzo LSP extension",
216
299
  scopes: ["global"],
217
- detect: (rt) => detectLsp(rt.cwd),
218
- plan: (d) => {
219
- if (d.state === "unknown") {
220
- return {
221
- action: "skip",
222
- detail: lspSkipReason(d),
223
- reason: lspSkipReason(d),
224
- remedy: LSP_SKIP_REMEDY,
225
- };
226
- }
227
- // One child per detected editor, exactly like the mcp row's files: a
228
- // machine with several VS Code forks on PATH should not have the extension
229
- // pushed into all of them because they happen to be installed.
230
- const children: PlanChildRow[] = d.editors.map((e) => ({
231
- key: e.editor.cli,
232
- label: `${e.editor.cli} — ${e.editor.name}`,
233
- action: e.installed ? ("unchanged" as const) : ("add" as const),
234
- }));
300
+ detect: (rt, _scope, probe) => detectLsp(rt.cwd, undefined, probe),
301
+ plan: (d, _scope, roots) => {
302
+ if (d.state === "unknown") return draftLspNothingFound(d);
303
+ const children = lspChildren(d, roots);
304
+ // The editors an install could actually act on: missing the extension AND
305
+ // not blocked. A blocked editor is not a candidate the user declined.
306
+ const installable = installableEditors(d);
307
+ const installed = d.editors.filter((e) => e.installed);
308
+ const blocked = draftLspRowBlock(d, installable.length + installed.length);
309
+ if (blocked !== undefined) return { ...blocked, children };
235
310
  // The detail names WHAT is being placed and WHERE — never the row's own
236
311
  // state, which the action column already carries. `installed (VSCodium)`
237
312
  // beside a child row reading `codium — VSCodium (unchanged)` said the
238
313
  // editor's name twice and "nothing happens here" twice.
239
- if (d.state === "installed") {
314
+ if (installable.length === 0) {
240
315
  return {
241
316
  action: "none",
242
317
  detail: `Terrazzo extension in ${lspEditorNames(d).join(", ")}`,
243
318
  children,
244
319
  };
245
320
  }
246
- const pending = d.editors.filter((e) => !e.installed);
247
321
  return {
248
322
  action: "install",
249
- detail: `Terrazzo extension → ${pending
323
+ detail: `Terrazzo extension → ${installable
250
324
  .map((e) => e.editor.name)
251
325
  .join(", ")}`,
252
326
  children,
253
327
  };
254
328
  },
255
- removalPlan: (d) => {
256
- // No editor CLI on PATH is the SAME named skip the forward plan reports:
329
+ removalPlan: (d, _scope, roots) => {
330
+ // No editor found at all is the SAME named skip the forward plan reports:
257
331
  // there is nothing on this machine to run an uninstall with.
258
- if (d.state === "unknown") {
259
- return {
260
- action: "skip",
261
- detail: lspSkipReason(d),
262
- reason: lspSkipReason(d),
263
- remedy: LSP_SKIP_REMEDY,
264
- };
265
- }
332
+ if (d.state === "unknown") return draftLspNothingFound(d);
266
333
  // Removal is composed from what detection says we OWN — a copy that is
267
334
  // present, whatever its version. The forward plan's version gate would let
268
335
  // a dead pre-0.8.3 copy this command installed survive its own `--undo`.
269
336
  const owned = ownedLspEditors(d);
337
+ // Nothing owned AND something blocked is the same skip the forward row
338
+ // reports, for the same reason: the uninstall would need a CLI this
339
+ // machine has not got, or a write pragma must not make.
340
+ const blocked = draftLspRowBlock(d, owned.length);
341
+ if (blocked !== undefined) {
342
+ return { ...blocked, children: lspChildren(d, roots) };
343
+ }
270
344
  if (owned.length === 0) {
271
345
  return { action: "none", detail: "no editor carries the extension" };
272
346
  }
@@ -283,7 +357,7 @@ const lspTarget = defineTarget<LspDetection>({
283
357
  };
284
358
  },
285
359
  compose: (d, chosen) => composeLsp(d, chosen),
286
- composeRemoval: (d) => composeLspRemoval(d),
360
+ composeRemoval: (d, undoKey) => composeLspRemoval(d, undoKey),
287
361
  });
288
362
 
289
363
  /**
@@ -303,10 +377,18 @@ const mcpChild = (
303
377
  group: McpDetection["groups"][number],
304
378
  roots: Roots,
305
379
  ): PlanChildRow => {
380
+ const label = shortenPath(group.path, roots);
381
+ // A file pragma cannot write is a child SKIP with its own reason, not an
382
+ // `add` that fails. The probe ran before anything was composed, so the row
383
+ // knows this without having tried.
384
+ const blocked = mcpBlockReason(d, group, roots);
385
+ if (blocked !== undefined) {
386
+ return { key: group.path, label, action: "skip", reason: blocked };
387
+ }
306
388
  const state = mcpGroupState(d, group.path);
307
389
  return {
308
390
  key: group.path,
309
- label: shortenPath(group.path, roots),
391
+ label,
310
392
  action:
311
393
  state === "registered"
312
394
  ? "unchanged"
@@ -320,7 +402,7 @@ const mcpTarget = defineTarget<McpDetection>({
320
402
  id: "mcp",
321
403
  title: "MCP server registration",
322
404
  scopes: ["global", "project"],
323
- detect: (rt, scope) => detectMcp(rt, scope),
405
+ detect: (rt, scope, probe) => detectMcp(rt, scope, probe),
324
406
  plan: (d, scope, roots) => {
325
407
  if (d.groups.length === 0) {
326
408
  return {
@@ -330,7 +412,24 @@ const mcpTarget = defineTarget<McpDetection>({
330
412
  };
331
413
  }
332
414
  const children = d.groups.map((group) => mcpChild(d, group, roots));
333
- const pending = children.filter((c) => c.action !== "unchanged");
415
+ // Every file blocked means the row's whole answer is the block: it carries
416
+ // the first one's reason and the entry to declare by hand.
417
+ const writable = d.groups.filter(
418
+ (group) => mcpGroupBlock(d, group.path) === undefined,
419
+ );
420
+ const block = writable.length === 0 ? firstMcpBlock(d, roots) : undefined;
421
+ if (block !== undefined) {
422
+ return {
423
+ action: "skip",
424
+ detail: block.reason,
425
+ reason: block.reason,
426
+ remedy: block.remedy,
427
+ children,
428
+ };
429
+ }
430
+ const pending = children.filter(
431
+ (c) => c.action !== "unchanged" && c.action !== "skip",
432
+ );
334
433
  return {
335
434
  action: pending.length === 0 ? "none" : "update",
336
435
  detail: `${d.groups.length} ${
@@ -356,7 +455,7 @@ const mcpTarget = defineTarget<McpDetection>({
356
455
  },
357
456
  compose: (d, chosen) =>
358
457
  composeMcp(d, chosen ? selectedGroups(d, chosen) : d.groups),
359
- composeRemoval: (d) => composeMcpRemoval(d),
458
+ composeRemoval: (d, undoKey) => composeMcpRemoval(d, undoKey),
360
459
  });
361
460
 
362
461
  const skillsTarget = defineTarget<SkillsDetection>({
@@ -402,7 +501,12 @@ const skillsTarget = defineTarget<SkillsDetection>({
402
501
  if (owned.length === 0) {
403
502
  return { action: "none", detail: "no link to remove" };
404
503
  }
405
- const dirs = [...new Set(owned.map((a) => shortenPath(a.linkPath, roots)))];
504
+ // Dedupe to the FOLDERS the links live in — `linkPath` itself is one path
505
+ // per link, so a Set over it deduped nothing and the removal preview
506
+ // printed an 18-path wall where the forward plan says `… → 2 folders`.
507
+ const dirs = [
508
+ ...new Set(owned.map((a) => shortenPath(dirname(a.linkPath), roots))),
509
+ ];
406
510
  return {
407
511
  action: "remove",
408
512
  detail: `${owned.length} ${owned.length === 1 ? "link" : "links"}`,
@@ -414,7 +518,7 @@ const skillsTarget = defineTarget<SkillsDetection>({
414
518
  };
415
519
  },
416
520
  compose: (d) => composeSkills(d),
417
- composeRemoval: (d) => composeSkillsRemoval(d),
521
+ composeRemoval: (d, undoKey) => composeSkillsRemoval(d, undoKey),
418
522
  });
419
523
 
420
524
  /** THE table, in display order. */
@@ -43,10 +43,46 @@ export type CompletionsState = "absent" | "installed" | "stale";
43
43
 
44
44
  /**
45
45
  * The detected state of the Terrazzo LSP extension across the VS Code-family
46
- * editors on this machine, probed up front by `detectLsp`: `installed` (every
47
- * editor whose CLI is on PATH already has it — a re-run skips), `absent` (at
48
- * least one detected editor is missing it, so the sideload runs for those), or
49
- * `unknown` (NO editor CLI was found on PATH — the step is a named skip, since
50
- * there is nothing to install into).
46
+ * editors on this machine, probed up front by `detectLsp`.
47
+ *
48
+ * An editor is FOUND by any of three probes — its CLI on PATH, its CLI inside
49
+ * its macOS app bundle, or its per-user configuration directory — and the
50
+ * state is about every editor found, whichever probe found it:
51
+ *
52
+ * - `installed`: every found editor already carries a working copy, so a
53
+ * re-run skips. A BLOCKED editor (no CLI to run, or an extensions folder
54
+ * this command must not write to) counts here when it is installed: the
55
+ * copy really is there, and the block is only about changing it.
56
+ * - `absent`: at least one found editor is missing it. The sideload runs for
57
+ * the ones that are missing it AND can be written to — never for a blocked
58
+ * editor, whose row carries its own remedy instead.
59
+ * - `unknown`: NO editor was found by any of the three probes, so the step is
60
+ * a named skip: there is nothing on this machine to install into.
51
61
  */
52
62
  export type LspState = "installed" | "absent" | "unknown";
63
+
64
+ /**
65
+ * Why a write cannot happen at a path — a fact with a remedy, never an error.
66
+ *
67
+ * The two kinds are kept apart because their remedies are unrelated: a store
68
+ * path needs a DECLARATION in the config that produced it, and a read-only
69
+ * path needs permissions changed. Collapsing them to one "not writable" would
70
+ * print the `chmod` advice at a Nix user, which cannot work — `chmod` on the
71
+ * store is not the fix, and the next `nixos-rebuild` would undo it.
72
+ *
73
+ * It lives here, beside {@link LspState} and {@link McpTargetState}, because
74
+ * it is the same kind of thing: a setup-domain state vocabulary that the lsp
75
+ * detection, the mcp detection, the target table and doctor all read.
76
+ * `probeWritable` (`operations/writability.ts`) is what produces one.
77
+ */
78
+ export type WriteBlock =
79
+ | {
80
+ readonly kind: "nix-store";
81
+ /** The store path the target resolves to — the evidence, and the remedy's subject. */
82
+ readonly resolved: string;
83
+ }
84
+ | {
85
+ readonly kind: "read-only";
86
+ /** The existing node that refused `W_OK` — the path a remedy must name. */
87
+ readonly path: string;
88
+ };
@@ -25,6 +25,7 @@
25
25
  export {
26
26
  assertExecOk,
27
27
  checkExecOk,
28
+ failPragma,
28
29
  guardMissingBinary,
29
30
  } from "./assertExecOk.js";
30
31
  export type {
@@ -33,6 +33,7 @@ import {
33
33
  } from "../../kernel/runtime/paths.js";
34
34
  import {
35
35
  detectPrefixClashes,
36
+ harvestPrefixes,
36
37
  resolvePackageJson,
37
38
  } from "../../kernel/runtime/refs/resolve.js";
38
39
  import { createLazyStore } from "../../kernel/runtime/store.js";
@@ -333,6 +334,60 @@ describe("sources update — conflicting @prefix detection (A5)", () => {
333
334
  ]);
334
335
  });
335
336
 
337
+ it("ignores a prefix declaration inside a string literal", () => {
338
+ // The code-standards pack ships guidance ABOUT Turtle: its `cs:code`
339
+ // examples contain declarations, including a deliberately discouraged one.
340
+ // Scanning raw text reported a clash on every single update for a file that
341
+ // asserts no triple in that namespace at all.
342
+ const clashes = detectPrefixClashes([
343
+ { content: "@prefix ex: <https://a.test/#> .\nex:One a ex:T ." },
344
+ {
345
+ content: [
346
+ "@prefix cs: <https://cs.test/#> .",
347
+ 'cs:rule cs:code """',
348
+ "# Bad: do not do this",
349
+ "@prefix ex: <https://wrong.test/data/> .",
350
+ '""" .',
351
+ ].join("\n"),
352
+ },
353
+ ]);
354
+
355
+ expect(clashes).toEqual([]);
356
+ });
357
+
358
+ it("does not let a literal rebind a real prefix in the harvested map", () => {
359
+ // Worse than a spurious warning: last-wins meant a code sample could
360
+ // overwrite a real namespace, so every entity of the losing package
361
+ // compacted to the wrong prefix.
362
+ const prefixes = harvestPrefixes([
363
+ { content: "@prefix ex: <https://a.test/#> .\nex:One a ex:T ." },
364
+ {
365
+ content: [
366
+ 'cs:rule cs:code """',
367
+ "@prefix ex: <https://wrong.test/data/> .",
368
+ '""" .',
369
+ ].join("\n"),
370
+ },
371
+ ]);
372
+
373
+ expect(prefixes.ex).toBe("https://a.test/#");
374
+ });
375
+
376
+ it("still reads declarations that follow a literal in the same file", () => {
377
+ // Masking must not swallow the rest of the file.
378
+ const prefixes = harvestPrefixes([
379
+ {
380
+ content: [
381
+ 'cs:rule cs:code """@prefix ignored: <https://no.test/> .""" .',
382
+ "@prefix real: <https://yes.test/#> .",
383
+ ].join("\n"),
384
+ },
385
+ ]);
386
+
387
+ expect(prefixes.real).toBe("https://yes.test/#");
388
+ expect(prefixes.ignored).toBeUndefined();
389
+ });
390
+
336
391
  it("does NOT flag harmless same-label/same-IRI redeclarations", () => {
337
392
  const clashes = detectPrefixClashes([
338
393
  { content: "@prefix ex: <https://a.test/#> .\nex:One a ex:T ." },