@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.
- package/README.md +72 -0
- package/dist/pragma +0 -0
- package/package.json +14 -13
- package/src/bin.ts +2 -2
- package/src/capabilities/block/blockList.test.ts +79 -0
- package/src/capabilities/block/blockList.verb.ts +18 -5
- package/src/capabilities/block/pack.ts +12 -1
- package/src/capabilities/block/parity.test.ts +9 -2
- package/src/capabilities/capabilities/capabilities.test.ts +10 -1
- package/src/capabilities/capabilities/capabilities.verb.ts +1 -1
- package/src/capabilities/capabilities/catalog.ts +7 -3
- package/src/capabilities/capabilities/hints.ts +8 -17
- package/src/capabilities/capabilities/types.ts +2 -0
- package/src/capabilities/colophon/colophon.render.test.ts +31 -4
- package/src/capabilities/colophon/colophon.render.ts +13 -4
- package/src/capabilities/colophon/colophon.test.ts +3 -3
- package/src/capabilities/colophon/colophon.verb.ts +4 -1
- package/src/capabilities/colophon/markdownTerminal.ts +33 -14
- package/src/capabilities/colophon/pragmaColophon.ts +4 -4
- package/src/capabilities/colophon/types.ts +1 -1
- package/src/capabilities/config/fields.ts +21 -92
- package/src/capabilities/config/set.test.ts +6 -6
- package/src/capabilities/config/set.verb.ts +5 -5
- package/src/capabilities/config/show.render.test.ts +65 -0
- package/src/capabilities/config/show.render.ts +56 -10
- package/src/capabilities/config/show.verb.ts +5 -5
- package/src/capabilities/create/byteEquality.subprocess.test.ts +1 -1
- package/src/capabilities/create/byteEquality.test.ts +7 -4
- package/src/capabilities/create/compiledCreate.subprocess.test.ts +1 -1
- package/src/capabilities/create/create.test.ts +32 -3
- package/src/capabilities/create/create.verb.ts +69 -11
- package/src/capabilities/doctor/checks/checkConfigFile.ts +1 -1
- package/src/capabilities/doctor/checks/checkMcpCommands.ts +6 -0
- package/src/capabilities/doctor/checks/checkMcpConfigured.ts +10 -4
- package/src/capabilities/doctor/checks/deriveBand.test.ts +42 -0
- package/src/capabilities/doctor/checks/deriveBand.ts +28 -0
- package/src/capabilities/doctor/doctor.render.test.ts +170 -0
- package/src/capabilities/doctor/doctor.render.ts +116 -34
- package/src/capabilities/doctor/doctor.test.ts +31 -1
- package/src/capabilities/doctor/runChecks.ts +28 -4
- package/src/capabilities/doctor/types.ts +15 -0
- package/src/capabilities/ontology/index.ts +10 -3
- package/src/capabilities/ontology/ontology.test.ts +157 -5
- package/src/capabilities/ontology/verbs.ts +192 -93
- package/src/capabilities/prompt/prompt.render.test.ts +94 -0
- package/src/capabilities/prompt/prompt.render.ts +23 -6
- package/src/capabilities/prompt/prompt.test.ts +61 -0
- package/src/capabilities/reference.test.ts +32 -0
- package/src/capabilities/resources/provider.ts +28 -14
- package/src/capabilities/resources/resources.test.ts +99 -0
- package/src/capabilities/scopeTypeSync.test.ts +37 -0
- package/src/capabilities/setup/index.ts +7 -1
- package/src/capabilities/setup/operations/setupCompletions.ts +49 -16
- package/src/capabilities/setup/operations/setupGenerator.ts +157 -61
- package/src/capabilities/setup/operations/setupLsp.ts +56 -12
- package/src/capabilities/setup/operations/setupMcp.ts +216 -39
- package/src/capabilities/setup/operations/setupSkills.ts +7 -1
- package/src/capabilities/setup/setup.render.test.ts +109 -0
- package/src/capabilities/setup/setup.render.ts +61 -14
- package/src/capabilities/setup/setup.test.ts +352 -1
- package/src/capabilities/setup/setup.verb.ts +57 -5
- package/src/capabilities/setup/types.ts +59 -2
- package/src/capabilities/shared/bands.ts +16 -0
- package/src/capabilities/sources/collectStatus.ts +19 -4
- package/src/capabilities/sources/runUpdate.ts +58 -1
- package/src/capabilities/sources/sources.test.ts +187 -1
- package/src/capabilities/sources/status.render.test.ts +65 -0
- package/src/capabilities/sources/status.render.ts +50 -23
- package/src/capabilities/surface.test.ts +34 -9
- package/src/capabilities/tier/lookup.render.test.ts +72 -0
- package/src/capabilities/tier/lookup.render.ts +33 -8
- package/src/capabilities/tier/lookup.test.ts +15 -0
- package/src/capabilities/tier/runLookup.ts +28 -0
- package/src/constants.test.ts +1 -1
- package/src/constants.ts +2 -2
- package/src/kernel/completion/__snapshots__/emitScripts.test.ts.snap +13 -14
- package/src/kernel/completion/bashDrive.test.ts +3 -3
- package/src/kernel/completion/complete.test.ts +3 -5
- package/src/kernel/completion/entitySource.ts +14 -6
- package/src/kernel/completion/model.test.ts +2 -2
- package/src/kernel/completion/model.ts +0 -1
- package/src/kernel/completion/parse.test.ts +2 -4
- package/src/kernel/error/PragmaError.test.ts +4 -4
- package/src/kernel/error/renderError.ts +1 -1
- package/src/kernel/packs/compile.ts +1 -1
- package/src/kernel/project/cli/buildProgram.test.ts +51 -7
- package/src/kernel/project/cli/buildProgram.ts +11 -0
- package/src/kernel/project/cli/globalFlags.test.ts +12 -4
- package/src/kernel/project/cli/globalFlags.ts +14 -14
- package/src/kernel/project/cli/rootHelp.ts +4 -2
- package/src/kernel/project/mcp/instructions.ts +1 -1
- package/src/kernel/project/mcp/mcpError.ts +52 -0
- package/src/kernel/project/mcp/prompts/prompts.test.ts +8 -0
- package/src/kernel/project/mcp/prompts/provider.ts +36 -6
- package/src/kernel/render/formatters.ts +5 -4
- package/src/kernel/render/renderers.test.ts +34 -0
- package/src/kernel/render/renderers.ts +25 -7
- package/src/kernel/render/style.test.ts +31 -0
- package/src/kernel/render/style.ts +74 -0
- package/src/kernel/runtime/graphpack/build.ts +28 -0
- package/src/kernel/runtime/graphpack/buildIndex.test.ts +103 -0
- package/src/kernel/runtime/graphpack/buildIndex.ts +124 -36
- package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +3 -6
- package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -2
- package/src/kernel/runtime/graphpack/graphpack.test.ts +69 -1
- package/src/kernel/runtime/graphpack/read.ts +28 -13
- package/src/kernel/runtime/graphpack/types.ts +14 -0
- package/src/kernel/runtime/loadSession.ts +4 -12
- package/src/kernel/runtime/refs/resolve.ts +63 -3
- package/src/kernel/runtime/storeReadiness.ts +61 -0
- package/src/kernel/runtime/types.ts +1 -1
- package/src/kernel/runtime/wasmEmbed.test.ts +3 -1
- package/src/kernel/spec/emitReference.test.ts +196 -0
- package/src/kernel/spec/emitReference.ts +491 -0
- package/src/kernel/spec/emitSurface.ts +4 -2
- package/src/kernel/spec/index.ts +2 -0
- package/src/kernel/spec/surfaceConformance.test.ts +2 -2
- package/src/testing/PARITY_GAPS.ts +1 -1
- package/src/testing/behavioral/autoLlm.e2e.test.ts +12 -2
- package/src/testing/behavioral/completion.test.ts +2 -2
- package/src/testing/behavioral/disclosure.test.ts +12 -0
- package/src/testing/behavioral/docExamples.test.ts +283 -0
- package/src/testing/behavioral/errorMatrix.storeless.test.ts +2 -1
- package/src/testing/behavioral/journeys.cli.test.ts +3 -1
- package/src/testing/behavioral/journeys.defaultPack.test.ts +571 -0
- package/src/testing/behavioral/journeys.interactiveTty.e2e.test.ts +66 -0
- package/src/testing/behavioral/journeys.livePack.e2e.test.ts +129 -0
- package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +176 -0
- package/src/testing/behavioral/mcpSurface.test.ts +1 -1
- package/src/testing/behavioral/staleCommands.test.ts +76 -0
- package/src/testing/eval/__snapshots__/eval.test.ts.snap +2 -2
- package/src/testing/eval/cases/assembly.ts +3 -3
- package/src/testing/eval/cases/effectful.ts +6 -3
- package/src/testing/eval/cases/readNouns.ts +4 -1
- package/src/testing/fixtures/fixtureCapability.ts +156 -0
- package/src/testing/fixtures/graph/defaultPack.ts +178 -0
- package/src/testing/fixtures/graph/owlExport.ts +95 -0
- package/src/testing/helpers/extractSnippets.ts +29 -0
- package/src/testing/helpers/golden.ts +1 -1
- 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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
+
});
|