@intentius/chant 0.124.44 → 0.124.46

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 (249) hide show
  1. package/dist/capabilities/aws.paths +148 -0
  2. package/dist/capabilities/base-mappings.json +122 -0
  3. package/dist/capabilities/catalog.d.ts +89 -0
  4. package/dist/capabilities/catalog.d.ts.map +1 -0
  5. package/dist/capabilities/catalog.js +204 -0
  6. package/dist/capabilities/language.d.ts +103 -0
  7. package/dist/capabilities/language.d.ts.map +1 -0
  8. package/dist/capabilities/language.js +335 -0
  9. package/dist/capabilities/matcher.d.ts +65 -0
  10. package/dist/capabilities/matcher.d.ts.map +1 -0
  11. package/dist/capabilities/matcher.js +299 -0
  12. package/dist/capabilities/view.d.ts +207 -0
  13. package/dist/capabilities/view.d.ts.map +1 -0
  14. package/dist/capabilities/view.js +484 -0
  15. package/dist/ci-identity.d.ts +183 -12
  16. package/dist/ci-identity.d.ts.map +1 -1
  17. package/dist/ci-identity.js +362 -53
  18. package/dist/cli/commands/lint.d.ts.map +1 -1
  19. package/dist/cli/commands/lint.js +1 -0
  20. package/dist/cli/handlers/build.d.ts.map +1 -1
  21. package/dist/cli/handlers/build.js +17 -1
  22. package/dist/cli/handlers/declared-graph.d.ts +44 -0
  23. package/dist/cli/handlers/declared-graph.d.ts.map +1 -0
  24. package/dist/cli/handlers/declared-graph.js +87 -0
  25. package/dist/cli/handlers/graph.d.ts +25 -0
  26. package/dist/cli/handlers/graph.d.ts.map +1 -1
  27. package/dist/cli/handlers/graph.js +93 -87
  28. package/dist/cli/handlers/pr-env.d.ts +8 -2
  29. package/dist/cli/handlers/pr-env.d.ts.map +1 -1
  30. package/dist/cli/handlers/pr-env.js +127 -225
  31. package/dist/cli/handlers/pr.d.ts +3 -0
  32. package/dist/cli/handlers/pr.d.ts.map +1 -1
  33. package/dist/cli/handlers/pr.js +1 -0
  34. package/dist/cli/handlers/search-count.d.ts +89 -0
  35. package/dist/cli/handlers/search-count.d.ts.map +1 -0
  36. package/dist/cli/handlers/search-count.js +132 -0
  37. package/dist/cli/handlers/search.d.ts +358 -6
  38. package/dist/cli/handlers/search.d.ts.map +1 -1
  39. package/dist/cli/handlers/search.js +1065 -155
  40. package/dist/cli/main.d.ts.map +1 -1
  41. package/dist/cli/main.js +23 -3
  42. package/dist/cli/mcp/server.d.ts.map +1 -1
  43. package/dist/cli/mcp/server.js +3 -0
  44. package/dist/cli/mcp/tools/capabilities.d.ts +7 -0
  45. package/dist/cli/mcp/tools/capabilities.d.ts.map +1 -0
  46. package/dist/cli/mcp/tools/capabilities.js +27 -0
  47. package/dist/cli/mcp/workspace-tools.d.ts +3 -1
  48. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  49. package/dist/cli/mcp/workspace-tools.js +47 -2
  50. package/dist/cli/parse-args.d.ts.map +1 -1
  51. package/dist/cli/parse-args.js +64 -17
  52. package/dist/cli/registry.d.ts +22 -0
  53. package/dist/cli/registry.d.ts.map +1 -1
  54. package/dist/components/cli-support.d.ts +2 -0
  55. package/dist/components/cli-support.d.ts.map +1 -1
  56. package/dist/components/cli-support.js +4 -2
  57. package/dist/components/fan-out-support.d.ts +3 -0
  58. package/dist/components/fan-out-support.d.ts.map +1 -1
  59. package/dist/components/fan-out-support.js +1 -1
  60. package/dist/components/pr-env-ports.d.ts +29 -0
  61. package/dist/components/pr-env-ports.d.ts.map +1 -0
  62. package/dist/components/pr-env-ports.js +77 -0
  63. package/dist/components/pr-env-run.d.ts +254 -0
  64. package/dist/components/pr-env-run.d.ts.map +1 -0
  65. package/dist/components/pr-env-run.js +376 -0
  66. package/dist/components/pr-environment.d.ts +91 -15
  67. package/dist/components/pr-environment.d.ts.map +1 -1
  68. package/dist/components/pr-environment.js +95 -9
  69. package/dist/components/pr-pipeline.d.ts +10 -1
  70. package/dist/components/pr-pipeline.d.ts.map +1 -1
  71. package/dist/config-environments.d.ts +92 -0
  72. package/dist/config-environments.d.ts.map +1 -0
  73. package/dist/config-environments.js +102 -0
  74. package/dist/config.d.ts +24 -84
  75. package/dist/config.d.ts.map +1 -1
  76. package/dist/config.js +9 -96
  77. package/dist/graph-detail.d.ts.map +1 -1
  78. package/dist/graph-detail.js +15 -3
  79. package/dist/graph-effective.d.ts +33 -2
  80. package/dist/graph-effective.d.ts.map +1 -1
  81. package/dist/graph-effective.js +215 -68
  82. package/dist/graph-ir.d.ts +23 -0
  83. package/dist/graph-ir.d.ts.map +1 -1
  84. package/dist/graph-ir.js +130 -0
  85. package/dist/graph-sub-template.d.ts +11 -0
  86. package/dist/graph-sub-template.d.ts.map +1 -0
  87. package/dist/graph-sub-template.js +19 -0
  88. package/dist/lexicon.d.ts +9 -0
  89. package/dist/lexicon.d.ts.map +1 -1
  90. package/dist/lifecycle/observe.d.ts +28 -0
  91. package/dist/lifecycle/observe.d.ts.map +1 -1
  92. package/dist/lifecycle/observe.js +23 -1
  93. package/dist/lint/rule.d.ts +6 -0
  94. package/dist/lint/rule.d.ts.map +1 -1
  95. package/dist/live-endpoint.d.ts +1 -1
  96. package/dist/live-endpoint.d.ts.map +1 -1
  97. package/dist/live-endpoint.js +1 -1
  98. package/dist/observation.d.ts +48 -1
  99. package/dist/observation.d.ts.map +1 -1
  100. package/dist/observation.js +21 -5
  101. package/dist/op/activities/activity-contracts.d.ts +4 -4
  102. package/dist/op/op-waves.d.ts +17 -0
  103. package/dist/op/op-waves.d.ts.map +1 -1
  104. package/dist/op/op-waves.js +23 -1
  105. package/dist/pr-forge.d.ts +15 -1
  106. package/dist/pr-forge.d.ts.map +1 -1
  107. package/dist/pr-forge.js +28 -16
  108. package/dist/pr-requires.d.ts +5 -1
  109. package/dist/pr-requires.d.ts.map +1 -1
  110. package/dist/wave-runtime.mjs +251 -250
  111. package/dist/workspace/capabilities-contract.d.ts +66 -0
  112. package/dist/workspace/capabilities-contract.d.ts.map +1 -0
  113. package/dist/workspace/capabilities-contract.js +107 -0
  114. package/dist/workspace/capabilities.schema.json +229 -0
  115. package/dist/workspace/ci-cloud-identity.d.ts +3 -1
  116. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -1
  117. package/dist/workspace/ci-cloud-identity.js +16 -1
  118. package/dist/workspace/ci-run.d.ts +35 -8
  119. package/dist/workspace/ci-run.d.ts.map +1 -1
  120. package/dist/workspace/ci-run.js +95 -21
  121. package/dist/workspace/ci-secrets.d.ts.map +1 -1
  122. package/dist/workspace/ci-secrets.js +4 -3
  123. package/dist/workspace/compose-graph.d.ts +9 -0
  124. package/dist/workspace/compose-graph.d.ts.map +1 -1
  125. package/dist/workspace/compose-graph.js +3 -1
  126. package/dist/workspace/conformance/index.d.ts +1 -1
  127. package/dist/workspace/conformance/index.d.ts.map +1 -1
  128. package/dist/workspace/conformance/index.js +39 -2
  129. package/dist/workspace/declaration.d.ts +10 -0
  130. package/dist/workspace/declaration.d.ts.map +1 -1
  131. package/dist/workspace/declaration.js +17 -1
  132. package/dist/workspace/declaration.schema.json +36 -1
  133. package/dist/workspace/graph-cache.d.ts +8 -0
  134. package/dist/workspace/graph-cache.d.ts.map +1 -1
  135. package/dist/workspace/graph-cache.js +27 -3
  136. package/dist/workspace/graph-cli.d.ts +39 -2
  137. package/dist/workspace/graph-cli.d.ts.map +1 -1
  138. package/dist/workspace/graph-cli.js +66 -24
  139. package/dist/workspace/graph.schema.json +148 -0
  140. package/dist/workspace/kind-readers.d.ts +3 -0
  141. package/dist/workspace/kind-readers.d.ts.map +1 -1
  142. package/dist/workspace/kind-readers.js +7 -1
  143. package/dist/workspace/member-commands.d.ts +10 -1
  144. package/dist/workspace/member-commands.d.ts.map +1 -1
  145. package/dist/workspace/member-commands.js +17 -1
  146. package/dist/workspace/nested-graph.d.ts +2 -0
  147. package/dist/workspace/nested-graph.d.ts.map +1 -1
  148. package/dist/workspace/nested-graph.js +32 -1
  149. package/dist/workspace/reason-codes.d.ts +10 -0
  150. package/dist/workspace/reason-codes.d.ts.map +1 -1
  151. package/dist/workspace/reason-codes.js +12 -0
  152. package/dist/workspace/search-cli.d.ts +204 -0
  153. package/dist/workspace/search-cli.d.ts.map +1 -0
  154. package/dist/workspace/search-cli.js +466 -0
  155. package/dist/workspace/search-contract.d.ts +56 -0
  156. package/dist/workspace/search-contract.d.ts.map +1 -0
  157. package/dist/workspace/search-contract.js +70 -0
  158. package/dist/workspace/search.schema.json +822 -0
  159. package/package.json +1 -1
  160. package/src/__snapshots__/okf.test.ts.snap +10 -0
  161. package/src/capabilities/aws.paths +148 -0
  162. package/src/capabilities/base-mappings.json +122 -0
  163. package/src/capabilities/catalog.ts +230 -0
  164. package/src/capabilities/language.test.ts +191 -0
  165. package/src/capabilities/language.ts +373 -0
  166. package/src/capabilities/matcher.ts +315 -0
  167. package/src/capabilities/view-compose.test.ts +76 -0
  168. package/src/capabilities/view.ts +631 -0
  169. package/src/ci-identity-targets.test.ts +275 -0
  170. package/src/ci-identity.ts +394 -61
  171. package/src/cli/commands/lint.ts +1 -0
  172. package/src/cli/handlers/build.ts +15 -1
  173. package/src/cli/handlers/declared-graph.ts +99 -0
  174. package/src/cli/handlers/graph.test.ts +94 -3
  175. package/src/cli/handlers/graph.ts +123 -96
  176. package/src/cli/handlers/pr-env.ts +135 -227
  177. package/src/cli/handlers/pr.ts +4 -0
  178. package/src/cli/handlers/search-build-roots.test.ts +144 -0
  179. package/src/cli/handlers/search-count.test.ts +191 -0
  180. package/src/cli/handlers/search-count.ts +165 -0
  181. package/src/cli/handlers/search-json.test.ts +333 -0
  182. package/src/cli/handlers/search-live.test.ts +387 -0
  183. package/src/cli/handlers/search-paths.test.ts +235 -0
  184. package/src/cli/handlers/search.ts +1312 -166
  185. package/src/cli/main.test.ts +28 -3
  186. package/src/cli/main.ts +23 -3
  187. package/src/cli/mcp/server.test.ts +3 -3
  188. package/src/cli/mcp/server.ts +3 -0
  189. package/src/cli/mcp/tools/capabilities.ts +32 -0
  190. package/src/cli/mcp/workspace-tools.e2e.test.ts +21 -1
  191. package/src/cli/mcp/workspace-tools.ts +47 -2
  192. package/src/cli/parse-args.ts +52 -13
  193. package/src/cli/registry.ts +22 -0
  194. package/src/components/cli-support.ts +6 -1
  195. package/src/components/fan-out-support.ts +4 -1
  196. package/src/components/pr-env-ports.ts +89 -0
  197. package/src/components/pr-env-run-bundle.test.ts +54 -0
  198. package/src/components/pr-env-run.test.ts +290 -0
  199. package/src/components/pr-env-run.ts +563 -0
  200. package/src/components/pr-environment.ts +161 -13
  201. package/src/components/pr-pipeline.ts +10 -1
  202. package/src/config-environments.ts +133 -0
  203. package/src/config.ts +37 -124
  204. package/src/graph-detail.ts +15 -3
  205. package/src/graph-effective.ts +226 -75
  206. package/src/graph-ir.test.ts +163 -0
  207. package/src/graph-ir.ts +147 -0
  208. package/src/graph-sub-template.ts +22 -0
  209. package/src/lexicon.ts +10 -0
  210. package/src/lifecycle/observe.test.ts +27 -1
  211. package/src/lifecycle/observe.ts +43 -1
  212. package/src/lint/rule.ts +6 -0
  213. package/src/live-endpoint.ts +1 -1
  214. package/src/observation.test.ts +25 -0
  215. package/src/observation.ts +69 -5
  216. package/src/op/op-waves.ts +38 -1
  217. package/src/pr-forge-bundle.test.ts +1 -1
  218. package/src/pr-forge.ts +44 -17
  219. package/src/pr-requires.test.ts +43 -6
  220. package/src/pr-requires.ts +5 -1
  221. package/src/workspace/capabilities-contract.test.ts +88 -0
  222. package/src/workspace/capabilities-contract.ts +132 -0
  223. package/src/workspace/capabilities.schema.json +229 -0
  224. package/src/workspace/ci-cloud-identity.test.ts +20 -0
  225. package/src/workspace/ci-cloud-identity.ts +14 -1
  226. package/src/workspace/ci-run.test.ts +74 -1
  227. package/src/workspace/ci-run.ts +122 -29
  228. package/src/workspace/ci-secrets.test.ts +1 -1
  229. package/src/workspace/ci-secrets.ts +4 -3
  230. package/src/workspace/compose-graph.ts +10 -1
  231. package/src/workspace/conformance/conformance.e2e.test.ts +14 -1
  232. package/src/workspace/conformance/index.ts +31 -2
  233. package/src/workspace/declaration.schema.json +36 -1
  234. package/src/workspace/declaration.test.ts +7 -0
  235. package/src/workspace/declaration.ts +24 -2
  236. package/src/workspace/graph-cache.ts +27 -3
  237. package/src/workspace/graph-cli.ts +96 -24
  238. package/src/workspace/graph.schema.json +148 -0
  239. package/src/workspace/kind-readers.e2e.test.ts +28 -0
  240. package/src/workspace/kind-readers.test.ts +1 -1
  241. package/src/workspace/kind-readers.ts +7 -1
  242. package/src/workspace/member-commands.ts +22 -1
  243. package/src/workspace/nested-graph.ts +32 -1
  244. package/src/workspace/reason-codes.test.ts +7 -0
  245. package/src/workspace/reason-codes.ts +12 -0
  246. package/src/workspace/search-cli.e2e.test.ts +305 -0
  247. package/src/workspace/search-cli.ts +591 -0
  248. package/src/workspace/search-contract.ts +95 -0
  249. package/src/workspace/search.schema.json +822 -0
@@ -76,6 +76,7 @@
76
76
 
77
77
  import type { OpEnvironment, OpSetupStep } from "../lexicon";
78
78
  import type { CiRunner } from "../ci-jobs";
79
+ import type { CloudTradeSpec } from "../ci-identity";
79
80
  import { gatePointProblems, type GatePoint } from "./gate-point";
80
81
  import { quorumProblems, type GateQuorumRule } from "./gate-quorum";
81
82
  import { notifyConfigProblem, type NotifyConfig } from "../notify";
@@ -261,6 +262,13 @@ export interface OpWaveJobGroup {
261
262
  * is requested.
262
263
  */
263
264
  idTokens?: Record<string, string>;
265
+ /**
266
+ * The cloud identities the group's jobs trade the job's OIDC token for
267
+ * (#4332), in place of the wave's: a {@link CloudTradeSpec}, as
268
+ * `cloudIdentityWave` in ../ci-identity.ts gives a group its apply
269
+ * identities. The trade runs before the checkout.
270
+ */
271
+ cloud?: CloudTradeSpec;
264
272
  }
265
273
 
266
274
  /** One wave: runs that apply together, behind one gate. */
@@ -321,6 +329,15 @@ export interface OpWave {
321
329
  * job's, and a group's own replace them.
322
330
  */
323
331
  idTokens?: Record<string, string>;
332
+ /**
333
+ * The cloud identities this wave's jobs trade the job's OIDC token for
334
+ * (#4332), before the checkout: a {@link CloudTradeSpec}. With `jobs`,
335
+ * the deciding job's, and a group's own `cloud` replaces it.
336
+ * `cloudIdentityWave` in ../ci-identity.ts gives the deciding job every
337
+ * plan identity of the wave's targets. Each later step of the job starts
338
+ * without the variables a token is requested with.
339
+ */
340
+ cloud?: CloudTradeSpec;
324
341
  /**
325
342
  * Split the wave's apply into job groups (#3914): a deciding job, which
326
343
  * runs with the wave's own settings, plans every run and decides the gate,
@@ -644,6 +661,7 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
644
661
  }
645
662
  if (wave.runner !== undefined) assertOpWaveRunner(where, `wave "${wave.name}" runner`, wave.runner);
646
663
  if (wave.idTokens !== undefined) assertOpWaveIdTokens(where, `wave "${wave.name}" idTokens`, wave.idTokens);
664
+ if (wave.cloud !== undefined) assertOpWaveCloud(where, `wave "${wave.name}" cloud`, wave.cloud);
647
665
  if (wave.jobs !== undefined) assertOpWaveJobGroups(where, wave);
648
666
  if (wave.discover !== undefined && typeof wave.discover !== "boolean") throw new Error(`${where}: wave "${wave.name}" has discover ${JSON.stringify(wave.discover)}; use true or false.`);
649
667
  if (wave.rest !== undefined && typeof wave.rest !== "boolean") throw new Error(`${where}: wave "${wave.name}" has rest ${JSON.stringify(wave.rest)}; use true or false.`);
@@ -684,6 +702,24 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
684
702
 
685
703
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
686
704
 
705
+ /** The key each cloud of a `cloud` trade names its identity under (`IDENTITY_KEY` in ../ci-identity.ts, kept here so this module imports none of it). */
706
+ const CLOUD_IDENTITY_KEY: Record<string, string> = { aws: "role", gcp: "serviceAccount", azure: "clientId" };
707
+
708
+ /** Refuse a `cloud` (#4332) the trade could not run: an unknown cloud, one that names no identity, or no audience. */
709
+ function assertOpWaveCloud(where: string, field: string, cloud: unknown): void {
710
+ if (cloud === null || typeof cloud !== "object" || Array.isArray(cloud) || Object.keys(cloud).length === 0) throw new Error(`${where}: ${field} must name one or more of aws, gcp and azure.`);
711
+ for (const [name, c] of Object.entries(cloud as Record<string, unknown>)) {
712
+ const key = CLOUD_IDENTITY_KEY[name];
713
+ if (!key) throw new Error(`${where}: ${field}.${name} is not a cloud; use aws, gcp or azure.`);
714
+ const t = c as Record<string, unknown> | null;
715
+ const targets = t?.targets;
716
+ const targetsOk = targets === undefined || (Array.isArray(targets) && targets.every((e) => e && Array.isArray((e as { targets?: unknown }).targets) && typeof (e as Record<string, unknown>)[key] === "string"));
717
+ if (!t || typeof t.audience !== "string" || !targetsOk || (typeof t[key] !== "string" && !(Array.isArray(targets) && targets.length > 0))) {
718
+ throw new Error(`${where}: ${field}.${name} must give an audience and a ${key}, or targets each with targets and a ${key}, as cloudIdentityWave gives them.`);
719
+ }
720
+ }
721
+ }
722
+
687
723
  /** Refuse a `runner` no forge could read (#3914). */
688
724
  function assertOpWaveRunner(where: string, field: string, runner: unknown): void {
689
725
  const labels = (value: unknown): boolean => Array.isArray(value) && value.length > 0 && value.every((l) => typeof l === "string" && l.trim() !== "");
@@ -723,7 +759,7 @@ function assertOpWaveJobGroups(where: string, wave: OpWave): void {
723
759
  if (group.name === "decide" || /^share-\d+$/.test(group.name)) throw new Error(`${where}: ${field} is named like the wave's own jobs (decide, share-<i>); name it another way.`);
724
760
  if (names.has(group.name)) throw new Error(`${where}: ${at} names job group "${group.name}" twice.`);
725
761
  names.add(group.name);
726
- const known = new Set(["name", "runs", "shares", "runner", "environment", "variables", "setup", "idTokens"]);
762
+ const known = new Set(["name", "runs", "shares", "runner", "environment", "variables", "setup", "idTokens", "cloud"]);
727
763
  const extra = Object.keys(group).find((key) => !known.has(key));
728
764
  if (extra !== undefined) throw new Error(`${where}: ${field}.${extra} is not a setting; a job group takes ${[...known].join(", ")}.`);
729
765
  if (!Array.isArray(group.runs) || group.runs.length === 0 || !group.runs.every((r) => typeof r === "string" && r.trim() !== "")) {
@@ -733,6 +769,7 @@ function assertOpWaveJobGroups(where: string, wave: OpWave): void {
733
769
  if (!Number.isInteger(shares) || shares < 1) throw new Error(`${where}: ${field} has shares ${group.shares}; use a whole number from 1.`);
734
770
  if (group.runner !== undefined) assertOpWaveRunner(where, `${field} runner`, group.runner);
735
771
  if (group.idTokens !== undefined) assertOpWaveIdTokens(where, `${field} idTokens`, group.idTokens);
772
+ if (group.cloud !== undefined) assertOpWaveCloud(where, `${field} cloud`, group.cloud);
736
773
  if (group.variables !== undefined && (group.variables === null || typeof group.variables !== "object" || Array.isArray(group.variables) || !Object.values(group.variables).every((v) => typeof v === "string"))) {
737
774
  throw new Error(`${where}: ${field} variables must be { "<name>": "<value>" }.`);
738
775
  }
@@ -34,7 +34,7 @@ describe("the pr-forge bundle", () => {
34
34
  expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
35
35
  }
36
36
  expect(inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual(["packages/core/src/pr-forge.ts"]);
37
- // About 50 KB today, after the pull request authors (#4000), pullRequestOpen for target locks (#4293), the four requires checks of an apply before merge (#4301) and the approver filter (#4337).
37
+ // About 50 KB today, after the pull request authors (#4000), pullRequestOpen for target locks (#4293), the four requires checks of an apply before merge (#4301) the approver filter (#4337) and the approved fields (#4287).
38
38
  expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(52 * 1024);
39
39
  });
40
40
  });
package/src/pr-forge.ts CHANGED
@@ -420,8 +420,11 @@ export interface PrForge {
420
420
  * counts when it came after the merge request's latest push, read from its
421
421
  * system notes, and never from the token's own user. `approvers` narrows
422
422
  * who counts (#4337), and a refusal names each approver left out and why.
423
+ * The result also lists who counted and who was left out (#4287), so a
424
+ * caller never parses `reason`. On GitLab the newest diff version must be
425
+ * of `head`, or the check is pending.
423
426
  */
424
- prApproved?(pr: number, head: string, approvers?: PrApproverFilter): Promise<PrRequireResult>;
427
+ prApproved?(pr: number, head: string, approvers?: PrApproverFilter): Promise<PrApprovalResult>;
425
428
  /**
426
429
  * The forge says `pr` merges as it stands: no conflicts, and on GitHub no
427
430
  * branch protection blocking it. Pending while the forge works it out. On
@@ -481,19 +484,26 @@ async function countApprovers(
481
484
  authors: ReadonlySet<string>,
482
485
  filter: PrApproverFilter | undefined,
483
486
  rule: (login: string) => Promise<string | undefined>,
484
- ): Promise<{ by: string[]; not: string[] }> {
487
+ ): Promise<{ by: string[]; not: PrExcludedApprover[] }> {
485
488
  const by: string[] = [];
486
- const not: string[] = [];
489
+ const not: PrExcludedApprover[] = [];
487
490
  for (const login of approved) {
488
491
  const why = authors.has(login) ? `${login} is one of its authors` : (excludedWhy(filter, login) ?? (filter?.forgeRule ? await rule(login) : undefined));
489
- if (why) not.push(why);
492
+ if (why) not.push({ login, reason: why });
490
493
  else by.push(login);
491
494
  }
492
495
  return { by, not };
493
496
  }
494
497
 
495
- /** A refusal for no approver counted, naming each one left out. */
496
- const noApprover = (line: string, not: readonly string[]): PrRequireResult => refuse(not.length > 0 ? `${line}: ${not.join("; ")}` : line);
498
+ /** The approved verdict over who counted: a pass naming them, or a refusal naming each one left out and why. */
499
+ function approvalVerdict(by: string[], not: PrExcludedApprover[], passLine: string, noneLine: string): PrApprovalResult {
500
+ const fields = { approvers: by, excluded: not };
501
+ if (by.length > 0) return { ...pass(`${passLine} by ${by.join(", ")}`), ...fields };
502
+ return { ...refuse(not.length > 0 ? `${noneLine}: ${not.map((n) => n.reason).join("; ")}` : noneLine), ...fields };
503
+ }
504
+
505
+ /** An approved refusal before anyone was counted. */
506
+ const notCounted = (r: PrRequireResult): PrApprovalResult => ({ ...r, approvers: [], excluded: [] });
497
507
 
498
508
  /** GitLab's access level for Developer, the least that may push. */
499
509
  const GITLAB_DEVELOPER = 30;
@@ -510,6 +520,18 @@ export interface PrRequireResult {
510
520
  reason: string;
511
521
  }
512
522
 
523
+ /**
524
+ * What {@link PrForge.prApproved} found (#4287). `approvers` and `excluded`
525
+ * are empty when it refused before counting, such as on a moved head or a
526
+ * request for changes.
527
+ */
528
+ export interface PrApprovalResult extends PrRequireResult {
529
+ /** The logins whose approval of the head counted, sorted. */
530
+ approvers: string[];
531
+ /** The logins that approved and did not count, each with the reason the refusal gives. */
532
+ excluded: PrExcludedApprover[];
533
+ }
534
+
513
535
  const short = (sha: string | undefined): string => (sha ?? "nothing").slice(0, 8);
514
536
  const pass = (reason: string): PrRequireResult => ({ passed: true, reason });
515
537
  const refuse = (reason: string, pending = false): PrRequireResult => ({ passed: false, ...(pending ? { pending: true } : {}), reason });
@@ -867,7 +889,7 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
867
889
  async prApproved(pr, head, approvers) {
868
890
  const got = await api.call("GET", `${repo}/pulls/${pr}`);
869
891
  const at = headOf(got);
870
- if (at !== head) return moved(`pull request ${pr}`, head, at);
892
+ if (at !== head) return notCounted(moved(`pull request ${pr}`, head, at));
871
893
  // A reviewer's newest deciding review stands, as in approvers; an approval counts only of this head.
872
894
  const standing = new Map<string, { state: string; commit?: string; official?: boolean }>();
873
895
  for (const r of await api.list(`${repo}/pulls/${pr}/reviews`, pageParams, pageSize)) {
@@ -879,7 +901,7 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
879
901
  standing.set(login, { state, ...(commit ? { commit } : {}), ...(typeof r.official === "boolean" ? { official: r.official } : {}) });
880
902
  }
881
903
  const changes = [...standing].filter(([, r]) => r.state === "CHANGES_REQUESTED" || r.state === "REQUEST_CHANGES").map(([l]) => l).sort();
882
- if (changes.length > 0) return refuse(`${changes.join(", ")} asked for changes`);
904
+ if (changes.length > 0) return notCounted(refuse(`${changes.join(", ")} asked for changes`));
883
905
  const authors = new Set(await authorsOf(pr));
884
906
  const approved = [...standing].filter(([, r]) => r.state === "APPROVED" && r.commit === head).map(([l]) => l).sort();
885
907
  const rule = async (login: string): Promise<string | undefined> => {
@@ -895,8 +917,7 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
895
917
  return p.some((x) => x !== undefined && GITHUB_WRITERS.has(x)) ? undefined : `${login} has no write access`;
896
918
  };
897
919
  const { by, not } = await countApprovers(approved, authors, approvers, rule);
898
- if (by.length === 0) return noApprover(`no reviewer other than its authors approved its head ${short(head)}`, not);
899
- return pass(`approved at ${short(head)} by ${by.join(", ")}`);
920
+ return approvalVerdict(by, not, `approved at ${short(head)}`, `no reviewer other than its authors approved its head ${short(head)}`);
900
921
  },
901
922
  async prMergeable(pr, head) {
902
923
  const got = await api.call("GET", `${repo}/pulls/${pr}`);
@@ -1262,14 +1283,21 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
1262
1283
  async prApproved(mr, head, approvers) {
1263
1284
  const got = await api.call("GET", `${notes}/${mr}`);
1264
1285
  const at = isObject(got) ? str(got.sha) : undefined;
1265
- if (at !== head) return moved(`merge request !${mr}`, head, at);
1286
+ if (at !== head) return notCounted(moved(`merge request !${mr}`, head, at));
1266
1287
  // The token's own user approves for the pipeline, never as a reviewer.
1267
1288
  const me = await api.call("GET", "/user");
1268
1289
  const self = isObject(me) ? str(me.username) : undefined;
1269
- if (!self) return refuse("GitLab did not say which user the job's token acts as, so no approval counts");
1270
- // An approval counts once it came after the latest push: the newest version of the diff.
1290
+ if (!self) return notCounted(refuse("GitLab did not say which user the job's token acts as, so no approval counts"));
1291
+ // An approval counts once it came after the latest push: the newest version of the diff, which must be of head.
1271
1292
  const versions = await api.call("GET", `${notes}/${mr}/versions`);
1272
- const since = Math.max(-Infinity, ...(Array.isArray(versions) ? versions : []).map((v) => Date.parse((isObject(v) ? str(v.created_at) : undefined) ?? "")).filter(Number.isFinite));
1293
+ let since = -Infinity;
1294
+ let latest: string | undefined;
1295
+ for (const v of Array.isArray(versions) ? versions : []) {
1296
+ const when = Date.parse((isObject(v) ? str(v.created_at) : undefined) ?? "");
1297
+ if (Number.isFinite(when) && when > since) [since, latest] = [when, isObject(v) ? str(v.head_commit_sha) : undefined];
1298
+ }
1299
+ // GitLab records a push's version after it moves the head, so an older one is asked again.
1300
+ if (latest !== head) return notCounted(refuse(`merge request !${mr}'s newest diff version is of ${short(latest)}, not ${short(head)}`, true));
1273
1301
  const said = new Map<string, { what: string; at: number; id?: number }>();
1274
1302
  for (const n of await api.list(`${notes}/${mr}/notes?sort=asc&order_by=created_at`, pageParams, 100)) {
1275
1303
  if (!isObject(n) || n.system !== true) continue;
@@ -1283,7 +1311,7 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
1283
1311
  if (!before || when >= before.at) said.set(who, { what, at: when, ...(id !== undefined ? { id } : {}) });
1284
1312
  }
1285
1313
  const changes = [...said].filter(([, v]) => v.what === "changes").map(([w]) => w).sort();
1286
- if (changes.length > 0) return refuse(`${changes.join(", ")} requested changes`);
1314
+ if (changes.length > 0) return notCounted(refuse(`${changes.join(", ")} requested changes`));
1287
1315
  const authors = new Set(await authorsOf(mr));
1288
1316
  const approved = [...said].filter(([, v]) => v.what === "approved" && v.at > since).map(([w]) => w).sort();
1289
1317
  const least = approvers?.minAccessLevel ?? GITLAB_DEVELOPER;
@@ -1301,8 +1329,7 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
1301
1329
  return level >= least ? undefined : `${login} has access level ${level}, under ${least}`;
1302
1330
  };
1303
1331
  const { by, not } = await countApprovers(approved, authors, approvers, rule);
1304
- if (by.length === 0) return noApprover(`no member other than its authors approved it after its latest push, ${short(head)}`, not);
1305
- return pass(`approved after ${short(head)} by ${by.join(", ")}`);
1332
+ return approvalVerdict(by, not, `approved after ${short(head)}`, `no member other than its authors approved it after its latest push, ${short(head)}`);
1306
1333
  },
1307
1334
  async prMergeable(mr, head) {
1308
1335
  const got = await api.call("GET", `${notes}/${mr}`);
@@ -32,14 +32,14 @@ describe("github", () => {
32
32
 
33
33
  test("approved: a reviewer other than the authors approved this head", async () => {
34
34
  const f = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD }] });
35
- expect(await f.prApproved!(7, HEAD)).toEqual({ passed: true, reason: "approved at aaaaaaaa by bob" });
35
+ expect(await f.prApproved!(7, HEAD)).toEqual({ passed: true, reason: "approved at aaaaaaaa by bob", approvers: ["bob"], excluded: [] });
36
36
  });
37
37
 
38
38
  test("approved: an approval of an older head, the author's own, or a request for changes does not pass", async () => {
39
39
  const older = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: OTHER }, { user: { login: "alice" }, state: "APPROVED", commit_id: HEAD }] });
40
40
  expect((await older.prApproved!(7, HEAD)).passed).toBe(false);
41
41
  const changes = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD }, { user: { login: "carol" }, state: "CHANGES_REQUESTED", commit_id: HEAD }] });
42
- expect(await changes.prApproved!(7, HEAD)).toEqual({ passed: false, reason: "carol asked for changes" });
42
+ expect(await changes.prApproved!(7, HEAD)).toEqual({ passed: false, reason: "carol asked for changes", approvers: [], excluded: [] });
43
43
  });
44
44
 
45
45
  test("approved: forgeRule counts only writers, exclude leaves a login out, and the refusal says why (#4337)", async () => {
@@ -53,10 +53,24 @@ describe("github", () => {
53
53
  [`GET ${repo}/collaborators/bob/permission`]: { permission: "read", role_name: "triage" },
54
54
  [`GET ${repo}/collaborators/Carol/permission`]: { permission: "write", role_name: "maintain" },
55
55
  });
56
- expect(await f.prApproved!(7, HEAD, { forgeRule: true })).toEqual({ passed: true, reason: "approved at aaaaaaaa by Carol" });
56
+ expect(await f.prApproved!(7, HEAD, { forgeRule: true })).toEqual({
57
+ passed: true,
58
+ reason: "approved at aaaaaaaa by Carol",
59
+ approvers: ["Carol"],
60
+ excluded: [
61
+ { login: "alice", reason: "alice is one of its authors" },
62
+ { login: "bob", reason: "bob has no write access" },
63
+ ],
64
+ });
57
65
  expect(await f.prApproved!(7, HEAD, { forgeRule: true, exclude: [{ login: "carol", reason: "carol asked for aaaaaaa" }] })).toEqual({
58
66
  passed: false,
59
67
  reason: "no reviewer other than its authors approved its head aaaaaaaa: carol asked for aaaaaaa; alice is one of its authors; bob has no write access",
68
+ approvers: [],
69
+ excluded: [
70
+ { login: "Carol", reason: "carol asked for aaaaaaa" },
71
+ { login: "alice", reason: "alice is one of its authors" },
72
+ { login: "bob", reason: "bob has no write access" },
73
+ ],
60
74
  });
61
75
  expect((await f.prApproved!(7, HEAD, { exclude: ["bob"] })).reason).toBe("approved at aaaaaaaa by Carol");
62
76
  });
@@ -112,7 +126,14 @@ describe("forgejo", () => {
112
126
  const f = forge({ [`GET ${repo}/pulls/4/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD, official: false }, { user: { login: "erin" }, state: "APPROVED", commit_id: HEAD, official: true }] });
113
127
  expect((await f.prApproved!(4, HEAD, { forgeRule: true })).reason).toBe("approved at aaaaaaaa by erin");
114
128
  const out = await checkPrRequires(f, 4, HEAD, { only: ["approved"], approvers: { forgeRule: true, exclude: ["erin"] } });
115
- expect(out[0]!.reason).toBe("no reviewer other than its authors approved its head aaaaaaaa: bob's review is not official; erin is excluded");
129
+ expect(out[0]).toMatchObject({
130
+ reason: "no reviewer other than its authors approved its head aaaaaaaa: bob's review is not official; erin is excluded",
131
+ approvers: [],
132
+ excluded: [
133
+ { login: "bob", reason: "bob's review is not official" },
134
+ { login: "erin", reason: "erin is excluded" },
135
+ ],
136
+ });
116
137
  });
117
138
 
118
139
  test("undiverged: the pull request's merge base is the base branch's tip", async () => {
@@ -150,7 +171,7 @@ describe("gitlab", () => {
150
171
  [`GET ${project}/merge_requests/3`]: mr(),
151
172
  [`GET ${project}/merge_requests/3/commits`]: [],
152
173
  "GET /api/v4/user": { id: 1, username: "chant-bot" },
153
- [`GET ${project}/merge_requests/3/versions`]: [{ created_at: "2026-10-01T10:00:00Z" }],
174
+ [`GET ${project}/merge_requests/3/versions`]: [{ created_at: "2026-10-01T10:00:00Z", head_commit_sha: HEAD }],
154
175
  ...routes,
155
176
  }),
156
177
  });
@@ -165,7 +186,7 @@ describe("gitlab", () => {
165
186
  });
166
187
  expect((await f.prApproved!(3, HEAD)).passed).toBe(false);
167
188
  const g = forge({ [`GET ${project}/merge_requests/3/notes`]: [note("bob", "approved this merge request", "2026-10-01T11:00:00Z")] });
168
- expect(await g.prApproved!(3, HEAD)).toEqual({ passed: true, reason: "approved after aaaaaaaa by bob" });
189
+ expect(await g.prApproved!(3, HEAD)).toEqual({ passed: true, reason: "approved after aaaaaaaa by bob", approvers: ["bob"], excluded: [] });
169
190
  const h = forge({ [`GET ${project}/merge_requests/3/notes`]: [note("bob", "approved this merge request", "2026-10-01T11:00:00Z"), note("carol", "requested changes", "2026-10-01T12:00:00Z")] });
170
191
  expect((await h.prApproved!(3, HEAD)).reason).toBe("carol requested changes");
171
192
  });
@@ -185,6 +206,22 @@ describe("gitlab", () => {
185
206
  expect((await f.prApproved!(3, HEAD, { forgeRule: true, minAccessLevel: 50 })).reason).toBe(
186
207
  "no member other than its authors approved it after its latest push, aaaaaaaa: bob has access level 20, under 50; dan has access level 40, under 50; eve is not a member",
187
208
  );
209
+ expect((await f.prApproved!(3, HEAD, { forgeRule: true })).excluded).toEqual([
210
+ { login: "bob", reason: "bob has access level 20, under 30" },
211
+ { login: "eve", reason: "eve is not a member" },
212
+ ]);
213
+ });
214
+
215
+ test("approved: pending until GitLab's newest diff version is of the head, as terragucci checked (#4287)", async () => {
216
+ const approval = note("bob", "approved this merge request", "2026-10-01T11:00:00Z");
217
+ const versions = [
218
+ { created_at: "2026-10-01T10:00:00Z", head_commit_sha: HEAD },
219
+ { created_at: "2026-10-01T12:00:00Z", head_commit_sha: OTHER },
220
+ ];
221
+ const f = forge({ [`GET ${project}/merge_requests/3/notes`]: [approval], [`GET ${project}/merge_requests/3/versions`]: versions });
222
+ expect(await f.prApproved!(3, HEAD)).toEqual({ passed: false, pending: true, reason: "merge request !3's newest diff version is of cccccccc, not aaaaaaaa", approvers: [], excluded: [] });
223
+ const none = forge({ [`GET ${project}/merge_requests/3/notes`]: [approval], [`GET ${project}/merge_requests/3/versions`]: [] });
224
+ expect(await none.prApproved!(3, HEAD)).toMatchObject({ passed: false, pending: true });
188
225
  });
189
226
 
190
227
  test("mergeable and undiverged read the merge request", async () => {
@@ -13,7 +13,7 @@
13
13
  * Like ./pr-forge.ts this imports nothing beyond it, so a product bundles it
14
14
  * without the TypeScript toolchain.
15
15
  */
16
- import type { PrApproverFilter, PrForge, PrRequireResult } from "./pr-forge";
16
+ import type { PrApproverFilter, PrExcludedApprover, PrForge, PrRequireResult } from "./pr-forge";
17
17
 
18
18
  /** The four checks, in the order they run and the report lists them. */
19
19
  export const PR_APPLY_REQUIRES = ["approved", "mergeable", "undiverged", "checks"] as const;
@@ -23,6 +23,10 @@ export type PrApplyRequire = (typeof PR_APPLY_REQUIRES)[number];
23
23
  /** One check as the pull-request report's `beforeMerge.requires` holds it. */
24
24
  export interface PrRequireOutcome extends PrRequireResult {
25
25
  name: PrApplyRequire;
26
+ /** `approved` only (#4287): the logins that counted, as {@link PrApprovalResult} gives them. */
27
+ approvers?: string[];
28
+ /** `approved` only (#4287): the logins left out, each with why. */
29
+ excluded?: PrExcludedApprover[];
26
30
  }
27
31
 
28
32
  export interface PrRequiresOptions {
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The `capabilities` document of the read contract (#4375): its schema, the
3
+ * document `chant search --capabilities --json` prints, and its failure.
4
+ */
5
+ import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { describe, expect, test } from "vitest";
8
+ import { contract, validSchema } from "./__fixtures__/contract-repo";
9
+ import { READ_CONTRACT_FLOOR, READ_CONTRACT_VERSION, REASONS } from "./reason-codes";
10
+ import { CAPABILITIES_OUTPUT_SCHEMA_ID, CAPABILITY_SUPPORT_CODES, capabilitiesDocument, capabilitiesFailure, formatCapabilities } from "./capabilities-contract";
11
+ import { READ_CONTRACT_COMMANDS, READ_CONTRACT_SCHEMAS, MCP_READ_TOOLS, mcpToolCall } from "./conformance";
12
+ import schema from "./capabilities.schema.json";
13
+
14
+ const { expectValid } = contract(schema);
15
+
16
+ /** The lexicons' mappings as they ship them, read as data: loading a lexicon's plugin takes seconds. */
17
+ const LEXICONS = join(import.meta.dirname, "..", "..", "..", "..", "lexicons");
18
+ const load = async (name: string) => {
19
+ if (name !== "aws" && name !== "terraform") throw new Error(`no lexicon ${name}`);
20
+ return { capabilityPack: { mapping: JSON.parse(readFileSync(join(LEXICONS, name, "src", "capabilities", "mapping.json"), "utf-8")) } };
21
+ };
22
+
23
+ describe("capabilities.schema.json", () => {
24
+ test("is a valid draft 2020-12 document at the contract version, naming the floor", () => {
25
+ expect(validSchema(schema)).toBe(true);
26
+ expect(schema.$id).toBe(CAPABILITIES_OUTPUT_SCHEMA_ID);
27
+ expect(schema.$id).toBe(`https://intentius.io/chant/schemas/workspace/capabilities/v${READ_CONTRACT_VERSION}/capabilities.schema.json`);
28
+ for (const branch of ["result", "failure"] as const) {
29
+ const props = (schema.$defs as Record<string, { properties: Record<string, unknown> }>)[branch].properties;
30
+ expect(props.contract).toEqual({ const: READ_CONTRACT_VERSION });
31
+ expect(props.$schema).toEqual({ const: schema.$id });
32
+ }
33
+ expect(schema.description).toContain(`chant ${READ_CONTRACT_FLOOR} and newer`);
34
+ });
35
+
36
+ test("names each support code in the closed list", () => {
37
+ for (const code of CAPABILITY_SUPPORT_CODES) expect(Object.keys(REASONS)).toContain(code);
38
+ });
39
+
40
+ test("is in the conformance suite as workspace search --capabilities, served by the capabilities MCP tool", () => {
41
+ expect(READ_CONTRACT_COMMANDS).toContain("search --capabilities");
42
+ expect(READ_CONTRACT_SCHEMAS["search --capabilities"]).toBe("capabilities.schema.json");
43
+ expect(MCP_READ_TOOLS["search --capabilities"]).toBe("capabilities");
44
+ expect(mcpToolCall(["workspace", "search", "--capabilities", "--json"])).toEqual({ name: "capabilities", arguments: {} });
45
+ expect(mcpToolCall(["workspace", "search", "app", "--capabilities", "--json"])).toBeUndefined();
46
+ });
47
+ });
48
+
49
+ describe("the document", () => {
50
+ test("lists the fifteen capabilities with a support statement for each lexicon asked about", async () => {
51
+ const doc = await capabilitiesDocument(["aws", "terraform", "no-such-lexicon"], load);
52
+ expectValid(doc);
53
+ expect(doc.contract).toBe(1);
54
+ expect(doc.capabilities).toHaveLength(16);
55
+ const reaches = doc.capabilities.find((c) => c.name === "reaches")!;
56
+ expect(reaches).toMatchObject({
57
+ shape: "edge",
58
+ search: "->[cap=reaches]",
59
+ depth: "declared",
60
+ params: [{ name: "action", optional: true }],
61
+ uses: ["can-assume", "grants"],
62
+ lexicons: {
63
+ aws: "supported",
64
+ terraform: "supported",
65
+ "no-such-lexicon": { code: "lexicon-not-loaded" },
66
+ },
67
+ });
68
+ expect(reaches.rule).toBe("reaches(r, x; action?) := r:Role ->{0,}can-assume ->grants($action) x");
69
+ expect(reaches.notEvaluated).toContain('Resource "*"');
70
+ expect(doc.helpers.map((h) => h.name)).toEqual(["in-sg", "ingress-of", "world-open", "allows", "denies", "pivot"]);
71
+ for (const c of doc.capabilities) {
72
+ expect(c.answers.length, c.name).toBeGreaterThan(0);
73
+ expect(c.search, c.name).toBe(c.shape === "set" ? `cap:${c.name}` : `->[cap=${c.name}]`);
74
+ }
75
+ });
76
+
77
+ test("prints as text, a line per capability with its parameters", async () => {
78
+ const lines = formatCapabilities(await capabilitiesDocument([], load));
79
+ expect(lines[0]).toMatch(/^cap:internet-facing +set +its subnet routes 0\.0\.0\.0\/0 to an internet gateway$/);
80
+ expect(lines).toContainEqual(expect.stringMatching(/^ +port\?: a port the open rule must cover, such as 22$/));
81
+ expect(lines.at(-1)).toBe("— 16 capabilities · --json prints each rule");
82
+ });
83
+
84
+ test("a refusal is a failure document with a code", async () => {
85
+ const doc = await capabilitiesFailure({ code: "usage-invalid", message: "m" });
86
+ expectValid(doc);
87
+ });
88
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The `capabilities` document of the read contract (#4375, ws-017): the
3
+ * capability catalog `chant search` answers `cap:` terms with, described by
4
+ * `capabilities.schema.json` beside this file.
5
+ *
6
+ * `chant search --capabilities --json` prints it for one project, with a
7
+ * support statement for each lexicon the project loads. `chant workspace
8
+ * search --capabilities --json` prints it from the workspace root's chant,
9
+ * for every lexicon a member loads (a terraform member's reader lexicon
10
+ * included, #4380): capability rules run once, under
11
+ * the workspace's chant, so the catalog is that chant's. chant's MCP
12
+ * `capabilities` tool returns whichever of the two applies where the server
13
+ * runs, unchanged.
14
+ */
15
+
16
+ import { resolve } from "node:path";
17
+ import type { ReasonCode } from "./reason-codes";
18
+ import type { CapabilityEntry } from "../capabilities/catalog";
19
+
20
+ /** The version of the `capabilities` document this chant writes. */
21
+ export const CAPABILITIES_CONTRACT_VERSION = 1;
22
+
23
+ /** `$id` of the JSON Schema for the document, shipped beside this file. */
24
+ export const CAPABILITIES_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/capabilities/v1/capabilities.schema.json";
25
+
26
+ /** Why no catalog was printed. */
27
+ export const CAPABILITIES_ERROR_CODES = ["usage-invalid"] as const satisfies readonly ReasonCode[];
28
+ export type CapabilitiesErrorCode = (typeof CAPABILITIES_ERROR_CODES)[number];
29
+
30
+ /** Why a lexicon can't supply a capability. */
31
+ export const CAPABILITY_SUPPORT_CODES = ["lexicon-has-no-mapping", "capability-not-derivable", "lexicon-not-loaded"] as const satisfies readonly ReasonCode[];
32
+
33
+ interface CapabilitiesHead {
34
+ $schema: typeof CAPABILITIES_OUTPUT_SCHEMA_ID;
35
+ contract: typeof CAPABILITIES_CONTRACT_VERSION;
36
+ /** The chant that wrote the document. */
37
+ chant: string;
38
+ }
39
+
40
+ export interface CapabilitiesResult extends CapabilitiesHead {
41
+ capabilities: CapabilityEntry[];
42
+ helpers: { name: string; rule: string }[];
43
+ }
44
+
45
+ export interface CapabilitiesFailure extends CapabilitiesHead {
46
+ error: { code: CapabilitiesErrorCode; message: string; hint?: string };
47
+ }
48
+
49
+ export type CapabilitiesDocument = CapabilitiesResult | CapabilitiesFailure;
50
+
51
+ async function head(): Promise<CapabilitiesHead> {
52
+ const { readChantPackageVersion } = await import("../package-version");
53
+ return { $schema: CAPABILITIES_OUTPUT_SCHEMA_ID, contract: CAPABILITIES_CONTRACT_VERSION, chant: readChantPackageVersion() };
54
+ }
55
+
56
+ /**
57
+ * The catalog, with a support statement for each lexicon named. Each is
58
+ * loaded on its own, so one that isn't installed is said to be, and the rest
59
+ * still read.
60
+ */
61
+ export async function capabilitiesDocument(
62
+ lexicons: string[],
63
+ load?: (name: string) => Promise<{ capabilityPack?: { mapping: unknown } }>,
64
+ ): Promise<CapabilitiesResult> {
65
+ const loadPlugin = load ?? (await import("../cli/plugins")).loadPlugin;
66
+ const { capabilityCatalog } = await import("../capabilities/catalog");
67
+ const mappings: Record<string, Parameters<typeof capabilityCatalog>[0][string]> = {};
68
+ const notLoaded: string[] = [];
69
+ for (const name of [...new Set(lexicons)].sort()) {
70
+ try {
71
+ mappings[name] = (await loadPlugin(name)).capabilityPack?.mapping as (typeof mappings)[string];
72
+ } catch {
73
+ notLoaded.push(name);
74
+ mappings[name] = undefined;
75
+ }
76
+ }
77
+ const catalog = capabilityCatalog(mappings);
78
+ for (const c of catalog.capabilities) {
79
+ for (const name of notLoaded) {
80
+ c.lexicons[name] = { code: "lexicon-not-loaded", message: `the ${name} lexicon isn't installed where this chant runs, so its mapping wasn't read` };
81
+ }
82
+ }
83
+ return { ...(await head()), ...catalog };
84
+ }
85
+
86
+ export async function capabilitiesFailure(error: CapabilitiesFailure["error"]): Promise<CapabilitiesFailure> {
87
+ return { ...(await head()), error };
88
+ }
89
+
90
+ /**
91
+ * The lexicons the members of the workspace at `cwd` load, for its catalog:
92
+ * each chant member's own, and for a member of a package's kind, such as
93
+ * `terraform`, the lexicon its reader project declares (#4380).
94
+ */
95
+ export async function workspaceLexicons(cwd: string): Promise<string[]> {
96
+ const { listWorkspace } = await import("./ls");
97
+ const { findWorkspaceRoot } = await import("../project-root");
98
+ const { resolveProjectLexicons } = await import("../cli/plugins");
99
+ const { loadKindRegistry } = await import("./kinds");
100
+ const located = findWorkspaceRoot(cwd);
101
+ const doc = listWorkspace({ cwd });
102
+ if (!located || "error" in doc) return [];
103
+ const out = new Set<string>();
104
+ const kinds = doc.members.some((m) => m.kind !== "chant" && m.readable) ? loadKindRegistry(doc.workspace.pins, located.dir).registry : undefined;
105
+ for (const m of doc.members) {
106
+ if (!m.readable) continue;
107
+ const reader = m.kind === "chant" ? undefined : kinds?.get(m.kind)?.graph?.lexicon;
108
+ if (reader) out.add(reader);
109
+ if (m.kind !== "chant") continue;
110
+ try {
111
+ for (const l of await resolveProjectLexicons(resolve(located.dir, m.dir))) out.add(l);
112
+ } catch {
113
+ // A member whose lexicons can't be resolved adds none; its own search says why.
114
+ }
115
+ }
116
+ return [...out];
117
+ }
118
+
119
+ /** The catalog as text: one capability per line, its search term and meaning, then its parameters. */
120
+ export function formatCapabilities(doc: CapabilitiesResult): string[] {
121
+ const width = Math.max(...doc.capabilities.map((c) => c.search.length));
122
+ const lines: string[] = [];
123
+ for (const c of doc.capabilities) {
124
+ lines.push(`${c.search.padEnd(width)} ${c.shape.padEnd(4)} ${c.meaning}`);
125
+ for (const p of c.params) lines.push(`${"".padEnd(width)} ${p.name}${p.optional ? "?" : ""}: ${p.meaning}`);
126
+ const unsupported = Object.entries(c.lexicons).filter(([, s]) => s !== "supported");
127
+ for (const [lex, s] of unsupported) lines.push(`${"".padEnd(width)} not in ${lex}: ${(s as { message: string }).message}`);
128
+ }
129
+ const lexicons = Object.keys(doc.capabilities[0]?.lexicons ?? {});
130
+ lines.push(`— ${doc.capabilities.length} capabilities${lexicons.length ? ` · lexicons ${lexicons.join(", ")}` : ""} · --json prints each rule`);
131
+ return lines;
132
+ }