@intentius/chant-lexicon-github 0.124.31 → 0.124.33

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 (157) hide show
  1. package/dist/action-pins.js +53 -0
  2. package/dist/codegen/docs-cli.js +2 -0
  3. package/dist/codegen/docs.js +142 -0
  4. package/dist/codegen/generate-cli.js +26 -0
  5. package/dist/codegen/generate-lexicon.js +36 -0
  6. package/dist/codegen/generate-typescript.js +108 -0
  7. package/dist/codegen/generate.js +111 -0
  8. package/dist/codegen/naming.js +49 -0
  9. package/dist/codegen/package.js +40 -0
  10. package/dist/codegen/parse.js +595 -0
  11. package/dist/codegen/patches.js +36 -0
  12. package/dist/components/ci-jobs.d.ts +7 -0
  13. package/dist/components/ci-jobs.d.ts.map +1 -1
  14. package/dist/components/ci-jobs.js +98 -0
  15. package/dist/components/generate-op-pipeline.js +440 -0
  16. package/dist/components/generate-op-waves-pipeline.js +158 -0
  17. package/dist/components/generate-pipeline.d.ts.map +1 -1
  18. package/dist/components/generate-pipeline.js +481 -0
  19. package/dist/composites/cache.js +17 -0
  20. package/dist/composites/catalog.js +859 -0
  21. package/dist/composites/checkout.js +25 -0
  22. package/dist/composites/dependabot.js +43 -0
  23. package/dist/composites/deploy-environment.js +38 -0
  24. package/dist/composites/docker-build.js +70 -0
  25. package/dist/composites/download-artifact.js +18 -0
  26. package/dist/composites/go-ci.js +62 -0
  27. package/dist/composites/index.js +15 -0
  28. package/dist/composites/node-ci.js +44 -0
  29. package/dist/composites/node-pipeline.js +96 -0
  30. package/dist/composites/pr-plan-report.js +129 -0
  31. package/dist/composites/python-ci.js +60 -0
  32. package/dist/composites/setup-go.js +18 -0
  33. package/dist/composites/setup-node.js +21 -0
  34. package/dist/composites/setup-python.js +18 -0
  35. package/dist/composites/upload-artifact.js +19 -0
  36. package/dist/coverage.js +32 -0
  37. package/dist/expression.js +177 -0
  38. package/dist/generated/index.js +26 -0
  39. package/dist/generated/runtime.js +4 -0
  40. package/dist/graph-edges.js +174 -0
  41. package/dist/import/generator.js +108 -0
  42. package/dist/import/parser.js +69 -0
  43. package/dist/index.js +22 -0
  44. package/dist/integrity.json +2 -2
  45. package/dist/lint/audit-catalog.js +69 -0
  46. package/dist/lint/audit-lineage.js +168 -0
  47. package/dist/lint/post-synth/gha006.js +50 -0
  48. package/dist/lint/post-synth/gha009.js +35 -0
  49. package/dist/lint/post-synth/gha011.js +34 -0
  50. package/dist/lint/post-synth/gha013.js +40 -0
  51. package/dist/lint/post-synth/gha017.js +25 -0
  52. package/dist/lint/post-synth/gha018.js +33 -0
  53. package/dist/lint/post-synth/gha019.js +58 -0
  54. package/dist/lint/post-synth/gha021.js +42 -0
  55. package/dist/lint/post-synth/gha022.js +44 -0
  56. package/dist/lint/post-synth/gha023.js +37 -0
  57. package/dist/lint/post-synth/gha024.js +32 -0
  58. package/dist/lint/post-synth/gha025.js +33 -0
  59. package/dist/lint/post-synth/gha026.js +32 -0
  60. package/dist/lint/post-synth/gha027.js +46 -0
  61. package/dist/lint/post-synth/gha028.js +28 -0
  62. package/dist/lint/post-synth/gha029.js +56 -0
  63. package/dist/lint/post-synth/gha030.js +35 -0
  64. package/dist/lint/post-synth/gha031.js +75 -0
  65. package/dist/lint/post-synth/gha032.js +35 -0
  66. package/dist/lint/post-synth/gha033.js +39 -0
  67. package/dist/lint/post-synth/gha034.js +35 -0
  68. package/dist/lint/post-synth/gha035.js +50 -0
  69. package/dist/lint/post-synth/gha036.js +37 -0
  70. package/dist/lint/post-synth/gha037.js +41 -0
  71. package/dist/lint/post-synth/gha038.js +35 -0
  72. package/dist/lint/post-synth/gha039.js +34 -0
  73. package/dist/lint/post-synth/gha040.js +36 -0
  74. package/dist/lint/post-synth/gha041.js +32 -0
  75. package/dist/lint/post-synth/gha042.js +33 -0
  76. package/dist/lint/post-synth/gha043.js +35 -0
  77. package/dist/lint/post-synth/gha044.js +36 -0
  78. package/dist/lint/post-synth/gha045.js +33 -0
  79. package/dist/lint/post-synth/gha046.js +55 -0
  80. package/dist/lint/post-synth/gha047.js +44 -0
  81. package/dist/lint/post-synth/gha048.js +34 -0
  82. package/dist/lint/post-synth/gha049.js +36 -0
  83. package/dist/lint/post-synth/gha050.js +37 -0
  84. package/dist/lint/post-synth/gha051.js +38 -0
  85. package/dist/lint/post-synth/gha052.js +35 -0
  86. package/dist/lint/post-synth/gha053.js +34 -0
  87. package/dist/lint/post-synth/gha054.js +35 -0
  88. package/dist/lint/post-synth/gha055.js +44 -0
  89. package/dist/lint/post-synth/gha056.js +32 -0
  90. package/dist/lint/post-synth/gha057.js +35 -0
  91. package/dist/lint/post-synth/gha058.js +42 -0
  92. package/dist/lint/post-synth/gha059.js +119 -0
  93. package/dist/lint/post-synth/gha060.js +129 -0
  94. package/dist/lint/post-synth/gha061.js +78 -0
  95. package/dist/lint/post-synth/gha062.js +65 -0
  96. package/dist/lint/post-synth/gha063.js +56 -0
  97. package/dist/lint/post-synth/gha064.js +47 -0
  98. package/dist/lint/post-synth/gha065.js +125 -0
  99. package/dist/lint/post-synth/gha066.js +40 -0
  100. package/dist/lint/post-synth/gha067.js +75 -0
  101. package/dist/lint/post-synth/gha068.js +47 -0
  102. package/dist/lint/post-synth/gha069.js +99 -0
  103. package/dist/lint/post-synth/index.js +114 -0
  104. package/dist/lint/post-synth/yaml-helpers.js +465 -0
  105. package/dist/lint/rules/data/action-usage-policy.js +16 -0
  106. package/dist/lint/rules/data/advisory-feed.js +15 -0
  107. package/dist/lint/rules/data/deprecated-versions.js +14 -0
  108. package/dist/lint/rules/data/flagged-actions.js +18 -0
  109. package/dist/lint/rules/data/known-action-slugs.js +38 -0
  110. package/dist/lint/rules/data/known-actions.js +12 -0
  111. package/dist/lint/rules/data/preinstalled-tools.js +36 -0
  112. package/dist/lint/rules/data/recommended-inputs.js +9 -0
  113. package/dist/lint/rules/data/risky-features.js +22 -0
  114. package/dist/lint/rules/data/secret-patterns.js +30 -0
  115. package/dist/lint/rules/data/trusted-action-owners.js +12 -0
  116. package/dist/lint/rules/data/untrusted-contexts.js +44 -0
  117. package/dist/lint/rules/deprecated-action-version.js +44 -0
  118. package/dist/lint/rules/detect-secrets.js +45 -0
  119. package/dist/lint/rules/extract-inline-structs.js +55 -0
  120. package/dist/lint/rules/file-job-limit.js +43 -0
  121. package/dist/lint/rules/index.js +16 -0
  122. package/dist/lint/rules/job-timeout.js +53 -0
  123. package/dist/lint/rules/missing-recommended-inputs.js +51 -0
  124. package/dist/lint/rules/no-hardcoded-secrets.js +39 -0
  125. package/dist/lint/rules/no-raw-expressions.js +44 -0
  126. package/dist/lint/rules/suggest-cache.js +60 -0
  127. package/dist/lint/rules/use-condition-builders.js +37 -0
  128. package/dist/lint/rules/use-matrix-builder.js +36 -0
  129. package/dist/lint/rules/use-typed-actions.js +41 -0
  130. package/dist/lint/rules/validate-concurrency.js +59 -0
  131. package/dist/lsp/completions.d.ts.map +1 -1
  132. package/dist/lsp/completions.js +18 -0
  133. package/dist/lsp/hover.d.ts.map +1 -1
  134. package/dist/lsp/hover.js +33 -0
  135. package/dist/manifest.json +1 -1
  136. package/dist/mcp/context-tools.js +152 -0
  137. package/dist/package-cli.js +20 -0
  138. package/dist/plugin.js +344 -0
  139. package/dist/serializer.js +572 -0
  140. package/dist/spec/fetch.js +46 -0
  141. package/dist/validate-cli.js +16 -0
  142. package/dist/validate.js +38 -0
  143. package/dist/variables.js +41 -0
  144. package/package.json +6 -6
  145. package/src/codegen/package.ts +2 -2
  146. package/src/components/__fixtures__/pr-loop-build.github.golden.yml +6 -2
  147. package/src/components/__fixtures__/pr-loop-record.github.golden.yml +74 -0
  148. package/src/components/__fixtures__/pr-loop-waves.github.golden.yml +178 -0
  149. package/src/components/__fixtures__/pr-loop.github.golden.yml +6 -2
  150. package/src/components/ci-jobs.ts +16 -2
  151. package/src/components/generate-op-pipeline.test.ts +11 -0
  152. package/src/components/generate-op-pipeline.ts +3 -3
  153. package/src/components/generate-pipeline.ts +74 -31
  154. package/src/components/generate-pr-pipeline.test.ts +99 -29
  155. package/src/components/generate-pr-waves-pipeline.test.ts +117 -0
  156. package/src/lsp/completions.ts +2 -1
  157. package/src/lsp/hover.ts +2 -1
@@ -60,7 +60,18 @@ import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/co
60
60
  import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
61
61
  import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
62
62
  import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_RUNNER } from "@intentius/chant/ci-jobs";
63
- import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prApplyRecordKey, prLoopJobs, prLoopSetup } from "@intentius/chant/components/pr-pipeline";
63
+ import {
64
+ PR_APPLY_RECORD,
65
+ PR_LOOP_REPORT_DIR,
66
+ prApplyGroup,
67
+ prRecordGroup,
68
+ prApplyRecordKey,
69
+ prApplyWaveJobName,
70
+ prLoopJobs,
71
+ prLoopSetup,
72
+ prLoopWaveJobs,
73
+ prLoopWithWaves,
74
+ } from "@intentius/chant/components/pr-pipeline";
64
75
  import { memberRepoPath } from "@intentius/chant/lexicon";
65
76
  import type {
66
77
  ComponentPipelineJob as GeneratedJob,
@@ -161,7 +172,10 @@ function gatedWaveGithubDoc(
161
172
 
162
173
  /**
163
174
  * The pull-request workflow (#3183): `plan` on each pull request into the
164
- * target branch, `apply` on each push to it. The plan job can comment and set
175
+ * target branch, and on each push to it `record` (#4168), or `apply` with
176
+ * `applyOnMerge`. The record job plans on the merged commit and writes the
177
+ * merge record, applying nothing, so it pushes to `chant/lifecycle` and runs
178
+ * one at a time per environment as the apply does. The plan job can comment and set
165
179
  * statuses but cannot push, since it runs the pull request's code; the apply
166
180
  * job can push, to record a pending fact on `chant/lifecycle`, and runs one
167
181
  * at a time per environment. Both keep their report as an artifact, and both
@@ -175,8 +189,17 @@ function gatedWaveGithubDoc(
175
189
  * path filter, since a change outside the member can reach it; the plan
176
190
  * selects from the whole change and passes `--member`, which keeps the
177
191
  * member's gate, note and statuses apart from the other members'.
192
+ *
193
+ * With a gate per wave (`waveGate`, #4003) the one apply job becomes
194
+ * `apply-wave-1` to `apply-wave-<n>`, one per wave of the component graph,
195
+ * each needing the one before. Each saves the record under its own cache key
196
+ * (the run, the attempt and the wave), and each restores the newest record
197
+ * for the pushed commit, so wave k reads what wave k-1 left, and a re-run of
198
+ * a wave that stopped at its gate reads what it left itself. Each keeps its
199
+ * report as `chant-pr-apply-wave-<k>`.
178
200
  */
179
201
  function prLoopGithubDoc(
202
+ components: DriverComponent[],
180
203
  env: string,
181
204
  image: string,
182
205
  beforeScript: string[],
@@ -187,41 +210,62 @@ function prLoopGithubDoc(
187
210
  const branch = loop.branch ?? "main";
188
211
  const member = options.member;
189
212
  const rooted = !member || member.dir === "." || member.dir === "";
190
- const [plan, apply] = prLoopJobs(loop.forge ?? "github", env, loop, member?.name);
213
+ const [plan, apply, record] = prLoopJobs(loop.forge ?? "github", env, loop, member?.name);
191
214
  const reportPath = member ? memberRepoPath(member, PR_LOOP_REPORT_DIR) : PR_LOOP_REPORT_DIR;
192
215
  // The apply's attempt record (#3543) lives in the cache under the pushed
193
216
  // commit. Cache keys cannot be overwritten, so each attempt saves under
194
217
  // its run and attempt, and a re-run restores the newest for the commit.
195
218
  const recordPath = member ? memberRepoPath(member, PR_APPLY_RECORD) : PR_APPLY_RECORD;
196
219
  const recordPrefix = `${prApplyRecordKey(env, "${{ github.sha }}", member?.name)}-`;
197
- const recordKey = `${recordPrefix}\${{ github.run_id }}-\${{ github.run_attempt }}`;
198
- const restoreRecord = {
199
- name: "Restore the record of an earlier attempt at this commit",
220
+ const recordKey = (wave?: number) => `${recordPrefix}\${{ github.run_id }}-\${{ github.run_attempt }}${wave ? `-wave-${wave}` : ""}`;
221
+ const restoreRecord = (wave?: number) => ({
222
+ name: wave ? "Restore the newest record of this commit's apply" : "Restore the record of an earlier attempt at this commit",
200
223
  uses: "actions/cache/restore@v4",
201
- with: { path: recordPath, key: recordKey, "restore-keys": recordPrefix },
202
- };
203
- const saveRecord = {
204
- name: "Keep the record for a re-run",
224
+ with: { path: recordPath, key: recordKey(wave), "restore-keys": recordPrefix },
225
+ });
226
+ const saveRecord = (wave?: number) => ({
227
+ name: wave ? "Keep the record for the next wave and a re-run" : "Keep the record for a re-run",
205
228
  if: "always()",
206
229
  uses: "actions/cache/save@v4",
207
- with: { path: recordPath, key: recordKey },
208
- };
230
+ with: { path: recordPath, key: recordKey(wave) },
231
+ });
232
+ const reportName = (job: typeof plan) => `chant-pr-${job.wave ? prApplyWaveJobName(job.wave) : job.jobName}`;
209
233
  const steps = (job: typeof plan, name: string): Array<Record<string, unknown>> => [
210
234
  checkoutStep(true),
211
235
  ...runSteps(prLoopSetup(image)),
212
236
  ...runSteps(beforeScript),
213
237
  ...(job.build ? [{ name: "Build the templates", run: job.build[0] }, { name: "Lint the project", run: job.build[1] }] : []),
214
- ...(job.jobName === "apply" ? [restoreRecord] : []),
215
- { name, env: job.env, run: job.command },
238
+ ...(job.jobName === "apply" ? [restoreRecord(job.wave)] : []),
239
+ // The job's setup lines (#4004) run in the command's shell, so a value they settle reaches it.
240
+ // The command runs through chant ci run (#4156), which writes the job's run record, signed when the CHANT_RUNNER_KEY secret is set.
241
+ { name, env: job.run.env, run: [...(job.setup ?? []), job.run.command].join("\n") },
216
242
  ...runSteps(extraScript),
217
- ...(job.jobName === "apply" ? [saveRecord] : []),
243
+ ...(job.jobName === "apply" ? [saveRecord(job.wave)] : []),
218
244
  {
219
- name: `Keep the ${job.jobName} report`,
245
+ name: `Keep the ${job.wave ? `wave ${job.wave} ` : ""}${job.jobName} report`,
220
246
  if: "always()",
221
247
  uses: UPLOAD_ARTIFACT,
222
- with: { name: `chant-pr-${job.jobName}`, path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true },
248
+ with: { name: reportName(job), path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true },
223
249
  },
224
250
  ];
251
+ const applyJob = (job: typeof plan, needs: string[], name: string) => ({
252
+ if: "github.event_name == 'push'",
253
+ "runs-on": GITHUB_RUNNER,
254
+ ...(needs.length > 0 ? { needs } : {}),
255
+ container: image,
256
+ concurrency: concurrency(prApplyGroup(env, member?.name)),
257
+ permissions: { contents: "write", "pull-requests": "write", statuses: "write" },
258
+ steps: steps(job, name),
259
+ });
260
+ const waveJobs = loop.waveGate ? prLoopWaveJobs(components, apply) : [];
261
+ const applyJobs: Array<[string, Record<string, unknown>]> = loop.waveGate
262
+ ? waveJobs.map((job) => [
263
+ prApplyWaveJobName(job.wave!),
264
+ applyJob(job, job.wave! > 1 ? [prApplyWaveJobName(job.wave! - 1)] : [], `Apply wave ${job.wave}, under the approval its plan has`),
265
+ ])
266
+ : loop.applyOnMerge
267
+ ? [["apply", applyJob(apply, [], "Apply the plan a reviewer approved")]]
268
+ : [["record", { ...applyJob(record, [], "Record the plan on the merged commit; nothing applies"), concurrency: concurrency(prRecordGroup(env, member?.name)) }]];
225
269
  const jobsDoc: Record<string, unknown> = {
226
270
  plan: {
227
271
  if: "github.event_name == 'pull_request'",
@@ -230,15 +274,9 @@ function prLoopGithubDoc(
230
274
  permissions: { contents: "read", "pull-requests": "write", statuses: "write" },
231
275
  steps: steps(plan, "Plan the members this pull request reaches"),
232
276
  },
233
- apply: {
234
- if: "github.event_name == 'push'",
235
- "runs-on": GITHUB_RUNNER,
236
- container: image,
237
- concurrency: concurrency(prApplyGroup(env, member?.name)),
238
- permissions: { contents: "write", "pull-requests": "write", statuses: "write" },
239
- steps: steps(apply, "Apply the plan a reviewer approved"),
240
- },
277
+ ...Object.fromEntries(applyJobs),
241
278
  };
279
+ const applyNames = applyJobs.map(([name]) => name);
242
280
  return {
243
281
  name: member ? `chant-pr.${member.name}.${env}` : `chant-pr-${env}`,
244
282
  environment: env,
@@ -246,10 +284,15 @@ function prLoopGithubDoc(
246
284
  ...(rooted ? {} : { defaults: { run: { "working-directory": member!.dir } } }),
247
285
  env: { ...options.variables, CHANT_ENV: env },
248
286
  jobsDoc,
249
- stages: ["plan", "apply"],
287
+ stages: ["plan", ...applyNames],
250
288
  jobs: [
251
289
  { jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
252
- { jobName: "apply", component: "pull request apply", stage: "apply", needs: [] },
290
+ ...applyNames.map((name, i) => ({
291
+ jobName: name,
292
+ component: loop.waveGate ? `pull request apply, wave ${i + 1}` : loop.applyOnMerge ? "pull request apply" : "merge record",
293
+ stage: name,
294
+ needs: i > 0 ? [applyNames[i - 1]] : [],
295
+ })),
253
296
  ],
254
297
  };
255
298
  }
@@ -315,10 +358,10 @@ export function buildGithubPipelineDoc(
315
358
  const byName = new Map(components.map((c) => [c.name, c]));
316
359
 
317
360
  if (options.prLoop) {
318
- if (options.gatedWaves || options.promoteTo !== undefined) {
319
- throw new Error("a pull-request pipeline has no wave or promote jobs; drop --wave-gate and --promote-to, or --pr-loop");
320
- }
321
- return prLoopGithubDoc(env, image, beforeScript, extraScript, options);
361
+ if (options.promoteTo !== undefined) throw new Error("a pull-request pipeline has no promote job; drop --promote-to or --pr-loop");
362
+ // #4003: --wave-gate beside --pr-loop gives the apply a gate per wave.
363
+ const prLoop = prLoopWithWaves(options.prLoop, options.gatedWaves);
364
+ return prLoopGithubDoc(components, env, image, beforeScript, extraScript, { ...options, prLoop });
322
365
  }
323
366
 
324
367
  if (options.gatedWaves) {
@@ -11,7 +11,7 @@ import { join } from "node:path";
11
11
  import { describe, expect, test } from "vitest";
12
12
  import { parseYAML } from "@intentius/chant/yaml";
13
13
  import type { DriverComponent } from "@intentius/chant/components/driver";
14
- import { prApplyGroup, prApplyRecordKey } from "@intentius/chant/components/pr-pipeline";
14
+ import { githubBaseFallback, prApplyGroup, prApplyRecordKey, prLoopJobs } from "@intentius/chant/components/pr-pipeline";
15
15
  import { generateGithubPipeline } from "./generate-pipeline";
16
16
 
17
17
  const ESTATE: DriverComponent[] = [
@@ -46,7 +46,7 @@ interface Job {
46
46
  }
47
47
 
48
48
  describe("the GitHub pull-request workflow", () => {
49
- const result = generateGithubPipeline(ESTATE, { env: "prod", prLoop: {} });
49
+ const result = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true } });
50
50
  const doc = parseYAML(result.yaml) as { name: string; on: Record<string, unknown>; jobs: Record<string, Job> };
51
51
 
52
52
  test("matches its golden", () => {
@@ -63,28 +63,49 @@ describe("the GitHub pull-request workflow", () => {
63
63
  });
64
64
 
65
65
  test("the plan job measures from the pull request's base and cannot push; the apply job can, one at a time", () => {
66
- const plan = doc.jobs.plan.steps.find((s) => s.run?.includes("pr-plan"))!;
67
- expect(plan.run).toBe('chant components pr-plan --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --output .chant/pr --forge github');
66
+ const plan = doc.jobs.plan.steps.find((s) => s.run?.includes("ci run plan"))!;
67
+ // #4156: the job runs pr-plan through chant ci run, in its own checkout, and pushes no record from the pull request's code.
68
+ expect(plan.run).toBe('chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github');
68
69
  expect(plan.env).toMatchObject({ BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}" });
70
+ // The runner key is an optional secret: unset, the expression is empty and the record is unsigned.
71
+ expect(plan.env?.CHANT_RUNNER_KEY).toBe("${{ secrets.CHANT_RUNNER_KEY }}");
69
72
  expect(doc.jobs.plan.permissions).toEqual({ contents: "read", "pull-requests": "write", statuses: "write" });
70
73
 
71
- const apply = doc.jobs.apply.steps.find((s) => s.run?.includes("pr-apply"))!;
72
- expect(apply.run).toContain("--require-review");
73
- expect(apply.env).toMatchObject({ BASE_SHA: "${{ github.event.before }}" });
74
+ const apply = doc.jobs.apply.steps.find((s) => s.run?.includes("ci run apply"))!;
75
+ // #4168: ci run builds pr-apply without --require-review unless the pipeline asks for it.
76
+ expect(apply.run).not.toContain("--require-review");
77
+ expect(apply.run).not.toContain("--no-push");
78
+ expect(apply.env).toMatchObject({ BASE_SHA: "${{ github.event.before }}", CHANT_RUNNER_KEY: "${{ secrets.CHANT_RUNNER_KEY }}" });
74
79
  expect(doc.jobs.apply.permissions.contents).toBe("write");
75
80
  expect(doc.jobs.apply.concurrency).toEqual({ group: "chant-apply-prod", "cancel-in-progress": false });
76
81
  for (const job of Object.values(doc.jobs)) expect(job.steps[0].with).toEqual({ "fetch-depth": 0 });
77
82
  });
78
83
 
84
+ test("a first push's all-zero github.event.before is settled in the apply's own shell, before pr-apply reads it (#4004)", () => {
85
+ const apply = doc.jobs.apply.steps.find((s) => s.run?.includes("ci run apply"))!;
86
+ const lines = apply.run!.trimEnd().split("\n");
87
+ expect(lines).toHaveLength(2);
88
+ expect(lines[0]).toBe(githubBaseFallback());
89
+ expect(lines[0]).toContain('[ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]');
90
+ expect(lines[0]).toContain('git merge-base FETCH_HEAD "$GITHUB_SHA"');
91
+ expect(lines[1]).toMatch(/^chant ci run apply --in-place --base "\$BASE_SHA"/);
92
+ const trunk = parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, branch: "trunk" } }).yaml) as { jobs: Record<string, Job> };
93
+ expect(trunk.jobs.apply.steps.find((s) => s.run?.includes("ci run apply"))!.run).toContain('git fetch origin "trunk"');
94
+ const plan = doc.jobs.plan.steps.find((s) => s.run?.includes("ci run plan"))!;
95
+ expect(plan.run).not.toContain("0000000000000000000000000000000000000000");
96
+ });
97
+
79
98
  test("the apply resumes from a record the cache keeps for the pushed commit across a re-run (#3543)", () => {
80
99
  const steps = doc.jobs.apply.steps;
81
100
  const restore = steps.findIndex((s) => s.uses === "actions/cache/restore@v4");
82
- const run = steps.findIndex((s) => s.run?.includes("pr-apply"));
101
+ const run = steps.findIndex((s) => s.run?.includes("ci run apply"));
83
102
  const save = steps.findIndex((s) => s.uses === "actions/cache/save@v4");
84
103
  expect(restore).toBeGreaterThan(-1);
85
104
  expect(restore).toBeLessThan(run);
86
105
  expect(save).toBeGreaterThan(run);
87
- expect(steps[run].run).toContain("--resume .chant/pr-resume/pr-apply.json");
106
+ // ci run runs the inner pr-apply in the checkout, where the cache restored the record.
107
+ expect(steps[run].run).toContain("--in-place");
108
+ expect(prLoopJobs("github", "prod")[1]!.command).toContain("--resume .chant/pr-resume/pr-apply.json");
88
109
  const key = "chant-apply-prod-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}";
89
110
  expect(steps[restore].with).toEqual({ path: ".chant/pr-resume/pr-apply.json", key, "restore-keys": "chant-apply-prod-${{ github.sha }}-" });
90
111
  // Saved even when the apply failed, which is when a re-run needs it.
@@ -92,7 +113,7 @@ describe("the GitHub pull-request workflow", () => {
92
113
  expect(steps[save].with).toEqual({ path: ".chant/pr-resume/pr-apply.json", key });
93
114
  // The plan job runs the pull request's code: it neither reads nor writes the record.
94
115
  expect(doc.jobs.plan.steps.some((s) => s.uses?.startsWith("actions/cache"))).toBe(false);
95
- expect(doc.jobs.plan.steps.find((s) => s.run?.includes("pr-plan"))!.run).not.toContain("--resume");
116
+ expect(doc.jobs.plan.steps.find((s) => s.run?.includes("ci run plan"))!.run).not.toContain("--resume");
96
117
  // The report artifact does not carry the record, which holds the members' outputs.
97
118
  const keep = steps.find((s) => s.uses?.startsWith("actions/upload-artifact"))!;
98
119
  expect(keep.with?.path).toBe(".chant/pr");
@@ -100,27 +121,29 @@ describe("the GitHub pull-request workflow", () => {
100
121
 
101
122
  test("runs in an image with git and installs OpenTofu, unless the caller names an image", () => {
102
123
  expect(doc.jobs.plan.steps[1].run).toContain("install-opentofu");
103
- const own = generateGithubPipeline(ESTATE, { env: "prod", image: "example/ci:1", prLoop: {} }).yaml;
124
+ const own = generateGithubPipeline(ESTATE, { env: "prod", image: "example/ci:1", prLoop: { applyOnMerge: true } }).yaml;
104
125
  expect(own).toContain("container: example/ci:1");
105
126
  expect(own).not.toContain("install-opentofu");
106
127
  });
107
128
 
108
129
  test("the gate, branch and review requirement are options", () => {
109
- const yaml = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { gate: "infra-apply", branch: "trunk", requireReview: false } }).yaml;
130
+ const yaml = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, gate: "infra-apply", branch: "trunk", requireReview: true } }).yaml;
110
131
  expect(yaml).toContain("--gate infra-apply");
111
132
  expect(yaml).toContain("- trunk");
112
- expect(yaml).not.toContain("--require-review");
133
+ expect(yaml).toContain("ci run apply --in-place --base \"$BASE_SHA\" --env prod --gate infra-apply --forge github --branch \"trunk\" --require-review");
134
+ expect(yaml).toContain('--branch "trunk"');
113
135
  });
114
136
 
115
- test("refuses a wave or promote job beside it", () => {
116
- expect(() => generateGithubPipeline(ESTATE, { prLoop: {}, gatedWaves: { gate: "g" } })).toThrow(/pull-request pipeline has no wave or promote jobs/);
117
- expect(() => generateGithubPipeline(ESTATE, { prLoop: {}, promoteTo: "prod" })).toThrow(/pull-request pipeline has no wave or promote jobs/);
137
+ test("refuses a promote job beside it, and a canary or a fixed base beside --wave-gate (#4003)", () => {
138
+ expect(() => generateGithubPipeline(ESTATE, { prLoop: { applyOnMerge: true }, promoteTo: "prod" })).toThrow(/pull-request pipeline has no promote job/);
139
+ expect(() => generateGithubPipeline(ESTATE, { prLoop: { applyOnMerge: true }, gatedWaves: { gate: "g", canary: ["net"] } })).toThrow(/takes no --canary/);
140
+ expect(() => generateGithubPipeline(ESTATE, { prLoop: { applyOnMerge: true }, gatedWaves: { gate: "g", base: "HEAD~1" } })).toThrow(/takes no --base/);
118
141
  });
119
142
 
120
143
  test("events and reportUrl stream the run's events into the report artifact and copy the reports out (#4007)", () => {
121
- const loud = parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop: { events: true, reportUrl: "s3://chant-reports/acme" } }).yaml) as { jobs: Record<string, Job> };
144
+ const loud = parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, events: true, reportUrl: "s3://chant-reports/acme" } }).yaml) as { jobs: Record<string, Job> };
122
145
  for (const job of ["plan", "apply"]) {
123
- const run = loud.jobs[job].steps.find((s) => s.run?.startsWith("chant components"))!;
146
+ const run = loud.jobs[job].steps.find((s) => s.run?.includes("chant ci run"))!;
124
147
  expect(run.run).toContain('--events-json --report-url "s3://chant-reports/acme"');
125
148
  expect(run.env?.CHANT_RUN_EVENTS_DIR).toBe(".chant/pr/run-events");
126
149
  }
@@ -131,7 +154,7 @@ describe("the GitHub pull-request workflow", () => {
131
154
 
132
155
  test("inside a workspace member: runs in the member's directory and keeps its own gate, note and apply group (#3465)", () => {
133
156
  const member = { name: "network", dir: "infra/network", file: ".github/workflows/chant-pr.network.prod.yml" };
134
- const scoped = generateGithubPipeline(ESTATE, { env: "prod", prLoop: {}, member });
157
+ const scoped = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true }, member });
135
158
  const mdoc = parseYAML(scoped.yaml) as {
136
159
  name: string;
137
160
  on: Record<string, unknown>;
@@ -142,13 +165,14 @@ describe("the GitHub pull-request workflow", () => {
142
165
  // No path filter: a change outside the member can reach it.
143
166
  expect(mdoc.on).toEqual({ pull_request: { branches: ["main"] }, push: { branches: ["main"] } });
144
167
  expect(mdoc.defaults).toEqual({ run: { "working-directory": "infra/network" } });
145
- const plan = mdoc.jobs.plan.steps.find((s) => s.run?.includes("pr-plan"))!;
146
- expect(plan.run).toBe('chant components pr-plan --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --output .chant/pr --forge github --member network');
147
- const apply = mdoc.jobs.apply.steps.find((s) => s.run?.includes("pr-apply"))!;
148
- expect(apply.run).toContain("--member network");
168
+ const plan = mdoc.jobs.plan.steps.find((s) => s.run?.includes("ci run plan"))!;
169
+ // ci run finds the member from the directory it runs in and passes --member to pr-plan and pr-apply.
170
+ expect(plan.run).toBe('chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github');
171
+ expect(prLoopJobs("github", "prod", {}, "network")[0]!.command).toContain("--member network");
172
+ const apply = mdoc.jobs.apply.steps.find((s) => s.run?.includes("ci run apply"))!;
149
173
  expect(mdoc.jobs.apply.concurrency).toEqual({ group: "chant-apply.network.prod", "cancel-in-progress": false });
150
174
  // The record path is relative to the member for --resume and to the repository for the cache.
151
- expect(apply.run).toContain("--resume .chant/pr-resume/pr-apply.json");
175
+ expect(apply.run).toContain("--in-place");
152
176
  const restore = mdoc.jobs.apply.steps.find((s) => s.uses === "actions/cache/restore@v4")!;
153
177
  expect(restore.with).toMatchObject({ path: "infra/network/.chant/pr-resume/pr-apply.json", "restore-keys": "chant-apply.network.prod-${{ github.sha }}-" });
154
178
  for (const job of Object.values(mdoc.jobs)) {
@@ -168,11 +192,11 @@ describe("the GitHub pull-request workflow", () => {
168
192
  });
169
193
 
170
194
  test("a member at the workspace root keeps the root's paths but its own names", () => {
171
- const yaml = generateGithubPipeline(ESTATE, { env: "prod", prLoop: {}, member: { name: "root", dir: "." } }).yaml;
195
+ const yaml = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true }, member: { name: "root", dir: "." } }).yaml;
172
196
  const mdoc = parseYAML(yaml) as { name: string; defaults?: unknown; jobs: Record<string, Job> };
173
197
  expect(mdoc.name).toBe("chant-pr.root.prod");
174
198
  expect(mdoc.defaults).toBeUndefined();
175
- expect(yaml).toContain("--member root");
199
+ expect(yaml).toContain("chant ci run plan --in-place");
176
200
  expect(yaml).toContain("path: .chant/pr");
177
201
  });
178
202
 
@@ -189,7 +213,7 @@ describe("the GitHub pull-request workflow", () => {
189
213
  });
190
214
 
191
215
  describe("the github pull-request pipeline with build (#4088)", () => {
192
- const result = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { build: true } });
216
+ const result = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, build: true } });
193
217
  const doc = parseYAML(result.yaml) as Record<string, any>;
194
218
  const golden = join(import.meta.dirname, "__fixtures__", "pr-loop-build.github.golden.yml");
195
219
 
@@ -207,14 +231,60 @@ describe("the github pull-request pipeline with build (#4088)", () => {
207
231
  });
208
232
 
209
233
  test("buildCommand replaces the build line and turns the option on", () => {
210
- const yaml = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { buildCommand: "npm run build" } }).yaml;
234
+ const yaml = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, buildCommand: "npm run build" } }).yaml;
211
235
  expect(yaml).toContain("npm run build");
212
236
  expect(yaml).not.toContain("chant build --env");
213
237
  expect(yaml).toContain("chant lint");
214
238
  });
215
239
 
216
240
  test("the default output carries neither line", () => {
217
- const plain = generateGithubPipeline(ESTATE, { env: "prod", prLoop: {} }).yaml;
241
+ const plain = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true } }).yaml;
218
242
  expect(plain).not.toContain("chant lint");
219
243
  });
220
244
  });
245
+
246
+ describe("the GitHub pull-request workflow records on merge by default (#4168)", () => {
247
+ const result = generateGithubPipeline(ESTATE, { env: "prod", prLoop: {} });
248
+ const doc = parseYAML(result.yaml) as { jobs: Record<string, Job> };
249
+ const golden = join(import.meta.dirname, "__fixtures__", "pr-loop-record.github.golden.yml");
250
+
251
+ test("matches its golden", () => {
252
+ if (process.env.UPDATE_GOLDEN) writeFileSync(golden, result.yaml);
253
+ expect(result.yaml).toBe(readFileSync(golden, "utf-8"));
254
+ });
255
+
256
+ test("a push runs the record job, which applies nothing and keeps no resume record", () => {
257
+ expect(result.jobs.map((j) => j.jobName)).toEqual(["plan", "record"]);
258
+ expect(doc.jobs.apply).toBeUndefined();
259
+ expect(doc.jobs.record.if).toBe("github.event_name == 'push'");
260
+ const run = doc.jobs.record.steps.find((s) => s.run?.includes("ci run record"))!;
261
+ expect(run.run!.trimEnd().split("\n")).toEqual([githubBaseFallback(), 'chant ci run record --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge github']);
262
+ expect(doc.jobs.record.steps.some((s) => s.uses?.startsWith("actions/cache"))).toBe(false);
263
+ // It writes the merge record and the change set to chant/lifecycle, one at a time per environment.
264
+ expect(doc.jobs.record.permissions.contents).toBe("write");
265
+ expect(doc.jobs.record.concurrency).toEqual({ group: "chant-record-prod", "cancel-in-progress": false });
266
+ expect(result.yaml).not.toContain("ci run apply");
267
+ });
268
+
269
+ test("a gate per wave gates the apply, so it needs applyOnMerge", () => {
270
+ expect(() => generateGithubPipeline(ESTATE, { env: "prod", prLoop: {}, gatedWaves: { gate: "release" } })).toThrow(/needs --apply-on-merge/);
271
+ });
272
+
273
+ test("the record job runs pr-record with the apply's flags", () => {
274
+ const [, , record] = prLoopJobs("github", "prod", { events: true, reportUrl: "s3://r/acme", waveGate: "w", requireReview: true, branch: "trunk" });
275
+ expect(record.jobName).toBe("record");
276
+ expect(record.command).toBe('chant components pr-record --base "$BASE_SHA" --env prod --gate pr-apply --output .chant/pr --forge github --events-json --report-url "s3://r/acme"');
277
+ expect(record.run.command).toBe('chant ci run record --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge github --branch "trunk" --events-json --report-url "s3://r/acme"');
278
+ });
279
+
280
+ test.skipIf(!hasActionlint())("passes actionlint", () => {
281
+ const dir = mkdtempSync(join(tmpdir(), "chant-pr-record-"));
282
+ try {
283
+ mkdirSync(join(dir, ".github", "workflows"), { recursive: true });
284
+ writeFileSync(join(dir, ".github", "workflows", "chant-pr.yml"), result.yaml);
285
+ execFileSync("actionlint", [join(dir, ".github", "workflows", "chant-pr.yml")], { cwd: dir, stdio: "pipe" });
286
+ } finally {
287
+ rmSync(dir, { recursive: true, force: true });
288
+ }
289
+ });
290
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The GitHub pull-request workflow with a gate per wave on merge (#4003):
3
+ * `--pr-loop --wave-gate <gate>` keeps the plan job on the pull request and
4
+ * writes one apply job per wave of the component graph, each running
5
+ * `pr-apply --wave-gate <gate> --wave <k>` after the one before it. The whole
6
+ * file has a golden, and actionlint checks it when it is installed.
7
+ */
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { describe, expect, test } from "vitest";
14
+ import { parseYAML } from "@intentius/chant/yaml";
15
+ import type { DriverComponent } from "@intentius/chant/components/driver";
16
+ import { generateGithubPipeline } from "./generate-pipeline";
17
+
18
+ const root = (name: string, dependsOn: string[] = []): DriverComponent => ({
19
+ name,
20
+ dependsOn,
21
+ deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: name }] }],
22
+ });
23
+ /** Three waves: net, then app, then web. */
24
+ const ESTATE: DriverComponent[] = [root("net"), root("app", ["net"]), root("web", ["app"])];
25
+
26
+ const GOLDEN = join(import.meta.dirname, "__fixtures__", "pr-loop-waves.github.golden.yml");
27
+
28
+ interface Step {
29
+ name?: string;
30
+ if?: string;
31
+ uses?: string;
32
+ run?: string;
33
+ env?: Record<string, string>;
34
+ with?: Record<string, unknown>;
35
+ }
36
+ interface Job {
37
+ if: string;
38
+ needs?: string[];
39
+ concurrency?: Record<string, unknown>;
40
+ permissions: Record<string, string>;
41
+ steps: Step[];
42
+ }
43
+
44
+ function hasActionlint(): boolean {
45
+ try {
46
+ execFileSync("actionlint", ["-version"], { stdio: "ignore" });
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ describe("the GitHub pull-request workflow with a gate per wave (#4003)", () => {
54
+ const result = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true }, gatedWaves: { gate: "release" } });
55
+ const doc = parseYAML(result.yaml) as { jobs: Record<string, Job> };
56
+
57
+ test("matches its golden", () => {
58
+ if (process.env.UPDATE_GOLDEN) writeFileSync(GOLDEN, result.yaml);
59
+ expect(result.yaml).toBe(readFileSync(GOLDEN, "utf-8"));
60
+ });
61
+
62
+ test("keeps the plan job, and writes one apply job per wave, each needing the one before", () => {
63
+ expect(Object.keys(doc.jobs)).toEqual(["plan", "apply-wave-1", "apply-wave-2", "apply-wave-3"]);
64
+ expect(result.jobs.map((j) => [j.jobName, j.needs])).toEqual([
65
+ ["plan", []],
66
+ ["apply-wave-1", []],
67
+ ["apply-wave-2", ["apply-wave-1"]],
68
+ ["apply-wave-3", ["apply-wave-2"]],
69
+ ]);
70
+ expect(doc.jobs["apply-wave-2"]!.needs).toEqual(["apply-wave-1"]);
71
+ const plan = doc.jobs.plan!.steps.find((s) => s.run?.includes("ci run plan"))!;
72
+ // The plan passes the wave gate for its note (#4003), and runs no wave.
73
+ expect(plan.run).toContain("--forge github --wave-gate release");
74
+ expect(plan.run).not.toContain("--wave ");
75
+ for (const k of [1, 2, 3]) {
76
+ const job = doc.jobs[`apply-wave-${k}`]!;
77
+ expect(job.if).toBe("github.event_name == 'push'");
78
+ expect(job.concurrency).toEqual({ group: "chant-apply-prod", "cancel-in-progress": false });
79
+ expect(job.permissions.contents).toBe("write");
80
+ const run = job.steps.find((s) => s.run?.includes("ci run apply"))!.run!;
81
+ // #4156: through chant ci run, which runs pr-apply --wave-gate release (no --require-review since #4168) --resume ... --wave <k>.
82
+ expect(run).toContain(`chant ci run apply --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge github --wave-gate release --wave ${k}`);
83
+ expect(job.steps.find((s) => s.run?.includes("ci run apply"))!.env?.CHANT_RUNNER_KEY).toBe("${{ secrets.CHANT_RUNNER_KEY }}");
84
+ }
85
+ });
86
+
87
+ test("each wave restores the commit's newest record and saves its own, so wave k reads what wave k-1 left", () => {
88
+ for (const k of [1, 2, 3]) {
89
+ const steps = doc.jobs[`apply-wave-${k}`]!.steps;
90
+ const restore = steps.find((s) => s.uses === "actions/cache/restore@v4")!;
91
+ const save = steps.find((s) => s.uses === "actions/cache/save@v4")!;
92
+ const key = `chant-apply-prod-\${{ github.sha }}-\${{ github.run_id }}-\${{ github.run_attempt }}-wave-${k}`;
93
+ expect(restore.with).toEqual({ path: ".chant/pr-resume/pr-apply.json", key, "restore-keys": "chant-apply-prod-${{ github.sha }}-" });
94
+ expect(save).toMatchObject({ if: "always()", with: { path: ".chant/pr-resume/pr-apply.json", key } });
95
+ const keep = steps.find((s) => s.uses?.startsWith("actions/upload-artifact"))!;
96
+ expect(keep.with?.name).toBe(`chant-pr-apply-wave-${k}`);
97
+ }
98
+ });
99
+
100
+ test("without --wave-gate the workflow is the one apply job it was", () => {
101
+ const plain = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true } });
102
+ expect(plain.jobs.map((j) => j.jobName)).toEqual(["plan", "apply"]);
103
+ expect(plain.yaml).not.toContain("--wave");
104
+ });
105
+
106
+ test.skipIf(!hasActionlint())("actionlint passes it", () => {
107
+ const tmp = mkdtempSync(join(tmpdir(), "pr-waves-actionlint-"));
108
+ try {
109
+ mkdirSync(join(tmp, ".github", "workflows"), { recursive: true });
110
+ const file = join(tmp, ".github", "workflows", "chant-pr.yml");
111
+ writeFileSync(file, result.yaml);
112
+ execFileSync("actionlint", [file], { cwd: tmp, stdio: "pipe" });
113
+ } finally {
114
+ rmSync(tmp, { recursive: true, force: true });
115
+ }
116
+ });
117
+ });
@@ -1,7 +1,8 @@
1
1
  import { createRequire } from "module";
2
+ import { moduleSourceUrl } from "@intentius/chant/module-source";
2
3
  import type { CompletionContext, CompletionItem } from "@intentius/chant/lsp/types";
3
4
  import { LexiconIndex, lexiconCompletions, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
4
- const require = createRequire(import.meta.url);
5
+ const require = createRequire(moduleSourceUrl(import.meta.url));
5
6
 
6
7
  let cachedIndex: LexiconIndex | null = null;
7
8
 
package/src/lsp/hover.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createRequire } from "module";
2
+ import { moduleSourceUrl } from "@intentius/chant/module-source";
2
3
  import type { HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
3
4
  import { LexiconIndex, lexiconHover, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
4
- const require = createRequire(import.meta.url);
5
+ const require = createRequire(moduleSourceUrl(import.meta.url));
5
6
 
6
7
  let cachedIndex: LexiconIndex | null = null;
7
8