@canonical/pragma-cli 0.32.0 → 0.33.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 (140) hide show
  1. package/README.md +72 -0
  2. package/dist/pragma +0 -0
  3. package/package.json +14 -13
  4. package/src/bin.ts +2 -2
  5. package/src/capabilities/block/blockList.test.ts +79 -0
  6. package/src/capabilities/block/blockList.verb.ts +18 -5
  7. package/src/capabilities/block/pack.ts +12 -1
  8. package/src/capabilities/block/parity.test.ts +9 -2
  9. package/src/capabilities/capabilities/capabilities.test.ts +10 -1
  10. package/src/capabilities/capabilities/capabilities.verb.ts +1 -1
  11. package/src/capabilities/capabilities/catalog.ts +7 -3
  12. package/src/capabilities/capabilities/hints.ts +8 -17
  13. package/src/capabilities/capabilities/types.ts +2 -0
  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 +4 -1
  18. package/src/capabilities/colophon/markdownTerminal.ts +33 -14
  19. package/src/capabilities/colophon/pragmaColophon.ts +4 -4
  20. package/src/capabilities/colophon/types.ts +1 -1
  21. package/src/capabilities/config/fields.ts +21 -92
  22. package/src/capabilities/config/set.test.ts +6 -6
  23. package/src/capabilities/config/set.verb.ts +5 -5
  24. package/src/capabilities/config/show.render.test.ts +65 -0
  25. package/src/capabilities/config/show.render.ts +56 -10
  26. package/src/capabilities/config/show.verb.ts +5 -5
  27. package/src/capabilities/create/byteEquality.subprocess.test.ts +1 -1
  28. package/src/capabilities/create/byteEquality.test.ts +7 -4
  29. package/src/capabilities/create/compiledCreate.subprocess.test.ts +1 -1
  30. package/src/capabilities/create/create.test.ts +32 -3
  31. package/src/capabilities/create/create.verb.ts +69 -11
  32. package/src/capabilities/doctor/checks/checkConfigFile.ts +1 -1
  33. package/src/capabilities/doctor/checks/checkMcpCommands.ts +6 -0
  34. package/src/capabilities/doctor/checks/checkMcpConfigured.ts +10 -4
  35. package/src/capabilities/doctor/checks/deriveBand.test.ts +42 -0
  36. package/src/capabilities/doctor/checks/deriveBand.ts +28 -0
  37. package/src/capabilities/doctor/doctor.render.test.ts +170 -0
  38. package/src/capabilities/doctor/doctor.render.ts +116 -34
  39. package/src/capabilities/doctor/doctor.test.ts +31 -1
  40. package/src/capabilities/doctor/runChecks.ts +28 -4
  41. package/src/capabilities/doctor/types.ts +15 -0
  42. package/src/capabilities/ontology/index.ts +10 -3
  43. package/src/capabilities/ontology/ontology.test.ts +157 -5
  44. package/src/capabilities/ontology/verbs.ts +192 -93
  45. package/src/capabilities/prompt/prompt.render.test.ts +94 -0
  46. package/src/capabilities/prompt/prompt.render.ts +23 -6
  47. package/src/capabilities/prompt/prompt.test.ts +61 -0
  48. package/src/capabilities/reference.test.ts +32 -0
  49. package/src/capabilities/resources/provider.ts +28 -14
  50. package/src/capabilities/resources/resources.test.ts +99 -0
  51. package/src/capabilities/scopeTypeSync.test.ts +37 -0
  52. package/src/capabilities/setup/index.ts +7 -1
  53. package/src/capabilities/setup/operations/setupCompletions.ts +49 -16
  54. package/src/capabilities/setup/operations/setupGenerator.ts +157 -61
  55. package/src/capabilities/setup/operations/setupLsp.ts +56 -12
  56. package/src/capabilities/setup/operations/setupMcp.ts +216 -39
  57. package/src/capabilities/setup/operations/setupSkills.ts +7 -1
  58. package/src/capabilities/setup/setup.render.test.ts +109 -0
  59. package/src/capabilities/setup/setup.render.ts +61 -14
  60. package/src/capabilities/setup/setup.test.ts +352 -1
  61. package/src/capabilities/setup/setup.verb.ts +57 -5
  62. package/src/capabilities/setup/types.ts +59 -2
  63. package/src/capabilities/shared/bands.ts +16 -0
  64. package/src/capabilities/sources/collectStatus.ts +19 -4
  65. package/src/capabilities/sources/runUpdate.ts +58 -1
  66. package/src/capabilities/sources/sources.test.ts +187 -1
  67. package/src/capabilities/sources/status.render.test.ts +65 -0
  68. package/src/capabilities/sources/status.render.ts +50 -23
  69. package/src/capabilities/surface.test.ts +34 -9
  70. package/src/capabilities/tier/lookup.render.test.ts +72 -0
  71. package/src/capabilities/tier/lookup.render.ts +33 -8
  72. package/src/capabilities/tier/lookup.test.ts +15 -0
  73. package/src/capabilities/tier/runLookup.ts +28 -0
  74. package/src/constants.test.ts +1 -1
  75. package/src/constants.ts +2 -2
  76. package/src/kernel/completion/__snapshots__/emitScripts.test.ts.snap +13 -14
  77. package/src/kernel/completion/bashDrive.test.ts +3 -3
  78. package/src/kernel/completion/complete.test.ts +3 -5
  79. package/src/kernel/completion/entitySource.ts +14 -6
  80. package/src/kernel/completion/model.test.ts +2 -2
  81. package/src/kernel/completion/model.ts +0 -1
  82. package/src/kernel/completion/parse.test.ts +2 -4
  83. package/src/kernel/error/PragmaError.test.ts +4 -4
  84. package/src/kernel/error/renderError.ts +1 -1
  85. package/src/kernel/packs/compile.ts +1 -1
  86. package/src/kernel/project/cli/buildProgram.test.ts +51 -7
  87. package/src/kernel/project/cli/buildProgram.ts +11 -0
  88. package/src/kernel/project/cli/globalFlags.test.ts +12 -4
  89. package/src/kernel/project/cli/globalFlags.ts +14 -14
  90. package/src/kernel/project/cli/rootHelp.ts +4 -2
  91. package/src/kernel/project/mcp/instructions.ts +1 -1
  92. package/src/kernel/project/mcp/mcpError.ts +52 -0
  93. package/src/kernel/project/mcp/prompts/prompts.test.ts +8 -0
  94. package/src/kernel/project/mcp/prompts/provider.ts +36 -6
  95. package/src/kernel/render/formatters.ts +5 -4
  96. package/src/kernel/render/renderers.test.ts +34 -0
  97. package/src/kernel/render/renderers.ts +25 -7
  98. package/src/kernel/render/style.test.ts +31 -0
  99. package/src/kernel/render/style.ts +74 -0
  100. package/src/kernel/runtime/graphpack/build.ts +28 -0
  101. package/src/kernel/runtime/graphpack/buildIndex.test.ts +103 -0
  102. package/src/kernel/runtime/graphpack/buildIndex.ts +124 -36
  103. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +3 -6
  104. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -2
  105. package/src/kernel/runtime/graphpack/graphpack.test.ts +69 -1
  106. package/src/kernel/runtime/graphpack/read.ts +28 -13
  107. package/src/kernel/runtime/graphpack/types.ts +14 -0
  108. package/src/kernel/runtime/loadSession.ts +4 -12
  109. package/src/kernel/runtime/refs/resolve.ts +63 -3
  110. package/src/kernel/runtime/storeReadiness.ts +61 -0
  111. package/src/kernel/runtime/types.ts +1 -1
  112. package/src/kernel/runtime/wasmEmbed.test.ts +3 -1
  113. package/src/kernel/spec/emitReference.test.ts +196 -0
  114. package/src/kernel/spec/emitReference.ts +491 -0
  115. package/src/kernel/spec/emitSurface.ts +4 -2
  116. package/src/kernel/spec/index.ts +2 -0
  117. package/src/kernel/spec/surfaceConformance.test.ts +2 -2
  118. package/src/testing/PARITY_GAPS.ts +1 -1
  119. package/src/testing/behavioral/autoLlm.e2e.test.ts +12 -2
  120. package/src/testing/behavioral/completion.test.ts +2 -2
  121. package/src/testing/behavioral/disclosure.test.ts +12 -0
  122. package/src/testing/behavioral/docExamples.test.ts +283 -0
  123. package/src/testing/behavioral/errorMatrix.storeless.test.ts +2 -1
  124. package/src/testing/behavioral/journeys.cli.test.ts +3 -1
  125. package/src/testing/behavioral/journeys.defaultPack.test.ts +571 -0
  126. package/src/testing/behavioral/journeys.interactiveTty.e2e.test.ts +66 -0
  127. package/src/testing/behavioral/journeys.livePack.e2e.test.ts +129 -0
  128. package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +176 -0
  129. package/src/testing/behavioral/mcpSurface.test.ts +1 -1
  130. package/src/testing/behavioral/staleCommands.test.ts +76 -0
  131. package/src/testing/eval/__snapshots__/eval.test.ts.snap +2 -2
  132. package/src/testing/eval/cases/assembly.ts +3 -3
  133. package/src/testing/eval/cases/effectful.ts +6 -3
  134. package/src/testing/eval/cases/readNouns.ts +4 -1
  135. package/src/testing/fixtures/fixtureCapability.ts +156 -0
  136. package/src/testing/fixtures/graph/defaultPack.ts +178 -0
  137. package/src/testing/fixtures/graph/owlExport.ts +95 -0
  138. package/src/testing/helpers/extractSnippets.ts +29 -0
  139. package/src/testing/helpers/golden.ts +1 -1
  140. package/src/capabilities/config/field.test.ts +0 -188
@@ -4,11 +4,24 @@
4
4
  * A lean port of the v1 `buildGraphIndex` (#856): three bulk SPARQL queries —
5
5
  * types, labels, and instance counts — joined in memory, rather than a query
6
6
  * per subject. Only typed named-node subjects are indexed (untyped subjects and
7
- * blank nodes are reachable via `graph query` but never listed). Each entity
8
- * carries the FROZEN `{ name, type }` minimum plus the enrichment fields
7
+ * blank-node subjects are reachable via `graph query` but never listed). Each
8
+ * entity carries the FROZEN `{ name, type }` minimum plus the enrichment fields
9
9
  * (`uri`, `prefixed`, `types`, `label`, `box`) the completion tier and reads
10
10
  * use. The output is pure JSON — no store handle survives — so the completion
11
11
  * tier reads it without ever booting oxigraph.
12
+ *
13
+ * Real-data correctness (things a single-typed toy graph never exercises):
14
+ * - Blank-node `rdf:type` objects (SHACL shapes, RDF lists) are ignored — a
15
+ * `_:b0` is never a domain class and must not become a `type:"_:b0"` key.
16
+ * - The abox primary type is the LEXICALLY-SMALLEST domain class, a
17
+ * deterministic tie-break: SPARQL returns a subject's types in unspecified
18
+ * order, so a multi-domain individual would otherwise get an arbitrary
19
+ * completion key that flips across builds/engines.
20
+ * - Multilingual labels prefer untagged / `@en`, then the lexically smallest —
21
+ * never the store's arbitrary first row.
22
+ * - An OWL-punned subject (a class/property IRI ALSO asserted as a domain
23
+ * individual) emits BOTH its tbox and abox facet, so its abox membership
24
+ * stays completable instead of vanishing behind the tbox facet.
12
25
  */
13
26
 
14
27
  import { compactUri } from "../../render/compactUri.js";
@@ -73,51 +86,109 @@ const ALT_NAME_PREDICATES = ["https://ds.canonical.com/name"];
73
86
  const isStdVocab = (uri: string): boolean =>
74
87
  STD_VOCAB_PREFIXES.some((ns) => uri.startsWith(ns));
75
88
 
76
- /** Classify a subject's full types into a box and a primary (filter) type. */
77
- function classify(
78
- fullTypes: readonly string[],
79
- ): { box: "tbox" | "abox"; primary: string } | null {
80
- if (fullTypes.length === 0) return null;
89
+ /** One classification facet: which box a subject occupies + its primary type. */
90
+ interface Facet {
91
+ readonly box: "tbox" | "abox";
92
+ readonly primary: string;
93
+ }
94
+
95
+ /**
96
+ * Classify a subject's full types into its facets — one per box it occupies.
97
+ *
98
+ * A plain individual or a plain class yields ONE facet. An OWL-punned subject
99
+ * (a class/property IRI ALSO asserted as a domain individual) yields BOTH its
100
+ * tbox and abox facet (A8), so its abox membership stays completable rather than
101
+ * disappearing behind the tbox one. The abox primary is the lexically-smallest
102
+ * domain class — a deterministic tie-break (A3), not the store's first row.
103
+ *
104
+ * @param fullTypes - The subject's asserted `rdf:type` values (blank nodes
105
+ * already dropped), in store order.
106
+ * @returns The subject's facets (empty when it has no usable type).
107
+ */
108
+ function classify(fullTypes: readonly string[]): Facet[] {
109
+ if (fullTypes.length === 0) return [];
110
+ const facets: Facet[] = [];
81
111
  const classMeta = fullTypes.find((t) => CLASS_METATYPES.has(t));
82
- if (classMeta) return { box: "tbox", primary: classMeta };
83
112
  const propMeta = fullTypes.find((t) => PROPERTY_METATYPES.has(t));
84
- if (propMeta) return { box: "tbox", primary: propMeta };
85
- // An individual: its filter key is the most specific domain class.
86
- const domain =
87
- fullTypes.find((t) => t !== NAMED_INDIVIDUAL && !isStdVocab(t)) ??
88
- fullTypes.find((t) => t !== NAMED_INDIVIDUAL) ??
89
- fullTypes[0];
90
- return { box: "abox", primary: domain as string };
113
+ if (classMeta !== undefined) facets.push({ box: "tbox", primary: classMeta });
114
+ else if (propMeta !== undefined)
115
+ facets.push({ box: "tbox", primary: propMeta });
116
+
117
+ // A subject's domain classes: its own types minus the structural std vocab
118
+ // and the `owl:NamedIndividual` typing artifact. Sorted, so the abox primary
119
+ // (the smallest) is stable across builds/engines regardless of store order.
120
+ const domainClasses = fullTypes
121
+ .filter((t) => t !== NAMED_INDIVIDUAL && !isStdVocab(t))
122
+ .sort();
123
+ const primaryDomain = domainClasses.at(0);
124
+ if (primaryDomain !== undefined) {
125
+ facets.push({ box: "abox", primary: primaryDomain });
126
+ } else if (facets.length === 0) {
127
+ // No schema facet and no domain class — a bare individual (only
128
+ // `owl:NamedIndividual`, or only std-vocab types). Keep it indexed under a
129
+ // deterministic primary rather than dropping it silently.
130
+ const fallback =
131
+ fullTypes
132
+ .filter((t) => t !== NAMED_INDIVIDUAL)
133
+ .sort()
134
+ .at(0) ?? [...fullTypes].sort().at(0);
135
+ if (fallback !== undefined) facets.push({ box: "abox", primary: fallback });
136
+ }
137
+ return facets;
91
138
  }
92
139
 
93
140
  /** A `VALUES ?p { … }` body from a predicate list. */
94
141
  const valuesList = (uris: readonly string[]): string =>
95
142
  uris.map((uri) => `<${uri}>`).join(" ");
96
143
 
144
+ /** Language rank for label selection: untagged (0) < `@en` (1) < any other (2). */
145
+ function rankLanguage(language: string): number {
146
+ if (language === "") return 0;
147
+ const lower = language.toLowerCase();
148
+ return lower === "en" || lower.startsWith("en-") ? 1 : 2;
149
+ }
150
+
97
151
  /**
98
152
  * Per subject, keep the object whose predicate ranks HIGHEST (lowest index) in
99
153
  * `preference`. The `?p` binding is projected so the winner is chosen by declared
100
154
  * preference order: SPARQL returns solutions in an unspecified order, so keeping
101
155
  * the first row per subject would be store-order-dependent (an entity carrying
102
156
  * both `rdfs:label` and `skos:prefLabel` could index either), not ranked.
157
+ *
158
+ * Ties within a predicate (a subject with several `rdfs:label` values — the
159
+ * multilingual case) are broken deterministically: untagged / `@en` first, then
160
+ * the lexically smallest value. So `"Button"@en`/`"Bouton"@fr` always resolves
161
+ * to `Button`, never whichever row the store happened to return first (A7).
103
162
  */
104
163
  function preferredBySubject(
105
164
  result: import("@canonical/ke").SelectResult,
106
165
  valueVar: string,
107
166
  preference: readonly string[],
108
167
  ): Map<string, string> {
109
- const best = new Map<string, { value: string; rank: number }>();
168
+ const best = new Map<
169
+ string,
170
+ { value: string; rank: number; langRank: number }
171
+ >();
110
172
  for (const term of result.termBindings) {
111
173
  if (term.s?.termType !== "NamedNode") continue;
112
- const value = term[valueVar]?.value;
174
+ const valueTerm = term[valueVar];
113
175
  const predicate = term.p?.value;
114
- if (!value || predicate === undefined) continue;
176
+ if (valueTerm === undefined || predicate === undefined) continue;
177
+ const value = valueTerm.value;
178
+ if (!value) continue;
115
179
  const rank = preference.indexOf(predicate);
116
180
  if (rank === -1) continue;
181
+ const language =
182
+ valueTerm.termType === "Literal" ? (valueTerm.language ?? "") : "";
183
+ const langRank = rankLanguage(language);
117
184
  const current = best.get(term.s.value);
118
- if (current === undefined || rank < current.rank) {
119
- best.set(term.s.value, { value, rank });
120
- }
185
+ const better =
186
+ current === undefined ||
187
+ rank < current.rank ||
188
+ (rank === current.rank &&
189
+ (langRank < current.langRank ||
190
+ (langRank === current.langRank && value < current.value)));
191
+ if (better) best.set(term.s.value, { value, rank, langRank });
121
192
  }
122
193
  const picked = new Map<string, string>();
123
194
  for (const [subject, entry] of best) picked.set(subject, entry.value);
@@ -179,8 +250,13 @@ export async function buildIndex(
179
250
  const instanceCountByType: Record<string, number> = {};
180
251
  for (const term of typesResult.termBindings) {
181
252
  if (term.s?.termType !== "NamedNode") continue;
253
+ const typeTerm = term.type;
254
+ // Ignore blank-node `rdf:type` objects (SHACL shapes / RDF lists): a `_:b0`
255
+ // is never a domain class, so it must not become a garbage primary type or
256
+ // inflate the per-type instance counts (A6).
257
+ if (typeTerm === undefined || typeTerm.termType === "BlankNode") continue;
182
258
  const subject = term.s.value;
183
- const typeUri = term.type?.value ?? "";
259
+ const typeUri = typeTerm.value;
184
260
  const bucket = typesBySubject.get(subject) ?? [];
185
261
  bucket.push(typeUri);
186
262
  typesBySubject.set(subject, bucket);
@@ -201,23 +277,35 @@ export async function buildIndex(
201
277
 
202
278
  const entities: PackIndexEntity[] = [];
203
279
  for (const [subject, fullTypes] of typesBySubject) {
204
- const classification = classify(fullTypes);
205
- if (classification === null) continue;
280
+ const facets = classify(fullTypes);
281
+ if (facets.length === 0) continue;
206
282
  const prefixed = compactUri(subject, prefixes);
207
283
  const altNames = altNamesBySubject.get(subject);
208
- entities.push({
209
- name: prefixed,
210
- type: compactUri(classification.primary, prefixes),
211
- uri: subject,
212
- prefixed,
213
- types: fullTypes.map((t) => compactUri(t, prefixes)),
214
- label: labelBySubject.get(subject) ?? null,
215
- ...(altNames && altNames.length > 0 ? { altNames } : {}),
216
- box: classification.box,
217
- description: descBySubject.get(subject) ?? null,
218
- });
284
+ const typesCompact = fullTypes.map((t) => compactUri(t, prefixes));
285
+ const label = labelBySubject.get(subject) ?? null;
286
+ const description = descBySubject.get(subject) ?? null;
287
+ // A punned subject emits one entity per facet (tbox class + abox individual).
288
+ for (const facet of facets) {
289
+ entities.push({
290
+ name: prefixed,
291
+ type: compactUri(facet.primary, prefixes),
292
+ uri: subject,
293
+ prefixed,
294
+ types: typesCompact,
295
+ label,
296
+ ...(altNames && altNames.length > 0 ? { altNames } : {}),
297
+ box: facet.box,
298
+ description,
299
+ });
300
+ }
219
301
  }
220
- entities.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
302
+ // Sort by name, then a deterministic tie-break (box, then type) so a punned
303
+ // subject's two facets keep a stable order across builds.
304
+ entities.sort((a, b) => {
305
+ if (a.name !== b.name) return a.name < b.name ? -1 : 1;
306
+ if (a.box !== b.box) return a.box === "tbox" ? -1 : 1;
307
+ return a.type < b.type ? -1 : a.type > b.type ? 1 : 0;
308
+ });
221
309
 
222
310
  return {
223
311
  version: 2,
@@ -1,8 +1,5 @@
1
1
  // AUTO-GENERATED by scripts/genEmbedded.ts — do not edit by hand.
2
2
  // Regenerate after changing sample.ttl: `bun run scripts/genEmbedded.ts`.
3
- export const dataNq =
4
- '<https://pragma.canonical.com/sample#Dialog> <https://pragma.canonical.com/sample#dependsOn> <https://pragma.canonical.com/sample#Card> .\n<https://pragma.canonical.com/sample#Dialog> <https://pragma.canonical.com/sample#dependsOn> <https://pragma.canonical.com/sample#Button> .\n<https://pragma.canonical.com/sample#Dialog> <https://pragma.canonical.com/sample#componentName> "Dialog" .\n<https://pragma.canonical.com/sample#Dialog> <http://www.w3.org/2000/01/rdf-schema#label> "Dialog" .\n<https://pragma.canonical.com/sample#Dialog> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://pragma.canonical.com/sample#Component> .\n<https://pragma.canonical.com/sample#Card> <https://pragma.canonical.com/sample#dependsOn> <https://pragma.canonical.com/sample#Button> .\n<https://pragma.canonical.com/sample#Card> <https://pragma.canonical.com/sample#componentName> "Card" .\n<https://pragma.canonical.com/sample#Card> <http://www.w3.org/2000/01/rdf-schema#label> "Card" .\n<https://pragma.canonical.com/sample#Card> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://pragma.canonical.com/sample#Component> .\n<https://pragma.canonical.com/sample#Button> <https://pragma.canonical.com/sample#componentName> "Button" .\n<https://pragma.canonical.com/sample#Button> <http://www.w3.org/2000/01/rdf-schema#label> "Button" .\n<https://pragma.canonical.com/sample#Button> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://pragma.canonical.com/sample#Component> .\n<https://pragma.canonical.com/sample#dependsOn> <http://www.w3.org/2000/01/rdf-schema#range> <https://pragma.canonical.com/sample#Component> .\n<https://pragma.canonical.com/sample#dependsOn> <http://www.w3.org/2000/01/rdf-schema#domain> <https://pragma.canonical.com/sample#Component> .\n<https://pragma.canonical.com/sample#dependsOn> <http://www.w3.org/2000/01/rdf-schema#label> "depends on" .\n<https://pragma.canonical.com/sample#dependsOn> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/2002/07/owl#ObjectProperty> .\n<https://pragma.canonical.com/sample#componentName> <http://www.w3.org/2000/01/rdf-schema#range> <http://www.w3.org/2001/XMLSchema#string> .\n<https://pragma.canonical.com/sample#componentName> <http://www.w3.org/2000/01/rdf-schema#domain> <https://pragma.canonical.com/sample#Component> .\n<https://pragma.canonical.com/sample#componentName> <http://www.w3.org/2000/01/rdf-schema#label> "component name" .\n<https://pragma.canonical.com/sample#componentName> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/2002/07/owl#DatatypeProperty> .\n<https://pragma.canonical.com/sample#Component> <http://www.w3.org/2000/01/rdf-schema#comment> "A reusable design-system building block." .\n<https://pragma.canonical.com/sample#Component> <http://www.w3.org/2000/01/rdf-schema#label> "Component" .\n<https://pragma.canonical.com/sample#Component> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/2002/07/owl#Class> .\n';
5
- export const schemaJson =
6
- '{"version":1,"sourcesHash":"8a510cc1fb8c950e","classes":[{"uri":"https://pragma.canonical.com/sample#Component","superclasses":[],"label":"Component","definition":"A reusable design-system building block."}],"properties":[{"uri":"https://pragma.canonical.com/sample#dependsOn","kind":"object","domains":["https://pragma.canonical.com/sample#Component"],"ranges":["https://pragma.canonical.com/sample#Component"],"label":"depends on"},{"uri":"https://pragma.canonical.com/sample#componentName","kind":"datatype","domains":["https://pragma.canonical.com/sample#Component"],"ranges":["http://www.w3.org/2001/XMLSchema#string"],"label":"component name"}],"inverses":[],"functionals":[],"datatypes":[],"namespaces":[["https://pragma.canonical.com/sample#","ex"]],"shaclConstraints":[],"unions":[],"instanceStats":[["http://www.w3.org/2002/07/owl#ObjectProperty",{"total":1,"named":1}],["https://pragma.canonical.com/sample#Component",{"total":3,"named":3}],["http://www.w3.org/2002/07/owl#DatatypeProperty",{"total":1,"named":1}],["http://www.w3.org/2002/07/owl#Class",{"total":1,"named":1}]],"selfReferential":[],"functionalViolations":[],"undeclaredPredicates":[],"annotations":[],"deepBlankNesting":false}';
7
- export const manifestJson =
8
- '{"name":"pragma-sample","version":"0.31.0","sourceRef":"embedded:sample.ttl","contentHash":"bfe536dc9afc625502a8f578aadf4eb9927b55260951bc61f58cf07219593034","prefixes":{"ex":"https://pragma.canonical.com/sample#","rdf":"http://www.w3.org/1999/02/22-rdf-syntax-ns#","rdfs":"http://www.w3.org/2000/01/rdf-schema#","owl":"http://www.w3.org/2002/07/owl#","xsd":"http://www.w3.org/2001/XMLSchema#"},"createdAt":"2026-07-18T04:52:05.250Z"}';
3
+ export const dataNq = "<https://pragma.canonical.com/sample#Dialog> <https://pragma.canonical.com/sample#dependsOn> <https://pragma.canonical.com/sample#Card> .\n<https://pragma.canonical.com/sample#Dialog> <https://pragma.canonical.com/sample#dependsOn> <https://pragma.canonical.com/sample#Button> .\n<https://pragma.canonical.com/sample#Dialog> <https://pragma.canonical.com/sample#componentName> \"Dialog\" .\n<https://pragma.canonical.com/sample#Dialog> <http://www.w3.org/2000/01/rdf-schema#label> \"Dialog\" .\n<https://pragma.canonical.com/sample#Dialog> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://pragma.canonical.com/sample#Component> .\n<https://pragma.canonical.com/sample#Card> <https://pragma.canonical.com/sample#dependsOn> <https://pragma.canonical.com/sample#Button> .\n<https://pragma.canonical.com/sample#Card> <https://pragma.canonical.com/sample#componentName> \"Card\" .\n<https://pragma.canonical.com/sample#Card> <http://www.w3.org/2000/01/rdf-schema#label> \"Card\" .\n<https://pragma.canonical.com/sample#Card> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://pragma.canonical.com/sample#Component> .\n<https://pragma.canonical.com/sample#Button> <https://pragma.canonical.com/sample#componentName> \"Button\" .\n<https://pragma.canonical.com/sample#Button> <http://www.w3.org/2000/01/rdf-schema#label> \"Button\" .\n<https://pragma.canonical.com/sample#Button> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://pragma.canonical.com/sample#Component> .\n<https://pragma.canonical.com/sample#dependsOn> <http://www.w3.org/2000/01/rdf-schema#range> <https://pragma.canonical.com/sample#Component> .\n<https://pragma.canonical.com/sample#dependsOn> <http://www.w3.org/2000/01/rdf-schema#domain> <https://pragma.canonical.com/sample#Component> .\n<https://pragma.canonical.com/sample#dependsOn> <http://www.w3.org/2000/01/rdf-schema#label> \"depends on\" .\n<https://pragma.canonical.com/sample#dependsOn> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/2002/07/owl#ObjectProperty> .\n<https://pragma.canonical.com/sample#componentName> <http://www.w3.org/2000/01/rdf-schema#range> <http://www.w3.org/2001/XMLSchema#string> .\n<https://pragma.canonical.com/sample#componentName> <http://www.w3.org/2000/01/rdf-schema#domain> <https://pragma.canonical.com/sample#Component> .\n<https://pragma.canonical.com/sample#componentName> <http://www.w3.org/2000/01/rdf-schema#label> \"component name\" .\n<https://pragma.canonical.com/sample#componentName> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/2002/07/owl#DatatypeProperty> .\n<https://pragma.canonical.com/sample#Component> <http://www.w3.org/2000/01/rdf-schema#comment> \"A reusable design-system building block.\" .\n<https://pragma.canonical.com/sample#Component> <http://www.w3.org/2000/01/rdf-schema#label> \"Component\" .\n<https://pragma.canonical.com/sample#Component> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/2002/07/owl#Class> .\n";
4
+ export const schemaJson = "{\"version\":1,\"sourcesHash\":\"8a510cc1fb8c950e\",\"classes\":[{\"uri\":\"https://pragma.canonical.com/sample#Component\",\"superclasses\":[],\"label\":\"Component\",\"definition\":\"A reusable design-system building block.\"}],\"properties\":[{\"uri\":\"https://pragma.canonical.com/sample#dependsOn\",\"kind\":\"object\",\"domains\":[\"https://pragma.canonical.com/sample#Component\"],\"ranges\":[\"https://pragma.canonical.com/sample#Component\"],\"label\":\"depends on\"},{\"uri\":\"https://pragma.canonical.com/sample#componentName\",\"kind\":\"datatype\",\"domains\":[\"https://pragma.canonical.com/sample#Component\"],\"ranges\":[\"http://www.w3.org/2001/XMLSchema#string\"],\"label\":\"component name\"}],\"inverses\":[],\"functionals\":[],\"datatypes\":[],\"namespaces\":[[\"https://pragma.canonical.com/sample#\",\"ex\"]],\"shaclConstraints\":[],\"unions\":[],\"instanceStats\":[[\"http://www.w3.org/2002/07/owl#ObjectProperty\",{\"total\":1,\"named\":1}],[\"https://pragma.canonical.com/sample#Component\",{\"total\":3,\"named\":3}],[\"http://www.w3.org/2002/07/owl#DatatypeProperty\",{\"total\":1,\"named\":1}],[\"http://www.w3.org/2002/07/owl#Class\",{\"total\":1,\"named\":1}]],\"selfReferential\":[],\"functionalViolations\":[],\"undeclaredPredicates\":[],\"annotations\":[],\"deepBlankNesting\":false}";
5
+ export const manifestJson = "{\"name\":\"pragma-sample\",\"version\":\"0.31.0\",\"sourceRef\":\"embedded:sample.ttl\",\"contentHash\":\"bfe536dc9afc625502a8f578aadf4eb9927b55260951bc61f58cf07219593034\",\"prefixes\":{\"ex\":\"https://pragma.canonical.com/sample#\",\"rdf\":\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\",\"rdfs\":\"http://www.w3.org/2000/01/rdf-schema#\",\"owl\":\"http://www.w3.org/2002/07/owl#\",\"xsd\":\"http://www.w3.org/2001/XMLSchema#\"},\"createdAt\":\"1970-01-01T00:00:00.000Z\",\"tripleCount\":23,\"entityCount\":3}";
@@ -1,4 +1,3 @@
1
1
  // AUTO-GENERATED by scripts/genEmbedded.ts — do not edit by hand.
2
2
  // Regenerate after changing sample.ttl: `bun run scripts/genEmbedded.ts`.
3
- export const indexJson =
4
- '{"version":2,"contentHash":"bfe536dc9afc625502a8f578aadf4eb9927b55260951bc61f58cf07219593034","prefixes":{"ex":"https://pragma.canonical.com/sample#","rdf":"http://www.w3.org/1999/02/22-rdf-syntax-ns#","rdfs":"http://www.w3.org/2000/01/rdf-schema#","owl":"http://www.w3.org/2002/07/owl#","xsd":"http://www.w3.org/2001/XMLSchema#"},"entities":[{"name":"ex:Button","type":"ex:Component","uri":"https://pragma.canonical.com/sample#Button","prefixed":"ex:Button","types":["ex:Component"],"label":"Button","box":"abox","description":null},{"name":"ex:Card","type":"ex:Component","uri":"https://pragma.canonical.com/sample#Card","prefixed":"ex:Card","types":["ex:Component"],"label":"Card","box":"abox","description":null},{"name":"ex:Component","type":"owl:Class","uri":"https://pragma.canonical.com/sample#Component","prefixed":"ex:Component","types":["owl:Class"],"label":"Component","box":"tbox","description":"A reusable design-system building block."},{"name":"ex:Dialog","type":"ex:Component","uri":"https://pragma.canonical.com/sample#Dialog","prefixed":"ex:Dialog","types":["ex:Component"],"label":"Dialog","box":"abox","description":null},{"name":"ex:componentName","type":"owl:DatatypeProperty","uri":"https://pragma.canonical.com/sample#componentName","prefixed":"ex:componentName","types":["owl:DatatypeProperty"],"label":"component name","box":"tbox","description":null},{"name":"ex:dependsOn","type":"owl:ObjectProperty","uri":"https://pragma.canonical.com/sample#dependsOn","prefixed":"ex:dependsOn","types":["owl:ObjectProperty"],"label":"depends on","box":"tbox","description":null}],"instanceCountByType":{"https://pragma.canonical.com/sample#Component":3,"http://www.w3.org/2002/07/owl#ObjectProperty":1,"http://www.w3.org/2002/07/owl#DatatypeProperty":1,"http://www.w3.org/2002/07/owl#Class":1}}';
3
+ export const indexJson = "{\"version\":2,\"contentHash\":\"bfe536dc9afc625502a8f578aadf4eb9927b55260951bc61f58cf07219593034\",\"prefixes\":{\"ex\":\"https://pragma.canonical.com/sample#\",\"rdf\":\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\",\"rdfs\":\"http://www.w3.org/2000/01/rdf-schema#\",\"owl\":\"http://www.w3.org/2002/07/owl#\",\"xsd\":\"http://www.w3.org/2001/XMLSchema#\"},\"entities\":[{\"name\":\"ex:Button\",\"type\":\"ex:Component\",\"uri\":\"https://pragma.canonical.com/sample#Button\",\"prefixed\":\"ex:Button\",\"types\":[\"ex:Component\"],\"label\":\"Button\",\"box\":\"abox\",\"description\":null},{\"name\":\"ex:Card\",\"type\":\"ex:Component\",\"uri\":\"https://pragma.canonical.com/sample#Card\",\"prefixed\":\"ex:Card\",\"types\":[\"ex:Component\"],\"label\":\"Card\",\"box\":\"abox\",\"description\":null},{\"name\":\"ex:Component\",\"type\":\"owl:Class\",\"uri\":\"https://pragma.canonical.com/sample#Component\",\"prefixed\":\"ex:Component\",\"types\":[\"owl:Class\"],\"label\":\"Component\",\"box\":\"tbox\",\"description\":\"A reusable design-system building block.\"},{\"name\":\"ex:Dialog\",\"type\":\"ex:Component\",\"uri\":\"https://pragma.canonical.com/sample#Dialog\",\"prefixed\":\"ex:Dialog\",\"types\":[\"ex:Component\"],\"label\":\"Dialog\",\"box\":\"abox\",\"description\":null},{\"name\":\"ex:componentName\",\"type\":\"owl:DatatypeProperty\",\"uri\":\"https://pragma.canonical.com/sample#componentName\",\"prefixed\":\"ex:componentName\",\"types\":[\"owl:DatatypeProperty\"],\"label\":\"component name\",\"box\":\"tbox\",\"description\":null},{\"name\":\"ex:dependsOn\",\"type\":\"owl:ObjectProperty\",\"uri\":\"https://pragma.canonical.com/sample#dependsOn\",\"prefixed\":\"ex:dependsOn\",\"types\":[\"owl:ObjectProperty\"],\"label\":\"depends on\",\"box\":\"tbox\",\"description\":null}],\"instanceCountByType\":{\"https://pragma.canonical.com/sample#Component\":3,\"http://www.w3.org/2002/07/owl#ObjectProperty\":1,\"http://www.w3.org/2002/07/owl#DatatypeProperty\":1,\"http://www.w3.org/2002/07/owl#Class\":1}}";
@@ -1,4 +1,10 @@
1
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
1
+ import {
2
+ existsSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
2
8
  import { tmpdir } from "node:os";
3
9
  import { join } from "node:path";
4
10
  import { executeLocal } from "@canonical/ke-graphql";
@@ -128,6 +134,68 @@ describe("graphpack hash stability (PROTECTED)", () => {
128
134
  });
129
135
  });
130
136
 
137
+ describe("graphpack manifest — persisted counts (A9/A10)", () => {
138
+ it("records tripleCount and a distinct-abox entityCount", async () => {
139
+ const { dir } = await build([{ path: "a.ttl", content: TTL }]);
140
+ const manifest = JSON.parse(
141
+ readFileSync(join(dir, MANIFEST_FILE), "utf-8"),
142
+ ) as { tripleCount?: number; entityCount?: number };
143
+ expect(typeof manifest.tripleCount).toBe("number");
144
+ expect(manifest.tripleCount ?? 0).toBeGreaterThan(0);
145
+ // TTL declares two individuals (ex:Button, ex:Card) → two abox subjects.
146
+ expect(manifest.entityCount).toBe(2);
147
+ });
148
+ });
149
+
150
+ describe("graphpack read — truncated data cache (A9)", () => {
151
+ it("a truncated-but-non-empty data.nq surfaces STORE_UNAVAILABLE", async () => {
152
+ // A UNIQUE graph so corrupting its cache never poisons the shared TTL pack.
153
+ const uniqueTtl = `${TTL}\nex:Truncated a ex:Component ; rdfs:label "Truncated" .\n`;
154
+ const { dir } = await build([{ path: "trunc.ttl", content: uniqueTtl }]);
155
+ const dataPath = join(dir, DATA_FILE);
156
+ const lines = readFileSync(dataPath, "utf-8")
157
+ .split("\n")
158
+ .filter((line) => line.trim() !== "");
159
+ expect(lines.length).toBeGreaterThan(1);
160
+ // Drop the last statement: the dump is now a PARTIAL graph — still
161
+ // non-empty (so it passes the size>0 completeness gate) but fewer triples
162
+ // than the manifest recorded, which the boot cross-check must catch.
163
+ writeFileSync(dataPath, `${lines.slice(0, -1).join("\n")}\n`);
164
+
165
+ let caught: unknown;
166
+ try {
167
+ const session = await readPack(dir);
168
+ session.store.dispose();
169
+ } catch (error) {
170
+ caught = error;
171
+ }
172
+ expect(caught).toMatchObject({ code: "STORE_UNAVAILABLE" });
173
+ });
174
+
175
+ it("tolerates a benign superset (actual > recorded), not corruption", async () => {
176
+ // A future ke counting change could load MORE triples than the manifest
177
+ // recorded. That is not truncation, so boot must NOT reject it — otherwise
178
+ // one counting change trips a fleet-wide false STORE_UNAVAILABLE. Simulate by
179
+ // lowering the manifest's tripleCount below the dump's actual count.
180
+ const uniqueTtl = `${TTL}\nex:Superset a ex:Component ; rdfs:label "Superset" .\n`;
181
+ const { dir } = await build([{ path: "superset.ttl", content: uniqueTtl }]);
182
+ const manifestPath = join(dir, MANIFEST_FILE);
183
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as {
184
+ tripleCount: number;
185
+ };
186
+ writeFileSync(
187
+ manifestPath,
188
+ JSON.stringify({ ...manifest, tripleCount: manifest.tripleCount - 1 }),
189
+ );
190
+
191
+ // The booted store holds one MORE triple than the (lowered) manifest — a
192
+ // superset. On the pre-fix exact-equality guard this threw; now it boots.
193
+ const session = await readPack(dir);
194
+ expect(session.index.entities.length).toBeGreaterThan(0);
195
+ session.store.dispose();
196
+ });
197
+ });
198
+
131
199
  describe("graphpack read — incomplete pack recovery (CLI + MCP)", () => {
132
200
  it("an incomplete pack is STORE_UNAVAILABLE with a `sources_update` tool recovery", async () => {
133
201
  // A dir with no manifest is an incomplete pack — readPack rejects before any
@@ -73,13 +73,28 @@ export async function readPack(dir: string): Promise<StoreSession> {
73
73
  JSON.parse(readFileSync(join(dir, INDEX_FILE), "utf-8")),
74
74
  );
75
75
 
76
- // Guard against a corrupt/unparseable `data.nq` beside an intact manifest:
77
- // ke silently falls back to an EMPTY store (and rewrites an empty dump).
78
- // If the index says the pack is populated but the store booted with no
79
- // triples, the cache is ruined — surface STORE_UNAVAILABLE (and, via the
80
- // now-empty dump, `packIsComplete` turns false so recovery rebuilds) rather
81
- // than serve an empty graph as if it were the pack.
82
- if (index.entities.length > 0 && !(await storeHasTriples(store))) {
76
+ // Guard against a corrupt or TRUNCATED `data.nq` beside an intact manifest:
77
+ // ke silently serves whatever loaded (falling back to an EMPTY store on a
78
+ // parse failure, and rewriting an empty dump). When the manifest records the
79
+ // build-time triple count (A9), require the booted store to hold AT LEAST it:
80
+ // a SHORTFALL means the dump was truncated (fewer triples than were built) —
81
+ // it passes the mere size>0 completeness gate yet would serve a partial graph
82
+ // silently, so it must surface STORE_UNAVAILABLE here. A benign SUPERSET (more
83
+ // triples than recorded — e.g. a future ke counting change) is TOLERATED
84
+ // rather than treated as corruption, so such a change can't trip a fleet-wide
85
+ // false STORE_UNAVAILABLE. Older packs without the count fall back to the
86
+ // "populated index but empty store" check (via the now-empty dump,
87
+ // `packIsComplete` turns false so recovery rebuilds) rather than serving an
88
+ // empty graph as the pack.
89
+ const actualTriples = await countTriples(store);
90
+ if (manifest.tripleCount !== undefined) {
91
+ if (actualTriples < manifest.tripleCount) {
92
+ store.dispose();
93
+ throw packUnavailable(
94
+ `The pack at ${dir} has a corrupt data cache (expected at least ${manifest.tripleCount} triples, loaded ${actualTriples}).`,
95
+ );
96
+ }
97
+ } else if (index.entities.length > 0 && actualTriples === 0) {
83
98
  store.dispose();
84
99
  throw packUnavailable(`The pack at ${dir} has a corrupt data cache.`);
85
100
  }
@@ -93,10 +108,10 @@ export async function readPack(dir: string): Promise<StoreSession> {
93
108
  };
94
109
  }
95
110
 
96
- /** Whether the booted store holds at least one triple (a cheap ASK probe). */
97
- async function storeHasTriples(store: StoreSession["store"]): Promise<boolean> {
98
- const result = (await store.query("ASK { ?s ?p ?o }" as never)) as {
99
- result: boolean;
100
- };
101
- return result.result === true;
111
+ /** Count the booted store's triples (a cheap aggregate over the union graph). */
112
+ async function countTriples(store: StoreSession["store"]): Promise<number> {
113
+ const result = (await store.query(
114
+ "SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }" as never,
115
+ )) as import("@canonical/ke").SelectResult;
116
+ return Number(result.bindings.at(0)?.n ?? 0);
102
117
  }
@@ -105,6 +105,20 @@ export const manifestSchema = z.object({
105
105
  contentHash: z.string(),
106
106
  prefixes: z.record(z.string(), z.string()),
107
107
  createdAt: z.string(),
108
+ /**
109
+ * The store's triple count at build time. Cross-checked against the booted
110
+ * store so a truncated-but-non-empty `data.nq` (a partial graph that passes
111
+ * the mere size>0 completeness gate) surfaces as STORE_UNAVAILABLE rather
112
+ * than being served silently (A9). Optional — packs built before this field
113
+ * skip the check.
114
+ */
115
+ tripleCount: z.number().optional(),
116
+ /**
117
+ * The distinct abox entity count (matches {@link entityTotal}). Lets
118
+ * `sources status` report the figure without parsing the whole `index.json`
119
+ * (A10). Optional — packs built before this field fall back to the index read.
120
+ */
121
+ entityCount: z.number().optional(),
108
122
  });
109
123
 
110
124
  /** Pack provenance and the prefixes the store was built with. */
@@ -9,13 +9,11 @@
9
9
  * than silently reaching out.
10
10
  */
11
11
 
12
- import { RECOVERY_CLI_PREFIX } from "../../constants.js";
13
12
  import type { ConfigLayers } from "../config/types.js";
14
- import { PragmaError } from "../error/PragmaError.js";
15
- import { cliRecovery } from "../error/recovery.js";
16
13
  import { materializeEmbeddedPack } from "./graphpack/embedded.js";
17
14
  import { readPack } from "./graphpack/read.js";
18
15
  import { resolveSources } from "./resolveSources.js";
16
+ import { storeUnavailable } from "./storeReadiness.js";
19
17
  import type { StoreSession } from "./types.js";
20
18
 
21
19
  /** What {@link loadStoreSession} needs from the runtime. */
@@ -39,15 +37,9 @@ export async function loadStoreSession(
39
37
  const decision = resolveSources(layers, ctx.cwd);
40
38
 
41
39
  if (decision.kind === "unavailable") {
42
- throw PragmaError.storeUnavailable(`${decision.reason}.`, {
43
- recovery: cliRecovery(
44
- `${RECOVERY_CLI_PREFIX}sources update`,
45
- "Build the local store from the configured packages.",
46
- // An agent recovers by calling the tool (then retrying — PR9's C1 cold-
47
- // store retry makes a post-update retry succeed).
48
- { tool: "sources_update" },
49
- ),
50
- });
40
+ // The SAME cold-store diagnostic the native MCP surfaces raise (shared via
41
+ // storeReadiness), so a cold store reads identically on every surface.
42
+ throw storeUnavailable(decision.reason);
51
43
  }
52
44
 
53
45
  const dir =
@@ -9,7 +9,7 @@
9
9
  * machine-independent. Reached only from the `sources update` Task body.
10
10
  */
11
11
 
12
- import { existsSync, readdirSync, readFileSync } from "node:fs";
12
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
13
13
  import { createRequire } from "node:module";
14
14
  import { dirname, join, relative } from "node:path";
15
15
  import { RECOVERY_CLI_PREFIX } from "../../../constants.js";
@@ -84,9 +84,28 @@ function walkTtl(
84
84
  // build (a `.`-prefixed Turtle local name isn't even valid).
85
85
  if (entry.name.startsWith(".")) continue;
86
86
  const full = join(dir, entry.name);
87
- if (entry.isDirectory()) {
87
+ // Resolve symlinks: pnpm / workspace trees link their sources, but
88
+ // `Dirent.isFile`/`isDirectory` are BOTH false for a symlink — so a
89
+ // symlinked `.ttl` was silently skipped (L6). Follow the link to recover a
90
+ // symlinked FILE, but NEVER recurse into a symlinked DIRECTORY: a symlink
91
+ // cycle (a link to an ancestor dir) would recurse without bound — a
92
+ // stack-overflow RangeError surfacing as an INTERNAL "please report" — and a
93
+ // linked directory could pull `.ttl` from OUTSIDE the package root. Linking
94
+ // individual source files (all L6 needs) stays supported; a dangling link is
95
+ // skipped, not fatal.
96
+ let isDir = entry.isDirectory();
97
+ let isFile = entry.isFile();
98
+ if (entry.isSymbolicLink()) {
99
+ try {
100
+ isFile = statSync(full).isFile();
101
+ isDir = false;
102
+ } catch {
103
+ continue;
104
+ }
105
+ }
106
+ if (isDir) {
88
107
  walkTtl(full, base, label, out);
89
- } else if (entry.isFile() && entry.name.endsWith(".ttl")) {
108
+ } else if (isFile && entry.name.endsWith(".ttl")) {
90
109
  out.push({
91
110
  path: `${label}/${relative(base, full)}`,
92
111
  content: readFileSync(full, "utf-8"),
@@ -143,6 +162,47 @@ export function harvestPrefixes(
143
162
  return prefixes;
144
163
  }
145
164
 
165
+ /** One `@prefix` clash: a label declared with two or more distinct IRIs. */
166
+ export interface PrefixClash {
167
+ readonly label: string;
168
+ /** The distinct IRIs bound to the label, in first-seen order. */
169
+ readonly iris: readonly string[];
170
+ }
171
+
172
+ /**
173
+ * Detect `@prefix` label clashes across a set of sources.
174
+ *
175
+ * {@link harvestPrefixes} resolves clashes last-wins (Turtle's own rule),
176
+ * silently compacting the losing package's URIs to the wrong prefix (or to full
177
+ * IRIs). This surfaces the clashes so `sources update` can warn (A5): a label
178
+ * bound to two or more distinct IRIs. Harmless same-label/same-IRI
179
+ * redeclarations (the common case) are NOT clashes. Result is sorted by label
180
+ * so the warnings are deterministic.
181
+ *
182
+ * @param sources - The packages' labelled RDF sources.
183
+ * @returns One entry per clashing label, each with its distinct IRIs.
184
+ */
185
+ export function detectPrefixClashes(
186
+ sources: readonly { readonly content: string }[],
187
+ ): PrefixClash[] {
188
+ const byLabel = new Map<string, string[]>();
189
+ for (const { content } of sources) {
190
+ for (const match of content.matchAll(PREFIX_DECL)) {
191
+ const [, label, iri] = match;
192
+ if (label === undefined || iri === undefined) continue;
193
+ const iris = byLabel.get(label) ?? [];
194
+ if (!iris.includes(iri)) iris.push(iri);
195
+ byLabel.set(label, iris);
196
+ }
197
+ }
198
+ const clashes: PrefixClash[] = [];
199
+ for (const [label, iris] of byLabel) {
200
+ if (iris.length > 1) clashes.push({ label, iris });
201
+ }
202
+ clashes.sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
203
+ return clashes;
204
+ }
205
+
146
206
  /**
147
207
  * Locate a package's `package.json` from the project.
148
208
  *
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The STORELESS store-readiness check — the ONE source of the cold-store
3
+ * `STORE_UNAVAILABLE` error (its message AND its `pragma sources update` /
4
+ * `sources_update` recovery), shared by the lazy store boot ({@link
5
+ * loadStoreSession}) and the native MCP discovery surfaces (the `prompts/*`
6
+ * provider, the `pragma:{+uri}` resource browser).
7
+ *
8
+ * A cold store must present the SAME diagnostic on every surface: an agent that
9
+ * lists prompts over the `prompt_list` tool (store-backed, so its `needsStore`
10
+ * pre-check throws) and one that lists them over native `prompts/list` must see
11
+ * the identical `STORE_UNAVAILABLE` + `sources_update` recovery, not one error
12
+ * and one silently-empty list. Centralizing the construction here is what keeps
13
+ * those surfaces from drifting apart.
14
+ *
15
+ * It reaches only {@link resolveSources} (a lock + config probe — no store, no
16
+ * ke), so a caller on the storeless fast path can pre-check availability without
17
+ * ever booting the store.
18
+ */
19
+
20
+ import { RECOVERY_CLI_PREFIX } from "../../constants.js";
21
+ import type { ConfigLayers } from "../config/types.js";
22
+ import { PragmaError } from "../error/PragmaError.js";
23
+ import { cliRecovery } from "../error/recovery.js";
24
+ import { resolveSources } from "./resolveSources.js";
25
+
26
+ /**
27
+ * Build the canonical cold-store `STORE_UNAVAILABLE` error: the reason plus the
28
+ * single `pragma sources update` recovery that also names the `sources_update`
29
+ * tool an agent calls (then retries — PR9's C1 cold-store retry succeeds).
30
+ *
31
+ * @param reason - The boot decision's reason (e.g. "the locked pack is missing").
32
+ * @returns The structured STORE_UNAVAILABLE error.
33
+ */
34
+ export function storeUnavailable(reason: string): PragmaError {
35
+ return PragmaError.storeUnavailable(`${reason}.`, {
36
+ recovery: cliRecovery(
37
+ `${RECOVERY_CLI_PREFIX}sources update`,
38
+ "Build the local store from the configured packages.",
39
+ { tool: "sources_update" },
40
+ ),
41
+ });
42
+ }
43
+
44
+ /**
45
+ * The STORE_UNAVAILABLE error to surface when the store cannot boot from the
46
+ * current config/lock, or `undefined` when a store read may proceed. STORELESS —
47
+ * probes the decision table only, never constructs the store.
48
+ *
49
+ * @param layers - The resolved config layers.
50
+ * @param cwd - The project directory the boot resolves against.
51
+ * @returns The error to surface cold, or `undefined` when the store is available.
52
+ */
53
+ export function checkStoreAvailable(
54
+ layers: ConfigLayers,
55
+ cwd: string,
56
+ ): PragmaError | undefined {
57
+ const decision = resolveSources(layers, cwd);
58
+ return decision.kind === "unavailable"
59
+ ? storeUnavailable(decision.reason)
60
+ : undefined;
61
+ }
@@ -17,7 +17,7 @@ import type { ConfigLayers } from "../config/types.js";
17
17
  * separately so inferred mode can shape output without switching behaviour.
18
18
  */
19
19
  export interface GlobalFlags {
20
- /** Condensed Markdown output for LLM/agent consumption. */
20
+ /** Condensed Markdown output, from `--format llm` or auto-detection. */
21
21
  readonly llm: boolean;
22
22
  /** True when `llm` was inferred from a non-interactive stdout, not requested. */
23
23
  readonly autoLlm?: boolean;