@canonical/pragma-cli 0.32.0 → 0.34.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 (295) hide show
  1. package/README.md +72 -0
  2. package/dist/pragma +0 -0
  3. package/package.json +19 -15
  4. package/pragma.conf.ts +664 -0
  5. package/src/bin.ts +15 -6
  6. package/src/capabilities/block.parity.test.ts +250 -0
  7. package/src/capabilities/capabilities/capabilities.render.ts +3 -2
  8. package/src/capabilities/capabilities/capabilities.test.ts +10 -1
  9. package/src/capabilities/capabilities/capabilities.verb.ts +32 -14
  10. package/src/capabilities/capabilities/catalog.ts +20 -8
  11. package/src/capabilities/capabilities/hints.ts +21 -36
  12. package/src/capabilities/capabilities/types.ts +2 -0
  13. package/src/capabilities/colophon/collectColophon.ts +34 -19
  14. package/src/capabilities/colophon/colophon.render.test.ts +31 -4
  15. package/src/capabilities/colophon/colophon.render.ts +13 -4
  16. package/src/capabilities/colophon/colophon.test.ts +3 -3
  17. package/src/capabilities/colophon/colophon.verb.ts +11 -4
  18. package/src/capabilities/colophon/markdownTerminal.ts +33 -14
  19. package/src/capabilities/colophon/types.ts +16 -7
  20. package/src/capabilities/config/collectConfigShow.test.ts +57 -0
  21. package/src/capabilities/config/collectConfigShow.ts +41 -4
  22. package/src/capabilities/config/fields.ts +10 -108
  23. package/src/capabilities/config/set.test.ts +6 -6
  24. package/src/capabilities/config/set.verb.ts +12 -8
  25. package/src/capabilities/config/show.render.test.ts +137 -0
  26. package/src/capabilities/config/show.render.ts +57 -16
  27. package/src/capabilities/config/show.verb.ts +9 -8
  28. package/src/capabilities/config/types.ts +8 -0
  29. package/src/capabilities/create/byteEquality.subprocess.test.ts +1 -1
  30. package/src/capabilities/create/byteEquality.test.ts +82 -101
  31. package/src/capabilities/create/compiledCreate.subprocess.test.ts +53 -16
  32. package/src/capabilities/create/constants.ts +75 -0
  33. package/src/capabilities/create/create.mcp.test.ts +2 -2
  34. package/src/capabilities/create/create.test.ts +192 -16
  35. package/src/capabilities/create/create.verb.ts +208 -79
  36. package/src/capabilities/create/index.ts +2 -10
  37. package/src/capabilities/create/pickGenerator.ts +34 -28
  38. package/src/capabilities/create/templates.embedded.generated.ts +4 -29
  39. package/src/capabilities/create/types.ts +10 -6
  40. package/src/capabilities/{standard/parity.test.ts → distribution.parity.test.ts} +24 -14
  41. package/src/capabilities/distribution.test.ts +120 -0
  42. package/src/capabilities/distribution.ts +59 -0
  43. package/src/capabilities/doctor/checks/checkConfigFile.ts +6 -5
  44. package/src/capabilities/doctor/checks/checkKeStore.ts +2 -2
  45. package/src/capabilities/doctor/checks/checkMcpCommands.ts +6 -0
  46. package/src/capabilities/doctor/checks/checkMcpConfigured.ts +15 -8
  47. package/src/capabilities/doctor/checks/checkPackageRefs.ts +79 -45
  48. package/src/capabilities/doctor/checks/checkPragmaVersion.ts +3 -2
  49. package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +61 -11
  50. package/src/capabilities/doctor/checks/checkShellCompletions.ts +63 -23
  51. package/src/capabilities/doctor/checks/checkSkillsSymlinked.ts +3 -2
  52. package/src/capabilities/doctor/checks/deriveBand.test.ts +42 -0
  53. package/src/capabilities/doctor/checks/deriveBand.ts +28 -0
  54. package/src/capabilities/doctor/doctor.render.test.ts +170 -0
  55. package/src/capabilities/doctor/doctor.render.ts +117 -34
  56. package/src/capabilities/doctor/doctor.test.ts +68 -9
  57. package/src/capabilities/doctor/doctor.verb.ts +6 -2
  58. package/src/capabilities/doctor/runChecks.ts +33 -9
  59. package/src/capabilities/doctor/types.ts +16 -1
  60. package/src/capabilities/emptyRecovery.test.ts +32 -15
  61. package/src/capabilities/graph/inspect.verb.ts +3 -2
  62. package/src/capabilities/graph/query.verb.ts +3 -2
  63. package/src/capabilities/graph/runQuery.ts +8 -5
  64. package/src/capabilities/index.ts +22 -18
  65. package/src/capabilities/info/collectInfo.ts +10 -6
  66. package/src/capabilities/info/info.render.ts +3 -2
  67. package/src/capabilities/info/info.verb.ts +3 -2
  68. package/src/capabilities/lazy.test.ts +93 -2
  69. package/src/capabilities/ontology/index.ts +10 -3
  70. package/src/capabilities/ontology/ontology.test.ts +157 -5
  71. package/src/capabilities/ontology/verbs.ts +203 -104
  72. package/src/capabilities/prompt/prompt.render.test.ts +94 -0
  73. package/src/capabilities/prompt/prompt.render.ts +23 -6
  74. package/src/capabilities/prompt/prompt.test.ts +68 -1
  75. package/src/capabilities/prompt/verbs.ts +24 -11
  76. package/src/capabilities/reference.test.ts +32 -0
  77. package/src/capabilities/resources/provider.ts +58 -25
  78. package/src/capabilities/resources/resources.test.ts +176 -21
  79. package/src/capabilities/scopeTypeSync.test.ts +37 -0
  80. package/src/capabilities/setup/index.ts +7 -1
  81. package/src/capabilities/setup/operations/setupCompletions.ts +49 -16
  82. package/src/capabilities/setup/operations/setupGenerator.ts +166 -63
  83. package/src/capabilities/setup/operations/setupLsp.ts +56 -12
  84. package/src/capabilities/setup/operations/setupMcp.ts +216 -39
  85. package/src/capabilities/setup/operations/setupSkills.ts +10 -4
  86. package/src/capabilities/setup/setup.render.test.ts +109 -0
  87. package/src/capabilities/setup/setup.render.ts +61 -14
  88. package/src/capabilities/setup/setup.test.ts +352 -1
  89. package/src/capabilities/setup/setup.verb.ts +68 -9
  90. package/src/capabilities/setup/shell.ts +29 -3
  91. package/src/capabilities/setup/types.ts +59 -2
  92. package/src/capabilities/shared/bands.ts +16 -0
  93. package/src/capabilities/skill/discover.ts +11 -5
  94. package/src/capabilities/skill/render.ts +4 -5
  95. package/src/capabilities/skill/verbs.ts +7 -7
  96. package/src/capabilities/sources/collectStatus.ts +69 -49
  97. package/src/capabilities/sources/index.ts +1 -1
  98. package/src/capabilities/sources/installSkills.ts +2 -2
  99. package/src/capabilities/sources/runUpdate.ts +146 -98
  100. package/src/capabilities/sources/sources.test.ts +264 -84
  101. package/src/capabilities/sources/status.render.test.ts +94 -0
  102. package/src/capabilities/sources/status.render.ts +55 -33
  103. package/src/capabilities/sources/status.verb.ts +15 -8
  104. package/src/capabilities/sources/types.ts +18 -25
  105. package/src/capabilities/sources/update.render.ts +9 -2
  106. package/src/capabilities/sources/update.verb.ts +12 -23
  107. package/src/capabilities/surface.test.ts +65 -17
  108. package/src/capabilities/tier.parity.test.ts +144 -0
  109. package/src/capabilities/upgrade/upgrade.verb.ts +7 -3
  110. package/src/constants.test.ts +9 -7
  111. package/src/constants.ts +49 -23
  112. package/src/identity.test.ts +333 -0
  113. package/src/index.ts +2 -2
  114. package/src/kernel/completion/__snapshots__/emitScripts.test.ts.snap +18 -16
  115. package/src/kernel/completion/complete.test.ts +3 -5
  116. package/src/kernel/completion/emitScripts.test.ts +1 -1
  117. package/src/kernel/completion/emitScripts.ts +4 -2
  118. package/src/kernel/completion/entitySource.test.ts +185 -41
  119. package/src/kernel/completion/entitySource.ts +131 -113
  120. package/src/kernel/completion/model.test.ts +3 -3
  121. package/src/kernel/completion/model.ts +11 -1
  122. package/src/kernel/completion/parse.test.ts +2 -4
  123. package/src/kernel/completion/safety.test.ts +82 -36
  124. package/src/kernel/completion/shellDrive.test.ts +592 -0
  125. package/src/kernel/completion/templates/fish.ts +28 -6
  126. package/src/kernel/completion/types.ts +3 -3
  127. package/src/kernel/config/__snapshots__/firstRun.test.ts.snap +1 -1
  128. package/src/kernel/config/defaults.test.ts +85 -0
  129. package/src/kernel/config/defaults.ts +12 -23
  130. package/src/kernel/config/evaluateProjectConfig.ts +2 -3
  131. package/src/kernel/config/findProjectConfig.ts +4 -3
  132. package/src/kernel/config/firstRun.ts +12 -5
  133. package/src/kernel/config/globalConfig.ts +2 -2
  134. package/src/kernel/config/index.ts +4 -5
  135. package/src/kernel/config/paths.ts +17 -17
  136. package/src/kernel/config/readConfig.test.ts +242 -6
  137. package/src/kernel/config/readConfig.ts +8 -8
  138. package/src/kernel/config/schema.test.ts +50 -0
  139. package/src/kernel/config/schema.ts +97 -10
  140. package/src/kernel/config/types.ts +73 -19
  141. package/src/kernel/copy.test.ts +453 -0
  142. package/src/kernel/error/PragmaError.test.ts +5 -5
  143. package/src/kernel/error/constants.ts +3 -2
  144. package/src/kernel/error/index.ts +1 -1
  145. package/src/kernel/error/recovery.test.ts +22 -45
  146. package/src/kernel/error/recovery.ts +22 -27
  147. package/src/kernel/error/renderError.ts +1 -1
  148. package/src/kernel/error/types.ts +5 -4
  149. package/src/kernel/packs/collect.test.ts +195 -11
  150. package/src/kernel/packs/collect.ts +213 -58
  151. package/src/kernel/packs/compile.test.ts +163 -7
  152. package/src/kernel/packs/compile.ts +15 -12
  153. package/src/kernel/packs/disclosure.test.ts +5 -2
  154. package/src/kernel/packs/graphql/fetchGraphqlLookup.ts +11 -10
  155. package/src/kernel/packs/graphqlSource.test.ts +6 -3
  156. package/src/kernel/packs/iri.ts +5 -7
  157. package/src/kernel/packs/renderPack.ts +5 -3
  158. package/src/kernel/packs/resolveEntity.ts +21 -17
  159. package/src/kernel/packs/runBodies.ts +15 -14
  160. package/src/kernel/packs/schema.ts +96 -10
  161. package/src/kernel/packs/sparql/runSelect.test.ts +104 -10
  162. package/src/kernel/packs/sparql/runSelect.ts +73 -34
  163. package/src/kernel/packs/toolDescription.test.ts +10 -2
  164. package/src/kernel/packs/types.ts +39 -4
  165. package/src/kernel/packs/uniqueness.ts +4 -4
  166. package/src/kernel/project/cli/buildProgram.test.ts +60 -11
  167. package/src/kernel/project/cli/buildProgram.ts +11 -0
  168. package/src/kernel/project/cli/dispatch.test.ts +67 -0
  169. package/src/kernel/project/cli/dispatch.ts +34 -12
  170. package/src/kernel/project/cli/globalFlags.test.ts +12 -4
  171. package/src/kernel/project/cli/globalFlags.ts +14 -14
  172. package/src/kernel/project/cli/helpFormat.ts +1 -1
  173. package/src/kernel/project/cli/rootHelp.ts +109 -107
  174. package/src/kernel/project/mcp/buildServer.ts +11 -6
  175. package/src/kernel/project/mcp/instructions.ts +12 -7
  176. package/src/kernel/project/mcp/mcpError.ts +52 -0
  177. package/src/kernel/project/mcp/parity.test.ts +39 -1
  178. package/src/kernel/project/mcp/prompts/prompts.test.ts +128 -5
  179. package/src/kernel/project/mcp/prompts/provider.ts +51 -7
  180. package/src/kernel/project/mcp/prompts/source.ts +68 -67
  181. package/src/kernel/project/mcp/registerVerb.ts +25 -6
  182. package/src/kernel/project/mcp/serve.ts +11 -3
  183. package/src/kernel/project/mcp/toolDescriptions.test.ts +6 -2
  184. package/src/kernel/render/disclosure.ts +4 -2
  185. package/src/kernel/render/formatters.ts +5 -4
  186. package/src/kernel/render/prefixes.test.ts +36 -0
  187. package/src/kernel/render/prefixes.ts +40 -13
  188. package/src/kernel/render/renderers.test.ts +35 -1
  189. package/src/kernel/render/renderers.ts +25 -7
  190. package/src/kernel/render/style.test.ts +31 -0
  191. package/src/kernel/render/style.ts +74 -0
  192. package/src/kernel/runtime/graphpack/build.ts +57 -3
  193. package/src/kernel/runtime/graphpack/buildIndex.test.ts +149 -0
  194. package/src/kernel/runtime/graphpack/buildIndex.ts +173 -50
  195. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +5 -8
  196. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +3 -4
  197. package/src/kernel/runtime/graphpack/embedded/pack.stories.generated.ts +3 -0
  198. package/src/kernel/runtime/graphpack/embedded.ts +37 -11
  199. package/src/kernel/runtime/graphpack/graphpack.test.ts +274 -6
  200. package/src/kernel/runtime/graphpack/hash.ts +3 -2
  201. package/src/kernel/runtime/graphpack/index.ts +1 -1
  202. package/src/kernel/runtime/graphpack/manifest.ts +114 -12
  203. package/src/kernel/runtime/graphpack/read.ts +32 -22
  204. package/src/kernel/runtime/graphpack/schemas.ts +74 -0
  205. package/src/kernel/runtime/graphpack/stories.ts +84 -0
  206. package/src/kernel/runtime/graphpack/types.ts +72 -45
  207. package/src/kernel/runtime/loadSession.ts +4 -12
  208. package/src/kernel/runtime/paths.ts +64 -15
  209. package/src/kernel/runtime/probe.test.ts +8 -4
  210. package/src/kernel/runtime/readEntity.ts +6 -5
  211. package/src/kernel/runtime/refs/gitOps.ts +1 -19
  212. package/src/kernel/runtime/refs/parseRef.ts +7 -7
  213. package/src/kernel/runtime/refs/resolve.ts +141 -65
  214. package/src/kernel/runtime/resolveSources.test.ts +63 -24
  215. package/src/kernel/runtime/resolveSources.ts +36 -25
  216. package/src/kernel/runtime/storeReadiness.ts +62 -0
  217. package/src/kernel/runtime/storeless.test.ts +1 -1
  218. package/src/kernel/runtime/types.ts +1 -1
  219. package/src/kernel/runtime/wasmEmbed.test.ts +25 -20
  220. package/src/kernel/spec/emitReference.test.ts +196 -0
  221. package/src/kernel/spec/emitReference.ts +624 -0
  222. package/src/kernel/spec/emitSurface.ts +13 -7
  223. package/src/kernel/spec/index.ts +2 -0
  224. package/src/kernel/spec/surfaceConformance.test.ts +2 -2
  225. package/src/kernel/spec/surfaceConformance.ts +8 -0
  226. package/src/kernel/spec/types.ts +29 -9
  227. package/src/kernel/spec/validate.ts +2 -1
  228. package/src/kernel/vocabulary.test.ts +164 -0
  229. package/src/kernel/vocabulary.ts +146 -0
  230. package/src/testing/PARITY_GAPS.ts +9 -3
  231. package/src/testing/behavioral/README.md +3 -3
  232. package/src/testing/behavioral/autoLlm.e2e.test.ts +12 -2
  233. package/src/testing/behavioral/completion.test.ts +21 -8
  234. package/src/testing/behavioral/disclosure.test.ts +12 -0
  235. package/src/testing/behavioral/docExamples.test.ts +283 -0
  236. package/src/testing/behavioral/errorMatrix.mcp.test.ts +4 -3
  237. package/src/testing/behavioral/errorMatrix.storeless.test.ts +3 -2
  238. package/src/testing/behavioral/exitCodes.e2e.test.ts +6 -5
  239. package/src/testing/behavioral/harness.foundation.test.ts +14 -5
  240. package/src/testing/behavioral/journeys.cli.test.ts +116 -73
  241. package/src/testing/behavioral/journeys.defaultPack.test.ts +477 -0
  242. package/src/testing/behavioral/journeys.firstInstall.test.ts +167 -0
  243. package/src/testing/behavioral/journeys.interactiveTty.e2e.test.ts +66 -0
  244. package/src/testing/behavioral/journeys.livePack.e2e.test.ts +129 -0
  245. package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +179 -0
  246. package/src/testing/behavioral/journeys.packageStories.test.ts +188 -0
  247. package/src/testing/behavioral/liveReadSurface.ts +4 -3
  248. package/src/testing/behavioral/mcpSurface.test.ts +10 -5
  249. package/src/testing/behavioral/staleCommands.test.ts +86 -0
  250. package/src/testing/eval/__snapshots__/eval.test.ts.snap +9 -9
  251. package/src/testing/eval/cases/assembly.ts +16 -9
  252. package/src/testing/eval/cases/effectful.ts +31 -16
  253. package/src/testing/eval/cases/readNouns.ts +23 -18
  254. package/src/testing/eval/cases/stable.ts +5 -1
  255. package/src/testing/eval/eval.test.ts +7 -1
  256. package/src/testing/fixtures/fixtureCapability.ts +201 -1
  257. package/src/testing/fixtures/graph/canonical.ts +6 -3
  258. package/src/testing/fixtures/graph/defaultPack.ts +179 -0
  259. package/src/testing/fixtures/graph/owlExport.ts +97 -0
  260. package/src/testing/helpers/extractSnippets.ts +29 -0
  261. package/src/testing/helpers/fixtureGraph.ts +33 -16
  262. package/src/testing/helpers/golden.ts +1 -1
  263. package/src/testing/helpers/packRuntime.ts +2 -3
  264. package/src/testing/helpers/projectCli.ts +2 -1
  265. package/src/testing/helpers/projectMcp.ts +6 -0
  266. package/src/testing/perf/budgets.test.ts +4 -0
  267. package/src/testing/perf/budgets.ts +22 -7
  268. package/src/testing/perf/globalSetup.ts +121 -6
  269. package/src/types.ts +2 -2
  270. package/src/capabilities/block/blockList.verb.ts +0 -150
  271. package/src/capabilities/block/index.ts +0 -25
  272. package/src/capabilities/block/pack.ts +0 -155
  273. package/src/capabilities/block/parity.test.ts +0 -180
  274. package/src/capabilities/block/tierChain.ts +0 -78
  275. package/src/capabilities/colophon/pragmaColophon.ts +0 -66
  276. package/src/capabilities/config/field.test.ts +0 -188
  277. package/src/capabilities/modifier/index.ts +0 -15
  278. package/src/capabilities/modifier/pack.ts +0 -64
  279. package/src/capabilities/standard/index.ts +0 -18
  280. package/src/capabilities/standard/pack.ts +0 -128
  281. package/src/capabilities/tier/index.ts +0 -19
  282. package/src/capabilities/tier/lookup.render.ts +0 -34
  283. package/src/capabilities/tier/lookup.test.ts +0 -68
  284. package/src/capabilities/tier/lookup.verb.ts +0 -44
  285. package/src/capabilities/tier/pack.ts +0 -28
  286. package/src/capabilities/tier/runLookup.ts +0 -59
  287. package/src/capabilities/token/addConfig.render.ts +0 -27
  288. package/src/capabilities/token/addConfig.test.ts +0 -62
  289. package/src/capabilities/token/addConfig.verb.ts +0 -40
  290. package/src/capabilities/token/index.ts +0 -19
  291. package/src/capabilities/token/pack.ts +0 -58
  292. package/src/capabilities/token/runAddConfig.ts +0 -95
  293. package/src/kernel/completion/bashDrive.test.ts +0 -73
  294. package/src/kernel/runtime/graphpack/embedded/sample.ttl +0 -39
  295. package/src/kernel/runtime/lock.ts +0 -90
@@ -5,6 +5,7 @@ import {
5
5
  mkdtempSync,
6
6
  readFileSync,
7
7
  rmSync,
8
+ symlinkSync,
8
9
  writeFileSync,
9
10
  } from "node:fs";
10
11
  import type { createRequire } from "node:module";
@@ -13,19 +14,30 @@ import { join } from "node:path";
13
14
  import { runTask } from "@canonical/task/node";
14
15
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
16
  import { VERSION } from "../../constants.js";
16
- import type { ConfigLayers, PackageEntry } from "../../kernel/config/types.js";
17
+ import type {
18
+ ConfigLayers,
19
+ PackDeclaration,
20
+ } from "../../kernel/config/types.js";
17
21
  import { PragmaError } from "../../kernel/error/PragmaError.js";
18
22
  import { executeVerb } from "../../kernel/project/cli/dispatch.js";
19
23
  import { bootRuntime } from "../../kernel/runtime/boot.js";
20
24
  import { createQueryFacade } from "../../kernel/runtime/facade.js";
21
- import { readLock } from "../../kernel/runtime/lock.js";
22
- import { lockPath, packDir } from "../../kernel/runtime/paths.js";
23
- import { resolvePackageJson } from "../../kernel/runtime/refs/resolve.js";
25
+ import { readManifest } from "../../kernel/runtime/graphpack/manifest.js";
26
+ import {
27
+ activePackPath,
28
+ packDir,
29
+ readActivePack,
30
+ } from "../../kernel/runtime/paths.js";
31
+ import {
32
+ detectPrefixClashes,
33
+ resolvePackageJson,
34
+ } from "../../kernel/runtime/refs/resolve.js";
24
35
  import { createLazyStore } from "../../kernel/runtime/store.js";
25
36
  import type { GlobalFlags, PragmaRuntime } from "../../kernel/runtime/types.js";
26
37
  import type { VerbSpec } from "../../kernel/spec/types.js";
27
38
  import { projectMcp } from "../../testing/helpers/projectMcp.js";
28
39
  import { discoverSkills } from "../skill/discover.js";
40
+ import { collectStatus } from "./collectStatus.js";
29
41
  import { sourcesModule } from "./index.js";
30
42
  import { buildUpdateTask } from "./runUpdate.js";
31
43
 
@@ -53,18 +65,17 @@ const tmp = (prefix: string): string => {
53
65
  return dir;
54
66
  };
55
67
 
56
- /** A runtime whose config is the given package list (no config files needed). */
57
- function runtimeFor(cwd: string, packages: PackageEntry[]): PragmaRuntime {
68
+ /** A runtime whose config is the given pack list (no config files needed). */
69
+ function runtimeFor(cwd: string, packs: PackDeclaration[]): PragmaRuntime {
58
70
  const layers: ConfigLayers = {
59
- config: { channel: "normal", packages },
71
+ config: { channel: "normal", packs },
60
72
  origins: {
61
73
  tier: "default",
62
74
  channel: "default",
63
75
  detail: "default",
64
- packages: "project",
76
+ packs: "project",
65
77
  stories: "default",
66
78
  prefixes: "default",
67
- prompts: "default",
68
79
  },
69
80
  global: { path: "/nonexistent", exists: false },
70
81
  project: { exists: false },
@@ -96,30 +107,24 @@ function filePackage(): string {
96
107
  return pkg;
97
108
  }
98
109
 
99
- describe("sources lock round-trip (PROTECTED)", () => {
100
- it("file source: builds, locks, and --frozen rewrites a byte-identical lock", async () => {
110
+ describe("sources update round-trip (PROTECTED)", () => {
111
+ it("file source: builds and points the project at the built pack", async () => {
101
112
  const pkg = filePackage();
102
113
  const cwd = tmp("pragma-proj-");
103
114
  const runtime = runtimeFor(cwd, [
104
115
  { name: "pkg-a", source: `file://${pkg}` },
105
116
  ]);
106
117
 
107
- const result = await runTask(await buildUpdateTask(runtime, false));
118
+ const result = await runTask(await buildUpdateTask(runtime));
108
119
  expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/);
109
-
110
- const lock = readLock(cwd);
111
- expect(lock?.packs).toHaveLength(1);
112
- expect(lock?.packs[0]?.name).toBe("pkg-a");
113
- expect(lock?.packs[0]?.resolved).toBe(pkg);
114
- expect(lock?.contentHash).toBe(result.contentHash);
115
-
116
- const firstBytes = readFileSync(lockPath(cwd), "utf-8");
117
- // A --frozen re-run reproduces the locked state byte-for-byte.
118
- await runTask(await buildUpdateTask(runtime, true));
119
- expect(readFileSync(lockPath(cwd), "utf-8")).toBe(firstBytes);
120
+ expect(result.packs).toEqual([
121
+ { name: "pkg-a", resolved: pkg, sourceCount: 1, storyCount: 0 },
122
+ ]);
123
+ // The pointer the next boot reads names exactly the pack just built.
124
+ expect(readActivePack(cwd)).toBe(result.contentHash);
120
125
  });
121
126
 
122
- it("git source: resolves and locks a commit SHA, --frozen pins it", async () => {
127
+ it("git source: resolves the ref to a commit SHA", async () => {
123
128
  const repo = tmp("pragma-repo-");
124
129
  const git = (args: string[]) =>
125
130
  execFileSync("git", args, {
@@ -144,25 +149,83 @@ describe("sources lock round-trip (PROTECTED)", () => {
144
149
  { name: "pkg-git", source: `git+file://${repo}#main` },
145
150
  ]);
146
151
 
147
- await runTask(await buildUpdateTask(runtime, false));
148
- const lock = readLock(cwd);
149
- expect(lock?.packs[0]?.resolved).toMatch(/^[0-9a-f]{40}$/);
152
+ const result = await runTask(await buildUpdateTask(runtime));
153
+ const sha = result.packs.at(0)?.resolved;
154
+ expect(sha).toMatch(/^[0-9a-f]{40}$/);
155
+ expect(readActivePack(cwd)).toBe(result.contentHash);
156
+ // The revision reaches the manifest's provenance label, which is the only
157
+ // place `sources status` and `doctor` can read it from now that no lock
158
+ // records it. Same `<name>@<kind>:<resolved>` shape the bundler writes.
159
+ expect(readManifest(packDir(result.contentHash))?.sourceRef).toBe(
160
+ `pkg-git@git:${sha}`,
161
+ );
162
+ });
163
+
164
+ it("a re-run over an unchanged source leaves the pointer byte-identical (L1)", async () => {
165
+ // On base the lock file carried a `resolvedAt` timestamp, so a no-op re-run
166
+ // rewrote it and dirtied the tree. The pointer holds only the content hash,
167
+ // which is a pure function of the sources — so this is now true by
168
+ // construction. The guard stays because it is the property users cared
169
+ // about: repeating an update changes nothing when nothing changed.
170
+ const pkg = filePackage();
171
+ const cwd = tmp("pragma-proj-");
172
+ const runtime = runtimeFor(cwd, [
173
+ { name: "pkg-a", source: `file://${pkg}` },
174
+ ]);
175
+ await runTask(await buildUpdateTask(runtime));
176
+ const firstBytes = readFileSync(activePackPath(cwd), "utf-8");
177
+ await runTask(await buildUpdateTask(runtime));
178
+ expect(readFileSync(activePackPath(cwd), "utf-8")).toBe(firstBytes);
179
+ });
180
+
181
+ it("follows a symlinked .ttl into the build (L6)", async () => {
182
+ // pnpm / workspace trees symlink their sources; Dirent.isFile is false for a
183
+ // symlink, so on base the only `.ttl` was skipped → 0 sources → the empty
184
+ // build. Following the link ingests it and the build succeeds.
185
+ const pkg = tmp("pragma-symlink-pkg-");
186
+ mkdirSync(join(pkg, "definitions"), { recursive: true });
187
+ const realTtl = join(pkg, "real.ttl");
188
+ writeFileSync(realTtl, TTL);
189
+ symlinkSync(realTtl, join(pkg, "definitions", "linked.ttl"));
190
+ const cwd = tmp("pragma-proj-");
191
+ const runtime = runtimeFor(cwd, [
192
+ { name: "pkg-a", source: `file://${pkg}` },
193
+ ]);
194
+
195
+ const result = await runTask(await buildUpdateTask(runtime));
196
+ expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/);
197
+ expect(readActivePack(cwd)).toBe(result.contentHash);
198
+ });
199
+
200
+ it("does not recurse into a symlinked directory cycle (L6 safety)", async () => {
201
+ // The L6 fix follows symlinked FILES but must NOT recurse into a symlinked
202
+ // DIRECTORY: a link to an ancestor dir is a cycle that, if walked, recurses
203
+ // without bound → a stack-overflow RangeError (an INTERNAL "please report").
204
+ // The real `.ttl` is still ingested and the build completes.
205
+ const pkg = tmp("pragma-symlink-cycle-");
206
+ mkdirSync(join(pkg, "definitions"), { recursive: true });
207
+ writeFileSync(join(pkg, "definitions", "widget.ttl"), TTL);
208
+ // `definitions/loop` → `definitions` (its own parent): a symlink cycle.
209
+ symlinkSync(join(pkg, "definitions"), join(pkg, "definitions", "loop"));
210
+ const cwd = tmp("pragma-proj-");
211
+ const runtime = runtimeFor(cwd, [
212
+ { name: "pkg-a", source: `file://${pkg}` },
213
+ ]);
150
214
 
151
- const firstBytes = readFileSync(lockPath(cwd), "utf-8");
152
- await runTask(await buildUpdateTask(runtime, true));
153
- expect(readFileSync(lockPath(cwd), "utf-8")).toBe(firstBytes);
215
+ const result = await runTask(await buildUpdateTask(runtime));
216
+ expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/);
217
+ expect(readActivePack(cwd)).toBe(result.contentHash);
154
218
  });
155
219
 
156
- it("undo restores the prior lock (no prior → removed)", async () => {
220
+ it("undo removes the pointer when the project had none before", async () => {
157
221
  const pkg = filePackage();
158
222
  const cwd = tmp("pragma-proj-");
159
223
  const runtime = runtimeFor(cwd, [
160
224
  { name: "pkg-a", source: `file://${pkg}` },
161
225
  ]);
162
226
  const { runUndo } = await import("@canonical/task/node");
163
- await runUndo(await buildUpdateTask(runtime, false));
164
- // Prior lock was absent, so undo removes the file.
165
- expect(readLock(cwd)).toBeUndefined();
227
+ await runUndo(await buildUpdateTask(runtime));
228
+ expect(readActivePack(cwd)).toBeUndefined();
166
229
  });
167
230
  });
168
231
 
@@ -178,7 +241,7 @@ describe("sources update — package-declared prefixes (M1)", () => {
178
241
  { name: "pkg-a", source: `file://${pkg}` },
179
242
  ]);
180
243
 
181
- const result = await runTask(await buildUpdateTask(runtime, false));
244
+ const result = await runTask(await buildUpdateTask(runtime));
182
245
  const index = JSON.parse(
183
246
  readFileSync(join(packDir(result.contentHash), "index.json"), "utf-8"),
184
247
  ) as {
@@ -200,6 +263,114 @@ describe("sources update — package-declared prefixes (M1)", () => {
200
263
  });
201
264
  });
202
265
 
266
+ describe("sources update — refuses an empty store (A4)", () => {
267
+ /** A local package that ships NO `.ttl` (an empty definitions directory). */
268
+ function emptyPackage(): string {
269
+ const pkg = tmp("pragma-empty-pkg-");
270
+ mkdirSync(join(pkg, "definitions"), { recursive: true });
271
+ return pkg;
272
+ }
273
+
274
+ it("a package with no .ttl is refused, and no pointer is written", async () => {
275
+ // On base this builds a 0-triple pack whose empty data.nq fails the
276
+ // completeness gate — so the "successful" update boots to a PERMANENT
277
+ // STORE_UNAVAILABLE loop. The fix refuses before writing the pointer.
278
+ const pkg = emptyPackage();
279
+ const cwd = tmp("pragma-proj-");
280
+ const runtime = runtimeFor(cwd, [
281
+ { name: "pkg-a", source: `file://${pkg}` },
282
+ ]);
283
+
284
+ await expect(buildUpdateTask(runtime)).rejects.toMatchObject({
285
+ code: "CONFIG_ERROR",
286
+ });
287
+ // No pointer → the embedded fallback / prior state survives, no boot loop.
288
+ expect(readActivePack(cwd)).toBeUndefined();
289
+ });
290
+
291
+ it("no configured packs is refused rather than pointing at an empty pack", async () => {
292
+ const cwd = tmp("pragma-proj-");
293
+ const runtime = runtimeFor(cwd, []);
294
+ await expect(buildUpdateTask(runtime)).rejects.toMatchObject({
295
+ code: "CONFIG_ERROR",
296
+ });
297
+ expect(readActivePack(cwd)).toBeUndefined();
298
+ });
299
+
300
+ it("a comment-only .ttl (0 triples) is refused too", async () => {
301
+ const pkg = tmp("pragma-noTriples-pkg-");
302
+ mkdirSync(join(pkg, "definitions"), { recursive: true });
303
+ // Parses fine, but yields no triples — the post-build 0-triple guard.
304
+ writeFileSync(
305
+ join(pkg, "definitions", "empty.ttl"),
306
+ "# only a comment, no triples\n",
307
+ );
308
+ const cwd = tmp("pragma-proj-");
309
+ const runtime = runtimeFor(cwd, [
310
+ { name: "pkg-a", source: `file://${pkg}` },
311
+ ]);
312
+ await expect(buildUpdateTask(runtime)).rejects.toMatchObject({
313
+ code: "CONFIG_ERROR",
314
+ });
315
+ expect(readActivePack(cwd)).toBeUndefined();
316
+ });
317
+ });
318
+
319
+ describe("sources update — conflicting @prefix detection (A5)", () => {
320
+ it("flags a label bound to two different IRIs across packages", () => {
321
+ const clashes = detectPrefixClashes([
322
+ { content: "@prefix ex: <https://a.test/#> .\nex:One a ex:T ." },
323
+ { content: "@prefix ex: <https://b.test/#> .\nex:Two a ex:T ." },
324
+ ]);
325
+ expect(clashes).toHaveLength(1);
326
+ expect(clashes.at(0)?.label).toBe("ex");
327
+ expect(clashes.at(0)?.iris).toEqual([
328
+ "https://a.test/#",
329
+ "https://b.test/#",
330
+ ]);
331
+ });
332
+
333
+ it("does NOT flag harmless same-label/same-IRI redeclarations", () => {
334
+ const clashes = detectPrefixClashes([
335
+ { content: "@prefix ex: <https://a.test/#> .\nex:One a ex:T ." },
336
+ { content: "@prefix ex: <https://a.test/#> .\nex:Two a ex:T ." },
337
+ ]);
338
+ expect(clashes).toEqual([]);
339
+ });
340
+
341
+ it("warns loudly on a cross-package prefix clash during update", async () => {
342
+ const owl = "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n";
343
+ const pkgA = tmp("pragma-clash-a-");
344
+ mkdirSync(join(pkgA, "definitions"), { recursive: true });
345
+ writeFileSync(
346
+ join(pkgA, "definitions", "a.ttl"),
347
+ `@prefix ex: <https://a.test/#> .\n${owl}ex:Thing a owl:Class .\nex:one a ex:Thing .\n`,
348
+ );
349
+ const pkgB = tmp("pragma-clash-b-");
350
+ mkdirSync(join(pkgB, "definitions"), { recursive: true });
351
+ writeFileSync(
352
+ join(pkgB, "definitions", "b.ttl"),
353
+ `@prefix ex: <https://b.test/#> .\n${owl}ex:Widget a owl:Class .\nex:two a ex:Widget .\n`,
354
+ );
355
+ const cwd = tmp("pragma-proj-");
356
+ const reports: string[] = [];
357
+ const runtime: PragmaRuntime = {
358
+ ...runtimeFor(cwd, [
359
+ { name: "pkg-a", source: `file://${pkgA}` },
360
+ { name: "pkg-b", source: `file://${pkgB}` },
361
+ ]),
362
+ report: (message: string) => reports.push(message),
363
+ };
364
+
365
+ await runTask(await buildUpdateTask(runtime));
366
+ expect(
367
+ reports.some(
368
+ (r) => r.includes('Prefix "ex:"') && r.includes("conflicting"),
369
+ ),
370
+ ).toBe(true);
371
+ });
372
+ });
373
+
203
374
  describe("sources update — network-free preview (M2)", () => {
204
375
  const updateVerb = sourcesModule.verbs[1] as VerbSpec;
205
376
  const DRY_RUN = { dryRun: true, undo: false, yes: false };
@@ -218,11 +389,12 @@ describe("sources update — network-free preview (M2)", () => {
218
389
 
219
390
  // A successful plan — the unreachable clone was never attempted.
220
391
  expect(outcome.exitCode).toBe(0);
221
- expect(outcome.stdout).toContain("Resolve and build 1 package(s)");
392
+ expect(outcome.stdout).toContain("Resolve and build 1 pack(s)");
222
393
  expect(outcome.stdout).toContain(UNREACHABLE);
223
- // The one project mutation is previewed, not performed.
224
- expect(outcome.stdout).toContain("pragma.lock.json");
225
- expect(readLock(cwd)).toBeUndefined();
394
+ // The project mutation is previewed, not performed: the plan names the
395
+ // exact pointer file a real run would write, and no pointer landed.
396
+ expect(outcome.stdout).toContain(activePackPath(cwd));
397
+ expect(readActivePack(cwd)).toBeUndefined();
226
398
  // The store was never even asked for.
227
399
  expect(runtime.store.booted).toBe(false);
228
400
  });
@@ -231,7 +403,7 @@ describe("sources update — network-free preview (M2)", () => {
231
403
  const cwd = tmp("pragma-proj-");
232
404
  writeFileSync(
233
405
  join(cwd, "pragma.config.ts"),
234
- `export default { packages: [{ name: "pkg-remote", source: "${UNREACHABLE}" }] };\n`,
406
+ `export default { packs: [{ name: "pkg-remote", source: "${UNREACHABLE}" }] };\n`,
235
407
  );
236
408
 
237
409
  const mcp = await projectMcp([sourcesModule], cwd);
@@ -245,25 +417,8 @@ describe("sources update — network-free preview (M2)", () => {
245
417
  });
246
418
  const plan = (envelope.data as { plan: string[] }).plan;
247
419
  expect(plan.some((line) => line.includes(UNREACHABLE))).toBe(true);
248
- // Plan-first withheld the write — no lock landed.
249
- expect(readLock(cwd)).toBeUndefined();
250
- });
251
- });
252
-
253
- describe("sources update — reproducible-or-fail (m5)", () => {
254
- it("--frozen refuses a package that has no lock entry", async () => {
255
- const pkg = filePackage();
256
- const cwd = tmp("pragma-proj-");
257
- const runtime = runtimeFor(cwd, [
258
- { name: "pkg-a", source: `file://${pkg}` },
259
- ]);
260
-
261
- // No prior lock → nothing to reproduce → refuse rather than silently
262
- // advance. The throw is raised during setup, before any Task is returned.
263
- await expect(buildUpdateTask(runtime, true)).rejects.toMatchObject({
264
- code: "CONFIG_ERROR",
265
- });
266
- expect(readLock(cwd)).toBeUndefined();
420
+ // Plan-first withheld the write — no pointer landed.
421
+ expect(readActivePack(cwd)).toBeUndefined();
267
422
  });
268
423
  });
269
424
 
@@ -331,7 +486,7 @@ describe("sources update — data-failure classification (U6)", () => {
331
486
 
332
487
  let caught: unknown;
333
488
  try {
334
- await buildUpdateTask(runtime, false);
489
+ await buildUpdateTask(runtime);
335
490
  } catch (error) {
336
491
  caught = error;
337
492
  }
@@ -349,8 +504,8 @@ describe("sources update — data-failure classification (U6)", () => {
349
504
  expect(err.recovery?.message ?? "").not.toContain("report this issue");
350
505
  // The recovery points the user at a runnable, useful next step.
351
506
  expect(err.recovery?.cli).toBe("pragma sources update --verbose");
352
- // Nothing was locked on failure.
353
- expect(readLock(cwd)).toBeUndefined();
507
+ // Nothing was pointed at on failure.
508
+ expect(readActivePack(cwd)).toBeUndefined();
354
509
  });
355
510
 
356
511
  it("classifies a git clone failure, naming the package (not INTERNAL)", async () => {
@@ -367,7 +522,7 @@ describe("sources update — data-failure classification (U6)", () => {
367
522
 
368
523
  let caught: unknown;
369
524
  try {
370
- await buildUpdateTask(runtime, false);
525
+ await buildUpdateTask(runtime);
371
526
  } catch (error) {
372
527
  caught = error;
373
528
  }
@@ -376,7 +531,7 @@ describe("sources update — data-failure classification (U6)", () => {
376
531
  expect(err.code).toBe("CONFIG_ERROR");
377
532
  expect(err.message).toContain("pkg-remote");
378
533
  expect(err.message).not.toContain("Internal error");
379
- expect(readLock(cwd)).toBeUndefined();
534
+ expect(readActivePack(cwd)).toBeUndefined();
380
535
  });
381
536
 
382
537
  it("names the SPECIFIC bad file among several good ones", async () => {
@@ -390,12 +545,12 @@ describe("sources update — data-failure classification (U6)", () => {
390
545
  const cwd = tmp("pragma-proj-");
391
546
  const runtime = runtimeFor(cwd, [{ name: "mix", source: `file://${pkg}` }]);
392
547
 
393
- await expect(buildUpdateTask(runtime, false)).rejects.toMatchObject({
548
+ await expect(buildUpdateTask(runtime)).rejects.toMatchObject({
394
549
  code: "CONFIG_ERROR",
395
550
  });
396
551
  let caught: unknown;
397
552
  try {
398
- await buildUpdateTask(runtime, false);
553
+ await buildUpdateTask(runtime);
399
554
  } catch (error) {
400
555
  caught = error;
401
556
  }
@@ -427,9 +582,9 @@ describe("sources update — hidden files and --skip-invalid", () => {
427
582
  { name: "pkg-a", source: `file://${pkg}` },
428
583
  ]);
429
584
 
430
- const result = await runTask(await buildUpdateTask(runtime, false));
585
+ const result = await runTask(await buildUpdateTask(runtime));
431
586
  expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/);
432
- expect(readLock(cwd)?.packs).toHaveLength(1);
587
+ expect(readActivePack(cwd)).toMatch(/^[0-9a-f]{64}$/);
433
588
  });
434
589
 
435
590
  it("without --skip-invalid, one malformed source fails the whole update", async () => {
@@ -438,10 +593,10 @@ describe("sources update — hidden files and --skip-invalid", () => {
438
593
  const runtime = runtimeFor(cwd, [
439
594
  { name: "pkg-a", source: `file://${pkg}` },
440
595
  ]);
441
- await expect(buildUpdateTask(runtime, false, false)).rejects.toMatchObject({
596
+ await expect(buildUpdateTask(runtime, false)).rejects.toMatchObject({
442
597
  code: "CONFIG_ERROR",
443
598
  });
444
- expect(readLock(cwd)).toBeUndefined();
599
+ expect(readActivePack(cwd)).toBeUndefined();
445
600
  });
446
601
 
447
602
  it("with --skip-invalid, drops the bad source, warns loudly, and builds from the rest", async () => {
@@ -453,10 +608,10 @@ describe("sources update — hidden files and --skip-invalid", () => {
453
608
  report: (message: string) => reports.push(message),
454
609
  };
455
610
 
456
- const result = await runTask(await buildUpdateTask(runtime, false, true));
611
+ const result = await runTask(await buildUpdateTask(runtime, true));
457
612
  // Built from the good widget.ttl, not aborted.
458
613
  expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/);
459
- expect(readLock(cwd)?.packs).toHaveLength(1);
614
+ expect(readActivePack(cwd)).toMatch(/^[0-9a-f]{64}$/);
460
615
  // Loud per-source warning names the dropped file …
461
616
  expect(
462
617
  reports.some(
@@ -476,10 +631,10 @@ describe("sources update — hidden files and --skip-invalid", () => {
476
631
  ...runtimeFor(cwd, [{ name: "pkg-a", source: `file://${pkg}` }]),
477
632
  report: () => {},
478
633
  };
479
- await expect(buildUpdateTask(runtime, false, true)).rejects.toMatchObject({
634
+ await expect(buildUpdateTask(runtime, true)).rejects.toMatchObject({
480
635
  code: "CONFIG_ERROR",
481
636
  });
482
- expect(readLock(cwd)).toBeUndefined();
637
+ expect(readActivePack(cwd)).toBeUndefined();
483
638
  });
484
639
  });
485
640
 
@@ -573,7 +728,7 @@ describe("sources update — installs package skills (U10)", () => {
573
728
  { name: "pkg-a", source: `file://${pkg}` },
574
729
  ]);
575
730
 
576
- await runTask(await buildUpdateTask(runtime, false));
731
+ await runTask(await buildUpdateTask(runtime));
577
732
 
578
733
  // Installed as a symlink into $XDG_DATA_HOME/pragma/skills …
579
734
  const linked = join(dataHome, "pragma", "skills", "foo");
@@ -595,7 +750,7 @@ describe("sources update — installs package skills (U10)", () => {
595
750
  const runtime = runtimeFor(cwd, [
596
751
  { name: "pkg-a", source: `file://${pkg}` },
597
752
  ]);
598
- await runTask(await buildUpdateTask(runtime, false));
753
+ await runTask(await buildUpdateTask(runtime));
599
754
 
600
755
  const shared = discoverSkills(cwd).filter((s) => s.name === "shared");
601
756
  expect(shared).toHaveLength(1);
@@ -609,16 +764,42 @@ describe("sources update — installs package skills (U10)", () => {
609
764
  { name: "pkg-a", source: `file://${pkg}` },
610
765
  ]);
611
766
  const { runUndo } = await import("@canonical/task/node");
612
- await runUndo(await buildUpdateTask(runtime, false));
767
+ await runUndo(await buildUpdateTask(runtime));
613
768
 
614
769
  expect(existsSync(join(dataHome, "pragma", "skills", "bar"))).toBe(false);
615
770
  });
616
771
  });
617
772
 
773
+ describe("sources status — entityCount from the manifest (A10)", () => {
774
+ it("reports the count from the manifest even when index.json is unreadable", async () => {
775
+ // A pack of its OWN (unique TTL ⇒ unique content hash), so corrupting its
776
+ // cached index cannot reach any other test's pack in the shared cache.
777
+ const pkg = tmp("pragma-a10-pkg-");
778
+ mkdirSync(join(pkg, "definitions"), { recursive: true });
779
+ writeFileSync(
780
+ join(pkg, "definitions", "widget.ttl"),
781
+ `${TTL}ex:a10only a ex:Widget ; rdfs:label "A10" .\n`,
782
+ );
783
+ const cwd = tmp("pragma-proj-");
784
+ const runtime = runtimeFor(cwd, [
785
+ { name: "pkg-a10", source: `file://${pkg}` },
786
+ ]);
787
+ const result = await runTask(await buildUpdateTask(runtime));
788
+
789
+ // Corrupt index.json but keep it non-empty, so the pack still reads as
790
+ // complete: any path that PARSED it to count would now yield null.
791
+ writeFileSync(join(packDir(result.contentHash), "index.json"), "not json");
792
+
793
+ const status = await collectStatus(bootRuntime(flagsJson, cwd));
794
+ // Two abox individuals (ex:one, ex:a10only); ex:Widget is tbox, not counted.
795
+ expect(status.entityCount).toBe(2);
796
+ });
797
+ });
798
+
618
799
  describe("sources status CLI-json == MCP tool (PROTECTED)", () => {
619
- it("a cold store's status is byte-equal on both surfaces", async () => {
620
- // Both surfaces boot from the SAME cwd + isolated config (default packages,
621
- // no lock), so the storeless status must be identical.
800
+ it("a fresh install's status is byte-equal on both surfaces", async () => {
801
+ // Both surfaces boot from the SAME cwd + isolated config (default packs,
802
+ // nothing built), so the storeless status must be identical.
622
803
  const cwd = tmp("pragma-proj-");
623
804
  const cli = await executeVerb(
624
805
  statusVerb,
@@ -634,9 +815,8 @@ describe("sources status CLI-json == MCP tool (PROTECTED)", () => {
634
815
 
635
816
  expect(cliEnvelope).toEqual(mcpEnvelope);
636
817
  expect(cliEnvelope.ok).toBe(true);
637
- expect((cliEnvelope.data as { lockPresent: boolean }).lockPresent).toBe(
638
- false,
639
- );
640
- expect((cliEnvelope.data as { cached: boolean }).cached).toBe(false);
818
+ // Nothing built here, and the packs are the distribution's own — so the
819
+ // embedded snapshot is what answers reads, and status says exactly that.
820
+ expect((cliEnvelope.data as { store: string }).store).toBe("embedded");
641
821
  });
642
822
  });
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { type RenderStyle, styleFor } from "../../kernel/render/style.js";
3
+ import { renderSourcesStatusPlain } from "./status.render.js";
4
+ import type { SourcesStatusData } from "./types.js";
5
+
6
+ /** A project reading from its own built pack, with two configured sources. */
7
+ const BUILT: SourcesStatusData = {
8
+ cwd: "/repo",
9
+ store: "built",
10
+ contentHash: "abcdef012345aaaa",
11
+ sourceRef: "ds,extra",
12
+ builtAt: "2026-01-01",
13
+ entityCount: 42,
14
+ sources: [
15
+ { name: "ds", ref: "git+https://example.test/ds.git#main" },
16
+ { name: "extra", ref: "extra@1" },
17
+ ],
18
+ };
19
+
20
+ /** A sentinel styler — enabled, each color wrapped in a visible tag. */
21
+ const TAGGED: RenderStyle = {
22
+ enabled: true,
23
+ bold: (t) => `<b>${t}</b>`,
24
+ dim: (t) => `<d>${t}</d>`,
25
+ cyan: (t) => `<c>${t}</c>`,
26
+ green: (t) => `<g>${t}</g>`,
27
+ yellow: (t) => `<y>${t}</y>`,
28
+ };
29
+
30
+ describe("renderSourcesStatusPlain", () => {
31
+ it("is byte-identical to the plain form when the styler is disabled", () => {
32
+ expect(renderSourcesStatusPlain(BUILT, styleFor(false))).toBe(
33
+ [
34
+ "Store: ready",
35
+ " pack: abcdef012345 — 42 entities, built 2026-01-01",
36
+ " from: ds,extra",
37
+ "",
38
+ "Sources:",
39
+ " ds git+https://example.test/ds.git#main",
40
+ " extra extra@1",
41
+ ].join("\n"),
42
+ );
43
+ });
44
+
45
+ it("bolds the heading, aligns names, and dims refs on a TTY", () => {
46
+ const out = renderSourcesStatusPlain(BUILT, TAGGED);
47
+ const nameWidth = "extra".length;
48
+ expect(out).toContain("<b>Sources:</b>");
49
+ expect(out).toContain(
50
+ ` ${"ds".padEnd(nameWidth)} <d>git+https://example.test/ds.git#main</d>`,
51
+ );
52
+ expect(out).toContain(` ${"extra".padEnd(nameWidth)} <d>extra@1</d>`);
53
+ });
54
+
55
+ it("names the embedded snapshot AND the update that would replace it", () => {
56
+ // The embedded pack answers reads on a fresh install, but it IS a snapshot —
57
+ // rendering it as "ready" would read as "up to date", which it is not.
58
+ const out = renderSourcesStatusPlain(
59
+ {
60
+ ...BUILT,
61
+ store: "embedded",
62
+ sourceRef: "@canonical/design-system@git:abc",
63
+ },
64
+ styleFor(false),
65
+ );
66
+ expect(out).toContain("Store: embedded snapshot");
67
+ expect(out).toContain("pragma sources update");
68
+ expect(out).toContain(" from: @canonical/design-system@git:abc");
69
+ });
70
+
71
+ it("reports an unbuilt store with its recovery and no pack line", () => {
72
+ const out = renderSourcesStatusPlain(
73
+ {
74
+ ...BUILT,
75
+ store: "unavailable",
76
+ contentHash: null,
77
+ sourceRef: null,
78
+ builtAt: null,
79
+ entityCount: null,
80
+ },
81
+ styleFor(false),
82
+ );
83
+ expect(out).toContain("Store: not built (run `pragma sources update`)");
84
+ expect(out).not.toContain(" pack: ");
85
+ });
86
+
87
+ it("says so when no packs are configured", () => {
88
+ const out = renderSourcesStatusPlain(
89
+ { ...BUILT, sources: [] },
90
+ styleFor(false),
91
+ );
92
+ expect(out).toContain(" (none configured)");
93
+ });
94
+ });