@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
@@ -46,6 +46,7 @@ import {
46
46
  redactUrl,
47
47
  } from "../../kernel/runtime/refs/parseRef.js";
48
48
  import {
49
+ detectPrefixClashes,
49
50
  harvestPrefixes,
50
51
  type ResolvedPackage,
51
52
  resolvePackage,
@@ -172,11 +173,41 @@ export async function buildUpdateTask(
172
173
  // token contract); config still wins any clash. The merged map is persisted
173
174
  // in the manifest, so boot reads the same names.
174
175
  const inputs = resolved.flatMap((pkg) => pkg.sources);
176
+
177
+ // Refuse to lock an empty store (A4). A package that ships no `.ttl` (or no
178
+ // configured packages at all) would build a 0-triple pack whose empty
179
+ // `data.nq` fails the completeness gate — so the "successful" update boots to
180
+ // a PERMANENT `STORE_UNAVAILABLE` loop. Fail loudly here, leaving the embedded
181
+ // fallback (or the prior lock) intact, instead of writing that broken lock.
182
+ if (inputs.length === 0) {
183
+ throw PragmaError.configError(
184
+ entries.length === 0
185
+ ? "No packages are configured, so there are no sources to build a store from. The embedded sample pack answers reads until you add a package."
186
+ : `The ${entries.length} configured package(s) resolved 0 RDF sources (no definitions/**.ttl or data/**.ttl). Refusing to write a lock for an empty store.`,
187
+ {
188
+ recovery: cliRecovery(
189
+ `${RECOVERY_CLI_PREFIX}sources update --verbose`,
190
+ "Add a package that ships `.ttl` under definitions/ or data/, then re-run.",
191
+ ),
192
+ },
193
+ );
194
+ }
195
+
175
196
  const prefixes = {
176
197
  ...CORE_PREFIXES,
177
198
  ...harvestPrefixes(inputs),
178
199
  ...(layers.config.prefixes ?? {}),
179
200
  };
201
+
202
+ // Warn on conflicting `@prefix` declarations across packages (A5): last-wins
203
+ // is silent otherwise, compacting the losing package's URIs to the wrong
204
+ // prefix. Display-only, so a warning (not a hard failure) is the right call.
205
+ for (const clash of detectPrefixClashes(inputs)) {
206
+ report?.(
207
+ `Prefix "${clash.label}:" is declared with conflicting IRIs across packages (${clash.iris.join(" vs ")}); last wins, so some entities may compact to the wrong prefix.`,
208
+ );
209
+ }
210
+
180
211
  report?.(`Building store from ${inputs.length} source(s)`);
181
212
  if (verbose) for (const input of inputs) report?.(` parse ${input.path}`);
182
213
 
@@ -239,17 +270,43 @@ export async function buildUpdateTask(
239
270
  `${built.reused ? "Reused" : "Built"} store ${built.contentHash.slice(0, 12)}`,
240
271
  );
241
272
 
273
+ // Refuse to lock a 0-triple build (A4): non-empty sources can still parse to
274
+ // no triples (e.g. comment-only TTL, or a file of only `@prefix` lines). A
275
+ // locked empty pack boots to the same `STORE_UNAVAILABLE` loop, so treat it
276
+ // as a data error rather than a silent "success". (A manifest predating the
277
+ // triple count is left alone — `undefined` never trips this.)
278
+ if (built.manifest.tripleCount === 0) {
279
+ throw PragmaError.configError(
280
+ `The configured sources parsed to 0 RDF triples, so the store would be empty. Refusing to write a lock for an empty store.`,
281
+ {
282
+ recovery: cliRecovery(
283
+ `${RECOVERY_CLI_PREFIX}sources update --verbose`,
284
+ "Check that the package sources actually contain RDF triples, then re-run.",
285
+ ),
286
+ },
287
+ );
288
+ }
289
+
242
290
  const now = new Date().toISOString();
243
291
  const lock: PragmaLock = {
244
292
  version: 1,
245
293
  contentHash: built.contentHash,
246
294
  packs: resolved.map((pkg) => {
247
295
  const prev = existing?.packs.find((entry) => entry.name === pkg.name);
296
+ // Preserve the prior `resolvedAt` when nothing actually changed — under
297
+ // --frozen (re-pinned to the same revision) AND on a plain re-run that
298
+ // resolves the IDENTICAL revision from the same source. Otherwise every
299
+ // `sources update` stamped a fresh timestamp and rewrote the lock, dirtying
300
+ // the git tree on a no-op run (L1). A moved revision still restamps.
301
+ const reproducible =
302
+ prev !== undefined &&
303
+ (frozen ||
304
+ (prev.resolved === pkg.resolved && prev.source === pkg.source));
248
305
  return {
249
306
  name: pkg.name,
250
307
  source: pkg.source,
251
308
  resolved: pkg.resolved,
252
- resolvedAt: frozen && prev ? prev.resolvedAt : now,
309
+ resolvedAt: reproducible && prev ? prev.resolvedAt : now,
253
310
  };
254
311
  }),
255
312
  };
@@ -5,6 +5,7 @@ import {
5
5
  mkdtempSync,
6
6
  readFileSync,
7
7
  rmSync,
8
+ symlinkSync,
8
9
  writeFileSync,
9
10
  } from "node:fs";
10
11
  import type { createRequire } from "node:module";
@@ -20,12 +21,16 @@ import { bootRuntime } from "../../kernel/runtime/boot.js";
20
21
  import { createQueryFacade } from "../../kernel/runtime/facade.js";
21
22
  import { readLock } from "../../kernel/runtime/lock.js";
22
23
  import { lockPath, packDir } from "../../kernel/runtime/paths.js";
23
- import { resolvePackageJson } from "../../kernel/runtime/refs/resolve.js";
24
+ import {
25
+ detectPrefixClashes,
26
+ resolvePackageJson,
27
+ } from "../../kernel/runtime/refs/resolve.js";
24
28
  import { createLazyStore } from "../../kernel/runtime/store.js";
25
29
  import type { GlobalFlags, PragmaRuntime } from "../../kernel/runtime/types.js";
26
30
  import type { VerbSpec } from "../../kernel/spec/types.js";
27
31
  import { projectMcp } from "../../testing/helpers/projectMcp.js";
28
32
  import { discoverSkills } from "../skill/discover.js";
33
+ import { collectStatus } from "./collectStatus.js";
29
34
  import { sourcesModule } from "./index.js";
30
35
  import { buildUpdateTask } from "./runUpdate.js";
31
36
 
@@ -153,6 +158,60 @@ describe("sources lock round-trip (PROTECTED)", () => {
153
158
  expect(readFileSync(lockPath(cwd), "utf-8")).toBe(firstBytes);
154
159
  });
155
160
 
161
+ it("a non-frozen re-run over an unchanged source rewrites a byte-identical lock (L1)", async () => {
162
+ // On base, every non-frozen update stamped a fresh `resolvedAt`, so a no-op
163
+ // re-run dirtied the tree. When the revision is unchanged the timestamp is
164
+ // preserved, so the lock is byte-identical.
165
+ const pkg = filePackage();
166
+ const cwd = tmp("pragma-proj-");
167
+ const runtime = runtimeFor(cwd, [
168
+ { name: "pkg-a", source: `file://${pkg}` },
169
+ ]);
170
+ await runTask(await buildUpdateTask(runtime, false));
171
+ const firstBytes = readFileSync(lockPath(cwd), "utf-8");
172
+ await runTask(await buildUpdateTask(runtime, false));
173
+ expect(readFileSync(lockPath(cwd), "utf-8")).toBe(firstBytes);
174
+ });
175
+
176
+ it("follows a symlinked .ttl into the build (L6)", async () => {
177
+ // pnpm / workspace trees symlink their sources; Dirent.isFile is false for a
178
+ // symlink, so on base the only `.ttl` was skipped → 0 sources → the empty
179
+ // build. Following the link ingests it and the build+lock succeed.
180
+ const pkg = tmp("pragma-symlink-pkg-");
181
+ mkdirSync(join(pkg, "definitions"), { recursive: true });
182
+ const realTtl = join(pkg, "real.ttl");
183
+ writeFileSync(realTtl, TTL);
184
+ symlinkSync(realTtl, join(pkg, "definitions", "linked.ttl"));
185
+ const cwd = tmp("pragma-proj-");
186
+ const runtime = runtimeFor(cwd, [
187
+ { name: "pkg-a", source: `file://${pkg}` },
188
+ ]);
189
+
190
+ const result = await runTask(await buildUpdateTask(runtime, false));
191
+ expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/);
192
+ expect(readLock(cwd)?.packs).toHaveLength(1);
193
+ });
194
+
195
+ it("does not recurse into a symlinked directory cycle (L6 safety)", async () => {
196
+ // The L6 fix follows symlinked FILES but must NOT recurse into a symlinked
197
+ // DIRECTORY: a link to an ancestor dir is a cycle that, if walked, recurses
198
+ // without bound → a stack-overflow RangeError (an INTERNAL "please report").
199
+ // The real `.ttl` is still ingested and the build completes.
200
+ const pkg = tmp("pragma-symlink-cycle-");
201
+ mkdirSync(join(pkg, "definitions"), { recursive: true });
202
+ writeFileSync(join(pkg, "definitions", "widget.ttl"), TTL);
203
+ // `definitions/loop` → `definitions` (its own parent): a symlink cycle.
204
+ symlinkSync(join(pkg, "definitions"), join(pkg, "definitions", "loop"));
205
+ const cwd = tmp("pragma-proj-");
206
+ const runtime = runtimeFor(cwd, [
207
+ { name: "pkg-a", source: `file://${pkg}` },
208
+ ]);
209
+
210
+ const result = await runTask(await buildUpdateTask(runtime, false));
211
+ expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/);
212
+ expect(readLock(cwd)?.packs).toHaveLength(1);
213
+ });
214
+
156
215
  it("undo restores the prior lock (no prior → removed)", async () => {
157
216
  const pkg = filePackage();
158
217
  const cwd = tmp("pragma-proj-");
@@ -200,6 +259,114 @@ describe("sources update — package-declared prefixes (M1)", () => {
200
259
  });
201
260
  });
202
261
 
262
+ describe("sources update — refuses an empty store (A4)", () => {
263
+ /** A local package that ships NO `.ttl` (an empty definitions directory). */
264
+ function emptyPackage(): string {
265
+ const pkg = tmp("pragma-empty-pkg-");
266
+ mkdirSync(join(pkg, "definitions"), { recursive: true });
267
+ return pkg;
268
+ }
269
+
270
+ it("a package with no .ttl is refused, and no lock is written", async () => {
271
+ // On base this builds a 0-triple pack whose empty data.nq fails the
272
+ // completeness gate — so the "successful" update boots to a PERMANENT
273
+ // STORE_UNAVAILABLE loop. The fix refuses before writing the lock.
274
+ const pkg = emptyPackage();
275
+ const cwd = tmp("pragma-proj-");
276
+ const runtime = runtimeFor(cwd, [
277
+ { name: "pkg-a", source: `file://${pkg}` },
278
+ ]);
279
+
280
+ await expect(buildUpdateTask(runtime, false)).rejects.toMatchObject({
281
+ code: "CONFIG_ERROR",
282
+ });
283
+ // No lock → the embedded fallback / prior state survives, no boot loop.
284
+ expect(readLock(cwd)).toBeUndefined();
285
+ });
286
+
287
+ it("no configured packages is refused rather than locking an empty pack", async () => {
288
+ const cwd = tmp("pragma-proj-");
289
+ const runtime = runtimeFor(cwd, []);
290
+ await expect(buildUpdateTask(runtime, false)).rejects.toMatchObject({
291
+ code: "CONFIG_ERROR",
292
+ });
293
+ expect(readLock(cwd)).toBeUndefined();
294
+ });
295
+
296
+ it("a comment-only .ttl (0 triples) is refused too", async () => {
297
+ const pkg = tmp("pragma-noTriples-pkg-");
298
+ mkdirSync(join(pkg, "definitions"), { recursive: true });
299
+ // Parses fine, but yields no triples — the post-build 0-triple guard.
300
+ writeFileSync(
301
+ join(pkg, "definitions", "empty.ttl"),
302
+ "# only a comment, no triples\n",
303
+ );
304
+ const cwd = tmp("pragma-proj-");
305
+ const runtime = runtimeFor(cwd, [
306
+ { name: "pkg-a", source: `file://${pkg}` },
307
+ ]);
308
+ await expect(buildUpdateTask(runtime, false)).rejects.toMatchObject({
309
+ code: "CONFIG_ERROR",
310
+ });
311
+ expect(readLock(cwd)).toBeUndefined();
312
+ });
313
+ });
314
+
315
+ describe("sources update — conflicting @prefix detection (A5)", () => {
316
+ it("flags a label bound to two different IRIs across packages", () => {
317
+ const clashes = detectPrefixClashes([
318
+ { content: "@prefix ex: <https://a.test/#> .\nex:One a ex:T ." },
319
+ { content: "@prefix ex: <https://b.test/#> .\nex:Two a ex:T ." },
320
+ ]);
321
+ expect(clashes).toHaveLength(1);
322
+ expect(clashes.at(0)?.label).toBe("ex");
323
+ expect(clashes.at(0)?.iris).toEqual([
324
+ "https://a.test/#",
325
+ "https://b.test/#",
326
+ ]);
327
+ });
328
+
329
+ it("does NOT flag harmless same-label/same-IRI redeclarations", () => {
330
+ const clashes = detectPrefixClashes([
331
+ { content: "@prefix ex: <https://a.test/#> .\nex:One a ex:T ." },
332
+ { content: "@prefix ex: <https://a.test/#> .\nex:Two a ex:T ." },
333
+ ]);
334
+ expect(clashes).toEqual([]);
335
+ });
336
+
337
+ it("warns loudly on a cross-package prefix clash during update", async () => {
338
+ const owl = "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n";
339
+ const pkgA = tmp("pragma-clash-a-");
340
+ mkdirSync(join(pkgA, "definitions"), { recursive: true });
341
+ writeFileSync(
342
+ join(pkgA, "definitions", "a.ttl"),
343
+ `@prefix ex: <https://a.test/#> .\n${owl}ex:Thing a owl:Class .\nex:one a ex:Thing .\n`,
344
+ );
345
+ const pkgB = tmp("pragma-clash-b-");
346
+ mkdirSync(join(pkgB, "definitions"), { recursive: true });
347
+ writeFileSync(
348
+ join(pkgB, "definitions", "b.ttl"),
349
+ `@prefix ex: <https://b.test/#> .\n${owl}ex:Widget a owl:Class .\nex:two a ex:Widget .\n`,
350
+ );
351
+ const cwd = tmp("pragma-proj-");
352
+ const reports: string[] = [];
353
+ const runtime: PragmaRuntime = {
354
+ ...runtimeFor(cwd, [
355
+ { name: "pkg-a", source: `file://${pkgA}` },
356
+ { name: "pkg-b", source: `file://${pkgB}` },
357
+ ]),
358
+ report: (message: string) => reports.push(message),
359
+ };
360
+
361
+ await runTask(await buildUpdateTask(runtime, false));
362
+ expect(
363
+ reports.some(
364
+ (r) => r.includes('Prefix "ex:"') && r.includes("conflicting"),
365
+ ),
366
+ ).toBe(true);
367
+ });
368
+ });
369
+
203
370
  describe("sources update — network-free preview (M2)", () => {
204
371
  const updateVerb = sourcesModule.verbs[1] as VerbSpec;
205
372
  const DRY_RUN = { dryRun: true, undo: false, yes: false };
@@ -615,6 +782,25 @@ describe("sources update — installs package skills (U10)", () => {
615
782
  });
616
783
  });
617
784
 
785
+ describe("sources status — entityCount from the manifest (A10)", () => {
786
+ it("reports the count without re-parsing index.json", async () => {
787
+ const pkg = filePackage(); // ex:Widget class + ex:one individual
788
+ const cwd = tmp("pragma-proj-");
789
+ const runtime = runtimeFor(cwd, [
790
+ { name: "pkg-a", source: `file://${pkg}` },
791
+ ]);
792
+ const result = await runTask(await buildUpdateTask(runtime, false));
793
+
794
+ // Delete index.json: any path that PARSED it to count would now fail —
795
+ // proving the figure is read from the manifest's persisted entityCount.
796
+ rmSync(join(packDir(result.contentHash), "index.json"), { force: true });
797
+
798
+ const status = await collectStatus(bootRuntime(flagsJson, cwd));
799
+ // One abox individual (ex:one); the ex:Widget class is tbox, not counted.
800
+ expect(status.entityCount).toBe(1);
801
+ });
802
+ });
803
+
618
804
  describe("sources status CLI-json == MCP tool (PROTECTED)", () => {
619
805
  it("a cold store's status is byte-equal on both surfaces", async () => {
620
806
  // Both surfaces boot from the SAME cwd + isolated config (default packages,
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { type RenderStyle, styleFor } from "../../kernel/render/style.js";
3
+ import { renderSourcesStatusPlain } from "./status.render.js";
4
+ import type { SourcesStatusData } from "./types.js";
5
+
6
+ const DATA: SourcesStatusData = {
7
+ cwd: "/repo",
8
+ lockPresent: true,
9
+ contentHash: "abcdef012345aaaa",
10
+ cached: true,
11
+ builtAt: "2026-01-01",
12
+ entityCount: 42,
13
+ sources: [
14
+ {
15
+ name: "ds",
16
+ ref: "ds@1",
17
+ resolved: "1111111111112222",
18
+ staleness: "up-to-date",
19
+ },
20
+ {
21
+ name: "extra",
22
+ ref: "extra@1",
23
+ resolved: null,
24
+ staleness: "config-drift",
25
+ },
26
+ ],
27
+ };
28
+
29
+ /** A sentinel styler — enabled, each color wrapped in a visible tag. */
30
+ const TAGGED: RenderStyle = {
31
+ enabled: true,
32
+ bold: (t) => `<b>${t}</b>`,
33
+ dim: (t) => `<d>${t}</d>`,
34
+ cyan: (t) => `<c>${t}</c>`,
35
+ green: (t) => `<g>${t}</g>`,
36
+ yellow: (t) => `<y>${t}</y>`,
37
+ };
38
+
39
+ describe("renderSourcesStatusPlain", () => {
40
+ it("is byte-identical to the plain form when the styler is disabled", () => {
41
+ expect(renderSourcesStatusPlain(DATA, styleFor(false))).toBe(
42
+ [
43
+ "Store: ready",
44
+ " pack: abcdef012345 — 42 entities, built 2026-01-01",
45
+ "",
46
+ "Sources:",
47
+ " ds [ok] @ 111111111111",
48
+ " extra [config drift]",
49
+ ].join("\n"),
50
+ );
51
+ });
52
+
53
+ it("bolds the heading, aligns names, and tints markers on a TTY", () => {
54
+ const out = renderSourcesStatusPlain(DATA, TAGGED);
55
+ const nameWidth = "extra".length;
56
+ expect(out).toContain("<b>Sources:</b>");
57
+ // up-to-date tints green with a dim resolved suffix; drift tints yellow.
58
+ expect(out).toContain(
59
+ ` ${"ds".padEnd(nameWidth)} <g>[ok]</g><d> @ 111111111111</d>`,
60
+ );
61
+ expect(out).toContain(
62
+ ` ${"extra".padEnd(nameWidth)} <y>[config drift]</y>`,
63
+ );
64
+ });
65
+ });
@@ -2,6 +2,7 @@
2
2
  * Formatters for `pragma sources status` — plain, llm, json.
3
3
  */
4
4
 
5
+ import { defaultStyle, type RenderStyle } from "../../kernel/render/style.js";
5
6
  import type { Formatters } from "../../kernel/spec/types.js";
6
7
  import type { SourcesStatusData } from "./types.js";
7
8
 
@@ -11,31 +12,57 @@ const STALENESS_MARK: Record<string, string> = {
11
12
  uncached: "not built",
12
13
  };
13
14
 
15
+ /**
16
+ * Render `sources status` as plain text.
17
+ *
18
+ * @param data - The resolved sources-status payload.
19
+ * @param style - TTY styling; defaults to the process style. On a color-capable
20
+ * terminal the `Sources:` heading is bold, source names align into a column,
21
+ * and each staleness marker is tinted (green up-to-date, else yellow); off a
22
+ * TTY the styler is inert, so the output is byte-identical to the plain form.
23
+ * @returns The formatted status block.
24
+ */
25
+ export function renderSourcesStatusPlain(
26
+ data: SourcesStatusData,
27
+ style: RenderStyle = defaultStyle(),
28
+ ): string {
29
+ const lines = [
30
+ data.lockPresent
31
+ ? `Store: ${data.cached ? "ready" : "locked but not cached"}`
32
+ : "Store: no lock (run `pragma sources update`)",
33
+ ];
34
+ if (data.cached) {
35
+ lines.push(
36
+ ` pack: ${data.contentHash?.slice(0, 12)} — ${data.entityCount ?? "?"} entities, built ${data.builtAt ?? "?"}`,
37
+ );
38
+ }
39
+ lines.push("", style.enabled ? style.bold("Sources:") : "Sources:");
40
+ if (data.sources.length === 0) {
41
+ lines.push(" (none configured)");
42
+ }
43
+ const nameWidth = style.enabled
44
+ ? Math.max(0, ...data.sources.map((source) => source.name.length))
45
+ : 0;
46
+ for (const source of data.sources) {
47
+ const mark = STALENESS_MARK[source.staleness] ?? source.staleness;
48
+ const resolved = source.resolved
49
+ ? ` @ ${source.resolved.slice(0, 12)}`
50
+ : "";
51
+ if (!style.enabled) {
52
+ lines.push(` ${source.name} [${mark}]${resolved}`);
53
+ continue;
54
+ }
55
+ const tint = source.staleness === "up-to-date" ? style.green : style.yellow;
56
+ lines.push(
57
+ ` ${source.name.padEnd(nameWidth)} ${tint(`[${mark}]`)}${style.dim(resolved)}`,
58
+ );
59
+ }
60
+ return lines.join("\n");
61
+ }
62
+
14
63
  export const statusFormatters: Formatters<SourcesStatusData> = {
15
64
  plain(data) {
16
- const lines = [
17
- data.lockPresent
18
- ? `Store: ${data.cached ? "ready" : "locked but not cached"}`
19
- : "Store: no lock (run `pragma sources update`)",
20
- ];
21
- if (data.cached) {
22
- lines.push(
23
- ` pack: ${data.contentHash?.slice(0, 12)} — ${data.entityCount ?? "?"} entities, built ${data.builtAt ?? "?"}`,
24
- );
25
- }
26
- lines.push("", "Sources:");
27
- if (data.sources.length === 0) {
28
- lines.push(" (none configured)");
29
- }
30
- for (const source of data.sources) {
31
- const resolved = source.resolved
32
- ? ` @ ${source.resolved.slice(0, 12)}`
33
- : "";
34
- lines.push(
35
- ` ${source.name} [${STALENESS_MARK[source.staleness]}]${resolved}`,
36
- );
37
- }
38
- return lines.join("\n");
65
+ return renderSourcesStatusPlain(data);
39
66
  },
40
67
 
41
68
  llm(data) {
@@ -23,12 +23,11 @@ describe("surface conformance — capabilities ⊆ covenant (PROTECTED)", () =>
23
23
  expect(() => assertConforms(emitted, golden)).not.toThrow();
24
24
  });
25
25
 
26
- it("emits config show + the PR6 setters + the PR9 additive set, in covenant order", () => {
26
+ it("emits config show + the one-command config set, in covenant order (field-verbs retired, B3)", () => {
27
+ // AV-228 B3 retired the per-field `tier`/`channel`/`detail` setters; the
28
+ // config noun is now just the `show` reader and the `set <key> <value>` writer.
27
29
  expect(emitted.nouns.config?.verbs).toEqual([
28
30
  { v: "show", mcp: "config_show" },
29
- { v: "tier", args: ["<path>"], mutates: true, mcp: "config_tier" },
30
- { v: "channel", args: ["<name>"], mutates: true, mcp: "config_channel" },
31
- { v: "detail", args: ["<level>"], mutates: true, mcp: "config_detail" },
32
31
  {
33
32
  v: "set",
34
33
  args: ["<key>", "<value>"],
@@ -95,6 +94,15 @@ describe("surface conformance — capabilities ⊆ covenant (PROTECTED)", () =>
95
94
  it("emits the authored read nouns (ontology TBox, storeless skill, graph inspect)", () => {
96
95
  expect(emitted.nouns.ontology?.verbs).toEqual([
97
96
  { v: "list", needsStore: true, mcp: "ontology_list" },
97
+ {
98
+ v: "lookup",
99
+ args: ["<prefix>"],
100
+ flags: ["--properties", "--full-uris", "--class"],
101
+ needsStore: true,
102
+ mcp: "ontology_lookup",
103
+ },
104
+ // `show` is the deprecated alias of `lookup` (AV-228 B1) — same shape,
105
+ // both blessed in the covenant.
98
106
  {
99
107
  v: "show",
100
108
  args: ["<prefix>"],
@@ -146,11 +154,28 @@ describe("surface conformance — capabilities ⊆ covenant (PROTECTED)", () =>
146
154
  it("emits setup as a mixed self+sub noun (self is a tool, sub-verbs are mcp:false)", () => {
147
155
  // The one covenant noun both directly runnable AND with sub-verbs. The self
148
156
  // verb is the `setup` tool; the four installers are CLI-only (mcp:false).
157
+ // The band-aware verbs (setup/mcp/skills) carry the --scope/--global/--local
158
+ // flags; completions/lsp are single-band installers with no scope.
149
159
  expect(emitted.nouns.setup?.verbs).toEqual([
150
- { v: "setup", mutates: true, mcp: "setup" },
151
- { v: "mcp", mutates: true, mcp: false },
160
+ {
161
+ v: "setup",
162
+ flags: ["--scope", "--global", "--local"],
163
+ mutates: true,
164
+ mcp: "setup",
165
+ },
166
+ {
167
+ v: "mcp",
168
+ flags: ["--scope", "--global", "--local"],
169
+ mutates: true,
170
+ mcp: false,
171
+ },
152
172
  { v: "completions", mutates: true, mcp: false },
153
- { v: "skills", mutates: true, mcp: false },
173
+ {
174
+ v: "skills",
175
+ flags: ["--scope", "--global", "--local"],
176
+ mutates: true,
177
+ mcp: false,
178
+ },
154
179
  { v: "lsp", mutates: true, mcp: false },
155
180
  ]);
156
181
  });
@@ -184,14 +209,14 @@ describe("surface COMPLETE — emitted == covenant (PROTECTED)", () => {
184
209
  // The CLOSING direction: assertConforms already proves emitted ⊆ covenant;
185
210
  // this proves covenant ⊆ emitted, so together the tool sets are EQUAL — the
186
211
  // surface-complete milestone. After PR7, every covenant tool is realized.
187
- it("emits every covenant tool (all 40) — set equality with the covenant", () => {
212
+ it("emits every covenant tool (all 38) — set equality with the covenant", () => {
188
213
  const emittedTools = new Set(emitted.mcpSurface.tools);
189
214
  const missing = golden.mcpSurface.tools.filter((t) => !emittedTools.has(t));
190
215
  expect(missing).toEqual([]);
191
216
  expect([...emitted.mcpSurface.tools].sort()).toEqual(
192
217
  [...golden.mcpSurface.tools].sort(),
193
218
  );
194
- expect(emitted.mcpSurface.tools).toHaveLength(40);
219
+ expect(emitted.mcpSurface.tools).toHaveLength(38);
195
220
  });
196
221
 
197
222
  // The covenant edit: the non-tool MCP surface is frozen too.
@@ -0,0 +1,72 @@
1
+ import chalk from "chalk";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { type TierLookupData, tierLookupFormatters } from "./lookup.render.js";
4
+
5
+ const TIER: TierLookupData = {
6
+ uri: "cs:apps/lxd-panel",
7
+ name: "apps/lxd-panel",
8
+ blocks: ["LXD Panel", "Status Bar"],
9
+ };
10
+
11
+ /** Run `body` with stdout's `isTTY` forced to `value`, then restore it. */
12
+ function withStdoutTty(value: boolean | undefined, body: () => void): void {
13
+ const stream = process.stdout as { isTTY?: boolean };
14
+ const saved = stream.isTTY;
15
+ stream.isTTY = value;
16
+ try {
17
+ body();
18
+ } finally {
19
+ stream.isTTY = saved;
20
+ }
21
+ }
22
+
23
+ describe("tierLookupFormatters", () => {
24
+ it("underlines the plain title with an ═ rule (B7)", () => {
25
+ const [title, rule, blank, blocks] = tierLookupFormatters
26
+ .plain(TIER)
27
+ .split("\n");
28
+ expect(title).toBe("apps/lxd-panel (cs:apps/lxd-panel)");
29
+ expect(rule).toBe("═".repeat(Math.max((title as string).length, 24)));
30
+ expect(blank).toBe("");
31
+ expect(blocks).toBe(" blocks: LXD Panel, Status Bar");
32
+ });
33
+
34
+ it("floors the plain rule at 24 columns for a short title", () => {
35
+ const out = tierLookupFormatters.plain({ ...TIER, uri: "x", name: "a" });
36
+ expect(out.split("\n").at(1)).toBe("═".repeat(24));
37
+ });
38
+
39
+ it("heads the llm entity read at H2, never H1 (B2)", () => {
40
+ const out = tierLookupFormatters.llm(TIER);
41
+ expect(out.startsWith("## apps/lxd-panel\n")).toBe(true);
42
+ expect(out).not.toMatch(/^# /m);
43
+ });
44
+ });
45
+
46
+ describe("tierLookupFormatters — shared style seam (B7)", () => {
47
+ const prevLevel = chalk.level;
48
+ afterEach(() => {
49
+ chalk.level = prevLevel;
50
+ });
51
+
52
+ it("styles the plain title on a color-capable TTY (single-sourced frame)", () => {
53
+ chalk.level = 1;
54
+ withStdoutTty(true, () => {
55
+ // Delegating to `renderLookupPlain` now carries the same TTY tint block and
56
+ // skill lookups get — previously the tier title stayed unstyled.
57
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: asserting the ESC byte is the point
58
+ expect(tierLookupFormatters.plain(TIER)).toMatch(/\x1b\[/);
59
+ });
60
+ });
61
+
62
+ it("keeps piped plain output byte-stable off a TTY", () => {
63
+ chalk.level = 3;
64
+ withStdoutTty(undefined, () => {
65
+ const out = tierLookupFormatters.plain(TIER);
66
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: asserting NO ESC byte survives
67
+ expect(out).not.toMatch(/\x1b\[/);
68
+ expect(out.split("\n").at(0)).toBe("apps/lxd-panel (cs:apps/lxd-panel)");
69
+ expect(out.split("\n").at(3)).toBe(" blocks: LXD Panel, Status Bar");
70
+ });
71
+ });
72
+ });