@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
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Project a {@link PragmaError} into an SDK `McpError` for the NATIVE MCP
3
+ * surfaces (the `prompts/*` provider, the `pragma:{+uri}` resource browser),
4
+ * which signal failure by throwing a JSON-RPC error rather than by returning the
5
+ * `{ ok, error }` tool envelope.
6
+ *
7
+ * A native read must not swallow a failure into plain-text "success" content: it
8
+ * carries the machine `code` AND the structured `recovery` into the JSON-RPC
9
+ * `data`, so an agent that hits a cold store over `prompts/list` or a resource
10
+ * read sees the SAME `STORE_UNAVAILABLE` + `sources_update` recovery the tool
11
+ * envelope carries — never a bare string with the recovery dropped.
12
+ *
13
+ * The SDK's `McpError`/`ErrorCode` are PASSED IN by the caller (which already
14
+ * dynamic-imported them in its `register`), so this module — reachable on the
15
+ * capabilities import graph — never statically pulls the SDK onto the
16
+ * `--help`/`__complete` fast path.
17
+ */
18
+
19
+ import type { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
20
+ import type { PragmaError } from "../../error/PragmaError.js";
21
+
22
+ /** The SDK error primitives a provider dynamic-imports and hands to {@link mcpErrorFrom}. */
23
+ export interface McpErrorPrimitives {
24
+ readonly McpError: typeof McpError;
25
+ readonly ErrorCode: typeof ErrorCode;
26
+ }
27
+
28
+ /**
29
+ * Build an `McpError` from a {@link PragmaError}, mapping the machine code to a
30
+ * JSON-RPC code and preserving `code`/`recovery`/`suggestions`/`validOptions` in
31
+ * `data`.
32
+ *
33
+ * @param error - The structured pragma error to project.
34
+ * @param primitives - The SDK `McpError`/`ErrorCode` the caller imported.
35
+ * @returns An `McpError` carrying the diagnostic + recovery in its `data`.
36
+ */
37
+ export function mcpErrorFrom(
38
+ error: PragmaError,
39
+ { McpError: McpErrorCtor, ErrorCode: ErrorCodeEnum }: McpErrorPrimitives,
40
+ ): McpError {
41
+ // A bad/missing entity or rejected input is the caller's fault (InvalidParams);
42
+ // an unreachable store or internal fault is the server's (InternalError).
43
+ const jsonRpcCode =
44
+ error.code === "ENTITY_NOT_FOUND" || error.code === "INVALID_INPUT"
45
+ ? ErrorCodeEnum.InvalidParams
46
+ : ErrorCodeEnum.InternalError;
47
+ const data: Record<string, unknown> = { code: error.code };
48
+ if (error.recovery) data.recovery = error.recovery;
49
+ if (error.suggestions.length > 0) data.suggestions = error.suggestions;
50
+ if (error.validOptions) data.validOptions = error.validOptions;
51
+ return new McpErrorCtor(jsonRpcCode, error.message, data);
52
+ }
@@ -47,6 +47,14 @@ describe("instructions — handshake orientation (PROTECTED)", () => {
47
47
  );
48
48
  });
49
49
 
50
+ it("states the plan-first/confirm convention in the orientation (D2)", () => {
51
+ // The confirm gate must be surfaced at handshake, not discovered by an agent
52
+ // tripping it. Same single source as the `capabilities` tool's conventions.
53
+ const text = buildInstructions(capabilities);
54
+ expect(text.toLowerCase()).toContain("plan-first");
55
+ expect(text).toContain("confirm: true");
56
+ });
57
+
50
58
  it("orients a cold agent to check/build the store before any store read", () => {
51
59
  // Store-blind guard: the handshake must point a cold agent at the store
52
60
  // pre-check (sources_status → sources_update) BEFORE the sample/query steps,
@@ -10,9 +10,19 @@
10
10
  * arguments. The SDK request schemas are dynamic-imported inside `register`, so
11
11
  * this module — reachable on the capabilities import graph through the prompt
12
12
  * module — never pulls the SDK types onto the `--help`/`__complete` fast path.
13
+ *
14
+ * Both handlers open with a STORELESS cold-store pre-check ({@link
15
+ * checkStoreAvailable}) so the native surface converges with the store-backed
16
+ * `prompt_list` tool: a cold store surfaces the SAME `STORE_UNAVAILABLE` +
17
+ * `sources_update` recovery here as the tool's `needsStore` guard raises, rather
18
+ * than a silent `[]` (list) or a bogus "not found" (get) that masks the cold
19
+ * store. When the store is available (including the embedded fallback) both
20
+ * handlers behave exactly as before.
13
21
  */
14
22
 
23
+ import { checkStoreAvailable } from "../../../runtime/storeReadiness.js";
15
24
  import type { McpPromptProvider } from "../../../spec/types.js";
25
+ import { mcpErrorFrom } from "../mcpError.js";
16
26
  import { listPromptSummaries, readPrompt } from "./source.js";
17
27
 
18
28
  /** Substitute `{{arg}}` placeholders in a template body with provided values. */
@@ -41,17 +51,37 @@ export const promptProvider: McpPromptProvider = {
41
51
  // of whether the active graph ships prompt entities.
42
52
  server.server.registerCapabilities({ prompts: { listChanged: false } });
43
53
 
44
- server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({
45
- prompts: listPromptSummaries(runtime).map((prompt) => ({
46
- name: prompt.name,
47
- ...(prompt.description ? { description: prompt.description } : {}),
48
- })),
49
- }));
54
+ // A STORELESS cold-store pre-check, shared with the store-backed `prompt_list`
55
+ // tool (whose `needsStore` guard throws) and the resource browser: a cold
56
+ // store must present the SAME STORE_UNAVAILABLE + `sources_update` recovery on
57
+ // native `prompts/*` as on the tool surface, not silently list from a stale
58
+ // index (or `[]`) here while the tool errors. Available (incl. the embedded
59
+ // fallback) → the native list stays storeless and the get stays store-backed.
60
+ const guardStore = async (): Promise<void> => {
61
+ const unavailable = checkStoreAvailable(
62
+ await runtime.loadConfig(),
63
+ runtime.cwd,
64
+ );
65
+ if (unavailable) throw mcpErrorFrom(unavailable, { McpError, ErrorCode });
66
+ };
67
+
68
+ server.server.setRequestHandler(ListPromptsRequestSchema, async () => {
69
+ await guardStore();
70
+ return {
71
+ prompts: listPromptSummaries(runtime).map((prompt) => ({
72
+ name: prompt.name,
73
+ ...(prompt.description ? { description: prompt.description } : {}),
74
+ })),
75
+ };
76
+ });
50
77
 
51
78
  server.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
79
+ await guardStore();
52
80
  const { name, arguments: args } = request.params;
53
81
  const entry = await readPrompt(runtime, name);
54
82
  if (!entry) {
83
+ // Reached only when the store IS available — a genuine miss, not a cold
84
+ // store (which `guardStore` already surfaced as STORE_UNAVAILABLE).
55
85
  throw new McpError(
56
86
  ErrorCode.InvalidParams,
57
87
  `Prompt "${name}" not found`,
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * The grammar's {@link Formatters} (plain / llm / json — no ink field) is the
5
5
  * per-verb output contract. {@link selectFormatter} picks one by the parsed
6
- * global flags with a fixed precedence: `--format json` wins, then `--llm`,
7
- * then the plain terminal default. The dispatcher uses the plain/llm branches
8
- * directly and wraps the json branch in the machine envelope (D3).
6
+ * global flags with a fixed precedence: `--format json` wins, then the llm mode
7
+ * (`--format llm` or auto-detected on a non-interactive stdout), then the plain
8
+ * terminal default. The dispatcher uses the plain/llm branches directly and
9
+ * wraps the json branch in the machine envelope (D3).
9
10
  */
10
11
 
11
12
  import type { GlobalFlags } from "../runtime/types.js";
@@ -14,7 +15,7 @@ import type { Formatters } from "../spec/types.js";
14
15
  /**
15
16
  * Pick the formatter for the active output mode.
16
17
  *
17
- * Precedence: `--format json` > `--llm` > plain.
18
+ * Precedence: `--format json` > llm (`--format llm` / auto-detected) > plain.
18
19
  *
19
20
  * @param flags - The parsed global flags for this invocation.
20
21
  * @param formatters - The verb's three-mode formatter set.
@@ -14,6 +14,7 @@ import {
14
14
  renderLookupLlm,
15
15
  renderLookupPlain,
16
16
  } from "./renderers.js";
17
+ import { type RenderStyle, styleFor } from "./style.js";
17
18
 
18
19
  interface Widget {
19
20
  readonly uri: string;
@@ -144,6 +145,39 @@ describe("render matrix (list/lookup/empty × plain/llm/json × detail)", () =>
144
145
  });
145
146
  });
146
147
 
148
+ describe("lookup beautify (TTY style seam)", () => {
149
+ const lookupOpts = {
150
+ title: (w: Widget) => w.name,
151
+ fields: fieldsFor("standard"),
152
+ sections: sectionsFor("standard"),
153
+ };
154
+
155
+ it("bolds the title, dims the rule, and cyans field labels on a TTY", () => {
156
+ const tagged: RenderStyle = {
157
+ enabled: true,
158
+ bold: (t) => `B(${t})`,
159
+ dim: (t) => `D(${t})`,
160
+ cyan: (t) => `C(${t})`,
161
+ green: (t) => t,
162
+ yellow: (t) => t,
163
+ };
164
+ const lines = renderLookupPlain(
165
+ widgets[0] as Widget,
166
+ lookupOpts,
167
+ tagged,
168
+ ).split("\n");
169
+ expect(lines.at(0)).toBe("B(Button)");
170
+ expect(lines.at(1)).toBe(`D(${"═".repeat(24)})`);
171
+ expect(lines).toContain(" C(Kind): component");
172
+ });
173
+
174
+ it("is byte-identical to the plain lookup when the styler is disabled", () => {
175
+ expect(
176
+ renderLookupPlain(widgets[0] as Widget, lookupOpts, styleFor(false)),
177
+ ).toBe(`Button\n${"═".repeat(24)}\n\n Kind: component`);
178
+ });
179
+ });
180
+
147
181
  describe("error render matrix (× plain/llm/json)", () => {
148
182
  it("renders each error across the three formats", () => {
149
183
  const notFound = PragmaError.notFound("block", "Buton", {
@@ -15,6 +15,7 @@ import type {
15
15
  SectionDef,
16
16
  } from "./contracts.js";
17
17
  import { DEFAULT_PREFIX_MAP } from "./prefixes.js";
18
+ import { defaultStyle, type RenderStyle, styleFor } from "./style.js";
18
19
 
19
20
  type RenderMode = "plain" | "llm";
20
21
 
@@ -104,27 +105,40 @@ export function renderListLlm<T>(
104
105
  return lines.join("\n");
105
106
  }
106
107
 
108
+ /**
109
+ * Render an entity lookup as plain text.
110
+ *
111
+ * @param entity - The looked-up entity.
112
+ * @param options - The lookup layout (title, fields, sections).
113
+ * @param style - TTY styling; defaults to the process style. On a color-capable
114
+ * terminal the title is bold, its `═` rule dim, and field labels cyan; off a
115
+ * TTY the styler is inert, so the output is byte-identical to the plain form.
116
+ * @returns The formatted lookup block.
117
+ */
107
118
  export function renderLookupPlain<T>(
108
119
  entity: T,
109
120
  options: RenderLookupOptions<T>,
121
+ style: RenderStyle = defaultStyle(),
110
122
  ): string {
111
- return renderLookup(entity, options, "plain");
123
+ return renderLookup(entity, options, "plain", style);
112
124
  }
113
125
 
114
126
  export function renderLookupLlm<T>(
115
127
  entity: T,
116
128
  options: RenderLookupOptions<T>,
117
129
  ): string {
118
- return renderLookup(entity, options, "llm");
130
+ // The condensed form is a byte-frozen agent contract — never styled.
131
+ return renderLookup(entity, options, "llm", styleFor(false));
119
132
  }
120
133
 
121
134
  function renderLookup<T>(
122
135
  entity: T,
123
136
  options: RenderLookupOptions<T>,
124
137
  mode: RenderMode,
138
+ style: RenderStyle,
125
139
  ): string {
126
140
  const title = options.title(entity);
127
- const fields = renderLookupFields(entity, options, mode);
141
+ const fields = renderLookupFields(entity, options, mode, style);
128
142
  const sections = renderLookupSections(entity, options, mode);
129
143
 
130
144
  if (mode === "llm") {
@@ -132,13 +146,17 @@ function renderLookup<T>(
132
146
  }
133
147
 
134
148
  const headingRule = "═".repeat(Math.max(title.length, 24));
135
- return [title, headingRule, "", ...fields, ...sections].join("\n").trimEnd();
149
+ const head = style.enabled
150
+ ? [style.bold(title), style.dim(headingRule)]
151
+ : [title, headingRule];
152
+ return [...head, "", ...fields, ...sections].join("\n").trimEnd();
136
153
  }
137
154
 
138
155
  function renderLookupFields<T>(
139
156
  entity: T,
140
157
  options: RenderLookupOptions<T>,
141
158
  mode: RenderMode,
159
+ style: RenderStyle,
142
160
  ): string[] {
143
161
  const prefixes = options.prefixes ?? DEFAULT_PREFIX_MAP;
144
162
  return options.fields.flatMap((field) => {
@@ -148,9 +166,9 @@ function renderLookupFields<T>(
148
166
  }
149
167
 
150
168
  const formatted = formatInlineValue(value, prefixes);
151
- return mode === "llm"
152
- ? [`- ${field.label}: ${formatted}`]
153
- : [` ${field.label}: ${formatted}`];
169
+ if (mode === "llm") return [`- ${field.label}: ${formatted}`];
170
+ const label = style.enabled ? style.cyan(field.label) : field.label;
171
+ return [` ${label}: ${formatted}`];
154
172
  });
155
173
  }
156
174
 
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { defaultStyle, styleFor } from "./style.js";
3
+
4
+ describe("styleFor", () => {
5
+ it("passes every color through unchanged when disabled", () => {
6
+ const style = styleFor(false);
7
+ expect(style.enabled).toBe(false);
8
+ expect(style.bold("x")).toBe("x");
9
+ expect(style.dim("x")).toBe("x");
10
+ expect(style.cyan("x")).toBe("x");
11
+ expect(style.green("x")).toBe("x");
12
+ expect(style.yellow("x")).toBe("x");
13
+ });
14
+
15
+ it("reports enabled and drives chalk when enabled", () => {
16
+ const style = styleFor(true);
17
+ expect(style.enabled).toBe(true);
18
+ // A non-TTY test process leaves chalk at level 0, so the text survives
19
+ // verbatim; what the beautified renderers gate on is `enabled`.
20
+ expect(style.cyan("x")).toContain("x");
21
+ });
22
+ });
23
+
24
+ describe("defaultStyle", () => {
25
+ it("is disabled when stdout is not a TTY (keeps piped output byte-stable)", () => {
26
+ // The vitest runner's stdout is never a TTY, so the beautify seam is inert
27
+ // here — the guarantee that piped / agent output is unstyled and stable.
28
+ expect(process.stdout.isTTY).not.toBe(true);
29
+ expect(defaultStyle().enabled).toBe(false);
30
+ });
31
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Terminal-styling seam for the human (TTY) output path.
3
+ *
4
+ * The condensed/agent (`llm`) and JSON paths are byte-frozen contracts; only an
5
+ * interactive terminal earns color and alignment. A {@link RenderStyle} carries
6
+ * that decision as data so a pure renderer can consult it without reading the
7
+ * process itself: {@link defaultStyle} probes chalk's own TTY/env detection (the
8
+ * same signal the help system leans on), so piped / redirected / MCP output —
9
+ * where `enabled` is false — renders byte-for-byte as before, while an attended
10
+ * terminal renders with `enabled: true`. Passing an explicit style keeps the
11
+ * beautified renderers deterministically testable in both modes.
12
+ */
13
+
14
+ import chalk from "chalk";
15
+
16
+ /** The palette + gate a renderer consults to beautify the human path. */
17
+ export interface RenderStyle {
18
+ /** Whether to align + colorize — true only on a color-capable TTY. */
19
+ readonly enabled: boolean;
20
+ /** Emphasize a heading or primary label. */
21
+ bold(text: string): string;
22
+ /** De-emphasize secondary text (rules, markers, paths, punctuation). */
23
+ dim(text: string): string;
24
+ /** Highlight a value or identifier. */
25
+ cyan(text: string): string;
26
+ /** Tint a healthy / up-to-date status. */
27
+ green(text: string): string;
28
+ /** Tint a status that wants attention. */
29
+ yellow(text: string): string;
30
+ }
31
+
32
+ /** The identity styler — every function returns its input unchanged. */
33
+ const PLAIN_STYLE: RenderStyle = {
34
+ enabled: false,
35
+ bold: (text) => text,
36
+ dim: (text) => text,
37
+ cyan: (text) => text,
38
+ green: (text) => text,
39
+ yellow: (text) => text,
40
+ };
41
+
42
+ /**
43
+ * Build a {@link RenderStyle} for a known color decision.
44
+ *
45
+ * @param enabled - True to colorize + align via chalk; false for the identity
46
+ * styler (byte-identical to the pre-beautify plain output).
47
+ * @returns A style whose functions color, or pass through, accordingly.
48
+ */
49
+ export function styleFor(enabled: boolean): RenderStyle {
50
+ if (!enabled) return PLAIN_STYLE;
51
+ return {
52
+ enabled: true,
53
+ bold: (text) => chalk.bold(text),
54
+ dim: (text) => chalk.dim(text),
55
+ cyan: (text) => chalk.cyan(text),
56
+ green: (text) => chalk.green(text),
57
+ yellow: (text) => chalk.yellow(text),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * The active style for this process's stdout.
63
+ *
64
+ * Gated on BOTH signals: stdout must be an attended TTY, so piped / redirected /
65
+ * MCP output stays byte-stable even where `supports-color` fires off a TTY (CI
66
+ * sets `GITHUB_ACTIONS`, which chalk honors); AND chalk must report a usable
67
+ * color level, so `NO_COLOR` on a real terminal still yields the plain form.
68
+ *
69
+ * @returns A colorizing style on a color-capable TTY, else the identity styler.
70
+ * @note Impure — reads `process.stdout.isTTY` and chalk's color level.
71
+ */
72
+ export function defaultStyle(): RenderStyle {
73
+ return styleFor(process.stdout.isTTY === true && chalk.level > 0);
74
+ }
@@ -121,6 +121,16 @@ export async function buildPack(
121
121
  const index = await buildIndex(store, store.prefixes, hash);
122
122
  writeFileSync(join(temp, INDEX_FILE), JSON.stringify(index));
123
123
 
124
+ const tripleCount = await countTriples(store);
125
+ // Distinct abox subjects — the same figure `entityTotal` reports (A10),
126
+ // so `sources status` reads it from the manifest without parsing the
127
+ // whole index.json. Keep this in sync with `entityTotal` in entitySource.
128
+ const entityCount = new Set(
129
+ index.entities
130
+ .filter((entity) => entity.box === "abox")
131
+ .map((entity) => entity.uri ?? entity.name),
132
+ ).size;
133
+
124
134
  const manifest: Manifest = {
125
135
  name: options.name,
126
136
  version: options.version,
@@ -128,6 +138,8 @@ export async function buildPack(
128
138
  contentHash: hash,
129
139
  prefixes: { ...store.prefixes },
130
140
  createdAt: new Date().toISOString(),
141
+ tripleCount,
142
+ entityCount,
131
143
  };
132
144
  // Written LAST — the completeness marker for the directory.
133
145
  writeFileSync(join(temp, MANIFEST_FILE), JSON.stringify(manifest));
@@ -146,3 +158,19 @@ export async function buildPack(
146
158
  if (existsSync(temp)) rmSync(temp, { recursive: true, force: true });
147
159
  }
148
160
  }
161
+
162
+ /**
163
+ * Count a store's triples (a cheap aggregate over the union graph).
164
+ *
165
+ * @param store - The booted store.
166
+ * @returns The number of triples.
167
+ * @note Impure — runs a COUNT query against the store.
168
+ */
169
+ async function countTriples(
170
+ store: Awaited<ReturnType<typeof createStore>>,
171
+ ): Promise<number> {
172
+ const result = (await store.query(
173
+ "SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }" as never,
174
+ )) as import("@canonical/ke").SelectResult;
175
+ return Number(result.bindings.at(0)?.n ?? 0);
176
+ }
@@ -0,0 +1,103 @@
1
+ import { createStore } from "@canonical/ke";
2
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
3
+ import {
4
+ OWL_EXPORT_ABOX_SUBJECTS,
5
+ OWL_EXPORT_PREFIXES,
6
+ OWL_EXPORT_TTL,
7
+ } from "../../../testing/fixtures/graph/owlExport.js";
8
+ import { entityTotal } from "../../completion/entitySource.js";
9
+ import { buildIndex } from "./buildIndex.js";
10
+ import type { PackIndex } from "./types.js";
11
+
12
+ type Store = import("@canonical/ke").Store;
13
+
14
+ /**
15
+ * The counters/classifiers `buildIndex` builds are masked by the embedded
16
+ * single-typed toy graph; the multi-typed OWL-export fixture is where the
17
+ * backlog-A bugs bite. Each `it` fails on the pre-fix code and passes after.
18
+ */
19
+ describe("buildIndex — real-data counters/classifiers (backlog A)", () => {
20
+ let store: Store;
21
+ let index: PackIndex;
22
+
23
+ beforeAll(async () => {
24
+ store = await createStore({
25
+ sources: [{ content: OWL_EXPORT_TTL, path: "owl-export.ttl" }],
26
+ prefixes: OWL_EXPORT_PREFIXES,
27
+ });
28
+ index = await buildIndex(store, OWL_EXPORT_PREFIXES, "test-hash");
29
+ });
30
+
31
+ afterAll(() => {
32
+ store.dispose();
33
+ });
34
+
35
+ const byName = (name: string): PackIndex["entities"][number][] =>
36
+ index.entities.filter((entity) => entity.name === name);
37
+
38
+ it("A1: entityTotal counts DISTINCT abox subjects, not the raw multiset", () => {
39
+ // Every individual once — despite Protégé's `owl:NamedIndividual` dual-typing.
40
+ expect(entityTotal(index)).toBe(OWL_EXPORT_ABOX_SUBJECTS.length);
41
+
42
+ // The raw multiset the old code summed is strictly larger: each individual
43
+ // double-counts under `owl:NamedIndividual`, plus the class/property
44
+ // meta-buckets pile on top.
45
+ const rawSum = Object.values(index.instanceCountByType).reduce(
46
+ (sum, n) => sum + n,
47
+ 0,
48
+ );
49
+ expect(rawSum).toBeGreaterThan(entityTotal(index));
50
+
51
+ // The abox names match the oracle exactly.
52
+ const aboxNames = index.entities
53
+ .filter((entity) => entity.box === "abox")
54
+ .map((entity) => entity.name);
55
+ expect([...new Set(aboxNames)].sort()).toEqual([
56
+ ...OWL_EXPORT_ABOX_SUBJECTS,
57
+ ]);
58
+ });
59
+
60
+ it("A3: a multi-domain individual gets a deterministic (lexical) primary type", () => {
61
+ // ex:toggle is both ex:Interactive and ex:FormControl; the smaller wins,
62
+ // every build/engine — never whichever type the store returned first.
63
+ const toggle = byName("ex:toggle").at(0);
64
+ expect(toggle?.type).toBe("ex:FormControl");
65
+ expect(toggle?.box).toBe("abox");
66
+ });
67
+
68
+ it("A6: a blank-node rdf:type is ignored, never a garbage primary type", () => {
69
+ // ex:field is typed by an anonymous class too; the blank node must not win.
70
+ expect(byName("ex:field").at(0)?.type).toBe("ex:FormControl");
71
+ // No entity type, no listed type, and no instance-count bucket is a blank node.
72
+ for (const entity of index.entities) {
73
+ expect(entity.type.startsWith("_:")).toBe(false);
74
+ for (const type of entity.types ?? []) {
75
+ expect(type.startsWith("_:")).toBe(false);
76
+ }
77
+ }
78
+ for (const key of Object.keys(index.instanceCountByType)) {
79
+ expect(key.startsWith("_:")).toBe(false);
80
+ }
81
+ });
82
+
83
+ it("A7: a multilingual label resolves to @en, deterministically", () => {
84
+ // "Button"@en / "Bouton"@fr → Button; the @lang no longer flips the pick.
85
+ expect(byName("ds:button").at(0)?.label).toBe("Button");
86
+ expect(byName("ex:toggle").at(0)?.label).toBe("Toggle");
87
+ expect(byName("ds:datePicker").at(0)?.label).toBe("Date Picker");
88
+ });
89
+
90
+ it("A8: an OWL-punned subject emits BOTH its tbox and abox facet", () => {
91
+ // ex:Slider is a class (tbox) AND an ex:Category individual (abox) — both
92
+ // must survive, so its abox membership stays completable.
93
+ const facets = byName("ex:Slider");
94
+ expect(facets).toHaveLength(2);
95
+ expect(facets.map((facet) => facet.box).sort()).toEqual(["abox", "tbox"]);
96
+ expect(facets.find((facet) => facet.box === "tbox")?.type).toBe(
97
+ "owl:Class",
98
+ );
99
+ expect(facets.find((facet) => facet.box === "abox")?.type).toBe(
100
+ "ex:Category",
101
+ );
102
+ });
103
+ });