@canonical/pragma-cli 0.33.0 → 0.34.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 (250) hide show
  1. package/README.md +7 -7
  2. package/dist/pragma +0 -0
  3. package/package.json +18 -15
  4. package/pragma.conf.ts +664 -0
  5. package/src/bin.ts +13 -4
  6. package/src/capabilities/block.parity.test.ts +250 -0
  7. package/src/capabilities/capabilities/capabilities.render.ts +3 -2
  8. package/src/capabilities/capabilities/capabilities.verb.ts +31 -13
  9. package/src/capabilities/capabilities/catalog.ts +18 -10
  10. package/src/capabilities/capabilities/hints.ts +13 -19
  11. package/src/capabilities/colophon/collectColophon.ts +34 -19
  12. package/src/capabilities/colophon/colophon.verb.ts +8 -4
  13. package/src/capabilities/colophon/types.ts +16 -7
  14. package/src/capabilities/config/collectConfigShow.test.ts +57 -0
  15. package/src/capabilities/config/collectConfigShow.ts +41 -4
  16. package/src/capabilities/config/fields.ts +0 -27
  17. package/src/capabilities/config/set.verb.ts +7 -3
  18. package/src/capabilities/config/show.render.test.ts +77 -5
  19. package/src/capabilities/config/show.render.ts +7 -12
  20. package/src/capabilities/config/show.verb.ts +4 -3
  21. package/src/capabilities/config/types.ts +8 -0
  22. package/src/capabilities/create/byteEquality.test.ts +78 -100
  23. package/src/capabilities/create/compiledCreate.subprocess.test.ts +52 -15
  24. package/src/capabilities/create/constants.ts +75 -0
  25. package/src/capabilities/create/create.mcp.test.ts +2 -2
  26. package/src/capabilities/create/create.test.ts +162 -15
  27. package/src/capabilities/create/create.verb.ts +140 -69
  28. package/src/capabilities/create/index.ts +2 -10
  29. package/src/capabilities/create/pickGenerator.ts +34 -28
  30. package/src/capabilities/create/templates.embedded.generated.ts +4 -29
  31. package/src/capabilities/create/types.ts +10 -6
  32. package/src/capabilities/{standard/parity.test.ts → distribution.parity.test.ts} +24 -14
  33. package/src/capabilities/distribution.test.ts +120 -0
  34. package/src/capabilities/distribution.ts +59 -0
  35. package/src/capabilities/doctor/checks/checkConfigFile.ts +6 -5
  36. package/src/capabilities/doctor/checks/checkKeStore.ts +2 -2
  37. package/src/capabilities/doctor/checks/checkMcpConfigured.ts +7 -6
  38. package/src/capabilities/doctor/checks/checkPackageRefs.ts +79 -45
  39. package/src/capabilities/doctor/checks/checkPragmaVersion.ts +3 -2
  40. package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +61 -11
  41. package/src/capabilities/doctor/checks/checkShellCompletions.ts +63 -23
  42. package/src/capabilities/doctor/checks/checkSkillsSymlinked.ts +3 -2
  43. package/src/capabilities/doctor/doctor.render.test.ts +6 -6
  44. package/src/capabilities/doctor/doctor.render.ts +2 -1
  45. package/src/capabilities/doctor/doctor.test.ts +37 -8
  46. package/src/capabilities/doctor/doctor.verb.ts +6 -2
  47. package/src/capabilities/doctor/runChecks.ts +5 -5
  48. package/src/capabilities/doctor/types.ts +1 -1
  49. package/src/capabilities/emptyRecovery.test.ts +32 -15
  50. package/src/capabilities/graph/inspect.verb.ts +3 -2
  51. package/src/capabilities/graph/query.verb.ts +3 -2
  52. package/src/capabilities/graph/runQuery.ts +8 -5
  53. package/src/capabilities/index.ts +22 -18
  54. package/src/capabilities/info/collectInfo.ts +10 -6
  55. package/src/capabilities/info/info.render.ts +3 -2
  56. package/src/capabilities/info/info.verb.ts +3 -2
  57. package/src/capabilities/lazy.test.ts +93 -2
  58. package/src/capabilities/ontology/verbs.ts +21 -21
  59. package/src/capabilities/prompt/prompt.test.ts +12 -6
  60. package/src/capabilities/prompt/verbs.ts +24 -11
  61. package/src/capabilities/resources/provider.ts +30 -11
  62. package/src/capabilities/resources/resources.test.ts +85 -29
  63. package/src/capabilities/setup/operations/setupGenerator.ts +16 -9
  64. package/src/capabilities/setup/operations/setupMcp.ts +2 -2
  65. package/src/capabilities/setup/operations/setupSkills.ts +3 -3
  66. package/src/capabilities/setup/setup.verb.ts +12 -5
  67. package/src/capabilities/setup/shell.ts +29 -3
  68. package/src/capabilities/skill/discover.ts +11 -5
  69. package/src/capabilities/skill/render.ts +4 -5
  70. package/src/capabilities/skill/verbs.ts +7 -7
  71. package/src/capabilities/sources/collectStatus.ts +55 -50
  72. package/src/capabilities/sources/index.ts +1 -1
  73. package/src/capabilities/sources/installSkills.ts +2 -2
  74. package/src/capabilities/sources/runUpdate.ts +113 -122
  75. package/src/capabilities/sources/sources.test.ts +111 -117
  76. package/src/capabilities/sources/status.render.test.ts +53 -24
  77. package/src/capabilities/sources/status.render.ts +24 -29
  78. package/src/capabilities/sources/status.verb.ts +15 -8
  79. package/src/capabilities/sources/types.ts +18 -25
  80. package/src/capabilities/sources/update.render.ts +9 -2
  81. package/src/capabilities/sources/update.verb.ts +12 -23
  82. package/src/capabilities/surface.test.ts +33 -10
  83. package/src/capabilities/tier.parity.test.ts +144 -0
  84. package/src/capabilities/upgrade/upgrade.verb.ts +7 -3
  85. package/src/constants.test.ts +8 -6
  86. package/src/constants.ts +47 -21
  87. package/src/identity.test.ts +333 -0
  88. package/src/index.ts +2 -2
  89. package/src/kernel/completion/__snapshots__/emitScripts.test.ts.snap +5 -2
  90. package/src/kernel/completion/emitScripts.test.ts +1 -1
  91. package/src/kernel/completion/emitScripts.ts +4 -2
  92. package/src/kernel/completion/entitySource.test.ts +185 -41
  93. package/src/kernel/completion/entitySource.ts +117 -107
  94. package/src/kernel/completion/model.test.ts +1 -1
  95. package/src/kernel/completion/model.ts +11 -0
  96. package/src/kernel/completion/safety.test.ts +82 -36
  97. package/src/kernel/completion/shellDrive.test.ts +592 -0
  98. package/src/kernel/completion/templates/fish.ts +28 -6
  99. package/src/kernel/completion/types.ts +3 -3
  100. package/src/kernel/config/__snapshots__/firstRun.test.ts.snap +1 -1
  101. package/src/kernel/config/defaults.test.ts +85 -0
  102. package/src/kernel/config/defaults.ts +12 -23
  103. package/src/kernel/config/evaluateProjectConfig.ts +2 -3
  104. package/src/kernel/config/findProjectConfig.ts +4 -3
  105. package/src/kernel/config/firstRun.ts +12 -5
  106. package/src/kernel/config/globalConfig.ts +2 -2
  107. package/src/kernel/config/index.ts +4 -5
  108. package/src/kernel/config/paths.ts +17 -17
  109. package/src/kernel/config/readConfig.test.ts +242 -6
  110. package/src/kernel/config/readConfig.ts +8 -8
  111. package/src/kernel/config/schema.test.ts +50 -0
  112. package/src/kernel/config/schema.ts +97 -10
  113. package/src/kernel/config/types.ts +73 -19
  114. package/src/kernel/copy.test.ts +453 -0
  115. package/src/kernel/error/PragmaError.test.ts +1 -1
  116. package/src/kernel/error/constants.ts +3 -2
  117. package/src/kernel/error/index.ts +1 -1
  118. package/src/kernel/error/recovery.test.ts +22 -45
  119. package/src/kernel/error/recovery.ts +22 -27
  120. package/src/kernel/error/types.ts +5 -4
  121. package/src/kernel/packs/collect.test.ts +195 -11
  122. package/src/kernel/packs/collect.ts +213 -58
  123. package/src/kernel/packs/compile.test.ts +163 -7
  124. package/src/kernel/packs/compile.ts +15 -12
  125. package/src/kernel/packs/disclosure.test.ts +5 -2
  126. package/src/kernel/packs/graphql/fetchGraphqlLookup.ts +11 -10
  127. package/src/kernel/packs/graphqlSource.test.ts +6 -3
  128. package/src/kernel/packs/iri.ts +5 -7
  129. package/src/kernel/packs/renderPack.ts +5 -3
  130. package/src/kernel/packs/resolveEntity.ts +21 -17
  131. package/src/kernel/packs/runBodies.ts +15 -14
  132. package/src/kernel/packs/schema.ts +96 -10
  133. package/src/kernel/packs/sparql/runSelect.test.ts +104 -10
  134. package/src/kernel/packs/sparql/runSelect.ts +73 -34
  135. package/src/kernel/packs/toolDescription.test.ts +10 -2
  136. package/src/kernel/packs/types.ts +39 -4
  137. package/src/kernel/packs/uniqueness.ts +4 -4
  138. package/src/kernel/project/cli/buildProgram.test.ts +9 -4
  139. package/src/kernel/project/cli/dispatch.test.ts +67 -0
  140. package/src/kernel/project/cli/dispatch.ts +34 -12
  141. package/src/kernel/project/cli/helpFormat.ts +1 -1
  142. package/src/kernel/project/cli/rootHelp.ts +105 -105
  143. package/src/kernel/project/mcp/buildServer.ts +11 -6
  144. package/src/kernel/project/mcp/instructions.ts +11 -6
  145. package/src/kernel/project/mcp/parity.test.ts +39 -1
  146. package/src/kernel/project/mcp/prompts/prompts.test.ts +120 -5
  147. package/src/kernel/project/mcp/prompts/provider.ts +22 -8
  148. package/src/kernel/project/mcp/prompts/source.ts +68 -67
  149. package/src/kernel/project/mcp/registerVerb.ts +25 -6
  150. package/src/kernel/project/mcp/serve.ts +11 -3
  151. package/src/kernel/project/mcp/toolDescriptions.test.ts +6 -2
  152. package/src/kernel/render/disclosure.ts +4 -2
  153. package/src/kernel/render/prefixes.test.ts +36 -0
  154. package/src/kernel/render/prefixes.ts +40 -13
  155. package/src/kernel/render/renderers.test.ts +1 -1
  156. package/src/kernel/runtime/graphpack/build.ts +29 -3
  157. package/src/kernel/runtime/graphpack/buildIndex.test.ts +46 -0
  158. package/src/kernel/runtime/graphpack/buildIndex.ts +52 -17
  159. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +5 -5
  160. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +3 -3
  161. package/src/kernel/runtime/graphpack/embedded/pack.stories.generated.ts +3 -0
  162. package/src/kernel/runtime/graphpack/embedded.ts +37 -11
  163. package/src/kernel/runtime/graphpack/graphpack.test.ts +205 -5
  164. package/src/kernel/runtime/graphpack/hash.ts +3 -2
  165. package/src/kernel/runtime/graphpack/index.ts +1 -1
  166. package/src/kernel/runtime/graphpack/manifest.ts +114 -12
  167. package/src/kernel/runtime/graphpack/read.ts +4 -9
  168. package/src/kernel/runtime/graphpack/schemas.ts +74 -0
  169. package/src/kernel/runtime/graphpack/stories.ts +84 -0
  170. package/src/kernel/runtime/graphpack/types.ts +61 -48
  171. package/src/kernel/runtime/paths.ts +64 -15
  172. package/src/kernel/runtime/probe.test.ts +8 -4
  173. package/src/kernel/runtime/readEntity.ts +6 -5
  174. package/src/kernel/runtime/refs/gitOps.ts +1 -19
  175. package/src/kernel/runtime/refs/parseRef.ts +7 -7
  176. package/src/kernel/runtime/refs/resolve.ts +78 -62
  177. package/src/kernel/runtime/resolveSources.test.ts +63 -24
  178. package/src/kernel/runtime/resolveSources.ts +36 -25
  179. package/src/kernel/runtime/storeReadiness.ts +9 -8
  180. package/src/kernel/runtime/storeless.test.ts +1 -1
  181. package/src/kernel/runtime/wasmEmbed.test.ts +22 -19
  182. package/src/kernel/spec/emitReference.test.ts +3 -3
  183. package/src/kernel/spec/emitReference.ts +144 -11
  184. package/src/kernel/spec/emitSurface.ts +9 -5
  185. package/src/kernel/spec/surfaceConformance.test.ts +2 -2
  186. package/src/kernel/spec/surfaceConformance.ts +8 -0
  187. package/src/kernel/spec/types.ts +29 -9
  188. package/src/kernel/spec/validate.ts +2 -1
  189. package/src/kernel/vocabulary.test.ts +164 -0
  190. package/src/kernel/vocabulary.ts +146 -0
  191. package/src/testing/PARITY_GAPS.ts +8 -2
  192. package/src/testing/behavioral/README.md +3 -3
  193. package/src/testing/behavioral/completion.test.ts +19 -6
  194. package/src/testing/behavioral/errorMatrix.mcp.test.ts +4 -3
  195. package/src/testing/behavioral/errorMatrix.storeless.test.ts +1 -1
  196. package/src/testing/behavioral/exitCodes.e2e.test.ts +6 -5
  197. package/src/testing/behavioral/harness.foundation.test.ts +14 -5
  198. package/src/testing/behavioral/journeys.cli.test.ts +116 -75
  199. package/src/testing/behavioral/journeys.defaultPack.test.ts +82 -176
  200. package/src/testing/behavioral/journeys.firstInstall.test.ts +167 -0
  201. package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +9 -6
  202. package/src/testing/behavioral/journeys.packageStories.test.ts +188 -0
  203. package/src/testing/behavioral/liveReadSurface.ts +4 -3
  204. package/src/testing/behavioral/mcpSurface.test.ts +9 -4
  205. package/src/testing/behavioral/staleCommands.test.ts +10 -0
  206. package/src/testing/eval/__snapshots__/eval.test.ts.snap +7 -7
  207. package/src/testing/eval/cases/assembly.ts +13 -6
  208. package/src/testing/eval/cases/effectful.ts +25 -13
  209. package/src/testing/eval/cases/readNouns.ts +22 -20
  210. package/src/testing/eval/cases/stable.ts +5 -1
  211. package/src/testing/eval/eval.test.ts +7 -1
  212. package/src/testing/fixtures/fixtureCapability.ts +45 -1
  213. package/src/testing/fixtures/graph/canonical.ts +6 -3
  214. package/src/testing/fixtures/graph/defaultPack.ts +6 -5
  215. package/src/testing/fixtures/graph/owlExport.ts +7 -5
  216. package/src/testing/helpers/fixtureGraph.ts +33 -16
  217. package/src/testing/helpers/packRuntime.ts +2 -3
  218. package/src/testing/helpers/projectCli.ts +2 -1
  219. package/src/testing/helpers/projectMcp.ts +6 -0
  220. package/src/testing/perf/budgets.test.ts +4 -0
  221. package/src/testing/perf/budgets.ts +22 -7
  222. package/src/testing/perf/globalSetup.ts +121 -6
  223. package/src/types.ts +2 -2
  224. package/src/capabilities/block/blockList.test.ts +0 -79
  225. package/src/capabilities/block/blockList.verb.ts +0 -163
  226. package/src/capabilities/block/index.ts +0 -25
  227. package/src/capabilities/block/pack.ts +0 -166
  228. package/src/capabilities/block/parity.test.ts +0 -187
  229. package/src/capabilities/block/tierChain.ts +0 -78
  230. package/src/capabilities/colophon/pragmaColophon.ts +0 -66
  231. package/src/capabilities/modifier/index.ts +0 -15
  232. package/src/capabilities/modifier/pack.ts +0 -64
  233. package/src/capabilities/standard/index.ts +0 -18
  234. package/src/capabilities/standard/pack.ts +0 -128
  235. package/src/capabilities/tier/index.ts +0 -19
  236. package/src/capabilities/tier/lookup.render.test.ts +0 -72
  237. package/src/capabilities/tier/lookup.render.ts +0 -59
  238. package/src/capabilities/tier/lookup.test.ts +0 -83
  239. package/src/capabilities/tier/lookup.verb.ts +0 -44
  240. package/src/capabilities/tier/pack.ts +0 -28
  241. package/src/capabilities/tier/runLookup.ts +0 -87
  242. package/src/capabilities/token/addConfig.render.ts +0 -27
  243. package/src/capabilities/token/addConfig.test.ts +0 -62
  244. package/src/capabilities/token/addConfig.verb.ts +0 -40
  245. package/src/capabilities/token/index.ts +0 -19
  246. package/src/capabilities/token/pack.ts +0 -58
  247. package/src/capabilities/token/runAddConfig.ts +0 -95
  248. package/src/kernel/completion/bashDrive.test.ts +0 -73
  249. package/src/kernel/runtime/graphpack/embedded/sample.ttl +0 -39
  250. package/src/kernel/runtime/lock.ts +0 -90
@@ -0,0 +1,592 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import {
3
+ chmodSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { afterAll, describe, expect, it } from "vitest";
12
+ import { capabilities } from "../../capabilities/index.js";
13
+ import { BIN_NAME } from "../../constants.js";
14
+ import { completionFixture } from "../../testing/fixtures/completionFixture.js";
15
+ import { emitScripts } from "./emitScripts.js";
16
+
17
+ /**
18
+ * Drive the GENERATED completion scripts in real bash, zsh and fish.
19
+ *
20
+ * Every other test in this directory stops at the boundary of the script's
21
+ * text: `emitScripts.test.ts` snapshots it, `safety.test.ts` asserts what its
22
+ * characters may not be, `parse.test.ts` pins what the resolver does with
23
+ * words. Nothing executes it. A script that emits but does not parse leaves
24
+ * `_pragma` undefined and TAB silently dead forever, and a script that frames
25
+ * the delegation wrongly hands `__complete` the wrong slot while every unit
26
+ * test stays green. Both are only observable by running the thing.
27
+ *
28
+ * So this file runs it — over BOTH grammars:
29
+ *
30
+ * - the FIXTURE grammar, for the M1 `--flag=value` wordbreak regression (with
31
+ * `=` in `COMP_WORDBREAKS`, bash splits `--format=` into `--format` `=` ``;
32
+ * without the normalization at the top of `_pragma` the value slot falls
33
+ * through to the noun/positional arm and offers the WRONG candidates);
34
+ * - the LIVE grammar (`emitScripts(capabilities)`), which is the script users
35
+ * actually install and which no test had ever executed.
36
+ *
37
+ * Each driver puts a stand-in for the bin first on the child's `PATH`, which
38
+ * both RECORDS the argv it was handed and ANSWERS with two candidates, so every
39
+ * candidate set comes with the exec log that produced it and every exec can be
40
+ * followed to what the user is offered. That turns `emitScripts.ts`'s docblock
41
+ * claim — "the static tier answers STRUCTURE with zero exec; only
42
+ * `{kind:"names"}` value contexts shell out" — into an assertion, pins the exact
43
+ * argv the scripts hand `<bin> __complete`, and pins the last mile back.
44
+ * Standing first on `PATH` also means a developer with a real `pragma`
45
+ * installed can never have it exec'd by this suite.
46
+ *
47
+ * WHAT IS PROVEN, AND WHERE — this split must not be blurred:
48
+ *
49
+ * - **By execution on every machine that runs the suite:** bash parses both
50
+ * scripts; the live bash script offers the live grammar's structure with
51
+ * zero process exec; it hands `__complete` a literal argv and offers back
52
+ * what it answers; it honours the `minChars` gate. The bash describes are
53
+ * deliberately NOT gated on a `hasShell` probe — the package pins
54
+ * `os: ["linux"]`, so a machine with no usable bash should fail this file
55
+ * loudly rather than skip it and report nothing.
56
+ * - **By execution only where the shell is installed:** the same guarantees
57
+ * for zsh and fish. Those describes `skipIf` the shell is absent, which
58
+ * includes this development box and, today, CI — so on those machines they
59
+ * assert NOTHING, and say so by skipping visibly. Every assertion in them
60
+ * was executed during development against zsh 5.9 and fish 3.7.0 (obtained
61
+ * with `apt-get download zsh zsh-common fish fish-common` + `dpkg-deb -x`
62
+ * into a scratch prefix, leaving the box itself untouched). Adding
63
+ * `zsh fish` to CI's apt step is what would make them continuous.
64
+ * - **By static assertion only, everywhere:** that the zsh and fish scripts
65
+ * contain no `eval`, no backticks, guarded `compadd`, inert value lists and
66
+ * the exact delegation form — `safety.test.ts`'s script-safety describe. A
67
+ * static assertion is never an execution proof, and no name in this file
68
+ * claims one.
69
+ */
70
+
71
+ /** Whether a shell of this id is on PATH and runnable. */
72
+ function hasShell(id: string): boolean {
73
+ return (
74
+ spawnSync(id, ["-c", "printf ok"], { encoding: "utf-8" }).stdout === "ok"
75
+ );
76
+ }
77
+
78
+ /** The scripts for the fixture grammar (which has the global --format). */
79
+ const fixture = emitScripts([completionFixture]);
80
+ /** The scripts a user actually installs, for the LIVE grammar. */
81
+ const live = emitScripts(capabilities);
82
+
83
+ /** The frame separating one recorded exec from the next in the record file. */
84
+ const CALL = "<<<CALL>>>";
85
+
86
+ /**
87
+ * The two candidates the stub answers a delegation with, derived from the
88
+ * partial it was handed — the shape a real `__complete` reply has (every
89
+ * candidate extends the typed partial), which is what fish's engine filters on.
90
+ */
91
+ function answersFor(partial: string): string[] {
92
+ return [`${partial}-one`, `${partial}-two`];
93
+ }
94
+
95
+ /** Every temp dir this file mints, removed in one `afterAll`. */
96
+ const roots: string[] = [];
97
+
98
+ /** A tracked temp dir. */
99
+ function tempRoot(prefix: string): string {
100
+ const dir = mkdtempSync(join(tmpdir(), prefix));
101
+ roots.push(dir);
102
+ return dir;
103
+ }
104
+
105
+ afterAll(() => {
106
+ for (const dir of roots) rmSync(dir, { recursive: true, force: true });
107
+ });
108
+
109
+ /**
110
+ * A directory holding a stub named after the bin: a bash script that appends a
111
+ * framed copy of its own argv to `$RECORD` and answers with
112
+ * {@link answersFor}'s two candidates. Placed FIRST on each child's PATH by the
113
+ * drivers below.
114
+ *
115
+ * It ANSWERS as well as records because recording alone stops one step short of
116
+ * the guarantee: a script can exec `__complete` with a perfect argv and then
117
+ * drop every line it gets back, leaving TAB silently dead. Measured — with
118
+ * `COMPREPLY=()` appended to bash's dynamic function and `_matches=()` before
119
+ * zsh's `compadd`, entity completion offers nothing in either shell and 258
120
+ * tests across this directory, `setup`, `doctor` and the behavioural suite
121
+ * still passed. The reply assertions below are what fails now.
122
+ */
123
+ const stubDir = tempRoot("pragma-drive-stub-");
124
+ writeFileSync(
125
+ join(stubDir, BIN_NAME),
126
+ `#!/usr/bin/env bash\n{ printf '${CALL}\\n'; printf '%s\\n' "$@"; } >> "$RECORD"\nprintf '%s\\n' "\${@: -1}-one" "\${@: -1}-two"\n`,
127
+ );
128
+ chmodSync(join(stubDir, BIN_NAME), 0o755);
129
+
130
+ /** What one drive observed: the offered candidates and the execs it caused. */
131
+ interface Drive {
132
+ /** The candidates the shell was offered, line by line. */
133
+ readonly reply: string[];
134
+ /** One entry per exec of the recorder, each the full argv it received. */
135
+ readonly calls: string[][];
136
+ }
137
+
138
+ /** Split the recorder's log into one argv array per framed call. */
139
+ function parseCalls(record: string): string[][] {
140
+ return record
141
+ .split(`${CALL}\n`)
142
+ .slice(1)
143
+ .map((chunk) => chunk.split("\n").slice(0, -1));
144
+ }
145
+
146
+ /** A scratch dir holding a script file, a driver path and a fresh record. */
147
+ function scratch(script: string): {
148
+ file: string;
149
+ driver: string;
150
+ record: string;
151
+ } {
152
+ const dir = tempRoot("pragma-shelldrive-");
153
+ const file = join(dir, "completion");
154
+ const record = join(dir, "record");
155
+ writeFileSync(file, script);
156
+ writeFileSync(record, "");
157
+ return { file, driver: join(dir, "driver"), record };
158
+ }
159
+
160
+ /**
161
+ * The child environment: the recorder first, then the REAL bin dirs. Node
162
+ * resolves the spawned command through the CHILD's `PATH`, so a stub-only
163
+ * `PATH` cannot find the shell itself.
164
+ */
165
+ function childEnv(record: string): NodeJS.ProcessEnv {
166
+ return {
167
+ ...process.env,
168
+ PATH: `${stubDir}:${process.env.PATH ?? ""}`,
169
+ RECORD: record,
170
+ };
171
+ }
172
+
173
+ /** Non-empty stdout lines. */
174
+ function lines(stdout: string): string[] {
175
+ return stdout.split("\n").filter((line) => line.length > 0);
176
+ }
177
+
178
+ /**
179
+ * Source a generated bash script, place the cursor per COMP_WORDS/COMP_CWORD,
180
+ * invoke `_pragma`, and report COMPREPLY plus every exec the script performed.
181
+ */
182
+ function driveBash(
183
+ script: string,
184
+ words: readonly string[],
185
+ cword: number,
186
+ ): Drive {
187
+ const { file, record } = scratch(script);
188
+ const wordsLiteral = words
189
+ .map((word) => `'${word.replace(/'/g, "'\\''")}'`)
190
+ .join(" ");
191
+ const source = [
192
+ `source '${file}'`,
193
+ `COMP_WORDS=(${wordsLiteral})`,
194
+ `COMP_CWORD=${cword}`,
195
+ `_${BIN_NAME}`,
196
+ `printf '%s\\n' "\${COMPREPLY[@]}"`,
197
+ ].join("\n");
198
+ const result = spawnSync("bash", ["--norc", "--noprofile", "-c", source], {
199
+ encoding: "utf-8",
200
+ env: childEnv(record),
201
+ });
202
+ return {
203
+ reply: lines(result.stdout),
204
+ calls: parseCalls(readFileSync(record, "utf-8")),
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Source a generated zsh script with `words` + `CURRENT` set as zsh's
210
+ * completion system would, and report what it `compadd`s.
211
+ *
212
+ * `CURRENT` is bash's `COMP_CWORD` PLUS ONE — zsh indexes `words` from 1. The
213
+ * script ends in `_pragma "$@"`, which runs it, so the driver must not call it
214
+ * again or every candidate is offered twice. `compadd` and `_files` are stubbed
215
+ * because a `-f` zsh has no completion system loaded; the stub drops the `--`
216
+ * guard the templates emit exactly as the real `compadd` would.
217
+ */
218
+ function driveZsh(
219
+ script: string,
220
+ words: readonly string[],
221
+ cword: number,
222
+ ): Drive {
223
+ const { file, driver, record } = scratch(script);
224
+ writeFileSync(
225
+ driver,
226
+ [
227
+ "local -a words",
228
+ 'words=("$@")',
229
+ `CURRENT=${cword + 1}`,
230
+ 'compadd() { local a; for a in "$@"; do [[ $a == -- ]] || print -r -- "$a"; done }',
231
+ "_files() { : }",
232
+ `source '${file}'`,
233
+ "",
234
+ ].join("\n"),
235
+ );
236
+ const result = spawnSync("zsh", ["-f", driver, ...words], {
237
+ encoding: "utf-8",
238
+ env: childEnv(record),
239
+ });
240
+ return {
241
+ reply: lines(result.stdout),
242
+ calls: parseCalls(readFileSync(record, "utf-8")),
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Source a generated fish script and ask fish itself to complete a command
248
+ * line, via its first-class `complete -C`. The highest-fidelity of the three
249
+ * drivers: fish's own engine does the position matching and the filtering.
250
+ */
251
+ function driveFish(script: string, line: string): Drive {
252
+ const { file, record } = scratch(script);
253
+ const result = spawnSync(
254
+ "fish",
255
+ ["--no-config", "-c", "source $argv[1]; complete -C $argv[2]", file, line],
256
+ { encoding: "utf-8", env: childEnv(record) },
257
+ );
258
+ return {
259
+ reply: lines(result.stdout),
260
+ calls: parseCalls(readFileSync(record, "utf-8")),
261
+ };
262
+ }
263
+
264
+ /** `<shell> -n <file>`: does the shell accept this text as a script at all? */
265
+ function parses(shell: string, script: string): number | null {
266
+ const { file } = scratch(script);
267
+ return spawnSync(shell, ["-n", file], { encoding: "utf-8" }).status;
268
+ }
269
+
270
+ /** The argv the protocol pins for `pragma block lookup ds:global.component.but`. */
271
+ const PROTOCOL_ARGV = [
272
+ "__complete",
273
+ "--",
274
+ "block",
275
+ "lookup",
276
+ "ds:global.component.but",
277
+ ];
278
+
279
+ describe("generated bash — --flag=value wordbreak (M1)", () => {
280
+ it("routes --format=<TAB> to the format values (not nouns/positionals)", () => {
281
+ // Default COMP_WORDBREAKS split: `--format` `=` `` (empty current word).
282
+ const { reply } = driveBash(
283
+ fixture.bash,
284
+ ["pragma", "--format", "=", ""],
285
+ 3,
286
+ );
287
+ expect(reply.sort()).toEqual(["json", "llm", "plain"]);
288
+ });
289
+
290
+ it("filters the value by the partial typed after =", () => {
291
+ const { reply } = driveBash(
292
+ fixture.bash,
293
+ ["pragma", "--format", "=", "j"],
294
+ 3,
295
+ );
296
+ expect(reply).toEqual(["json"]);
297
+ });
298
+
299
+ it("routes a verb-scoped --format=<TAB> the same way", () => {
300
+ // After a noun/verb the value must still route as a flag value, never as
301
+ // block's positional (which would offer entity/enum candidates instead).
302
+ const { reply } = driveBash(
303
+ fixture.bash,
304
+ ["pragma", "block", "get", "--format", "=", ""],
305
+ 5,
306
+ );
307
+ expect(reply.sort()).toEqual(["json", "llm", "plain"]);
308
+ });
309
+
310
+ it("still completes the space form --format <TAB> (unbroken)", () => {
311
+ const { reply } = driveBash(fixture.bash, ["pragma", "--format", ""], 2);
312
+ expect(reply.sort()).toEqual(["json", "llm", "plain"]);
313
+ });
314
+ });
315
+
316
+ /** Live structural contexts, in the bash/zsh `words` + cursor-index form. */
317
+ const STRUCTURE = [
318
+ {
319
+ at: "pragma co",
320
+ words: ["pragma", "co"],
321
+ cword: 1,
322
+ offers: ["colophon", "config"],
323
+ },
324
+ {
325
+ at: "pragma block <TAB>",
326
+ words: ["pragma", "block", ""],
327
+ cword: 2,
328
+ offers: ["list", "lookup", "sample"],
329
+ },
330
+ {
331
+ at: "pragma setup <TAB>",
332
+ words: ["pragma", "setup", ""],
333
+ cword: 2,
334
+ offers: ["completions", "lsp", "mcp", "skills"],
335
+ },
336
+ {
337
+ at: "pragma create <TAB>",
338
+ words: ["pragma", "create", ""],
339
+ cword: 2,
340
+ offers: ["application", "component", "package"],
341
+ },
342
+ {
343
+ at: "pragma --format <TAB>",
344
+ words: ["pragma", "--format", ""],
345
+ cword: 2,
346
+ offers: ["json", "llm", "plain"],
347
+ },
348
+ {
349
+ at: "pragma block lookup --<TAB>",
350
+ words: ["pragma", "block", "lookup", "--"],
351
+ cword: 3,
352
+ offers: ["--detail", "--format", "--help", "--verbose"],
353
+ },
354
+ ] as const;
355
+
356
+ /**
357
+ * Every noun the live grammar declares — the whole table the zsh script
358
+ * `compadd`s for a partial noun. A literal, so a noun silently added, dropped
359
+ * or leaked into the table fails rather than widening the expectation.
360
+ */
361
+ const NOUNS = [
362
+ "block",
363
+ "capabilities",
364
+ "colophon",
365
+ "config",
366
+ "create",
367
+ "doctor",
368
+ "graph",
369
+ "info",
370
+ "mcp",
371
+ "modifier",
372
+ "ontology",
373
+ "prompt",
374
+ "setup",
375
+ "skill",
376
+ "sources",
377
+ "standard",
378
+ "tier",
379
+ "token",
380
+ "upgrade",
381
+ ] as const;
382
+
383
+ describe("generated bash — the live grammar's script", () => {
384
+ it("is a script bash accepts, for both the live and the fixture grammar", () => {
385
+ // The one executable syntax gate that runs everywhere. It is not vacuous:
386
+ // `SAFE_TOKEN_RE` admits shell reserved words, so a noun named `esac`
387
+ // would emit `case "$noun" in esac) … esac` and bash -n would reject it.
388
+ expect(parses("bash", live.bash)).toBe(0);
389
+ expect(parses("bash", fixture.bash)).toBe(0);
390
+ });
391
+
392
+ it.each(STRUCTURE)("answers $at from the inlined table, execing nothing", ({
393
+ words,
394
+ cword,
395
+ offers,
396
+ }) => {
397
+ const { reply, calls } = driveBash(live.bash, words, cword);
398
+ expect(reply.sort()).toEqual([...offers].sort());
399
+ // The structural tier is the point of a generated script: a TAB here
400
+ // must never cost a process spawn, nor depend on a pack being built.
401
+ expect(calls).toEqual([]);
402
+ });
403
+
404
+ it("hands __complete the protocol argv and OFFERS what it answers", () => {
405
+ // The last mile, and the only assertion that covers it: everything else
406
+ // here observes what the script SENDS. A script that sends a perfect argv
407
+ // and then drops the reply (`COMPREPLY=()` after the mapfile) leaves TAB
408
+ // silently dead and was green across 258 tests.
409
+ const { reply, calls } = driveBash(
410
+ live.bash,
411
+ ["pragma", "block", "lookup", "ds:global.component.but"],
412
+ 3,
413
+ );
414
+ expect(calls).toEqual([PROTOCOL_ARGV]);
415
+ expect(reply).toEqual(answersFor("ds:global.component.but"));
416
+ });
417
+
418
+ it("keeps an interleaved global value flag in the argv it delegates", () => {
419
+ // `parse.ts` is the one place that decides a flag's value is not a
420
+ // positional. It only ever sees what the script sends, so the script must
421
+ // send the flag AND its value, in place.
422
+ const { calls } = driveBash(
423
+ live.bash,
424
+ ["pragma", "block", "lookup", "--format", "json", "ds:g"],
425
+ 5,
426
+ );
427
+ expect(calls).toEqual([
428
+ ["__complete", "--", "block", "lookup", "--format", "json", "ds:g"],
429
+ ]);
430
+ });
431
+
432
+ it("delegates a variadic second positional with both words in place", () => {
433
+ const { calls } = driveBash(
434
+ live.bash,
435
+ ["pragma", "standard", "lookup", "cs:a", "cs:code.arr"],
436
+ 4,
437
+ );
438
+ expect(calls).toEqual([
439
+ ["__complete", "--", "standard", "lookup", "cs:a", "cs:code.arr"],
440
+ ]);
441
+ });
442
+
443
+ it("gates the exec on minChars: one typed char offers nothing and execs nothing", () => {
444
+ // `minChars` lives ONLY in the generated scripts — `__complete -- block
445
+ // lookup d` answers with candidates. This is the gate that stops a stray
446
+ // TAB from spawning a process, and nothing else observes it.
447
+ const one = driveBash(live.bash, ["pragma", "block", "lookup", "d"], 3);
448
+ expect(one.reply).toEqual([]);
449
+ expect(one.calls).toEqual([]);
450
+
451
+ const two = driveBash(live.bash, ["pragma", "block", "lookup", "ds"], 3);
452
+ expect(two.calls).toEqual([["__complete", "--", "block", "lookup", "ds"]]);
453
+ });
454
+ });
455
+
456
+ describe.skipIf(!hasShell("zsh"))(
457
+ "generated zsh — the live grammar's script (skipped where zsh is absent)",
458
+ () => {
459
+ it("is a script zsh accepts", () => {
460
+ expect(parses("zsh", live.zsh)).toBe(0);
461
+ });
462
+
463
+ // zsh's script `compadd`s the model's table and zsh's own completion system
464
+ // filters it — unlike bash (`compgen -W … -- "$cur"`) and fish, which filter
465
+ // before the shell sees the list. So a zsh assertion is about the OFFERED
466
+ // set, never the filtered one. That makes exactly ONE row a superset (a
467
+ // partial noun offers all 19 nouns, not the two that match); it gets its own
468
+ // test below, and the other five are asserted as the exact sets zsh really
469
+ // returns. An `arrayContaining` here would be blind to the regression this
470
+ // file exists to catch — a slot that offers EXTRA, wrong candidates.
471
+ it.each(
472
+ STRUCTURE.filter((row) => row.at !== "pragma co"),
473
+ )("OFFERS exactly the candidates for $at, execing nothing", ({
474
+ words,
475
+ cword,
476
+ offers,
477
+ }) => {
478
+ const { reply, calls } = driveZsh(live.zsh, words, cword);
479
+ expect(reply.sort()).toEqual([...offers].sort());
480
+ expect(calls).toEqual([]);
481
+ });
482
+
483
+ it("offers the WHOLE noun table for a partial noun, leaving the filtering to zsh", () => {
484
+ const { reply, calls } = driveZsh(live.zsh, ["pragma", "co"], 1);
485
+ // The whole table is the claim, so assert the whole table: nouns that do
486
+ // not start with `co` are offered too (the observable difference from
487
+ // bash, which returns exactly two here), and none that the grammar does
488
+ // not declare. Three `toContain`s passed against a table cut to three.
489
+ expect(reply.sort()).toEqual([...NOUNS].sort());
490
+ expect(calls).toEqual([]);
491
+ });
492
+
493
+ it("hands __complete the SAME protocol argv bash does, and OFFERS the answer", () => {
494
+ const { reply, calls } = driveZsh(
495
+ live.zsh,
496
+ ["pragma", "block", "lookup", "ds:global.component.but"],
497
+ 3,
498
+ );
499
+ expect(calls).toEqual([PROTOCOL_ARGV]);
500
+ // `_matches=()` before the `compadd` passes every other assertion here.
501
+ expect(reply).toEqual(answersFor("ds:global.component.but"));
502
+ });
503
+
504
+ it("gates the exec on minChars: one typed char execs nothing, two execs once", () => {
505
+ const one = driveZsh(live.zsh, ["pragma", "block", "lookup", "d"], 3);
506
+ expect(one.reply).toEqual([]);
507
+ expect(one.calls).toEqual([]);
508
+
509
+ // The positive half keeps the negative one honest: a driver that failed
510
+ // outright would also record nothing.
511
+ const two = driveZsh(live.zsh, ["pragma", "block", "lookup", "ds"], 3);
512
+ expect(two.calls).toEqual([
513
+ ["__complete", "--", "block", "lookup", "ds"],
514
+ ]);
515
+ });
516
+ },
517
+ );
518
+
519
+ describe.skipIf(!hasShell("fish"))(
520
+ "generated fish — the live grammar's script (skipped where fish is absent)",
521
+ () => {
522
+ it("is a script fish accepts", () => {
523
+ expect(parses("fish", live.fish)).toBe(0);
524
+ });
525
+
526
+ it.each([
527
+ { line: "pragma co", offers: ["colophon", "config"] },
528
+ { line: "pragma block ", offers: ["list", "lookup", "sample"] },
529
+ {
530
+ line: "pragma setup ",
531
+ offers: ["completions", "lsp", "mcp", "skills"],
532
+ },
533
+ {
534
+ line: "pragma create ",
535
+ offers: ["application", "component", "package"],
536
+ },
537
+ // fish's own inline convention: it returns the whole `--flag=value` word,
538
+ // not the bare value bash offers. Assert what fish returns.
539
+ {
540
+ line: "pragma --format=",
541
+ offers: ["--format=json", "--format=llm", "--format=plain"],
542
+ },
543
+ ])("answers $line from the inlined rules, execing nothing", ({
544
+ line,
545
+ offers,
546
+ }) => {
547
+ const { reply, calls } = driveFish(live.fish, line);
548
+ expect(reply.sort()).toEqual([...offers].sort());
549
+ expect(calls).toEqual([]);
550
+ });
551
+
552
+ it("hands __complete the SAME protocol argv bash does, and OFFERS the answer", () => {
553
+ const { reply, calls } = driveFish(
554
+ live.fish,
555
+ "pragma block lookup ds:global.component.but",
556
+ );
557
+ expect(calls).toEqual([PROTOCOL_ARGV]);
558
+ expect(reply).toEqual(answersFor("ds:global.component.but"));
559
+ });
560
+
561
+ it("answers `--<TAB>` with the flag names, execing nothing (as bash and zsh do)", () => {
562
+ // The sixth `STRUCTURE` row, kept apart because it is where fish USED to
563
+ // differ — measured, not assumed. fish evaluates EVERY `complete` rule
564
+ // matching a position, not one exclusive `case` arm, so the positional's
565
+ // `-a "(pragma __complete -- …)"` fired alongside the flag-name rules the
566
+ // moment the token cleared `minChars` — and `--` is two characters. That
567
+ // cost a process spawn per TAB on a flag name, and put whatever the
568
+ // delegate answered for a `--` partial into the user's candidate list.
569
+ // The name-source positional rule now ANDs `__pragma_notflag`, so fish
570
+ // holds `emitScripts.ts`'s "structure execs nothing" claim like the other
571
+ // two families.
572
+ const { reply, calls } = driveFish(live.fish, "pragma block lookup --");
573
+ expect(calls).toEqual([]);
574
+ expect(reply.sort()).toEqual(
575
+ ["--detail", "--format", "--help", "--verbose"].sort(),
576
+ );
577
+ });
578
+
579
+ it("gates the exec on minChars: one typed char execs nothing, two execs once", () => {
580
+ const one = driveFish(live.fish, "pragma block lookup d");
581
+ expect(one.reply).toEqual([]);
582
+ expect(one.calls).toEqual([]);
583
+
584
+ // The positive half keeps the negative one honest: a driver that failed
585
+ // outright would also record nothing.
586
+ const two = driveFish(live.fish, "pragma block lookup ds");
587
+ expect(two.calls).toEqual([
588
+ ["__complete", "--", "block", "lookup", "ds"],
589
+ ]);
590
+ });
591
+ },
592
+ );
@@ -8,7 +8,9 @@
8
8
  * via a command substitution — fish treats substitution results as literal
9
9
  * candidates (split on newlines, never evaluated). Each name rule ANDs a
10
10
  * `__<bin>_minchars` guard into its condition, so the exec waits for `minChars`
11
- * typed chars. No `eval`, no backticks.
11
+ * typed chars, and a name-source POSITIONAL also ANDs `__<bin>_notflag` — fish
12
+ * evaluates every matching rule rather than one exclusive branch, so without it
13
+ * a structural `--<TAB>` would spawn the delegate too. No `eval`, no backticks.
12
14
  *
13
15
  * Static-tier coarseness (the resolver is exact): positional value rules are
14
16
  * not index-gated, and a mixed self-verb + sub-verb noun only offers its
@@ -23,6 +25,11 @@ function fnBase(binName: string): string {
23
25
  return `__${binName.replace(/[^A-Za-z0-9_]/g, "_")}`;
24
26
  }
25
27
 
28
+ /** AND a guard into a rule's condition (or start one with it). */
29
+ function and(condition: string | undefined, guard: string): string {
30
+ return condition ? `${condition}; and ${guard}` : guard;
31
+ }
32
+
26
33
  /** AND a `minChars` guard into a name rule's condition (or start one). */
27
34
  function withMinChars(
28
35
  fn: string,
@@ -30,8 +37,7 @@ function withMinChars(
30
37
  minChars: number,
31
38
  ): string | undefined {
32
39
  if (minChars <= 0) return condition;
33
- const guard = `${fn}_minchars ${minChars}`;
34
- return condition ? `${condition}; and ${guard}` : guard;
40
+ return and(condition, `${fn}_minchars ${minChars}`);
35
41
  }
36
42
 
37
43
  /** The rule fragment completing one flag of a view (or a global flag). */
@@ -93,9 +99,20 @@ function positionalRule(
93
99
  if (positional.source.kind === "names") names = true;
94
100
  if (positional.source.kind === "files") files = true;
95
101
  }
96
- // A name source gates its exec on `minChars`; a values/files positional keeps
97
- // the bare position condition so it still completes on bare TAB.
98
- const cond = names ? withMinChars(fn, condition, minChars) : condition;
102
+ // A name source gates its exec on `minChars` AND on the current token not
103
+ // being a flag; a values/files positional keeps the bare position condition
104
+ // so it still completes on bare TAB.
105
+ //
106
+ // The `_notflag` half is what keeps a purely STRUCTURAL `--<TAB>` free of
107
+ // exec. fish evaluates every `complete` rule matching a position, so without
108
+ // it this positional rule fires alongside the flag-name rules the moment the
109
+ // token clears `minChars` — and `--` is two characters. That cost a process
110
+ // spawn per TAB on a flag name AND put the delegate's answer in the user's
111
+ // candidate list. bash and zsh route the same context through one exclusive
112
+ // `case` arm and exec nothing; this is how fish joins them.
113
+ const cond = names
114
+ ? withMinChars(fn, and(condition, `${fn}_notflag`), minChars)
115
+ : condition;
99
116
  const parts = [`complete -c ${binName} -n "${cond}"`];
100
117
  if (names) {
101
118
  parts.push(`-a "(${binName} __complete -- (${fn}_words) 2>/dev/null)"`);
@@ -147,6 +164,11 @@ export function fishScript(
147
164
  "end",
148
165
  ]
149
166
  : []),
167
+ // True when the token being completed is not a flag, so a name-source
168
+ // positional rule stays out of a structural `--<TAB>`.
169
+ `function ${fn}_notflag`,
170
+ " not string match -q -- '-*' (commandline -ct)",
171
+ "end",
150
172
  `function ${fn}_at`,
151
173
  " set -l cmd (commandline -opc)",
152
174
  " set -l noun ''",
@@ -26,7 +26,7 @@ export type CompletionSource =
26
26
  /** A closed value set (enum params) — inlined by the static tier. */
27
27
  | { readonly kind: "values"; readonly values: readonly string[] }
28
28
  /**
29
- * Names of a storeless source (index/skills/tiers/prompts/prefixes) — the
29
+ * Names of a storeless source (index/skills/prefixes) — the
30
30
  * dynamic tier reads them via the seam and ranks them with `match`/case. The
31
31
  * static scripts exec `__complete` for every name source, one code path.
32
32
  */
@@ -135,8 +135,8 @@ export interface CompletionRequest {
135
135
 
136
136
  /**
137
137
  * The dynamic-tier seam: reads candidate names for a `{kind:"names"}` source.
138
- * One source-dispatched reader serves every family — index, skills, tiers,
139
- * prompts, prefixes — so the resolver ranks them uniformly. The default
138
+ * One source-dispatched reader serves every family — index, skills, prefixes —
139
+ * so the resolver ranks them uniformly. The default
140
140
  * ({@link import("./entitySource.js").emptyNameSource}) yields nothing, so
141
141
  * structural completion never pays a read; {@link
142
142
  * import("./entitySource.js").indexCompletionEnv} wires the storeless sources.
@@ -4,7 +4,7 @@ exports[`first run > greets on stderr and seeds the global config 1`] = `
4
4
  [
5
5
  "Hello! Thanks for taking the time to try the pre-release pragma CLI.",
6
6
  "Please file issues and feedback at https://github.com/canonical/pragma/issues.",
7
- "Pragma stores its configuration in <CONFIG> (just created with defaults).",
7
+ "pragma stores its configuration in <CONFIG> (just created with defaults).",
8
8
  "A \`pragma.config.ts\` in this directory or above it overrides it per project.",
9
9
  "",
10
10
  ]