@canonical/pragma-cli 0.33.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 (250) hide show
  1. package/README.md +7 -7
  2. package/dist/pragma +0 -0
  3. package/package.json +18 -15
  4. package/pragma.conf.ts +664 -0
  5. package/src/bin.ts +13 -4
  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.verb.ts +31 -13
  9. package/src/capabilities/capabilities/catalog.ts +18 -10
  10. package/src/capabilities/capabilities/hints.ts +13 -19
  11. package/src/capabilities/colophon/collectColophon.ts +34 -19
  12. package/src/capabilities/colophon/colophon.verb.ts +8 -4
  13. package/src/capabilities/colophon/types.ts +16 -7
  14. package/src/capabilities/config/collectConfigShow.test.ts +57 -0
  15. package/src/capabilities/config/collectConfigShow.ts +41 -4
  16. package/src/capabilities/config/fields.ts +0 -27
  17. package/src/capabilities/config/set.verb.ts +7 -3
  18. package/src/capabilities/config/show.render.test.ts +77 -5
  19. package/src/capabilities/config/show.render.ts +7 -12
  20. package/src/capabilities/config/show.verb.ts +4 -3
  21. package/src/capabilities/config/types.ts +8 -0
  22. package/src/capabilities/create/byteEquality.test.ts +78 -100
  23. package/src/capabilities/create/compiledCreate.subprocess.test.ts +52 -15
  24. package/src/capabilities/create/constants.ts +75 -0
  25. package/src/capabilities/create/create.mcp.test.ts +2 -2
  26. package/src/capabilities/create/create.test.ts +162 -15
  27. package/src/capabilities/create/create.verb.ts +140 -69
  28. package/src/capabilities/create/index.ts +2 -10
  29. package/src/capabilities/create/pickGenerator.ts +34 -28
  30. package/src/capabilities/create/templates.embedded.generated.ts +4 -29
  31. package/src/capabilities/create/types.ts +10 -6
  32. package/src/capabilities/{standard/parity.test.ts → distribution.parity.test.ts} +24 -14
  33. package/src/capabilities/distribution.test.ts +120 -0
  34. package/src/capabilities/distribution.ts +59 -0
  35. package/src/capabilities/doctor/checks/checkConfigFile.ts +6 -5
  36. package/src/capabilities/doctor/checks/checkKeStore.ts +2 -2
  37. package/src/capabilities/doctor/checks/checkMcpConfigured.ts +7 -6
  38. package/src/capabilities/doctor/checks/checkPackageRefs.ts +79 -45
  39. package/src/capabilities/doctor/checks/checkPragmaVersion.ts +3 -2
  40. package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +61 -11
  41. package/src/capabilities/doctor/checks/checkShellCompletions.ts +63 -23
  42. package/src/capabilities/doctor/checks/checkSkillsSymlinked.ts +3 -2
  43. package/src/capabilities/doctor/doctor.render.test.ts +6 -6
  44. package/src/capabilities/doctor/doctor.render.ts +2 -1
  45. package/src/capabilities/doctor/doctor.test.ts +37 -8
  46. package/src/capabilities/doctor/doctor.verb.ts +6 -2
  47. package/src/capabilities/doctor/runChecks.ts +5 -5
  48. package/src/capabilities/doctor/types.ts +1 -1
  49. package/src/capabilities/emptyRecovery.test.ts +32 -15
  50. package/src/capabilities/graph/inspect.verb.ts +3 -2
  51. package/src/capabilities/graph/query.verb.ts +3 -2
  52. package/src/capabilities/graph/runQuery.ts +8 -5
  53. package/src/capabilities/index.ts +22 -18
  54. package/src/capabilities/info/collectInfo.ts +10 -6
  55. package/src/capabilities/info/info.render.ts +3 -2
  56. package/src/capabilities/info/info.verb.ts +3 -2
  57. package/src/capabilities/lazy.test.ts +93 -2
  58. package/src/capabilities/ontology/verbs.ts +21 -21
  59. package/src/capabilities/prompt/prompt.test.ts +12 -6
  60. package/src/capabilities/prompt/verbs.ts +24 -11
  61. package/src/capabilities/resources/provider.ts +30 -11
  62. package/src/capabilities/resources/resources.test.ts +85 -29
  63. package/src/capabilities/setup/operations/setupGenerator.ts +16 -9
  64. package/src/capabilities/setup/operations/setupMcp.ts +2 -2
  65. package/src/capabilities/setup/operations/setupSkills.ts +3 -3
  66. package/src/capabilities/setup/setup.verb.ts +12 -5
  67. package/src/capabilities/setup/shell.ts +29 -3
  68. package/src/capabilities/skill/discover.ts +11 -5
  69. package/src/capabilities/skill/render.ts +4 -5
  70. package/src/capabilities/skill/verbs.ts +7 -7
  71. package/src/capabilities/sources/collectStatus.ts +55 -50
  72. package/src/capabilities/sources/index.ts +1 -1
  73. package/src/capabilities/sources/installSkills.ts +2 -2
  74. package/src/capabilities/sources/runUpdate.ts +113 -122
  75. package/src/capabilities/sources/sources.test.ts +111 -117
  76. package/src/capabilities/sources/status.render.test.ts +53 -24
  77. package/src/capabilities/sources/status.render.ts +24 -29
  78. package/src/capabilities/sources/status.verb.ts +15 -8
  79. package/src/capabilities/sources/types.ts +18 -25
  80. package/src/capabilities/sources/update.render.ts +9 -2
  81. package/src/capabilities/sources/update.verb.ts +12 -23
  82. package/src/capabilities/surface.test.ts +33 -10
  83. package/src/capabilities/tier.parity.test.ts +144 -0
  84. package/src/capabilities/upgrade/upgrade.verb.ts +7 -3
  85. package/src/constants.test.ts +8 -6
  86. package/src/constants.ts +47 -21
  87. package/src/identity.test.ts +333 -0
  88. package/src/index.ts +2 -2
  89. package/src/kernel/completion/__snapshots__/emitScripts.test.ts.snap +5 -2
  90. package/src/kernel/completion/emitScripts.test.ts +1 -1
  91. package/src/kernel/completion/emitScripts.ts +4 -2
  92. package/src/kernel/completion/entitySource.test.ts +185 -41
  93. package/src/kernel/completion/entitySource.ts +117 -107
  94. package/src/kernel/completion/model.test.ts +1 -1
  95. package/src/kernel/completion/model.ts +11 -0
  96. package/src/kernel/completion/safety.test.ts +82 -36
  97. package/src/kernel/completion/shellDrive.test.ts +592 -0
  98. package/src/kernel/completion/templates/fish.ts +28 -6
  99. package/src/kernel/completion/types.ts +3 -3
  100. package/src/kernel/config/__snapshots__/firstRun.test.ts.snap +1 -1
  101. package/src/kernel/config/defaults.test.ts +85 -0
  102. package/src/kernel/config/defaults.ts +12 -23
  103. package/src/kernel/config/evaluateProjectConfig.ts +2 -3
  104. package/src/kernel/config/findProjectConfig.ts +4 -3
  105. package/src/kernel/config/firstRun.ts +12 -5
  106. package/src/kernel/config/globalConfig.ts +2 -2
  107. package/src/kernel/config/index.ts +4 -5
  108. package/src/kernel/config/paths.ts +17 -17
  109. package/src/kernel/config/readConfig.test.ts +242 -6
  110. package/src/kernel/config/readConfig.ts +8 -8
  111. package/src/kernel/config/schema.test.ts +50 -0
  112. package/src/kernel/config/schema.ts +97 -10
  113. package/src/kernel/config/types.ts +73 -19
  114. package/src/kernel/copy.test.ts +453 -0
  115. package/src/kernel/error/PragmaError.test.ts +1 -1
  116. package/src/kernel/error/constants.ts +3 -2
  117. package/src/kernel/error/index.ts +1 -1
  118. package/src/kernel/error/recovery.test.ts +22 -45
  119. package/src/kernel/error/recovery.ts +22 -27
  120. package/src/kernel/error/types.ts +5 -4
  121. package/src/kernel/packs/collect.test.ts +195 -11
  122. package/src/kernel/packs/collect.ts +213 -58
  123. package/src/kernel/packs/compile.test.ts +163 -7
  124. package/src/kernel/packs/compile.ts +15 -12
  125. package/src/kernel/packs/disclosure.test.ts +5 -2
  126. package/src/kernel/packs/graphql/fetchGraphqlLookup.ts +11 -10
  127. package/src/kernel/packs/graphqlSource.test.ts +6 -3
  128. package/src/kernel/packs/iri.ts +5 -7
  129. package/src/kernel/packs/renderPack.ts +5 -3
  130. package/src/kernel/packs/resolveEntity.ts +21 -17
  131. package/src/kernel/packs/runBodies.ts +15 -14
  132. package/src/kernel/packs/schema.ts +96 -10
  133. package/src/kernel/packs/sparql/runSelect.test.ts +104 -10
  134. package/src/kernel/packs/sparql/runSelect.ts +73 -34
  135. package/src/kernel/packs/toolDescription.test.ts +10 -2
  136. package/src/kernel/packs/types.ts +39 -4
  137. package/src/kernel/packs/uniqueness.ts +4 -4
  138. package/src/kernel/project/cli/buildProgram.test.ts +9 -4
  139. package/src/kernel/project/cli/dispatch.test.ts +67 -0
  140. package/src/kernel/project/cli/dispatch.ts +34 -12
  141. package/src/kernel/project/cli/helpFormat.ts +1 -1
  142. package/src/kernel/project/cli/rootHelp.ts +105 -105
  143. package/src/kernel/project/mcp/buildServer.ts +11 -6
  144. package/src/kernel/project/mcp/instructions.ts +11 -6
  145. package/src/kernel/project/mcp/parity.test.ts +39 -1
  146. package/src/kernel/project/mcp/prompts/prompts.test.ts +120 -5
  147. package/src/kernel/project/mcp/prompts/provider.ts +22 -8
  148. package/src/kernel/project/mcp/prompts/source.ts +68 -67
  149. package/src/kernel/project/mcp/registerVerb.ts +25 -6
  150. package/src/kernel/project/mcp/serve.ts +11 -3
  151. package/src/kernel/project/mcp/toolDescriptions.test.ts +6 -2
  152. package/src/kernel/render/disclosure.ts +4 -2
  153. package/src/kernel/render/prefixes.test.ts +36 -0
  154. package/src/kernel/render/prefixes.ts +40 -13
  155. package/src/kernel/render/renderers.test.ts +1 -1
  156. package/src/kernel/runtime/graphpack/build.ts +29 -3
  157. package/src/kernel/runtime/graphpack/buildIndex.test.ts +46 -0
  158. package/src/kernel/runtime/graphpack/buildIndex.ts +52 -17
  159. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +5 -5
  160. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +3 -3
  161. package/src/kernel/runtime/graphpack/embedded/pack.stories.generated.ts +3 -0
  162. package/src/kernel/runtime/graphpack/embedded.ts +37 -11
  163. package/src/kernel/runtime/graphpack/graphpack.test.ts +205 -5
  164. package/src/kernel/runtime/graphpack/hash.ts +3 -2
  165. package/src/kernel/runtime/graphpack/index.ts +1 -1
  166. package/src/kernel/runtime/graphpack/manifest.ts +114 -12
  167. package/src/kernel/runtime/graphpack/read.ts +4 -9
  168. package/src/kernel/runtime/graphpack/schemas.ts +74 -0
  169. package/src/kernel/runtime/graphpack/stories.ts +84 -0
  170. package/src/kernel/runtime/graphpack/types.ts +61 -48
  171. package/src/kernel/runtime/paths.ts +64 -15
  172. package/src/kernel/runtime/probe.test.ts +8 -4
  173. package/src/kernel/runtime/readEntity.ts +6 -5
  174. package/src/kernel/runtime/refs/gitOps.ts +1 -19
  175. package/src/kernel/runtime/refs/parseRef.ts +7 -7
  176. package/src/kernel/runtime/refs/resolve.ts +78 -62
  177. package/src/kernel/runtime/resolveSources.test.ts +63 -24
  178. package/src/kernel/runtime/resolveSources.ts +36 -25
  179. package/src/kernel/runtime/storeReadiness.ts +9 -8
  180. package/src/kernel/runtime/storeless.test.ts +1 -1
  181. package/src/kernel/runtime/wasmEmbed.test.ts +22 -19
  182. package/src/kernel/spec/emitReference.test.ts +3 -3
  183. package/src/kernel/spec/emitReference.ts +144 -11
  184. package/src/kernel/spec/emitSurface.ts +9 -5
  185. package/src/kernel/spec/surfaceConformance.test.ts +2 -2
  186. package/src/kernel/spec/surfaceConformance.ts +8 -0
  187. package/src/kernel/spec/types.ts +29 -9
  188. package/src/kernel/spec/validate.ts +2 -1
  189. package/src/kernel/vocabulary.test.ts +164 -0
  190. package/src/kernel/vocabulary.ts +146 -0
  191. package/src/testing/PARITY_GAPS.ts +8 -2
  192. package/src/testing/behavioral/README.md +3 -3
  193. package/src/testing/behavioral/completion.test.ts +19 -6
  194. package/src/testing/behavioral/errorMatrix.mcp.test.ts +4 -3
  195. package/src/testing/behavioral/errorMatrix.storeless.test.ts +1 -1
  196. package/src/testing/behavioral/exitCodes.e2e.test.ts +6 -5
  197. package/src/testing/behavioral/harness.foundation.test.ts +14 -5
  198. package/src/testing/behavioral/journeys.cli.test.ts +116 -75
  199. package/src/testing/behavioral/journeys.defaultPack.test.ts +82 -176
  200. package/src/testing/behavioral/journeys.firstInstall.test.ts +167 -0
  201. package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +9 -6
  202. package/src/testing/behavioral/journeys.packageStories.test.ts +188 -0
  203. package/src/testing/behavioral/liveReadSurface.ts +4 -3
  204. package/src/testing/behavioral/mcpSurface.test.ts +9 -4
  205. package/src/testing/behavioral/staleCommands.test.ts +10 -0
  206. package/src/testing/eval/__snapshots__/eval.test.ts.snap +7 -7
  207. package/src/testing/eval/cases/assembly.ts +13 -6
  208. package/src/testing/eval/cases/effectful.ts +25 -13
  209. package/src/testing/eval/cases/readNouns.ts +22 -20
  210. package/src/testing/eval/cases/stable.ts +5 -1
  211. package/src/testing/eval/eval.test.ts +7 -1
  212. package/src/testing/fixtures/fixtureCapability.ts +45 -1
  213. package/src/testing/fixtures/graph/canonical.ts +6 -3
  214. package/src/testing/fixtures/graph/defaultPack.ts +6 -5
  215. package/src/testing/fixtures/graph/owlExport.ts +7 -5
  216. package/src/testing/helpers/fixtureGraph.ts +33 -16
  217. package/src/testing/helpers/packRuntime.ts +2 -3
  218. package/src/testing/helpers/projectCli.ts +2 -1
  219. package/src/testing/helpers/projectMcp.ts +6 -0
  220. package/src/testing/perf/budgets.test.ts +4 -0
  221. package/src/testing/perf/budgets.ts +22 -7
  222. package/src/testing/perf/globalSetup.ts +121 -6
  223. package/src/types.ts +2 -2
  224. package/src/capabilities/block/blockList.test.ts +0 -79
  225. package/src/capabilities/block/blockList.verb.ts +0 -163
  226. package/src/capabilities/block/index.ts +0 -25
  227. package/src/capabilities/block/pack.ts +0 -166
  228. package/src/capabilities/block/parity.test.ts +0 -187
  229. package/src/capabilities/block/tierChain.ts +0 -78
  230. package/src/capabilities/colophon/pragmaColophon.ts +0 -66
  231. package/src/capabilities/modifier/index.ts +0 -15
  232. package/src/capabilities/modifier/pack.ts +0 -64
  233. package/src/capabilities/standard/index.ts +0 -18
  234. package/src/capabilities/standard/pack.ts +0 -128
  235. package/src/capabilities/tier/index.ts +0 -19
  236. package/src/capabilities/tier/lookup.render.test.ts +0 -72
  237. package/src/capabilities/tier/lookup.render.ts +0 -59
  238. package/src/capabilities/tier/lookup.test.ts +0 -83
  239. package/src/capabilities/tier/lookup.verb.ts +0 -44
  240. package/src/capabilities/tier/pack.ts +0 -28
  241. package/src/capabilities/tier/runLookup.ts +0 -87
  242. package/src/capabilities/token/addConfig.render.ts +0 -27
  243. package/src/capabilities/token/addConfig.test.ts +0 -62
  244. package/src/capabilities/token/addConfig.verb.ts +0 -40
  245. package/src/capabilities/token/index.ts +0 -19
  246. package/src/capabilities/token/pack.ts +0 -58
  247. package/src/capabilities/token/runAddConfig.ts +0 -95
  248. package/src/kernel/completion/bashDrive.test.ts +0 -73
  249. package/src/kernel/runtime/graphpack/embedded/sample.ttl +0 -39
  250. package/src/kernel/runtime/lock.ts +0 -90
@@ -0,0 +1,3 @@
1
+ // AUTO-GENERATED by scripts/bundle.ts — do not edit by hand.
2
+ // Regenerate from the packs pragma.conf.ts declares: `bun run bundle`.
3
+ export const storiesJson = "[]";
@@ -1,13 +1,27 @@
1
1
  /**
2
- * The embedded fallback pack: a small, self-contained pack compiled into the
3
- * binary as inlined strings (`pack.generated.ts`, produced by
4
- * `scripts/genEmbedded.ts`). It is the store the CLI boots when no packages are
5
- * configured beyond the defaults and no lock has been written yet — so a fresh
6
- * install answers store-backed reads without a network round-trip.
2
+ * The embedded pack: the distribution's own graph, compiled from the packs
3
+ * `pragma.conf.ts` declares and inlined into the binary as escaped strings
4
+ * (`pack.generated.ts`, produced by `scripts/bundle.ts`). It is the store the
5
+ * CLI boots when the user has not pinned their own packs and has not built
6
+ * anything yet — so a fresh install answers real store-backed reads with no
7
+ * network, no cache, and no git credentials.
8
+ *
9
+ * It is a SNAPSHOT, not a substitute for `sources update`: the manifest records
10
+ * which upstream revisions it was compiled from, and both `sources status` and
11
+ * `doctor` report that rather than claiming the store is up to date. A project
12
+ * that declares its own packs and has not built them is never served this graph:
13
+ * every read, `info`, `sources status`, `doctor` and the MCP prompt/resource
14
+ * surfaces take {@link resolveSources}' answer and refuse. The one exception is
15
+ * the shell-completion fast path, which cannot read config at all (see
16
+ * `kernel/completion/entitySource.ts`) and so still offers this graph's names as
17
+ * completion candidates — candidates, never content.
7
18
  *
8
19
  * The inlined strings are materialized into the ordinary content-addressed pack
9
20
  * cache on first use and then read back through {@link readPack}, so the
10
- * embedded pack and a built pack share one boot path. Inlining as JS strings
21
+ * embedded pack and a built pack share one boot path. The story records live in
22
+ * their OWN generated module (like the index): dispatch reads them on every
23
+ * command, and pulling them out of `pack.generated.ts` would load its ~1.9 MB
24
+ * of n-quads with them — a measured +28 ms on every invocation. Inlining as JS strings
11
25
  * (rather than file assets) guarantees the content survives `bun build
12
26
  * --compile` with no asset-embedding step.
13
27
  */
@@ -24,18 +38,29 @@ import { join } from "node:path";
24
38
  import { packDir, packsCacheDir } from "../paths.js";
25
39
  import { dataNq, manifestJson, schemaJson } from "./embedded/pack.generated.js";
26
40
  import { indexJson } from "./embedded/pack.index.generated.js";
41
+ import { storiesJson } from "./embedded/pack.stories.generated.js";
27
42
  import { packIsComplete } from "./manifest.js";
43
+ import { manifestSchema } from "./schemas.js";
28
44
  import {
29
45
  DATA_FILE,
30
46
  INDEX_FILE,
31
47
  MANIFEST_FILE,
32
- manifestSchema,
48
+ type Manifest,
33
49
  SCHEMA_FILE,
50
+ STORIES_FILE,
34
51
  } from "./types.js";
35
52
 
36
- /** The embedded pack's content hash (its cache-directory name). */
37
- export function embeddedContentHash(): string {
38
- return manifestSchema.parse(JSON.parse(manifestJson)).contentHash;
53
+ /**
54
+ * The embedded pack's manifest, parsed from the inlined string.
55
+ *
56
+ * Carries the provenance `sources status` and `doctor` report — `sourceRef`
57
+ * (which upstream revisions the snapshot was compiled from), `createdAt`, the
58
+ * counts — plus the `contentHash` that names its cache directory. Reading it
59
+ * costs one `JSON.parse` of a ~1 KB string, so neither surface has to
60
+ * materialize the 1.9 MB pack just to describe it.
61
+ */
62
+ export function embeddedManifest(): Manifest {
63
+ return manifestSchema.parse(JSON.parse(manifestJson));
39
64
  }
40
65
 
41
66
  /**
@@ -48,7 +73,7 @@ export function embeddedContentHash(): string {
48
73
  * @note Impure — writes the inlined pack files into the cache.
49
74
  */
50
75
  export function materializeEmbeddedPack(): string {
51
- const dir = packDir(embeddedContentHash());
76
+ const dir = packDir(embeddedManifest().contentHash);
52
77
  if (packIsComplete(dir)) return dir;
53
78
 
54
79
  mkdirSync(packsCacheDir(), { recursive: true });
@@ -57,6 +82,7 @@ export function materializeEmbeddedPack(): string {
57
82
  writeFileSync(join(temp, DATA_FILE), dataNq);
58
83
  writeFileSync(join(temp, SCHEMA_FILE), schemaJson);
59
84
  writeFileSync(join(temp, INDEX_FILE), indexJson);
85
+ writeFileSync(join(temp, STORIES_FILE), storiesJson);
60
86
  // Written last — the completeness marker.
61
87
  writeFileSync(join(temp, MANIFEST_FILE), manifestJson);
62
88
  if (!packIsComplete(dir)) {
@@ -1,18 +1,30 @@
1
1
  import {
2
2
  existsSync,
3
3
  mkdtempSync,
4
+ readdirSync,
4
5
  readFileSync,
5
6
  rmSync,
6
7
  writeFileSync,
7
8
  } from "node:fs";
8
9
  import { tmpdir } from "node:os";
9
- import { join } from "node:path";
10
+ import { basename, join } from "node:path";
10
11
  import { executeLocal } from "@canonical/ke-graphql";
11
12
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
12
13
  import { buildPack } from "./build.js";
14
+ import { embeddedManifest, materializeEmbeddedPack } from "./embedded.js";
13
15
  import { contentHash } from "./hash.js";
16
+ import { packIsComplete, validateManifest } from "./manifest.js";
14
17
  import { readPack } from "./read.js";
15
- import { DATA_FILE, INDEX_FILE, MANIFEST_FILE, SCHEMA_FILE } from "./types.js";
18
+ import { manifestSchema } from "./schemas.js";
19
+ import { activeStories } from "./stories.js";
20
+ import type { PackIndex } from "./types.js";
21
+ import {
22
+ DATA_FILE,
23
+ INDEX_FILE,
24
+ MANIFEST_FILE,
25
+ SCHEMA_FILE,
26
+ STORIES_FILE,
27
+ } from "./types.js";
16
28
 
17
29
  const PREFIXES = {
18
30
  ex: "https://pragma.canonical.com/sample#",
@@ -32,12 +44,16 @@ ex:Button a ex:Component ; rdfs:label "Button" ; ex:componentName "Button" .
32
44
  ex:Card a ex:Component ; rdfs:label "Card" ; ex:componentName "Card" .
33
45
  `;
34
46
 
35
- const build = (inputs: { path: string; content: string }[]) =>
47
+ const build = (
48
+ inputs: { path: string; content: string }[],
49
+ stories?: { path: string; content: string }[],
50
+ ) =>
36
51
  buildPack(inputs, {
37
52
  name: "test-pack",
38
53
  version: "0.0.0",
39
54
  sourceRef: "test:inline",
40
55
  prefixes: PREFIXES,
56
+ ...(stories === undefined ? {} : { stories }),
41
57
  });
42
58
 
43
59
  let savedCacheHome: string | undefined;
@@ -55,12 +71,22 @@ afterAll(() => {
55
71
  });
56
72
 
57
73
  describe("graphpack round-trip (PROTECTED)", () => {
58
- it("builds the four artifact files and reuses a cached pack", async () => {
74
+ it("builds the five artifact files and reuses a cached pack", async () => {
59
75
  const result = await build([{ path: "a.ttl", content: TTL }]);
60
76
  expect(result.reused).toBe(false);
61
- for (const file of [DATA_FILE, SCHEMA_FILE, INDEX_FILE, MANIFEST_FILE]) {
77
+ for (const file of [
78
+ DATA_FILE,
79
+ SCHEMA_FILE,
80
+ INDEX_FILE,
81
+ STORIES_FILE,
82
+ MANIFEST_FILE,
83
+ ]) {
62
84
  expect(existsSync(join(result.dir, file))).toBe(true);
63
85
  }
86
+ // Written even when the packages ship none — an OPTIONAL artifact would put
87
+ // the same condition in all three modules that name the set, which is how a
88
+ // pack ends up claiming stories its directory does not hold.
89
+ expect(readFileSync(join(result.dir, STORIES_FILE), "utf-8")).toBe("[]");
64
90
 
65
91
  // A second build over identical inputs is a pure cache hit — no rebuild.
66
92
  const again = await build([{ path: "a.ttl", content: TTL }]);
@@ -109,6 +135,131 @@ describe("graphpack round-trip (PROTECTED)", () => {
109
135
  });
110
136
  });
111
137
 
138
+ describe("the committed embedded pack (PROTECTED)", () => {
139
+ it("materializes exactly the files buildPack produces", async () => {
140
+ // The artifact set is named once, in types.ts, but THREE modules must obey
141
+ // it: buildPack writes them, packIsComplete gates on them, and
142
+ // materializeEmbeddedPack writes them back out. A SIXTH artifact added to
143
+ // only some of those yields a pack whose content hash claims more than its
144
+ // directory holds — which the next build then reuses, silently dropping the
145
+ // difference. Comparing the two directories catches that on the day it lands.
146
+ const built = await build([{ path: "a.ttl", content: TTL }]);
147
+ expect(readdirSync(materializeEmbeddedPack()).sort()).toEqual(
148
+ readdirSync(built.dir).sort(),
149
+ );
150
+ });
151
+
152
+ it("is self-consistent: complete, content-addressed, and non-empty", () => {
153
+ // No network, so CI runs it: the committed strings really do materialize a
154
+ // bootable pack whose parts agree with each other.
155
+ const dir = materializeEmbeddedPack();
156
+ const manifest = embeddedManifest();
157
+ expect(packIsComplete(dir)).toBe(true);
158
+ expect(basename(dir)).toBe(manifest.contentHash);
159
+ const index = JSON.parse(
160
+ readFileSync(join(dir, INDEX_FILE), "utf-8"),
161
+ ) as PackIndex;
162
+ expect(index.contentHash).toBe(manifest.contentHash);
163
+ expect(manifest.tripleCount ?? 0).toBeGreaterThan(0);
164
+ expect(manifest.entityCount ?? 0).toBeGreaterThan(0);
165
+ });
166
+ });
167
+
168
+ describe("graphpack carried stories (PROTECTED)", () => {
169
+ const STORY = {
170
+ path: "pkg/stories/recipe.json",
171
+ content: '{"noun":"recipe"}',
172
+ };
173
+
174
+ it("hashes stories as sources and round-trips them byte-for-byte", async () => {
175
+ const inputs = [{ path: "a.ttl", content: TTL }];
176
+ const without = await build(inputs);
177
+ const withStory = await build(inputs, [STORY]);
178
+
179
+ // A story-only edit is a NEW pack: the same RDF with a story attached must
180
+ // not reuse the pack built without it.
181
+ expect(withStory.contentHash).not.toBe(without.contentHash);
182
+ expect(
183
+ JSON.parse(readFileSync(join(withStory.dir, STORIES_FILE), "utf-8")),
184
+ ).toEqual([{ source: STORY.path, content: STORY.content }]);
185
+
186
+ // …and rebuilding with the same story is a pure cache hit.
187
+ expect((await build(inputs, [STORY])).reused).toBe(true);
188
+ });
189
+
190
+ it("a pack directory missing stories.json is incomplete", async () => {
191
+ // The migration path: a pack built by the previous kernel has four files,
192
+ // so it is refused (→ `pragma sources update`) rather than reused as if it
193
+ // carried the stories its hash covers.
194
+ const { dir } = await build([{ path: "a.ttl", content: TTL }]);
195
+ expect(packIsComplete(dir)).toBe(true);
196
+ rmSync(join(dir, STORIES_FILE));
197
+ expect(packIsComplete(dir)).toBe(false);
198
+ });
199
+
200
+ it("a non-array stories.json is incomplete, and rebuilds instead of silently losing nouns", async () => {
201
+ // Size alone let a non-empty but non-ARRAY `stories.json` pass, so
202
+ // `buildPack` reused the directory and every package-declared noun vanished
203
+ // while `sources update` reported success. The shape gate turns that into an
204
+ // ordinary torn pack: refused, then rebuilt.
205
+ const inputs = [{ path: "a.ttl", content: TTL }];
206
+ const { dir } = await build(inputs, [STORY]);
207
+ expect(packIsComplete(dir)).toBe(true);
208
+
209
+ for (const corrupt of [
210
+ '{"noun":"recipe"}',
211
+ '"a string"',
212
+ "null",
213
+ "7",
214
+ "{",
215
+ ]) {
216
+ writeFileSync(join(dir, STORIES_FILE), corrupt);
217
+ expect(packIsComplete(dir), corrupt).toBe(false);
218
+ }
219
+
220
+ // …and the rebuild is a REBUILD, not a reuse of the corrupt directory: the
221
+ // stories come back, so the nouns come back.
222
+ const rebuilt = await build(inputs, [STORY]);
223
+ expect(rebuilt.reused).toBe(false);
224
+ expect(packIsComplete(rebuilt.dir)).toBe(true);
225
+ expect(
226
+ JSON.parse(readFileSync(join(rebuilt.dir, STORIES_FILE), "utf-8")),
227
+ ).toEqual([{ source: STORY.path, content: STORY.content }]);
228
+ });
229
+
230
+ it("an EMPTY stories array stays complete — it is the ordinary no-stories pack", async () => {
231
+ // The gate is shape, not emptiness: `[]` is what a pack with no package
232
+ // stories legitimately carries, and refusing it would refuse most packs.
233
+ const { dir } = await build([{ path: "a.ttl", content: TTL }]);
234
+ expect(readFileSync(join(dir, STORIES_FILE), "utf-8")).toBe("[]");
235
+ expect(packIsComplete(dir)).toBe(true);
236
+ });
237
+
238
+ it("reads only records shaped { source, content } from a pack directory", async () => {
239
+ // `stories.json` lives in a user-writable cache. An element that is not a
240
+ // record used to be cast straight through and reported as
241
+ // `Ignored story undefined: …` — a diagnostic naming no file, repeated on
242
+ // every command. Records are checked, so a corrupt entry simply is not one.
243
+ const { dir } = await build([{ path: "a.ttl", content: TTL }], [STORY]);
244
+ writeFileSync(
245
+ join(dir, STORIES_FILE),
246
+ JSON.stringify([
247
+ 1,
248
+ null,
249
+ { source: "x" },
250
+ { source: 1, content: 2 },
251
+ {
252
+ source: STORY.path,
253
+ content: STORY.content,
254
+ },
255
+ ]),
256
+ );
257
+ expect(activeStories({ kind: "pack", dir, contentHash: "z" })).toEqual([
258
+ { source: STORY.path, content: STORY.content },
259
+ ]);
260
+ });
261
+ });
262
+
112
263
  describe("graphpack hash stability (PROTECTED)", () => {
113
264
  it("is order-independent and content-sensitive", async () => {
114
265
  const a = { path: "a.ttl", content: "ex:one a ex:X ." };
@@ -147,6 +298,55 @@ describe("graphpack manifest — persisted counts (A9/A10)", () => {
147
298
  });
148
299
  });
149
300
 
301
+ describe("the manifest hand validator agrees with the schema (PROTECTED)", () => {
302
+ // `readManifest` is on the storeless fast path, so it validates structurally
303
+ // instead of importing zod. `manifestSchema` stays the executable spec: this
304
+ // pins the two to accept EXACTLY the same payloads, in both directions, so a
305
+ // rule changed in one and not the other fails here rather than at a read.
306
+ const VALID = {
307
+ name: "pack",
308
+ version: "1.0.0",
309
+ sourceRef: "git+https://example.invalid#main",
310
+ contentHash: "abc123",
311
+ prefixes: { ex: "https://example.invalid#" },
312
+ createdAt: "2026-01-01T00:00:00.000Z",
313
+ };
314
+
315
+ const cases: Array<[string, unknown]> = [
316
+ ["the minimal valid manifest", VALID],
317
+ ["with both optional counts", { ...VALID, tripleCount: 3, entityCount: 2 }],
318
+ ["with only tripleCount", { ...VALID, tripleCount: 0 }],
319
+ ["with only entityCount", { ...VALID, entityCount: 0 }],
320
+ ["with empty prefixes", { ...VALID, prefixes: {} }],
321
+ ["with an unknown key", { ...VALID, extra: "ignored" }],
322
+ ["a missing required field", { ...VALID, name: undefined }],
323
+ ["a required field of the wrong type", { ...VALID, version: 2 }],
324
+ ["a non-string sourceRef", { ...VALID, sourceRef: null }],
325
+ ["a non-string contentHash", { ...VALID, contentHash: 7 }],
326
+ ["a non-string createdAt", { ...VALID, createdAt: 0 }],
327
+ ["prefixes as an array", { ...VALID, prefixes: [] }],
328
+ ["prefixes holding a non-string", { ...VALID, prefixes: { ex: 1 } }],
329
+ ["prefixes as null", { ...VALID, prefixes: null }],
330
+ ["a non-number tripleCount", { ...VALID, tripleCount: "3" }],
331
+ ["a non-number entityCount", { ...VALID, entityCount: "2" }],
332
+ ["a JSON array", [VALID]],
333
+ ["a JSON string", "manifest"],
334
+ ["null", null],
335
+ ["a number", 4],
336
+ ];
337
+
338
+ for (const [label, payload] of cases) {
339
+ it(`accepts/rejects ${label} exactly as manifestSchema does`, () => {
340
+ const bySchema = manifestSchema.safeParse(payload);
341
+ const byHand = validateManifest(payload);
342
+ expect(byHand !== undefined, label).toBe(bySchema.success);
343
+ // Agreement on the VALUE too, which is where strip semantics live: an
344
+ // unknown key must be dropped by both, not carried by one.
345
+ if (bySchema.success) expect(byHand).toEqual(bySchema.data);
346
+ });
347
+ }
348
+ });
349
+
150
350
  describe("graphpack read — truncated data cache (A9)", () => {
151
351
  it("a truncated-but-non-empty data.nq surfaces STORE_UNAVAILABLE", async () => {
152
352
  // A UNIQUE graph so corrupting its cache never poisons the shared TTL pack.
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * 1. {@link contentHash} — a SHA-256 (ke-graphql's `sha256Hex`) over the
6
6
  * canonicalized *source inputs* (sorted by path, path + content joined with
7
- * fixed delimiters). It names the pack's cache directory and is pinned in the
8
- * lock, so the same sources always resolve to the same directory and a
7
+ * fixed delimiters). It names the pack's cache directory and is what a
8
+ * project's active-pack pointer holds, so the same sources always resolve to
9
+ * the same directory and a
9
10
  * changed source is a new directory (free invalidation). Hashing the sorted
10
11
  * TTL *inputs* — never the `data.nq` dump, whose n-quads line order is not
11
12
  * canonical — keeps it deterministic.
@@ -10,7 +10,7 @@ export type {
10
10
  } from "./build.js";
11
11
  export { buildPack } from "./build.js";
12
12
  export { buildIndex } from "./buildIndex.js";
13
- export { embeddedContentHash, materializeEmbeddedPack } from "./embedded.js";
13
+ export { embeddedManifest, materializeEmbeddedPack } from "./embedded.js";
14
14
  export { contentHash, type HashInput, hashSources } from "./hash.js";
15
15
  export { packIsComplete, readManifest } from "./manifest.js";
16
16
  export { readPack } from "./read.js";
@@ -3,6 +3,13 @@
3
3
  * directory without a valid manifest is a torn build (writes land in a temp
4
4
  * directory and the manifest is renamed in last), so an absent or invalid
5
5
  * manifest means "treat the pack as not there".
6
+ *
7
+ * This module is on the storeless FAST PATH (`resolveSources` → `packIsComplete`
8
+ * → here, while `__complete` builds the command tree), so it validates the ~1 KB
9
+ * manifest structurally instead of importing zod to do it — that import was
10
+ * ~3–4 ms of a ~30 ms budget. {@link validateManifest} is held to the schema's
11
+ * behaviour by `graphpack.test.ts`, which pins the two to accept the same inputs
12
+ * in both directions; `schemas.ts` remains the executable specification.
6
13
  */
7
14
 
8
15
  import { existsSync, readFileSync, statSync } from "node:fs";
@@ -12,10 +19,61 @@ import {
12
19
  INDEX_FILE,
13
20
  MANIFEST_FILE,
14
21
  type Manifest,
15
- manifestSchema,
16
22
  SCHEMA_FILE,
23
+ STORIES_FILE,
17
24
  } from "./types.js";
18
25
 
26
+ /** Whether `value` is a plain object of strings — the `prefixes` map's shape. */
27
+ function isStringRecord(value: unknown): value is Record<string, string> {
28
+ return (
29
+ typeof value === "object" &&
30
+ value !== null &&
31
+ !Array.isArray(value) &&
32
+ Object.values(value).every((entry) => typeof entry === "string")
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Validate a parsed `manifest.json` payload structurally.
38
+ *
39
+ * Mirrors `manifestSchema` exactly, including its STRIP semantics: a valid
40
+ * manifest is reconstructed field by field, so an unknown key in the file is
41
+ * dropped rather than carried into the returned object (zod's `z.object`
42
+ * default, which several callers rely on when they re-serialize a manifest).
43
+ *
44
+ * @param value - The `JSON.parse` result to validate.
45
+ * @returns The manifest, or `undefined` when the payload does not conform.
46
+ */
47
+ export function validateManifest(value: unknown): Manifest | undefined {
48
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
49
+ return undefined;
50
+ }
51
+ const raw = value as Record<string, unknown>;
52
+ const { tripleCount, entityCount } = raw;
53
+ if (
54
+ typeof raw.name !== "string" ||
55
+ typeof raw.version !== "string" ||
56
+ typeof raw.sourceRef !== "string" ||
57
+ typeof raw.contentHash !== "string" ||
58
+ typeof raw.createdAt !== "string" ||
59
+ !isStringRecord(raw.prefixes) ||
60
+ (tripleCount !== undefined && typeof tripleCount !== "number") ||
61
+ (entityCount !== undefined && typeof entityCount !== "number")
62
+ ) {
63
+ return undefined;
64
+ }
65
+ return {
66
+ name: raw.name,
67
+ version: raw.version,
68
+ sourceRef: raw.sourceRef,
69
+ contentHash: raw.contentHash,
70
+ prefixes: raw.prefixes,
71
+ createdAt: raw.createdAt,
72
+ ...(tripleCount === undefined ? {} : { tripleCount }),
73
+ ...(entityCount === undefined ? {} : { entityCount }),
74
+ };
75
+ }
76
+
19
77
  /**
20
78
  * Read and validate a pack directory's manifest.
21
79
  *
@@ -27,29 +85,73 @@ export function readManifest(dir: string): Manifest | undefined {
27
85
  const path = join(dir, MANIFEST_FILE);
28
86
  if (!existsSync(path)) return undefined;
29
87
  try {
30
- return manifestSchema.parse(JSON.parse(readFileSync(path, "utf-8")));
88
+ return validateManifest(JSON.parse(readFileSync(path, "utf-8")));
31
89
  } catch {
32
90
  return undefined;
33
91
  }
34
92
  }
35
93
 
36
94
  /** Whether a pack directory holds a complete pack: a valid manifest AND every
37
- * non-empty query artifact — the `data.nq` dump, the extracted `schema.json`,
38
- * and the entity `index.json`. The manifest alone is not enough: an intact
39
- * manifest beside a missing/truncated `data.nq` boots EMPTY (a silent, then
40
- * permanent, loss), and a torn or evicted `schema.json`/`index.json` (manifest +
41
- * dump intact) would be REUSED by `buildPack` and then fail at BOOT as an
42
- * internal error. Requiring all three present + non-empty makes `buildPack`
43
- * rebuild a torn pack and makes the boot decision surface STORE_UNAVAILABLE
44
- * (the ordinary "not built" recovery) instead of a "please report this" crash. */
95
+ * non-empty content artifact — the `data.nq` dump, the extracted `schema.json`,
96
+ * the entity `index.json`, and the carried `stories.json`. The manifest alone is
97
+ * not enough: an intact manifest beside a missing/truncated `data.nq` boots
98
+ * EMPTY (a silent, then permanent, loss), and a torn or evicted
99
+ * `schema.json`/`index.json` (manifest + dump intact) would be REUSED by
100
+ * `buildPack` and then fail at BOOT as an internal error. `stories.json` is
101
+ * gated for the same reason and unconditionally: it is written even when empty
102
+ * (as `[]`, which is non-empty text), so a pack directory lacking it is one
103
+ * whose content hash covers stories the directory does not hold — reusing it
104
+ * would silently drop every package-declared noun. Requiring all four present +
105
+ * non-empty makes `buildPack` rebuild a torn pack and makes the boot decision
106
+ * surface STORE_UNAVAILABLE (the ordinary "not built" recovery) instead of a
107
+ * "please report this" crash. `stories.json` is additionally gated on its SHAPE
108
+ * — see {@link storiesAreReadable} for why it, and only it, is parsed here. */
45
109
  export function packIsComplete(dir: string): boolean {
46
110
  if (readManifest(dir) === undefined) return false;
47
- for (const file of [DATA_FILE, SCHEMA_FILE, INDEX_FILE]) {
111
+ for (const file of [DATA_FILE, SCHEMA_FILE, INDEX_FILE, STORIES_FILE]) {
48
112
  try {
49
113
  if (statSync(join(dir, file)).size <= 0) return false;
50
114
  } catch {
51
115
  return false;
52
116
  }
53
117
  }
54
- return true;
118
+ return storiesAreReadable(dir);
119
+ }
120
+
121
+ /**
122
+ * Whether `stories.json` holds what its reader expects: a JSON ARRAY.
123
+ *
124
+ * Size alone was not enough, and stories are the one artifact where that gap is
125
+ * SILENT. A non-empty but non-array `stories.json` passed the size gate, so
126
+ * `buildPack` REUSED the directory and every package-declared noun vanished
127
+ * while `sources update` reported success — with `parseRecords`' fix, without
128
+ * even an error line to attribute. Parsing it turns that into an incomplete
129
+ * pack: the boot decision reports the ordinary STORE_UNAVAILABLE and the next
130
+ * `buildPack` rebuilds, which is the recovery the other three artifacts already
131
+ * get.
132
+ *
133
+ * SHAPE ONLY, deliberately: `Array.isArray`, not a check of the records inside.
134
+ * A malformed or schema-invalid RECORD is the one guard in
135
+ * `packs/collect.validateStories`, which names it on stderr and under `doctor`
136
+ * — a story a third party got wrong must not make the whole pack unreadable.
137
+ *
138
+ * `data.nq` and `schema.json` stay SIZE-gated on purpose. A truncated `data.nq`
139
+ * is caught downstream by `readPack`'s A9 triple-count cross-check, which is
140
+ * stronger than any parse here; a corrupt `schema.json` still surfaces
141
+ * unclassified, and that gap is pinned separately rather than absorbed into this
142
+ * one — this is the fast path, and it parses only the artifact whose failure is
143
+ * otherwise invisible.
144
+ *
145
+ * @param dir - The pack directory.
146
+ * @returns Whether the stories artifact parses as an array.
147
+ * @note Impure — reads from disk.
148
+ */
149
+ function storiesAreReadable(dir: string): boolean {
150
+ try {
151
+ return Array.isArray(
152
+ JSON.parse(readFileSync(join(dir, STORIES_FILE), "utf-8")),
153
+ );
154
+ } catch {
155
+ return false;
156
+ }
55
157
  }
@@ -16,24 +16,19 @@ import { readFileSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
  import { createStore } from "@canonical/ke";
18
18
  import { compileFromExtraction } from "@canonical/ke-graphql";
19
- import { RECOVERY_CLI_PREFIX } from "../../../constants.js";
20
19
  import { PragmaError } from "../../error/PragmaError.js";
21
20
  import { cliRecovery } from "../../error/recovery.js";
22
21
  import type { StoreSession } from "../types.js";
23
22
  import { readManifest } from "./manifest.js";
24
- import {
25
- DATA_FILE,
26
- INDEX_FILE,
27
- packIndexSchema,
28
- SCHEMA_FILE,
29
- } from "./types.js";
23
+ import { packIndexSchema } from "./schemas.js";
24
+ import { DATA_FILE, INDEX_FILE, SCHEMA_FILE } from "./types.js";
30
25
 
31
26
  /** STORE_UNAVAILABLE with the canonical `pragma sources update` recovery (CLI + MCP). */
32
27
  function packUnavailable(reason: string): PragmaError {
33
28
  return PragmaError.storeUnavailable(reason, {
34
29
  recovery: cliRecovery(
35
- `${RECOVERY_CLI_PREFIX}sources update`,
36
- "Rebuild the local store from the configured packages.",
30
+ "sources update",
31
+ "Rebuild the local store from the configured packs.",
37
32
  // An agent recovers by calling the tool, then retrying (PR9 C1 cold-store
38
33
  // retry makes the post-update retry succeed).
39
34
  { tool: "sources_update" },
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The zod schemas validating a pack's persisted artifacts.
3
+ *
4
+ * They live HERE, apart from the contracts in `types.ts`, for one reason: zod is
5
+ * a value dependency, and `types.ts` is reachable from the storeless fast path.
6
+ * `capabilities/index.ts` → `resolveSources` → `packIsComplete` → `readManifest`
7
+ * put `manifest.json`'s schema on the graph `bin.ts` evaluates for `__complete`,
8
+ * costing ~3–4 ms of a ~30 ms budget to build a validator for a ~1 KB file the
9
+ * boot decision only needs to look at. `readManifest` now validates that file
10
+ * structurally, by hand, and this module is imported only by the two readers
11
+ * that are already off the fast path (`read.ts` boots the store; `embedded.ts`
12
+ * materializes the 1.9 MB embedded pack).
13
+ *
14
+ * The schemas remain the EXECUTABLE SPECIFICATION of what a valid artifact is:
15
+ * `graphpack.test.ts` pins `readManifest`'s hand validator to accept exactly
16
+ * what {@link manifestSchema} accepts, in both directions, so the two cannot
17
+ * drift. Change a rule here and that test fails until the hand validator
18
+ * follows. `capabilities/lazy.test.ts` pins the other half — that NO module on
19
+ * the storeless graph imports zod at all — so this split cannot silently close.
20
+ *
21
+ * Each schema is annotated `z.ZodType<T>` against the hand-written contract it
22
+ * validates, so a field added to one and not the other is a type error here
23
+ * rather than a runtime surprise at a read.
24
+ */
25
+
26
+ import { z } from "zod";
27
+ import type { Manifest, PackIndex, PackIndexEntity } from "./types.js";
28
+
29
+ /** zod schema validating a persisted {@link PackIndexEntity}. */
30
+ export const packIndexEntitySchema: z.ZodType<PackIndexEntity> = z.object({
31
+ name: z.string(),
32
+ type: z.string(),
33
+ uri: z.string().optional(),
34
+ prefixed: z.string().optional(),
35
+ types: z.array(z.string()).optional(),
36
+ label: z.string().nullable().optional(),
37
+ altNames: z.array(z.string()).optional(),
38
+ box: z.enum(["tbox", "abox"]).optional(),
39
+ description: z.string().nullable().optional(),
40
+ });
41
+
42
+ /** zod schema validating a persisted {@link PackIndex}. */
43
+ export const packIndexSchema: z.ZodType<PackIndex> = z.object({
44
+ version: z.union([z.literal(1), z.literal(2)]),
45
+ contentHash: z.string(),
46
+ prefixes: z.record(z.string(), z.string()),
47
+ entities: z.array(packIndexEntitySchema),
48
+ instanceCountByType: z.record(z.string(), z.number()),
49
+ });
50
+
51
+ /** zod schema validating a persisted `manifest.json`. */
52
+ export const manifestSchema: z.ZodType<Manifest> = z.object({
53
+ name: z.string(),
54
+ version: z.string(),
55
+ /** The config `packs` ref this pack was built from (verbatim), or a label. */
56
+ sourceRef: z.string(),
57
+ contentHash: z.string(),
58
+ prefixes: z.record(z.string(), z.string()),
59
+ createdAt: z.string(),
60
+ /**
61
+ * The store's triple count at build time. Cross-checked against the booted
62
+ * store so a truncated-but-non-empty `data.nq` (a partial graph that passes
63
+ * the mere size>0 completeness gate) surfaces as STORE_UNAVAILABLE rather
64
+ * than being served silently (A9). Optional — packs built before this field
65
+ * skip the check.
66
+ */
67
+ tripleCount: z.number().optional(),
68
+ /**
69
+ * The distinct abox entity count (matches `entityTotal`). Lets
70
+ * `sources status` report the figure without parsing the whole `index.json`
71
+ * (A10). Optional — packs built before this field fall back to the index read.
72
+ */
73
+ entityCount: z.number().optional(),
74
+ });