@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,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E3 (AV-231, Backlog E) — the network-gated, NON-BLOCKING live-pack job.
|
|
3
|
+
*
|
|
4
|
+
* A handful of behaviours can only be confirmed against a REAL oxigraph store
|
|
5
|
+
* built from the REAL default pack: result ordering (A3/A9), which language a
|
|
6
|
+
* multilingual label resolves to under real store order (A7), ke truncation of
|
|
7
|
+
* long literals, and `buildIndex`'s `Promise.all` re-entrancy when the same pack
|
|
8
|
+
* is built concurrently. Those depend on the network (fetching the shipped pack)
|
|
9
|
+
* and on real build cost, so this suite is GATED OFF by default — it never runs
|
|
10
|
+
* (or flakes) in the ordinary `bun test` pass. Set `PRAGMA_LIVE_PACK=1` to run
|
|
11
|
+
* it (a dedicated, non-required CI job).
|
|
12
|
+
*
|
|
13
|
+
* The assertions here run against the VENDORED default pack as a faithful local
|
|
14
|
+
* proxy for the shipped one, so a maintainer who flips the gate gets real signal
|
|
15
|
+
* today. Each carries a TODO for wiring the ACTUAL network pack fetch — the one
|
|
16
|
+
* piece this environment cannot provide.
|
|
17
|
+
*
|
|
18
|
+
* TODO(AV-231/E3): resolve the real shipped default pack (git/npm source) here
|
|
19
|
+
* instead of the vendored TTL, so ordering/truncation are asserted against the
|
|
20
|
+
* bytes agents actually receive.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, expect, it } from "vitest";
|
|
24
|
+
import { buildPack } from "../../kernel/runtime/graphpack/build.js";
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_PACK_PREFIXES,
|
|
27
|
+
DEFAULT_PACK_TTL,
|
|
28
|
+
} from "../fixtures/graph/defaultPack.js";
|
|
29
|
+
import {
|
|
30
|
+
bootFixtureRuntime,
|
|
31
|
+
type FixtureGraph,
|
|
32
|
+
} from "../helpers/fixtureGraph.js";
|
|
33
|
+
|
|
34
|
+
/** Non-blocking gate: skipped unless a maintainer opts in (or CI's live job). */
|
|
35
|
+
const liveGate = process.env.PRAGMA_LIVE_PACK ? describe : describe.skip;
|
|
36
|
+
|
|
37
|
+
liveGate(
|
|
38
|
+
"live-pack journey — real oxigraph ordering + build re-entrancy (E3)",
|
|
39
|
+
() => {
|
|
40
|
+
it("a SELECT ... ORDER BY returns a STABLE, deterministic ordering (A3/A9)", async () => {
|
|
41
|
+
const fixture = await bootFixtureRuntime({ ttl: DEFAULT_PACK_TTL });
|
|
42
|
+
try {
|
|
43
|
+
const query =
|
|
44
|
+
"SELECT ?name WHERE { ?c a ds:Component ; ds:name ?name } ORDER BY ?name";
|
|
45
|
+
const first = await fixture.runtime.query.sparql(query);
|
|
46
|
+
const second = await fixture.runtime.query.sparql(query);
|
|
47
|
+
const names = (result: typeof first): string[] =>
|
|
48
|
+
result.type === "select"
|
|
49
|
+
? result.bindings.map((binding) => String(binding.name))
|
|
50
|
+
: [];
|
|
51
|
+
// Repeated evaluation is identical, and ORDER BY is honoured.
|
|
52
|
+
expect(names(first)).toEqual(names(second));
|
|
53
|
+
expect(names(first)).toEqual(["Beta Badge", "Button", "Orphan Widget"]);
|
|
54
|
+
} finally {
|
|
55
|
+
await fixture.dispose();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("a multilingual label resolves to a SINGLE, tag-stripped value under real store order (A7)", async () => {
|
|
60
|
+
// TODO(AV-231/E3): against the live pack, pin the EXACT language the store
|
|
61
|
+
// yields first, so the index-label choice is a confirmed contract rather than
|
|
62
|
+
// the store-order accident it is today (see the A7 note in the E1 journey).
|
|
63
|
+
const fixture = await bootFixtureRuntime({ ttl: DEFAULT_PACK_TTL });
|
|
64
|
+
try {
|
|
65
|
+
const result = await fixture.runtime.query.sparql(
|
|
66
|
+
"SELECT ?l WHERE { ds:button rdfs:label ?l } ORDER BY ?l",
|
|
67
|
+
);
|
|
68
|
+
const labels =
|
|
69
|
+
result.type === "select"
|
|
70
|
+
? result.bindings.map((binding) => String(binding.l))
|
|
71
|
+
: [];
|
|
72
|
+
expect(labels).toEqual(["Bouton", "Button"]);
|
|
73
|
+
} finally {
|
|
74
|
+
await fixture.dispose();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("building the same sources CONCURRENTLY is re-entrant — one hash, no torn pack (buildIndex Promise.all)", async () => {
|
|
79
|
+
// buildIndex fans out four bulk SPARQL queries under Promise.all, and buildPack
|
|
80
|
+
// publishes atomically; N concurrent builds of identical sources must converge
|
|
81
|
+
// on ONE content hash with every pack complete (no half-written cache).
|
|
82
|
+
const inputs = [
|
|
83
|
+
{ path: "concurrent/pack.ttl", content: DEFAULT_PACK_TTL },
|
|
84
|
+
];
|
|
85
|
+
const options = {
|
|
86
|
+
name: "pragma",
|
|
87
|
+
version: "0.0.0-e3",
|
|
88
|
+
sourceRef: "e3-reentrancy",
|
|
89
|
+
prefixes: DEFAULT_PACK_PREFIXES,
|
|
90
|
+
};
|
|
91
|
+
const results = await Promise.all(
|
|
92
|
+
Array.from({ length: 5 }, () => buildPack(inputs, options)),
|
|
93
|
+
);
|
|
94
|
+
const hashes = new Set(results.map((result) => result.contentHash));
|
|
95
|
+
expect(hashes.size).toBe(1);
|
|
96
|
+
for (const result of results) {
|
|
97
|
+
expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("a long literal survives the build round-trip without silent ke truncation", async () => {
|
|
102
|
+
// TODO(AV-231/E3): confirm against the live pack, whose real summaries/guidelines
|
|
103
|
+
// are long enough to trip any downstream ke truncation (A7/A9 tail).
|
|
104
|
+
const longText = "x".repeat(20_000);
|
|
105
|
+
const ttl = `@prefix ds: <https://ds.canonical.com/> .
|
|
106
|
+
@prefix owl: <http://www.w3.org/2002/07/owl#> .
|
|
107
|
+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
|
|
108
|
+
ds:Component a owl:Class .
|
|
109
|
+
ds:summary a owl:DatatypeProperty ; rdfs:range xsd:string .
|
|
110
|
+
ds:big a ds:Component ; ds:summary "${longText}" .`;
|
|
111
|
+
let fixture: FixtureGraph | undefined;
|
|
112
|
+
try {
|
|
113
|
+
fixture = await bootFixtureRuntime({ ttl });
|
|
114
|
+
const result = await fixture.runtime.query.sparql(
|
|
115
|
+
"SELECT ?s WHERE { ds:big ds:summary ?s }",
|
|
116
|
+
);
|
|
117
|
+
const value =
|
|
118
|
+
result.type === "select"
|
|
119
|
+
? String(
|
|
120
|
+
(result.bindings.at(0) as { s?: string } | undefined)?.s ?? "",
|
|
121
|
+
)
|
|
122
|
+
: "";
|
|
123
|
+
expect(value.length).toBe(longText.length);
|
|
124
|
+
} finally {
|
|
125
|
+
await fixture?.dispose();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
);
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2 (AV-231, Backlog E) — the MCP lifecycle journey over a LONG-LIVED,
|
|
3
|
+
* in-process server.
|
|
4
|
+
*
|
|
5
|
+
* Fresh-runtime unit tests structurally cannot catch the MCP memoization bugs:
|
|
6
|
+
* they build a new runtime per assertion, so a store/config memo that outlives a
|
|
7
|
+
* mutation is never exercised. The real MCP server boots ONE runtime for its
|
|
8
|
+
* whole lifetime and shares it across every tool call — the exact condition
|
|
9
|
+
* under which a cold-store rejection could be memoized forever, or a stale config
|
|
10
|
+
* layer could survive a `config_set`. This file drives that server the way an
|
|
11
|
+
* agent does, over a single connection:
|
|
12
|
+
*
|
|
13
|
+
* - COLD read → STORE_UNAVAILABLE → `sources_update` (confirm) → the SAME
|
|
14
|
+
* server's retry read SUCCEEDS. Guards C1: a rejected boot must NOT be
|
|
15
|
+
* memoized, and a real mutation must invalidate the shared store so the next
|
|
16
|
+
* read re-boots against the freshly built pack.
|
|
17
|
+
* - `config_set` → a subsequent `config_show` on the same server sees the new
|
|
18
|
+
* value. Guards C2: the mutation must drop the config memo the store boot
|
|
19
|
+
* depends on.
|
|
20
|
+
* - The discovery sequence resolves from a COLD store (the storeless
|
|
21
|
+
* `capabilities` catalog boots with no pack), then browse → inspect works once
|
|
22
|
+
* the store is warm.
|
|
23
|
+
*
|
|
24
|
+
* Uses the in-process `projectMcp` harness (SDK in-memory transport) — no stdio,
|
|
25
|
+
* no network — over the vendored default pack.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { tmpdir } from "node:os";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
32
|
+
import { capabilities } from "../../capabilities/index.js";
|
|
33
|
+
import { readLock } from "../../kernel/runtime/lock.js";
|
|
34
|
+
import { DEFAULT_PACK_TTL } from "../fixtures/graph/defaultPack.js";
|
|
35
|
+
import { type McpHarness, projectMcp } from "../helpers/projectMcp.js";
|
|
36
|
+
|
|
37
|
+
/** The transient roots one test creates; removed in `afterEach`. */
|
|
38
|
+
let roots: string[] = [];
|
|
39
|
+
const tempDir = (prefix: string): string => {
|
|
40
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
41
|
+
roots.push(dir);
|
|
42
|
+
return dir;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A COLD project directory: a `pragma.config.ts` pointing at a real file
|
|
47
|
+
* package, but NO lock yet — so the store boots cold until `sources_update`.
|
|
48
|
+
*/
|
|
49
|
+
function coldProject(ttl: string): string {
|
|
50
|
+
const pkg = tempDir("e2-pkg-");
|
|
51
|
+
mkdirSync(join(pkg, "definitions"), { recursive: true });
|
|
52
|
+
writeFileSync(join(pkg, "definitions", "pack.ttl"), ttl);
|
|
53
|
+
const cwd = tempDir("e2-proj-");
|
|
54
|
+
writeFileSync(
|
|
55
|
+
join(cwd, "pragma.config.ts"),
|
|
56
|
+
`export default { packages: [{ name: "default", source: "file://${pkg}" }] };\n`,
|
|
57
|
+
);
|
|
58
|
+
return cwd;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let mcp: McpHarness | undefined;
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
roots = [];
|
|
64
|
+
});
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
await mcp?.cleanup();
|
|
67
|
+
mcp = undefined;
|
|
68
|
+
for (const dir of roots) rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("MCP lifecycle — cold store recovers after sources_update (E2, C1)", () => {
|
|
72
|
+
it("a cold read fails STORE_UNAVAILABLE with the sources_update recovery, then the retry succeeds", async () => {
|
|
73
|
+
const cwd = coldProject(DEFAULT_PACK_TTL);
|
|
74
|
+
mcp = await projectMcp(capabilities, cwd);
|
|
75
|
+
|
|
76
|
+
// 1) COLD: the store has no pack — a store-backed read must fail cleanly,
|
|
77
|
+
// pointing the agent at the recovery TOOL (not just a CLI string).
|
|
78
|
+
const cold = await mcp.callTool("block_list");
|
|
79
|
+
expect(cold.ok).toBe(false);
|
|
80
|
+
const error = cold.error as {
|
|
81
|
+
code: string;
|
|
82
|
+
recovery?: { mcp?: { tool?: string } };
|
|
83
|
+
};
|
|
84
|
+
expect(error.code).toBe("STORE_UNAVAILABLE");
|
|
85
|
+
expect(error.recovery?.mcp?.tool).toBe("sources_update");
|
|
86
|
+
expect(readLock(cwd)).toBeUndefined();
|
|
87
|
+
|
|
88
|
+
// 2) The agent runs the recovery tool for real (confirm) — it builds the pack.
|
|
89
|
+
const update = await mcp.callTool("sources_update", { confirm: true });
|
|
90
|
+
expect(update.ok).toBe(true);
|
|
91
|
+
// A real execution, not a plan-only preview.
|
|
92
|
+
expect(update.meta).not.toMatchObject({ planOnly: true });
|
|
93
|
+
expect((update.data as { contentHash: string }).contentHash).toMatch(
|
|
94
|
+
/^[0-9a-f]{64}$/,
|
|
95
|
+
);
|
|
96
|
+
expect(readLock(cwd)?.contentHash).toMatch(/^[0-9a-f]{64}$/);
|
|
97
|
+
|
|
98
|
+
// 3) RETRY on the SAME server: the shared store was invalidated by the
|
|
99
|
+
// mutation, so the memoized cold rejection is gone and the read re-boots
|
|
100
|
+
// against the new pack. This is the guarantee a fresh-runtime test cannot
|
|
101
|
+
// make.
|
|
102
|
+
const warm = await mcp.callTool("block_list");
|
|
103
|
+
expect(warm.ok).toBe(true);
|
|
104
|
+
const names = (warm.data as { name: string }[])
|
|
105
|
+
.map((row) => row.name)
|
|
106
|
+
.sort();
|
|
107
|
+
expect(names).toEqual(["Button", "Card"]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("MCP lifecycle — config_set is visible to the next read (E2, C2)", () => {
|
|
112
|
+
it("config_set updates the layer a subsequent config_show reads on the same server", async () => {
|
|
113
|
+
const cwd = tempDir("e2-cfg-");
|
|
114
|
+
mcp = await projectMcp(capabilities, cwd);
|
|
115
|
+
|
|
116
|
+
// Before: the default channel, from the default (unset) origin.
|
|
117
|
+
const before = await mcp.callTool("config_show");
|
|
118
|
+
const beforeData = before.data as {
|
|
119
|
+
config: { channel: string };
|
|
120
|
+
origins: { channel: string };
|
|
121
|
+
};
|
|
122
|
+
expect(beforeData.config.channel).toBe("normal");
|
|
123
|
+
expect(beforeData.origins.channel).toBe("default");
|
|
124
|
+
|
|
125
|
+
// Mutate: set the channel for real (confirm).
|
|
126
|
+
const set = await mcp.callTool("config_set", {
|
|
127
|
+
key: "channel",
|
|
128
|
+
value: "experimental",
|
|
129
|
+
confirm: true,
|
|
130
|
+
});
|
|
131
|
+
expect(set.ok).toBe(true);
|
|
132
|
+
|
|
133
|
+
// After: the SAME long-lived server's read reflects the write — proving the
|
|
134
|
+
// mutation dropped the config memo the boot depends on (not a stale layer).
|
|
135
|
+
const after = await mcp.callTool("config_show");
|
|
136
|
+
const afterData = after.data as {
|
|
137
|
+
config: { channel: string };
|
|
138
|
+
origins: { channel: string };
|
|
139
|
+
};
|
|
140
|
+
expect(afterData.config.channel).toBe("experimental");
|
|
141
|
+
// The write landed in the global layer, so the origin flips accordingly.
|
|
142
|
+
expect(afterData.origins.channel).toBe("global");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("MCP lifecycle — discovery from a cold store (E2)", () => {
|
|
147
|
+
it("the capabilities catalog resolves cold, then browse -> inspect works once warm", async () => {
|
|
148
|
+
const cwd = coldProject(DEFAULT_PACK_TTL);
|
|
149
|
+
mcp = await projectMcp(capabilities, cwd);
|
|
150
|
+
|
|
151
|
+
// Discovery is storeless: the capabilities catalog answers even with a cold
|
|
152
|
+
// store, so an agent can orient before the pack exists.
|
|
153
|
+
const catalog = await mcp.callTool("capabilities");
|
|
154
|
+
expect(catalog.ok).toBe(true);
|
|
155
|
+
const catalogData = catalog.data as {
|
|
156
|
+
discovery_sequence: unknown;
|
|
157
|
+
tools: unknown;
|
|
158
|
+
};
|
|
159
|
+
expect(catalogData.discovery_sequence).toBeDefined();
|
|
160
|
+
expect(catalogData.tools).toBeDefined();
|
|
161
|
+
|
|
162
|
+
// Warm the store, then follow a browse -> inspect discovery step end to end.
|
|
163
|
+
await mcp.callTool("sources_update", { confirm: true });
|
|
164
|
+
const list = await mcp.callTool("block_list");
|
|
165
|
+
expect(list.ok).toBe(true);
|
|
166
|
+
const first = (list.data as { name: string }[]).at(0);
|
|
167
|
+
expect(first?.name).toBeDefined();
|
|
168
|
+
|
|
169
|
+
const lookup = await mcp.callTool("block_lookup", {
|
|
170
|
+
name: [first?.name ?? ""],
|
|
171
|
+
});
|
|
172
|
+
expect(lookup.ok).toBe(true);
|
|
173
|
+
const results = (lookup.data as { results: { name: string }[] }).results;
|
|
174
|
+
expect(results.at(0)?.name).toBe(first?.name);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -92,7 +92,7 @@ function representativeValue(
|
|
|
92
92
|
);
|
|
93
93
|
return entity?.prefixed ?? entity?.name ?? "";
|
|
94
94
|
}
|
|
95
|
-
// An enum positional (e.g. `config
|
|
95
|
+
// An enum positional (e.g. `config set <key>`) must
|
|
96
96
|
// get a real member — a placeholder would fail the tool's zod schema at the
|
|
97
97
|
// SDK layer, before the handler, yielding a protocol error rather than a
|
|
98
98
|
// well-formed envelope.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale-command gate: the published README and every doc under `docs/` must be
|
|
3
|
+
* free of the retired v1 vocabulary. This is an ENFORCED test, not a lint — a
|
|
4
|
+
* doc that reintroduces a removed command (or the retired `--llm` flag) fails
|
|
5
|
+
* the build. The live `--format llm` form is deliberately NOT banned.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join, relative } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
|
|
13
|
+
/** The package root (`packages/cli/pragma/`), resolved from this test's URL. */
|
|
14
|
+
const packageRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
|
15
|
+
|
|
16
|
+
/** Recursively collect every `.md` file under a directory. */
|
|
17
|
+
function collectMarkdown(dir: string): string[] {
|
|
18
|
+
const out: string[] = [];
|
|
19
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
20
|
+
const full = join(dir, entry.name);
|
|
21
|
+
if (entry.isDirectory()) out.push(...collectMarkdown(full));
|
|
22
|
+
else if (entry.name.endsWith(".md")) out.push(full);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One retired pattern and a human-readable reason for the failure message. */
|
|
28
|
+
interface BannedPattern {
|
|
29
|
+
readonly pattern: RegExp;
|
|
30
|
+
readonly reason: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The retired v1 vocabulary. The live `--format llm` form is intentionally absent. */
|
|
34
|
+
const BANNED: readonly BannedPattern[] = [
|
|
35
|
+
{
|
|
36
|
+
pattern: /\bupdate-refs\b/,
|
|
37
|
+
reason: "retired `update-refs` (now `sources update`)",
|
|
38
|
+
},
|
|
39
|
+
{ pattern: /pragma\s+llm\b/, reason: "retired `llm` tool" },
|
|
40
|
+
{ pattern: /pragma\s+data\b/, reason: "retired `data` noun (now `sources`)" },
|
|
41
|
+
{
|
|
42
|
+
pattern: /pragma\s+tokens\b/,
|
|
43
|
+
reason: "retired plural `tokens` noun (now singular `token`)",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
pattern: /\btokens_(list|lookup|sample|add-config)\b/,
|
|
47
|
+
reason: "retired plural `tokens_*` tools (now `token_*`)",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
pattern: /--llm\b/,
|
|
51
|
+
reason:
|
|
52
|
+
"retired `--llm` flag (now the `--format llm` form, auto-detected when piped)",
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// Every shipped doc must be free of retired vocabulary: the README plus every
|
|
57
|
+
// page under `docs/`. CHANGELOG.md (at the package root, outside `docs/`) is
|
|
58
|
+
// DELIBERATELY exempt — its migration table legitimately cites the retired
|
|
59
|
+
// names (`data`, `update-refs`, plural `tokens`) to tell readers what they
|
|
60
|
+
// became, so scanning it would false-positive on that historical vocabulary.
|
|
61
|
+
const files = [
|
|
62
|
+
join(packageRoot, "README.md"),
|
|
63
|
+
...collectMarkdown(join(packageRoot, "docs")),
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
describe("stale-command gate — docs never mention retired v1 commands", () => {
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
const rel = relative(packageRoot, file);
|
|
69
|
+
it(`${rel} is free of retired commands`, () => {
|
|
70
|
+
const content = readFileSync(file, "utf-8");
|
|
71
|
+
for (const { pattern, reason } of BANNED) {
|
|
72
|
+
expect(pattern.test(content), `${rel}: ${reason}`).toBe(false);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
});
|
|
@@ -103,7 +103,7 @@ exports[`eval harness — seed gate > runs the full seed (stable + read-noun) ca
|
|
|
103
103
|
"passed": true,
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
|
-
"id": "prompt-set-tier-maps-to-config-
|
|
106
|
+
"id": "prompt-set-tier-maps-to-config-set",
|
|
107
107
|
"kind": "prompt",
|
|
108
108
|
"passed": true,
|
|
109
109
|
},
|
|
@@ -138,7 +138,7 @@ exports[`eval harness — seed gate > runs the full seed (stable + read-noun) ca
|
|
|
138
138
|
"passed": true,
|
|
139
139
|
},
|
|
140
140
|
{
|
|
141
|
-
"id": "tool-config-
|
|
141
|
+
"id": "tool-config-set-is-plan-first",
|
|
142
142
|
"kind": "tool",
|
|
143
143
|
"passed": true,
|
|
144
144
|
},
|
|
@@ -163,11 +163,11 @@ export const assemblyEvalCases: readonly EvalCase[] = [
|
|
|
163
163
|
},
|
|
164
164
|
},
|
|
165
165
|
{
|
|
166
|
-
id: "prompt-set-tier-maps-to-config-
|
|
166
|
+
id: "prompt-set-tier-maps-to-config-set",
|
|
167
167
|
kind: "prompt",
|
|
168
|
-
input: '"Set the active tier" should map to `
|
|
168
|
+
input: '"Set the active tier" should map to `config_set` (B3).',
|
|
169
169
|
async expect({ mcp }) {
|
|
170
|
-
assertIntentTool(await mcp.listTools(), "
|
|
170
|
+
assertIntentTool(await mcp.listTools(), "config_set", /tier/i);
|
|
171
171
|
},
|
|
172
172
|
},
|
|
173
173
|
{
|
|
@@ -70,12 +70,15 @@ export const effectfulEvalCases: readonly EvalCase[] = [
|
|
|
70
70
|
},
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
|
-
id: "tool-config-
|
|
73
|
+
id: "tool-config-set-is-plan-first",
|
|
74
74
|
kind: "tool",
|
|
75
75
|
input:
|
|
76
|
-
"
|
|
76
|
+
"config_set without `confirm` returns a plan (planOnly) and writes nothing — the uniform plan-first mutation contract.",
|
|
77
77
|
async expect({ mcp }) {
|
|
78
|
-
const result = await mcp.callTool("
|
|
78
|
+
const result = await mcp.callTool("config_set", {
|
|
79
|
+
key: "tier",
|
|
80
|
+
value: "apps/lxd",
|
|
81
|
+
});
|
|
79
82
|
assert.equal(result.ok, true);
|
|
80
83
|
const meta = result.meta as { planOnly?: boolean };
|
|
81
84
|
assert.equal(meta.planOnly, true);
|
|
@@ -90,16 +90,19 @@ export const readNounEvalCases: readonly EvalCase[] = [
|
|
|
90
90
|
id: "content-canonical-graph-has-4-components",
|
|
91
91
|
kind: "content",
|
|
92
92
|
input:
|
|
93
|
-
"the canonical fixture graph carries
|
|
93
|
+
"the canonical fixture graph carries 4 ds:Component individuals (Button, Modal, LXD Panel, Beta Widget); block list --all-tiers also surfaces the untiered Button Icon subcomponent (A2).",
|
|
94
94
|
async expect() {
|
|
95
95
|
await withCanonicalFixture(ALL_VISIBLE_CONFIG, async (mcp) => {
|
|
96
96
|
const result = await mcp.callTool("block_list", { allTiers: true });
|
|
97
97
|
const names = (result.data as { name: string }[])
|
|
98
98
|
.map((r) => r.name)
|
|
99
99
|
.sort();
|
|
100
|
+
// --all-tiers now reveals the untiered Button Icon subcomponent
|
|
101
|
+
// alongside the 4 tiered components (A2).
|
|
100
102
|
assert.deepEqual(names, [
|
|
101
103
|
"Beta Widget",
|
|
102
104
|
"Button",
|
|
105
|
+
"Button Icon",
|
|
103
106
|
"LXD Panel",
|
|
104
107
|
"Modal",
|
|
105
108
|
]);
|
|
@@ -173,3 +173,159 @@ export const fixtureEffectsModule: CapabilityModule = {
|
|
|
173
173
|
},
|
|
174
174
|
],
|
|
175
175
|
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A synthetic module that exercises every RENDER path of `emitReference` (D8 —
|
|
179
|
+
* never listed in `src/capabilities/index.ts`). Distinct from `fixtureModule`,
|
|
180
|
+
* which only exercises `emitSurface`: this one carries the doc-only shapes the
|
|
181
|
+
* reference generator reads and the machine surface ignores — a rich `doc`,
|
|
182
|
+
* examples with and without notes, a {@link DisclosureSpec}, every param kind
|
|
183
|
+
* as a flag (including a repeatable `string[]` flag), an OPTIONAL variadic
|
|
184
|
+
* positional, a destructive mutation AND a non-destructive one (the two
|
|
185
|
+
* `formatToolAnnotations` mutation branches), a visible MCP-withheld verb, a
|
|
186
|
+
* self-verb, a hidden verb the emitter must drop, and — so `renderNonToolSurface`
|
|
187
|
+
* renders both its optional bullets — an `mcpResources` template surface and an
|
|
188
|
+
* `mcpPrompts` native-prompt surface. The `register` hooks are no-ops: the
|
|
189
|
+
* reference emitter reads only `mcpResources.surface.templates` and the presence
|
|
190
|
+
* of `mcpPrompts`, never invoking either.
|
|
191
|
+
*/
|
|
192
|
+
export const fixtureReferenceModule: CapabilityModule = {
|
|
193
|
+
name: "fixture-reference",
|
|
194
|
+
verbs: [
|
|
195
|
+
{
|
|
196
|
+
path: ["gizmo", "scan"],
|
|
197
|
+
summary: "Scan the gizmos.",
|
|
198
|
+
doc: "Scan every gizmo under the active scope, optionally narrowing to named targets. Reads nothing off disk.",
|
|
199
|
+
params: [
|
|
200
|
+
{
|
|
201
|
+
kind: "string[]",
|
|
202
|
+
name: "targets",
|
|
203
|
+
doc: "Gizmo names to scan (all when omitted).",
|
|
204
|
+
positional: true,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
kind: "enum",
|
|
208
|
+
name: "mode",
|
|
209
|
+
doc: "Scan mode.",
|
|
210
|
+
values: ["fast", "slow"],
|
|
211
|
+
default: "fast",
|
|
212
|
+
},
|
|
213
|
+
{ kind: "number", name: "limit", doc: "Maximum gizmos to scan." },
|
|
214
|
+
{ kind: "string", name: "label", doc: "Label to stamp on the report." },
|
|
215
|
+
{ kind: "string[]", name: "include", doc: "Extra globs to include." },
|
|
216
|
+
{ kind: "boolean", name: "deep", doc: "Recurse into sub-gizmos." },
|
|
217
|
+
],
|
|
218
|
+
output: { formatters: passthroughFormatters },
|
|
219
|
+
examples: [
|
|
220
|
+
{ cmd: "pragma gizmo scan", note: "scan everything" },
|
|
221
|
+
{ cmd: "pragma gizmo scan alpha --mode slow" },
|
|
222
|
+
],
|
|
223
|
+
disclosure: { levels: ["summary", "detailed"], default: "summary" },
|
|
224
|
+
capability: {
|
|
225
|
+
needsStore: false,
|
|
226
|
+
mutates: false,
|
|
227
|
+
mcp: {
|
|
228
|
+
expose: true,
|
|
229
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
run: async () => ({ scanned: true }),
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
path: ["gizmo", "wipe"],
|
|
236
|
+
summary: "Wipe a gizmo.",
|
|
237
|
+
params: [
|
|
238
|
+
{
|
|
239
|
+
kind: "string",
|
|
240
|
+
name: "path",
|
|
241
|
+
doc: "The gizmo path to wipe.",
|
|
242
|
+
positional: true,
|
|
243
|
+
required: true,
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
output: { formatters: passthroughFormatters },
|
|
247
|
+
capability: {
|
|
248
|
+
needsStore: true,
|
|
249
|
+
mutates: true,
|
|
250
|
+
destructive: true,
|
|
251
|
+
mcp: {
|
|
252
|
+
expose: true,
|
|
253
|
+
annotations: {
|
|
254
|
+
readOnlyHint: false,
|
|
255
|
+
destructiveHint: true,
|
|
256
|
+
openWorldHint: false,
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
run: () => succeed({ wiped: true }),
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
path: ["gizmo", "tidy"],
|
|
264
|
+
summary: "Tidy the gizmo cache.",
|
|
265
|
+
params: [],
|
|
266
|
+
output: { formatters: passthroughFormatters },
|
|
267
|
+
capability: {
|
|
268
|
+
needsStore: false,
|
|
269
|
+
mutates: true,
|
|
270
|
+
destructive: false,
|
|
271
|
+
mcp: {
|
|
272
|
+
expose: true,
|
|
273
|
+
annotations: {
|
|
274
|
+
readOnlyHint: false,
|
|
275
|
+
destructiveHint: false,
|
|
276
|
+
openWorldHint: false,
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
run: () => succeed({ tidied: true }),
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
path: ["gizmo", "local"],
|
|
284
|
+
summary: "A visible gizmo verb withheld from MCP.",
|
|
285
|
+
params: [],
|
|
286
|
+
output: { formatters: passthroughFormatters },
|
|
287
|
+
capability: {
|
|
288
|
+
needsStore: false,
|
|
289
|
+
mutates: false,
|
|
290
|
+
mcp: { expose: false, reason: "CLI-only local helper" },
|
|
291
|
+
},
|
|
292
|
+
run: async () => ({ local: true }),
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
path: ["ping"],
|
|
296
|
+
summary: "Ping the gizmo host.",
|
|
297
|
+
params: [],
|
|
298
|
+
output: { formatters: passthroughFormatters },
|
|
299
|
+
capability: {
|
|
300
|
+
needsStore: false,
|
|
301
|
+
mutates: false,
|
|
302
|
+
mcp: {
|
|
303
|
+
expose: true,
|
|
304
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
run: async () => ({ pong: true }),
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
path: ["gizmo", "hidden"],
|
|
311
|
+
summary: "Hidden gizmo probe.",
|
|
312
|
+
hidden: true,
|
|
313
|
+
params: [],
|
|
314
|
+
output: { formatters: passthroughFormatters },
|
|
315
|
+
capability: {
|
|
316
|
+
needsStore: false,
|
|
317
|
+
mutates: false,
|
|
318
|
+
mcp: { expose: false, reason: "internal probe" },
|
|
319
|
+
},
|
|
320
|
+
run: async () => ({ ok: true }),
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
// A non-tool MCP surface, so `renderNonToolSurface` emits its Resources bullet.
|
|
324
|
+
// `register` is a no-op — the emitter reads only `surface.templates`.
|
|
325
|
+
mcpResources: {
|
|
326
|
+
surface: { templates: ["gizmo:{+uri}"] },
|
|
327
|
+
register: () => {},
|
|
328
|
+
},
|
|
329
|
+
// A native prompt surface, so `renderNonToolSurface` emits its Prompts bullet.
|
|
330
|
+
mcpPrompts: { register: () => {} },
|
|
331
|
+
};
|