@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
@@ -84,7 +84,7 @@ import { execFileSync } from "node:child_process";
84
84
  import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
85
85
  import { tmpdir } from "node:os";
86
86
  import { dirname, join, relative, resolve, sep } from "node:path";
87
- import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prLoopJobs, prLoopSetup, PR_LOOP_IMAGE, type PrLoopJob, type PrLoopPipelineOptions } from "../components/pr-pipeline";
87
+ import { PR_APPLY_RECORD, PR_ENV_POST_JOB, PR_ENV_REPORT_DIR, PR_LOOP_REPORT_DIR, prEnvJobs, prLoopJobs, prLoopSetup, PR_LOOP_IMAGE, type PrEnvJob, type PrLoopJob, type PrLoopPipelineOptions } from "../components/pr-pipeline";
88
88
  import { detectForge, ForgeEnvironmentError, forgeFromEnv, FORGE_KINDS, type CommitStatus, type ForgeKind, type PrForge } from "../pr-forge";
89
89
  import { holdsForgeToken, withoutForgeTokens } from "../ci-handover";
90
90
  import { postPlanReport } from "../pr-post";
@@ -114,20 +114,42 @@ import { hostRunnerKey } from "./ci-runner-key";
114
114
  import { requireRunnerStart } from "./ci-runner-start";
115
115
  import { securityGroupsAt } from "./security";
116
116
 
117
- /** The jobs a pull-request pipeline has: plan, and on merge record (#4168) or, opted in, apply. */
118
- export type CiJobName = PrLoopJob["jobName"];
119
- export const CI_JOB_NAMES: readonly CiJobName[] = ["plan", "apply", "record"];
117
+ /**
118
+ * The jobs a pull-request pipeline has: plan, and on merge record (#4168) or,
119
+ * opted in, apply; and with `--pr-env` (#4298, #4330) the jobs of a pull
120
+ * request's environment.
121
+ */
122
+ export type CiJobName = PrLoopJob["jobName"] | PrEnvJob["jobName"];
123
+ export const CI_JOB_NAMES: readonly CiJobName[] = ["plan", "apply", "record", "env-up", "env-post", "env-down", "env-sweep"];
124
+
125
+ /** The jobs of a pull request's environment (#4330). */
126
+ export const CI_ENV_JOB_NAMES: readonly PrEnvJob["jobName"][] = ["env-up", "env-post", "env-down", "env-sweep"];
120
127
 
121
- /** The Op each job is recorded as, which `ci.green` phases match. */
122
- export const CI_JOB_OPS: Record<CiJobName, string> = { plan: "pr-plan", apply: "pr-apply", record: "pr-record" };
128
+ /** Whether `job` is one of a pull request environment's jobs. */
129
+ export const isCiEnvJob = (job: CiJobName): job is PrEnvJob["jobName"] => (CI_ENV_JOB_NAMES as readonly string[]).includes(job);
130
+
131
+ /** The Op each job is recorded as, which `ci.green` phases match. An environment job's is its report's stem too. */
132
+ export const CI_JOB_OPS: Record<CiJobName, string> = { plan: "pr-plan", apply: "pr-apply", record: "pr-record", "env-up": "pr-env-up", "env-post": "pr-env-post", "env-down": "pr-env-down", "env-sweep": "pr-env-sweep" };
123
133
 
124
134
  /** What each job's Op says it does. */
125
135
  const CI_JOB_OVERVIEWS: Record<CiJobName, string> = {
126
136
  plan: "Plan the members this pull request reaches",
127
137
  apply: "Apply the plan a reviewer approved",
128
138
  record: "Record the plan on the merged commit; nothing applies",
139
+ "env-up": "Plan this pull request's environment and apply it once its plan is approved",
140
+ "env-post": "Keep the environment's record and post its note",
141
+ "env-down": "Tear down what this pull request's environment owns",
142
+ "env-sweep": "Tear down the environments whose TTL passed or whose pull request closed",
129
143
  };
130
144
 
145
+ /** One job as `ci run` runs it: a pull-request job, or an environment job (#4330). */
146
+ export type CiJob = Pick<PrLoopJob, "command" | "env" | "setup" | "build" | "wave"> & { jobName: CiJobName };
147
+
148
+ /** Where a job writes its report, inside its checkout. */
149
+ export function ciJobReportDir(job: CiJobName): string {
150
+ return isCiEnvJob(job) ? PR_ENV_REPORT_DIR : PR_LOOP_REPORT_DIR;
151
+ }
152
+
131
153
  /** Where a job the chant runner ran says it ran (#4094). */
132
154
  export const CHANT_RUNNER_ORIGIN: RunOrigin = { forge: "chant" };
133
155
 
@@ -167,6 +189,10 @@ export interface CiRunValues {
167
189
  pr?: number;
168
190
  /** The full commit the job runs at. */
169
191
  commit: string;
192
+ /** `--head`: the pull request's head, `HEAD_SHA`, which `env-post` reads (#4330). */
193
+ head?: string;
194
+ /** `--result`: the env-up job's result, `UP_RESULT`, which `env-post` reads on GitHub and Forgejo (#4330). */
195
+ upResult?: string;
170
196
  }
171
197
 
172
198
  export class CiRunError extends Error {
@@ -188,7 +214,7 @@ export class CiRunError extends Error {
188
214
  * runner sets for the job. `CHANT_RUNNER_KEY` is never in it.
189
215
  */
190
216
  export function ciJobEnv(
191
- job: PrLoopJob,
217
+ job: CiJob,
192
218
  input: {
193
219
  forge: ForgeKind;
194
220
  env: string;
@@ -211,12 +237,18 @@ export function ciJobEnv(
211
237
  const flags: Record<string, { value: string | undefined; flag: string }> = {
212
238
  BASE_SHA: { value: input.values.base, flag: "--base <sha>" },
213
239
  PR_NUMBER: { value: input.values.pr !== undefined ? String(input.values.pr) : undefined, flag: "--pr <number>" },
240
+ HEAD_SHA: { value: input.values.head, flag: "--head <sha>" },
241
+ UP_RESULT: { value: input.values.upResult, flag: "--result success|failure" },
214
242
  };
243
+ // #4330: an environment job's CHANT_ENV is pr-<n>, which a forge fills from the event.
244
+ const prEnv = isCiEnvJob(job.jobName) && input.values.pr !== undefined ? `pr-${input.values.pr}` : undefined;
215
245
  for (const [name, value] of Object.entries(job.env)) {
216
246
  const given = flags[name];
217
247
  if (given) {
218
248
  if (given.value === undefined) throw new CiRunError(`the ${job.jobName} job reads ${name}, which a forge fills from the event; pass ${given.flag}`);
219
249
  out[name] = given.value;
250
+ } else if (name === "CHANT_ENV" && prEnv) {
251
+ out[name] = prEnv;
220
252
  } else if (!value.includes("$")) {
221
253
  out[name] = value;
222
254
  } else if (input.secrets?.[name] !== undefined) {
@@ -231,7 +263,7 @@ export function ciJobEnv(
231
263
 
232
264
  /** What {@link ciJobOp} builds the Op from. */
233
265
  export interface CiJobOpInput {
234
- job: PrLoopJob;
266
+ job: CiJob;
235
267
  env: string;
236
268
  /** The directory the steps run in, inside the fresh checkout. */
237
269
  cwd: string;
@@ -251,7 +283,7 @@ export function withChant(line: string, chant?: string): string {
251
283
  }
252
284
 
253
285
  /** The job's own setup lines and its command, as one shell script, with `chant` at its start replaced by the command the caller names. */
254
- export function ciJobCommand(job: PrLoopJob, chant?: string): string {
286
+ export function ciJobCommand(job: CiJob, chant?: string): string {
255
287
  return [...(job.setup ?? []), withChant(job.command, chant)].join("\n");
256
288
  }
257
289
 
@@ -283,7 +315,7 @@ export function ciJobOp(input: CiJobOpInput): OpConfig {
283
315
  }
284
316
 
285
317
  /** The pull-request report statuses that mean the job did not do its work, which a failed job's `failure.code` takes. */
286
- const FAILED_REPORT_STATUSES = new Set(["plan-failed", "failed", "refused"]);
318
+ const FAILED_REPORT_STATUSES = new Set(["plan-failed", "failed", "refused", "incomplete"]);
287
319
 
288
320
  /** The exit code a failed shell step's error names (`command exited <n>`), when it names one. */
289
321
  function exitCodeOf(error: string | undefined): number | undefined {
@@ -441,10 +473,14 @@ export interface CiRunRequest {
441
473
  * `forge` then only shapes the job's variables.
442
474
  */
443
475
  noForge?: boolean;
444
- /** `--base`, `--pr` and `--commit` (default `HEAD`). */
476
+ /** `--base`, `--pr` and `--commit` (default `HEAD`; for `env-post`, `--base`). */
445
477
  base?: string;
446
478
  pr?: number;
447
479
  commit?: string;
480
+ /** `--head`: for `env-post` (#4330), the pull request's head its environment was applied from. */
481
+ head?: string;
482
+ /** `--result`: for `env-post` (#4330), the env-up job's result. */
483
+ upResult?: string;
448
484
  /**
449
485
  * The generator's options: `--gate`, `--branch`, `--events-json`,
450
486
  * `--report-url`, `--pr-build` and `--pr-build-command`, and `--wave-gate`
@@ -553,6 +589,11 @@ export interface CiRunResult {
553
589
  checkout: string;
554
590
  /** The job ran with `local` (#4161): its record stays on this machine. */
555
591
  local?: true;
592
+ /**
593
+ * For `env-up` (#4330): the `env-post` job the runner ran after it at
594
+ * `--base`, with its own token, which kept the record and posted the note.
595
+ */
596
+ post?: CiRunResult;
556
597
  }
557
598
 
558
599
  const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
@@ -619,7 +660,7 @@ function freshCheckout(top: string, commit: string): { dir: string; remove(): vo
619
660
  */
620
661
  async function recordInIndex(input: {
621
662
  url: string;
622
- job: CiJobName;
663
+ job: PrLoopJob["jobName"];
623
664
  commit: string;
624
665
  member?: string;
625
666
  pr?: number;
@@ -679,11 +720,14 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
679
720
  const cwd = realpathSync(resolve(req.cwd));
680
721
  const top = gitTop(cwd);
681
722
  if (!top) throw new CiRunError("a job runs in a fresh checkout of a commit, and this directory is not in a git repository");
682
- const commit = resolveJobCommit(top, req.commit ?? "HEAD");
723
+ // #4330: env-post runs at the target branch's commit, none of the change's code.
724
+ const commit = resolveJobCommit(top, req.commit ?? (req.job === PR_ENV_POST_JOB && req.base ? req.base : "HEAD"));
683
725
  if (req.inPlace && commit !== resolveJobCommit(top, "HEAD")) throw new CiRunError(`--in-place runs the checkout's HEAD, and --commit names ${commit.slice(0, 12)}`);
684
726
  if (req.local && req.inPlace) throw new CiRunError("--in-place runs a forge job in the forge's checkout, and --local runs one on this machine; pass one of them");
685
727
  const place = ciJobPlace(cwd, top);
686
- const jobs = prLoopJobs(req.forge, req.env, req.local ? localLoop(req.loop) : (req.loop ?? {}), place.member);
728
+ const loop = req.local ? localLoop(req.loop) : (req.loop ?? {});
729
+ // #4330: the jobs of a pull request's environment, as the generators wrap them with --pr-env.
730
+ const jobs: CiJob[] = isCiEnvJob(req.job) ? prEnvJobs(req.forge, loop, place.member) : prLoopJobs(req.forge, req.env, loop, place.member);
687
731
  const found = jobs.find((j) => j.jobName === req.job);
688
732
  if (!found) throw new CiRunError(`the pull-request pipeline has no ${req.job} job; it has ${jobs.map((j) => j.jobName).join(" and ")}`);
689
733
  if (req.wave !== undefined && (req.job !== "apply" || !req.loop?.waveGate)) throw new CiRunError("--wave names the one wave an apply runs, and takes --wave-gate: ci run apply --wave-gate <gate> --wave <k>");
@@ -699,15 +743,15 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
699
743
  const own = ciJobEnv(job, {
700
744
  forge: req.forge,
701
745
  env: req.env,
702
- values: { base: req.base, pr: req.pr, commit },
746
+ values: { base: req.base, pr: req.pr, commit, ...(req.head ? { head: req.head } : {}), ...(req.upResult ? { upResult: req.upResult } : {}) },
703
747
  hostEnv,
704
748
  passEnv: req.passEnv,
705
749
  secrets: ciSecretsFor(req.secrets, req.job),
706
750
  });
707
751
  // #4156: in place, the forge job's environment is the job's, less the runner key.
708
- // #4295: the plan runs the pull request's code, so no step of it gets a forge token, whatever gave it one.
752
+ // #4295: the plan and env-up run the pull request's code, so no step of them gets a forge token, whatever gave it one.
709
753
  const merged = req.inPlace ? { ...definedEnv(hostEnv), ...own } : own;
710
- const jobEnv = req.job === "plan" ? withoutForgeTokens(merged) : merged;
754
+ const jobEnv = req.job === "plan" || req.job === "env-up" ? withoutForgeTokens(merged) : merged;
711
755
  delete jobEnv[RUNNER_KEY_ENV];
712
756
  const poster = req.job === "plan" ? planPoster(req, hostEnv) : undefined;
713
757
  // #4096: the secrets and the job's secret-looking variables, redacted from everything the run writes and returns.
@@ -716,7 +760,9 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
716
760
  try {
717
761
  const ran = await runCiJob(req, { cwd, top, commit, place, job, jobEnv, ...(redact ? { redact } : {}) });
718
762
  const posted = poster ? await postFromReport(req, poster, ran, { commit, ...(place.member ? { member: place.member } : {}) }) : [];
719
- const withPosts = posted.length > 0 ? { ...ran, warnings: [...ran.warnings, ...posted] } : ran;
763
+ const envPost = req.job === "env-up" ? await envPostAfterUp(req, { cwd, top, head: commit, place, jobs, hostEnv, ran }) : undefined;
764
+ const withPost = envPost ? ("result" in envPost ? { ...ran, post: envPost.result } : { ...ran, warnings: [...ran.warnings, envPost.warning] }) : ran;
765
+ const withPosts = posted.length > 0 ? { ...withPost, warnings: [...withPost.warnings, ...posted] } : withPost;
720
766
  const result = started ? { ...withPosts, notes: [started, ...withPosts.notes] } : withPosts;
721
767
  return redact ? { ...result, warnings: result.warnings.map(redact), notes: result.notes.map(redact), ...(result.pushWarning ? { pushWarning: redact(result.pushWarning) } : {}) } : result;
722
768
  } catch (err) {
@@ -725,6 +771,40 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
725
771
  }
726
772
  }
727
773
 
774
+ /**
775
+ * The env-post job after an env-up (#4330), as a generated pipeline runs it
776
+ * in a job of its own: at `--base`, the target branch's commit, with the
777
+ * forge token env-up's steps never got, taking the report and record env-up
778
+ * handed on. None on `--local`, in place (the forge's env-post job posts),
779
+ * for a plain git server, without `--base` and `--pr`, or without a token.
780
+ */
781
+ async function envPostAfterUp(
782
+ req: CiRunRequest,
783
+ at: { cwd: string; top: string; head: string; place: { member?: string; dir: string }; jobs: CiJob[]; hostEnv: Record<string, string | undefined>; ran: CiRunResult },
784
+ ): Promise<{ result: CiRunResult } | { warning: string } | undefined> {
785
+ if (req.local || req.inPlace || req.noForge || req.pr === undefined) return undefined;
786
+ if (!req.base) return { warning: "env-post did not run: it runs at the target branch's commit, so pass --base <sha>" };
787
+ if (!holdsForgeToken(at.hostEnv)) return { warning: "env-post did not run: there is no forge token, so the environment's record is in the report and nothing kept it" };
788
+ const found = at.jobs.find((j) => j.jobName === PR_ENV_POST_JOB);
789
+ if (!found) return undefined;
790
+ const base = resolveJobCommit(at.top, req.base);
791
+ const job = { ...found, command: req.noForge ? found.command.replace(` --forge ${req.forge}`, "") : found.command };
792
+ const postReq: CiRunRequest = { ...req, job: PR_ENV_POST_JOB, commit: base, head: at.head, upResult: at.ran.status === "ok" ? "success" : "failure" };
793
+ delete postReq.status;
794
+ delete postReq.resumeRecord;
795
+ delete postReq.wave;
796
+ const jobEnv = ciJobEnv(job, { forge: req.forge, env: req.env, values: { base: req.base, pr: req.pr, commit: base, head: at.head, upResult: postReq.upResult! }, hostEnv: at.hostEnv, passEnv: req.passEnv, secrets: ciSecretsFor(req.secrets, PR_ENV_POST_JOB) });
797
+ delete jobEnv[RUNNER_KEY_ENV];
798
+ // Its own redactor: env-post holds the token env-up's steps never got.
799
+ const redact = ciRedactor(jobEnv, req.secrets);
800
+ try {
801
+ const result = await runCiJob(postReq, { cwd: at.cwd, top: at.top, commit: base, place: at.place, job, jobEnv, ...(redact ? { redact } : {}), ...(at.ran.report ? { seed: at.ran.report } : {}) });
802
+ return { result: redact ? { ...result, warnings: result.warnings.map(redact), notes: result.notes.map(redact), ...(result.pushWarning ? { pushWarning: redact(result.pushWarning) } : {}) } : result };
803
+ } catch (err) {
804
+ return { warning: `env-post did not run: ${message(err)}` };
805
+ }
806
+ }
807
+
728
808
  /**
729
809
  * Who posts the plan's note and `chant/plan` status (#4295): the runner,
730
810
  * from the report, once the plan's steps settled, with the forge token the
@@ -854,7 +934,7 @@ function jobCheckout(top: string, commit: string, inPlace?: boolean): { dir: str
854
934
  /** {@link ciRun} once the job and its environment are known. */
855
935
  async function runCiJob(
856
936
  req: CiRunRequest,
857
- at: { cwd: string; top: string; commit: string; place: { member?: string; dir: string }; job: PrLoopJob; jobEnv: Record<string, string>; redact?: Redact },
937
+ at: { cwd: string; top: string; commit: string; place: { member?: string; dir: string }; job: CiJob; jobEnv: Record<string, string>; redact?: Redact; seed?: string },
858
938
  ): Promise<CiRunResult> {
859
939
  const { cwd, top, commit, place, job, jobEnv, redact } = at;
860
940
  const local = req.local === true;
@@ -884,6 +964,8 @@ async function runCiJob(
884
964
  ...(req.chant ? { chant: req.chant } : {}),
885
965
  });
886
966
  const warnings: string[] = [...early];
967
+ // #4330: env-post takes the report env-up wrote, as a forge job takes it from the up job's outputs.
968
+ if (at.seed && existsSync(at.seed)) cpSync(at.seed, join(jobDir, ciJobReportDir(req.job)), { recursive: true });
887
969
  const resume = req.job === "apply" && req.resumeRecord ? { host: resolve(req.resumeRecord), job: join(jobDir, PR_APPLY_RECORD) } : undefined;
888
970
  if (resume && existsSync(resume.host)) {
889
971
  mkdirSync(dirname(resume.job), { recursive: true });
@@ -913,7 +995,7 @@ async function runCiJob(
913
995
  commit,
914
996
  ledger: { cwd: jobDir },
915
997
  runner: { kind: "pipeline", runtime: local ? "local" : "ci", runOrigin: origin },
916
- describeFailure: (records) => ciJobFailure({ job: req.job, op, records, reportDir: join(jobDir, PR_LOOP_REPORT_DIR) }),
998
+ describeFailure: (records) => ciJobFailure({ job: req.job, op, records, reportDir: join(jobDir, ciJobReportDir(req.job)) }),
917
999
  ...(req.runEvents ? { runEvents: { write: req.runEvents } } : {}),
918
1000
  ...(req.stepOutput ? { stepOutput: req.stepOutput } : {}),
919
1001
  ...(signer ? { signRecord: (record: OpRunRecord) => signOpRunRecord(record, signer!) } : {}),
@@ -946,10 +1028,10 @@ async function runCiJob(
946
1028
  warnings.push(`the resume record could not be kept at ${resume.host}: ${message(err)}`);
947
1029
  }
948
1030
  }
949
- const written = join(jobDir, PR_LOOP_REPORT_DIR);
1031
+ const written = join(jobDir, ciJobReportDir(req.job));
950
1032
  let report: string | null = null;
951
1033
  if (existsSync(written)) {
952
- report = resolve(cwd, req.output ?? PR_LOOP_REPORT_DIR);
1034
+ report = resolve(cwd, req.output ?? ciJobReportDir(req.job));
953
1035
  // In place the report is already where the forge keeps it, and stays as the command wrote it.
954
1036
  if (!(existsSync(report) && realpathSync(report) === realpathSync(written))) {
955
1037
  cpSync(written, report, { recursive: true });
@@ -958,7 +1040,8 @@ async function runCiJob(
958
1040
  }
959
1041
  const failed = result.records.find((r) => r.status === "fail");
960
1042
  const exitCode = failed?.phase === req.job ? exitCodeOf(failed.error) : undefined;
961
- if (reportUrl) {
1043
+ // The run index lists the pull-request jobs; an environment job's run is in its record only (#4330).
1044
+ if (reportUrl && !isCiEnvJob(req.job)) {
962
1045
  await recordInIndex({ url: reportUrl, job: req.job, commit, member: place.member, pr: req.pr, record: result.record, runOrigin: origin, signer: result.record.statement && signer ? signer.principal : null, exitCode: ciJobExitCode({ status: result.status, ...(exitCode !== undefined ? { exitCode } : {}) }), reportDir: written, limit: req.runIndexLimit, ...(redact ? { redact } : {}), warn: (w) => warnings.push(w) });
963
1046
  }
964
1047
  // In place the forge job's outcome is the command's, so a record that could not be written is a warning there.
@@ -1045,7 +1128,7 @@ export function ciLoopOptions(args: CommandContext["args"]): PrLoopPipelineOptio
1045
1128
  }
1046
1129
 
1047
1130
  const USAGE =
1048
- "chant ci run plan|record|apply --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--forge github|gitlab|forgejo] " +
1131
+ "chant ci run plan|record|apply|env-up|env-post|env-down|env-sweep --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--head <sha>] [--result <r>] [--forge github|gitlab|forgejo] " +
1049
1132
  "[--gate <name>] [--wave-gate <name>] [--wave <k>] [--branch <name>] [--events-json] [--report-url <url>] [--run-index-limit <n>] [--image <image>] [--before-script <line>]... " +
1050
1133
  "[--pr-build] [--pr-build-command <line>] [--require-review] [--apply-before-merge] [--no-status] [--status-url <url template>] " +
1051
1134
  "[--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [-o <dir>] [--in-place | --local] [--no-push] [--verbose] | chant ci run --at <sha> | chant ci run --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>]";
@@ -1101,9 +1184,11 @@ export async function runCiRun(ctx: CommandContext): Promise<number> {
1101
1184
  try {
1102
1185
  if (args.setupKey) return (await import("./ci-serve")).runnerKeySetupCommand(ctx);
1103
1186
  if (args.at !== undefined && args.extraPositional === undefined) return await requestBuildCommand(args.at);
1104
- const job = args.extraPositional;
1105
- if (job !== "plan" && job !== "apply" && job !== "record") throw new CiRunError(`ci run takes the job to run, plan, record or apply${job ? `, not ${JSON.stringify(job)}` : ""}`);
1106
- if (!args.env) throw new CiRunError("--env names the environment the pipeline is for, as chant build --components --generate <lexicon> --pr-loop took it: --env <env>");
1187
+ const job = args.extraPositional as CiJobName | undefined;
1188
+ if (!job || !CI_JOB_NAMES.includes(job)) throw new CiRunError(`ci run takes the job to run, ${CI_JOB_NAMES.join(", ")}${job ? `, not ${JSON.stringify(job)}` : ""}`);
1189
+ // #4330: an environment job's environment is pr-<n>; the sweep's run is recorded under --env, else local.
1190
+ const env = args.env ?? (isCiEnvJob(job) ? (args.pr !== undefined && job !== "env-sweep" ? `pr-${args.pr}` : "local") : undefined);
1191
+ if (!env) throw new CiRunError("--env names the environment the pipeline is for, as chant build --components --generate <lexicon> --pr-loop took it: --env <env>");
1107
1192
  const forge = args.forge ?? detectForge(process.env);
1108
1193
  if (!forge) throw new CiRunError("--forge names the forge the job posts to: --forge github|gitlab|forgejo");
1109
1194
  if (!(FORGE_KINDS as readonly string[]).includes(forge)) throw new CiRunError(`--forge takes github, gitlab or forgejo, not ${JSON.stringify(forge)}`);
@@ -1124,11 +1209,13 @@ export async function runCiRun(ctx: CommandContext): Promise<number> {
1124
1209
  cwd: process.cwd(),
1125
1210
  ...(status ? { status } : {}),
1126
1211
  job,
1127
- env: args.env,
1212
+ env,
1128
1213
  forge: forge as ForgeKind,
1129
1214
  ...(args.base ? { base: args.base } : {}),
1130
1215
  ...(args.pr !== undefined ? { pr: args.pr } : {}),
1131
1216
  ...(args.commit ? { commit: args.commit } : {}),
1217
+ ...(args.head ? { head: args.head } : {}),
1218
+ ...(args.planResult ? { upResult: args.planResult } : {}),
1132
1219
  loop: ciLoopOptions(reportUrl ? { ...args, reportUrl } : args),
1133
1220
  ...(args.ciInPlace ? { inPlace: true } : {}),
1134
1221
  ...(args.local ? { local: true } : {}),
@@ -1149,8 +1236,14 @@ export async function runCiRun(ctx: CommandContext): Promise<number> {
1149
1236
  for (const w of result.warnings) console.error(formatWarning({ message: w }));
1150
1237
  if (args.verbose) for (const n of result.notes) console.error(`ci run: ${n}`);
1151
1238
  console.error(formatCiRun(result));
1239
+ if (result.post) {
1240
+ for (const w of result.post.warnings) console.error(formatWarning({ message: w }));
1241
+ console.error(formatCiRun(result.post));
1242
+ }
1152
1243
  // #4156: a failed command's own exit code, such as 3 for a moved plan, is the job's, as it was before the job ran through ci run.
1153
- return ciJobExitCode(result);
1244
+ // #4330: an env-up whose env-post failed fails too, since its record was not kept.
1245
+ const code = ciJobExitCode(result);
1246
+ return code === 0 && result.post ? ciJobExitCode(result.post) : code;
1154
1247
  } catch (err) {
1155
1248
  console.error(formatError({ message: `chant ci run: ${message(err)}`, hint: USAGE }));
1156
1249
  return 1;
@@ -69,7 +69,7 @@ describe("the secrets file (#4096)", () => {
69
69
  expect(ciSecretsFor(undefined, "plan")).toEqual({});
70
70
  expect(ciSecretsFor({ ...s, jobs: { plan: [] } }, "plan")).toEqual({});
71
71
  expect(() => parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa" }, jobs: { plan: ["C"] } }), "s.json")).toThrow(/jobs\.plan names C, which the file does not set/);
72
- expect(() => parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa" }, jobs: { deploy: [] } }), "s.json")).toThrow(/the jobs are plan and apply/);
72
+ expect(() => parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa" }, jobs: { deploy: [] } }), "s.json")).toThrow(/the jobs are plan, apply, env-up/);
73
73
  expect(() => parseCiSecrets('{ "A": 1 }', "s.json")).toThrow(/must be a string/);
74
74
  // A parse error does not quote the text, which may hold a secret.
75
75
  expect(() => parseCiSecrets('{ "A": "hunter2', "s.json")).toThrow(/^s\.json starts with \{, and is not valid JSON$/);
@@ -37,7 +37,8 @@ export const SECRET_NAME = /TOKEN|SECRET|KEY|PASSWORD/i;
37
37
 
38
38
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
39
39
 
40
- const JOBS: readonly CiJobName[] = ["plan", "apply"];
40
+ /** The jobs a secrets file can give a list of its own: the pull-request jobs, and a pull request environment's (#4330). */
41
+ const JOBS: readonly CiJobName[] = ["plan", "apply", "env-up", "env-post", "env-down", "env-sweep"];
41
42
 
42
43
  /** The secrets file, read. */
43
44
  export interface CiSecrets {
@@ -176,9 +177,9 @@ export function parseCiSecrets(text: string, file: string): Omit<CiSecrets, "fil
176
177
  if (structured) {
177
178
  for (const key of Object.keys(doc)) if (key !== "secrets" && key !== "jobs") throw new CiSecretsError(`${file} has ${JSON.stringify(key)} beside secrets and jobs`);
178
179
  const given = doc.jobs ?? {};
179
- if (given === null || typeof given !== "object" || Array.isArray(given)) throw new CiSecretsError(`${file}: jobs must map plan and apply to lists of secret names`);
180
+ if (given === null || typeof given !== "object" || Array.isArray(given)) throw new CiSecretsError(`${file}: jobs must map ${JOBS.join(", ")} to lists of secret names`);
180
181
  for (const [job, names] of Object.entries(given as Record<string, unknown>)) {
181
- if (!(JOBS as readonly string[]).includes(job)) throw new CiSecretsError(`${file}: jobs names ${JSON.stringify(job)}, and the jobs are plan and apply`);
182
+ if (!(JOBS as readonly string[]).includes(job)) throw new CiSecretsError(`${file}: jobs names ${JSON.stringify(job)}, and the jobs are ${JOBS.join(", ")}`);
182
183
  if (!Array.isArray(names) || names.some((n) => typeof n !== "string")) throw new CiSecretsError(`${file}: jobs.${job} must be a list of secret names`);
183
184
  for (const n of names as string[]) if (!Object.prototype.hasOwnProperty.call(values, n)) throw new CiSecretsError(`${file}: jobs.${job} names ${n}, which the file does not set`);
184
185
  jobs[job as CiJobName] = [...(names as string[])];
@@ -41,6 +41,7 @@ import type { KindLinkRead } from "./kind-link-readers";
41
41
  import type { RecordLinkRow } from "./record-assets";
42
42
  import type { ReasonCode } from "./reason-codes";
43
43
  import { GRAPH_IR_VERSION, type GraphIR, type IRExport, type IRDanglingRef, type IRGroups, type IRImport, type IREdge, type IRNode } from "../graph-ir";
44
+ import type { ViewDocument } from "../capabilities/view";
44
45
 
45
46
  /** The version of the composed document `chant workspace graph` writes. */
46
47
  export const WORKSPACE_GRAPH_VERSION = 1;
@@ -105,6 +106,12 @@ export interface ComposedMember {
105
106
  * `chant workspace graph` on every member; null when the box declares neither.
106
107
  */
107
108
  box?: { factory: FactoryView | null; listing: ListingView | null } | null;
109
+ /**
110
+ * With `--capability-view` (#4378): the lexicon-neutral view the member's
111
+ * own chant mapped through its own lexicons, with `<member>/<id>` ids. null
112
+ * when the member's chant predates the view and printed none.
113
+ */
114
+ capabilityView?: ViewDocument | null;
108
115
  /** Whole-read facts the member's IR carried (`meta`, `pipeline`), kept apart from the composed sections. */
109
116
  meta?: Record<string, unknown>;
110
117
  pipeline?: unknown;
@@ -249,7 +256,9 @@ export interface ComposeInput {
249
256
  ir?: GraphIR;
250
257
  }
251
258
 
252
- const prefix = (member: string, id: string): string => `${member}/${id}`;
259
+ /** A member's node id as the composed workspace documents give it: `<member>/<id>`. `workspace search` uses the same rule (#4374). */
260
+ export const composedId = (member: string, id: string): string => `${member}/${id}`;
261
+ const prefix = composedId;
253
262
 
254
263
  const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
255
264
  const records = (v: unknown): Record<string, unknown>[] =>
@@ -39,6 +39,8 @@ const JSON_FLAG: Record<ReadContractCommand, string[]> = {
39
39
  "graph --composites": ["--json"],
40
40
  runs: ["--json"],
41
41
  wip: ["--json"],
42
+ search: ["--json"],
43
+ "search --capabilities": ["--json"],
42
44
  };
43
45
 
44
46
  /** The smallest reader that conforms, with a hook to misbehave. */
@@ -65,6 +67,17 @@ describe("the generated conformance workspace (#2679)", () => {
65
67
  expect(report.skipped).toEqual([]);
66
68
  }, 300_000);
67
69
 
70
+ test("search answers over the chant member with workspace graph's ids, and lists the others (#4374)", async () => {
71
+ const docs: Record<string, unknown>[] = [];
72
+ const report = await runWorkspaceReaderConformance(reader((doc) => (docs.push(doc), doc)), { workspaceDir: ws.dir, commands: ["search"] });
73
+ expect(report.problems).toEqual([]);
74
+ const doc = docs[0] as { at: null; matches: { id: string; member: string }[]; members: { name: string; status: string; alone?: boolean }[] };
75
+ expect(doc.at).toBeNull();
76
+ expect(doc.matches.length).toBeGreaterThan(0);
77
+ for (const m of doc.matches) expect(m.id.startsWith(`${m.member}/`), m.id).toBe(true);
78
+ expect(doc.members.find((m) => m.name === "delivery")).toMatchObject({ status: "searched", alone: true });
79
+ }, 300_000);
80
+
68
81
  test("graph --composites joins the fixture's composite to its component", async () => {
69
82
  const docs: Record<string, unknown>[] = [];
70
83
  await runWorkspaceReaderConformance(reader((doc) => (docs.push(doc), doc)), { workspaceDir: ws.dir, commands: ["graph --composites"] });
@@ -120,7 +133,7 @@ describe("commands (#2679)", () => {
120
133
  expect(report.problems).toEqual([]);
121
134
  expect(read).toEqual(["ls", "status", "graph --composites"]);
122
135
  expect(report.checked).toEqual(["ls", "status", "graph --composites"]);
123
- expect(report.skipped).toEqual(["graph", "check", "records", "records --uncommitted", "graph --intent", "runs", "wip"]);
136
+ expect(report.skipped).toEqual(["graph", "check", "records", "records --uncommitted", "graph --intent", "runs", "wip", "search", "search --capabilities"]);
124
137
  }, 300_000);
125
138
 
126
139
  test("a name that is not a contract command, or an empty list, is refused", () => {
@@ -56,7 +56,7 @@ import { isDeepStrictEqual } from "node:util";
56
56
  import { READ_CONTRACT_VERSION } from "../reason-codes";
57
57
 
58
58
  /** The read-contract commands, as a reader names them. */
59
- export const READ_CONTRACT_COMMANDS = ["ls", "graph", "check", "status", "records", "records --uncommitted", "graph --intent", "graph --composites", "runs", "wip"] as const;
59
+ export const READ_CONTRACT_COMMANDS = ["ls", "graph", "check", "status", "records", "records --uncommitted", "graph --intent", "graph --composites", "runs", "wip", "search", "search --capabilities"] as const;
60
60
  export type ReadContractCommand = (typeof READ_CONTRACT_COMMANDS)[number];
61
61
 
62
62
  /**
@@ -75,6 +75,10 @@ export const READ_CONTRACT_SCHEMAS: Record<ReadContractCommand, string> = {
75
75
  "graph --composites": "composites.schema.json",
76
76
  runs: "runs.schema.json",
77
77
  wip: "wip.schema.json",
78
+ // `workspace search` (#4374). Plain `chant search --json` prints the same document for one project.
79
+ search: "search.schema.json",
80
+ // The capability catalog (#4375), from the workspace's chant.
81
+ "search --capabilities": "capabilities.schema.json",
78
82
  };
79
83
 
80
84
  /** The flags that ask each command for its JSON document. A reader adds one of these and nothing else. */
@@ -89,6 +93,8 @@ export const READ_CONTRACT_JSON_FLAGS: Record<ReadContractCommand, readonly (rea
89
93
  "graph --composites": [[], ["--json"]],
90
94
  runs: [["--json"]],
91
95
  wip: [["--json"]],
96
+ search: [["--json"]],
97
+ "search --capabilities": [["--json"]],
92
98
  };
93
99
 
94
100
  /** The arguments the suite passes each read, on the reference workspace or the generated one. */
@@ -103,6 +109,9 @@ export const REFERENCE_READS: Record<ReadContractCommand, string[]> = {
103
109
  "graph --composites": [],
104
110
  runs: [],
105
111
  wip: [],
112
+ // A bare word: on either workspace it searches the chant member, delivery, and lists the others.
113
+ search: ["app"],
114
+ "search --capabilities": [],
106
115
  };
107
116
 
108
117
  /** One chant run: the arguments after `chant`, what it was given on stdin, and what it printed. */
@@ -344,6 +353,8 @@ export const MCP_READ_TOOLS: Partial<Record<ReadContractCommand, string>> = {
344
353
  "graph --intent": "workspace-graph",
345
354
  "graph --composites": "workspace-graph",
346
355
  runs: "workspace-runs",
356
+ search: "workspace-search",
357
+ "search --capabilities": "capabilities",
347
358
  };
348
359
 
349
360
  /**
@@ -359,9 +370,14 @@ export function mcpToolCall(argv: readonly string[]): { name: string; arguments:
359
370
  const args: Record<string, unknown> = {};
360
371
  const positional: string[] = [];
361
372
  const kinds: string[] = [];
373
+ const members: string[] = [];
362
374
  for (let i = 0; i < rest.length; i++) {
363
375
  const a = rest[i];
364
376
  const value = (): string | undefined => rest[++i];
377
+ if (a === "--") {
378
+ positional.push(...rest.slice(i + 1));
379
+ break;
380
+ }
365
381
  if (a === "--json") continue;
366
382
  if (a === "--format" && rest[i + 1] === "json") {
367
383
  i++;
@@ -381,7 +397,14 @@ export function mcpToolCall(argv: readonly string[]): { name: string; arguments:
381
397
  else if (a === "--by") args.by = value();
382
398
  else if (a === "--uncommitted") args.uncommitted = true;
383
399
  else if (a === "--composites") args.composites = true;
384
- else if (a.startsWith("-")) return undefined;
400
+ else if (verb === "search" && a === "--member") members.push(value() ?? "");
401
+ else if (verb === "search" && a === "--env") args.env = value();
402
+ else if (verb === "search" && a === "--snapshot") args.snapshot = value();
403
+ else if (verb === "search" && a === "--count-by") args.countBy = value();
404
+ else if (verb === "search" && a === "--show") args.show = value();
405
+ else if (verb === "search" && a === "--explain") args.explain = true;
406
+ else if (verb === "search" && a === "--capabilities") args.capabilities = true;
407
+ else if (/^-[A-Za-z-]/.test(a)) return undefined;
385
408
  else positional.push(a);
386
409
  }
387
410
  switch (verb) {
@@ -397,6 +420,12 @@ export function mcpToolCall(argv: readonly string[]): { name: string; arguments:
397
420
  return positional.length === 0 ? { name: "workspace-graph", arguments: { ...args, ...(kinds.length ? { kind: kinds } : {}) } } : undefined;
398
421
  case "runs":
399
422
  return positional.length === 0 && kinds.length === 0 ? { name: "workspace-runs", arguments: args } : undefined;
423
+ case "search":
424
+ // The catalog (#4375) is its own tool, and takes nothing.
425
+ if (args.capabilities) return positional.length === 0 && Object.keys(args).length === 1 && kinds.length === 0 && members.length === 0 ? { name: "capabilities", arguments: {} } : undefined;
426
+ return positional.length <= 1 && kinds.length === 0
427
+ ? { name: "workspace-search", arguments: { ...args, query: positional[0] ?? "", ...(members.length ? { member: members } : {}) } }
428
+ : undefined;
400
429
  default:
401
430
  return undefined;
402
431
  }
@@ -289,6 +289,33 @@
289
289
  "type": "string",
290
290
  "pattern": "^[1-9][0-9]*(m|h|d)$",
291
291
  "description": "How long a pull request's environment lives after its last apply, such as 72h, 90m or 7d. chant components pr-env sweep tears down one whose TTL passed. 72h when absent."
292
+ },
293
+ "teardown": {
294
+ "type": "object",
295
+ "description": "A command that tears down what no lexicon does, such as a Terraform root's copy of the environment (#4330). pr-env down and pr-env sweep run it after the ownership check, twice: CHANT_PR_ENV_STAGE=owned writes the candidates with their read-back markers to CHANT_PR_ENV_RESULT, and CHANT_PR_ENV_STAGE=teardown deletes the candidates in CHANT_PR_ENV_CANDIDATES and writes their outcomes. It runs without the forge token variables. Added in schema 1 by chant 0.124.45, so a declaration that uses it sets minReader to 0.124.45 or newer.",
296
+ "required": ["command"],
297
+ "additionalProperties": false,
298
+ "patternProperties": {
299
+ "^x-": true
300
+ },
301
+ "properties": {
302
+ "name": {
303
+ "type": "string",
304
+ "pattern": "^[a-z0-9][a-z0-9-]*$",
305
+ "description": "The name the teardown's outcomes are attributed to, as a lexicon's are. teardown when absent."
306
+ },
307
+ "command": {
308
+ "type": "array",
309
+ "minItems": 1,
310
+ "items": { "type": "string", "minLength": 1 },
311
+ "description": "Argv, run in the member's directory at the commit the job runs at. {env}, {pr} and {stage} are filled in."
312
+ },
313
+ "lexicons": {
314
+ "type": "array",
315
+ "items": { "type": "string", "minLength": 1 },
316
+ "description": "The lexicons the command tears down in place of, such as terraform. Their own teardown is left out, so one with no teardown execution does not leave the environment incomplete."
317
+ }
318
+ }
292
319
  }
293
320
  }
294
321
  }
@@ -363,7 +390,7 @@
363
390
  "ciCloudIdentityRules": {
364
391
  "type": "array",
365
392
  "minItems": 1,
366
- "description": "Rules, first match wins: a rule matches a pipeline when its environments match the pipeline's environment and its members match the pipeline's member. A rule with no environments matches every environment, and one with no members every member and a pipeline outside a member.",
393
+ "description": "Rules, first match wins: a rule matches a pipeline when its environments match the pipeline's environment and its members match the pipeline's member, and a target inside the member when its targets match the target too. A rule with no environments matches every environment, one with no members every member and a pipeline outside a member, and one with no targets every target.",
367
394
  "items": {
368
395
  "type": "object",
369
396
  "additionalProperties": false,
@@ -378,6 +405,14 @@
378
405
  "description": "Environment-name globs, such as prod or staging-*."
379
406
  },
380
407
  "members": { "$ref": "#/$defs/ciMemberGlobs" },
408
+ "targets": {
409
+ "type": "array",
410
+ "minItems": 1,
411
+ "uniqueItems": true,
412
+ "items": { "type": "string", "minLength": 1 },
413
+ "description": "Target globs inside the member (#4332), such as Terraform roots or Op wave run targets: * within one / segment, ** across segments, ? one character. The plan job trades for every plan identity the member's targets take, and an apply splits into one job per apply identity. Added in schema 1 by chant 0.124.45, so a declaration that uses it sets minReader to 0.124.45 or newer."
414
+ },
415
+ "name": { "type": "string", "pattern": "^[a-z0-9_-]+$", "description": "The name of the apply job group the rule's targets form (#4332). Default: from its first target glob. Added by chant 0.124.45." },
381
416
  "plan": { "type": "string", "minLength": 1, "description": "The identity the plan and record jobs take: a role ARN, a service account email or a client id." },
382
417
  "apply": { "type": "string", "minLength": 1, "description": "The identity the apply jobs take." }
383
418
  }
@@ -564,4 +564,11 @@ describe("ci.prEnvironment (#4298)", () => {
564
564
  expect(failure(base(members, { ci: { prEnvironment: {} } })).code).toBe("declaration-invalid");
565
565
  expect(failure(base(members, { ci: { prEnvironment: { template: "staging", ttl: "1w" } } })).code).toBe("declaration-invalid");
566
566
  });
567
+
568
+ test("a teardown command is read with its argv and the lexicons it covers (#4330)", () => {
569
+ const teardown = { name: "terragucci", command: ["terragucci", "pr-env", "teardown", "{stage}"], lexicons: ["terraform"] };
570
+ expect(parse(base(members, { ci: { prEnvironment: { template: "staging", teardown } } })).ci?.prEnvironment).toEqual({ template: "staging", ttl: "72h", teardown });
571
+ expect(failure(base(members, { ci: { prEnvironment: { template: "staging", teardown: { command: [] } } } })).code).toBe("declaration-invalid");
572
+ expect(failure(base(members, { ci: { prEnvironment: { template: "staging", teardown: { command: ["x"], runs: "y" } } } })).code).toBe("declaration-invalid");
573
+ });
567
574
  });