@intentius/chant 0.45.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/dist/audit/core.d.ts +21 -3
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +3 -2
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/build.d.ts +3 -3
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/cli/commands/build.d.ts.map +1 -1
  9. package/dist/cli/commands/check-lexicon.d.ts +14 -0
  10. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  11. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  12. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  13. package/dist/cli/commands/lint.d.ts.map +1 -1
  14. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  15. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  16. package/dist/cli/handlers/search.d.ts.map +1 -1
  17. package/dist/cli/main.d.ts.map +1 -1
  18. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  19. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  20. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  21. package/dist/cli/plugins.d.ts +1 -1
  22. package/dist/cli/plugins.d.ts.map +1 -1
  23. package/dist/cli/registry.d.ts +7 -0
  24. package/dist/cli/registry.d.ts.map +1 -1
  25. package/dist/cli/reporters/stylish.d.ts +15 -1
  26. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  27. package/dist/codegen/lexicon-regen.d.ts +11 -0
  28. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  29. package/dist/codegen/validate.d.ts +10 -0
  30. package/dist/codegen/validate.d.ts.map +1 -1
  31. package/dist/components/auto-release.d.ts +4 -0
  32. package/dist/components/auto-release.d.ts.map +1 -1
  33. package/dist/components/starter-plugin.d.ts +2 -0
  34. package/dist/components/starter-plugin.d.ts.map +1 -1
  35. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  36. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  37. package/dist/components/verbs/index.d.ts +8 -0
  38. package/dist/components/verbs/index.d.ts.map +1 -1
  39. package/dist/components/verbs/r2-sync.d.ts +76 -0
  40. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  41. package/dist/components/verbs/wrangler.d.ts +108 -0
  42. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  43. package/dist/config.d.ts +54 -0
  44. package/dist/config.d.ts.map +1 -1
  45. package/dist/deep-observation.d.ts +14 -0
  46. package/dist/deep-observation.d.ts.map +1 -1
  47. package/dist/effect-receipt.d.ts +177 -0
  48. package/dist/effect-receipt.d.ts.map +1 -0
  49. package/dist/env.d.ts +12 -1
  50. package/dist/env.d.ts.map +1 -1
  51. package/dist/fold/subset.d.ts +15 -2
  52. package/dist/fold/subset.d.ts.map +1 -1
  53. package/dist/index.d.ts +4 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/lexicon.d.ts +223 -2
  56. package/dist/lexicon.d.ts.map +1 -1
  57. package/dist/lifecycle/change-set.d.ts +33 -5
  58. package/dist/lifecycle/change-set.d.ts.map +1 -1
  59. package/dist/lifecycle/index.d.ts +3 -0
  60. package/dist/lifecycle/index.d.ts.map +1 -1
  61. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  62. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  63. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  64. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  65. package/dist/lifecycle/release-ledger.d.ts +20 -0
  66. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  67. package/dist/lifecycle/teardown.d.ts +132 -0
  68. package/dist/lifecycle/teardown.d.ts.map +1 -0
  69. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  70. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  71. package/dist/lint/engine.d.ts +6 -2
  72. package/dist/lint/engine.d.ts.map +1 -1
  73. package/dist/lint/knowledge-checks.d.ts +48 -0
  74. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  75. package/dist/lint/output-checks.d.ts +5 -0
  76. package/dist/lint/output-checks.d.ts.map +1 -0
  77. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  78. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  79. package/dist/lint/post-synth.d.ts +12 -0
  80. package/dist/lint/post-synth.d.ts.map +1 -1
  81. package/dist/lint/receipt-checks.d.ts +9 -0
  82. package/dist/lint/receipt-checks.d.ts.map +1 -0
  83. package/dist/lint/rule.d.ts +31 -0
  84. package/dist/lint/rule.d.ts.map +1 -1
  85. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  86. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  87. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  88. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  89. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  90. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  91. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  92. package/dist/lint/rules/index.d.ts +4 -1
  93. package/dist/lint/rules/index.d.ts.map +1 -1
  94. package/dist/okf-read.d.ts +78 -0
  95. package/dist/okf-read.d.ts.map +1 -0
  96. package/dist/op/builders.d.ts +134 -8
  97. package/dist/op/builders.d.ts.map +1 -1
  98. package/dist/op/index.d.ts +4 -2
  99. package/dist/op/index.d.ts.map +1 -1
  100. package/dist/op/local-executor.d.ts +2 -1
  101. package/dist/op/local-executor.d.ts.map +1 -1
  102. package/dist/op/receipt-store.d.ts +138 -0
  103. package/dist/op/receipt-store.d.ts.map +1 -0
  104. package/dist/op/types.d.ts +31 -1
  105. package/dist/op/types.d.ts.map +1 -1
  106. package/dist/secret-materialization.d.ts +138 -0
  107. package/dist/secret-materialization.d.ts.map +1 -0
  108. package/dist/secret-provenance.d.ts +218 -0
  109. package/dist/secret-provenance.d.ts.map +1 -0
  110. package/dist/serializer.d.ts +11 -0
  111. package/dist/serializer.d.ts.map +1 -1
  112. package/dist/testing.d.ts +136 -0
  113. package/dist/testing.d.ts.map +1 -0
  114. package/dist/yaml.d.ts.map +1 -1
  115. package/package.json +9 -1
  116. package/src/audit/core.test.ts +57 -0
  117. package/src/audit/core.ts +0 -0
  118. package/src/audit/detect-bundle.test.ts +1 -1
  119. package/src/audit/discover.test.ts +24 -0
  120. package/src/audit/discover.ts +11 -2
  121. package/src/audit/rules-doc.ts +11 -1
  122. package/src/build.test.ts +41 -0
  123. package/src/build.ts +34 -6
  124. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  125. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  126. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  127. package/src/cli/commands/audit.test.ts +53 -0
  128. package/src/cli/commands/audit.ts +1 -1
  129. package/src/cli/commands/build.test.ts +211 -0
  130. package/src/cli/commands/build.ts +126 -8
  131. package/src/cli/commands/check-lexicon.test.ts +45 -1
  132. package/src/cli/commands/check-lexicon.ts +45 -0
  133. package/src/cli/commands/lexicon-surface-diff.ts +9 -0
  134. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  135. package/src/cli/commands/lint.ts +32 -7
  136. package/src/cli/handlers/explain.test.ts +70 -1
  137. package/src/cli/handlers/graph.ts +4 -4
  138. package/src/cli/handlers/lifecycle.test.ts +345 -1
  139. package/src/cli/handlers/lifecycle.ts +300 -11
  140. package/src/cli/handlers/search.ts +5 -2
  141. package/src/cli/main.ts +12 -1
  142. package/src/cli/mcp/resource-handlers.ts +38 -1
  143. package/src/cli/mcp/server.test.ts +58 -1
  144. package/src/cli/mcp/tools/explain.ts +51 -2
  145. package/src/cli/plugins.ts +4 -2
  146. package/src/cli/registry.ts +7 -0
  147. package/src/cli/reporters/stylish.test.ts +154 -0
  148. package/src/cli/reporters/stylish.ts +154 -33
  149. package/src/codegen/lexicon-regen.ts +19 -1
  150. package/src/codegen/validate.test.ts +33 -0
  151. package/src/codegen/validate.ts +21 -2
  152. package/src/components/auto-release.ts +6 -0
  153. package/src/components/registry.test.ts +7 -2
  154. package/src/components/starter-plugin.ts +17 -0
  155. package/src/components/verbs/ensure-secret.test.ts +130 -0
  156. package/src/components/verbs/ensure-secret.ts +79 -0
  157. package/src/components/verbs/index.ts +8 -0
  158. package/src/components/verbs/r2-sync.test.ts +107 -0
  159. package/src/components/verbs/r2-sync.ts +124 -0
  160. package/src/components/verbs/wrangler.test.ts +170 -0
  161. package/src/components/verbs/wrangler.ts +241 -0
  162. package/src/config.test.ts +55 -0
  163. package/src/config.ts +88 -1
  164. package/src/deep-observation.test.ts +19 -0
  165. package/src/deep-observation.ts +17 -0
  166. package/src/effect-receipt-exclusion.test.ts +190 -0
  167. package/src/effect-receipt.test.ts +419 -0
  168. package/src/effect-receipt.ts +412 -0
  169. package/src/env.test.ts +35 -1
  170. package/src/env.ts +17 -3
  171. package/src/fold/subset.test.ts +26 -0
  172. package/src/fold/subset.ts +45 -19
  173. package/src/index.ts +4 -0
  174. package/src/lexicon.ts +227 -2
  175. package/src/lifecycle/change-set.ts +46 -7
  176. package/src/lifecycle/index.ts +3 -0
  177. package/src/lifecycle/observation-baseline.test.ts +46 -0
  178. package/src/lifecycle/observation-baseline.ts +33 -1
  179. package/src/lifecycle/receipt-plan.test.ts +250 -0
  180. package/src/lifecycle/receipt-plan.ts +249 -0
  181. package/src/lifecycle/release-ledger.ts +20 -0
  182. package/src/lifecycle/teardown.test.ts +568 -0
  183. package/src/lifecycle/teardown.ts +359 -0
  184. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  185. package/src/lifecycle/unobserved-gate.ts +102 -0
  186. package/src/lint/engine.ts +7 -1
  187. package/src/lint/knowledge-checks.test.ts +80 -0
  188. package/src/lint/knowledge-checks.ts +74 -0
  189. package/src/lint/output-checks.test.ts +85 -0
  190. package/src/lint/output-checks.ts +99 -0
  191. package/src/lint/pipeline-change-gate.test.ts +144 -0
  192. package/src/lint/pipeline-change-gate.ts +153 -0
  193. package/src/lint/post-synth.ts +15 -0
  194. package/src/lint/receipt-checks.test.ts +101 -0
  195. package/src/lint/receipt-checks.ts +93 -0
  196. package/src/lint/rule.ts +23 -0
  197. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  198. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  199. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  200. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  201. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  202. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  203. package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
  204. package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
  205. package/src/lint/rules/index.ts +10 -1
  206. package/src/okf-read.test.ts +149 -0
  207. package/src/okf-read.ts +197 -0
  208. package/src/op/builders.ts +179 -8
  209. package/src/op/effect-step.test.ts +311 -0
  210. package/src/op/index.ts +10 -3
  211. package/src/op/local-executor.ts +172 -25
  212. package/src/op/op.test.ts +25 -2
  213. package/src/op/receipt-store.ts +211 -0
  214. package/src/op/types.ts +33 -1
  215. package/src/secret-materialization.test.ts +199 -0
  216. package/src/secret-materialization.ts +235 -0
  217. package/src/secret-provenance.test.ts +388 -0
  218. package/src/secret-provenance.ts +475 -0
  219. package/src/serializer.ts +12 -0
  220. package/src/testing.test.ts +261 -0
  221. package/src/testing.ts +338 -0
  222. package/src/yaml.test.ts +88 -0
  223. package/src/yaml.ts +76 -6
@@ -4,15 +4,23 @@ import {
4
4
  resolveOwnershipMarker,
5
5
  resolveOwnershipEnv,
6
6
  ownershipEnvDisagreement,
7
+ isOwnershipParamRef,
7
8
  resolveFoldEnabled,
8
9
  resolveSandboxEnabled,
10
+ resolveKnowledgeDir,
9
11
  } from "../../config";
12
+ import { loadOkfBundle } from "../../okf-read";
13
+ import { unknownEnvError } from "../../env";
10
14
  import type { OwnershipMarker } from "../../ownership";
11
15
  import { resolveCliBuildParams } from "../build-params-cli";
12
16
  import type { Serializer, SerializerResult } from "../../serializer";
13
17
  import type { LexiconPlugin } from "../../lexicon";
14
18
  import { resolveLexiconVersions, collectBuildRootContributors } from "../plugins";
15
19
  import { runPostSynthChecks } from "../../lint/post-synth";
20
+ import { collectSecretDeclarations } from "../../secret-provenance";
21
+ import { coreReceiptChecks } from "../../lint/receipt-checks";
22
+ import { coreOutputChecks } from "../../lint/output-checks";
23
+ import { coreKnowledgeChecks } from "../../lint/knowledge-checks";
16
24
  import { applyConfiguredSeverity } from "../../lint/config";
17
25
  import { loadPolicyChecks } from "../../lint/policy";
18
26
  import { armSandboxPolicyExecution, runProjectPolicies } from "../../lint/policy-sandbox";
@@ -258,6 +266,24 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
258
266
  const disagreement = ownershipEnvDisagreement(config, paramsResolution.provenance);
259
267
  if (disagreement) warnings.push(formatWarning({ message: disagreement }));
260
268
 
269
+ // #1221 — dynamic-env legality. `--env` is validated against the declared
270
+ // `environments` in cli/main.ts; a param-bound `ownership.env` supplied via
271
+ // `--param env=<value>` reached here unchecked, so `--param env=pord`
272
+ // stamped a marker for an environment the project never declared. Same
273
+ // check, same site of truth: literal entries match by equality, entries
274
+ // containing `*` (e.g. `"pr-*"`) match as glob patterns, so an unbounded
275
+ // family like per-PR environments is declarable without listing each name.
276
+ if (isOwnershipParamRef(config.ownership?.env)) {
277
+ const dynamicEnvErr = unknownEnvError(env, config.environments);
278
+ if (dynamicEnvErr) {
279
+ errors.push(formatError({
280
+ message: dynamicEnvErr,
281
+ hint: 'Declare it in chant.config `environments` (a "pr-*" pattern entry covers a dynamic family), or pass a declared value.',
282
+ }));
283
+ return { success: false, resourceCount: 0, fileCount: 0, errors, warnings };
284
+ }
285
+ }
286
+
261
287
  // #1039 — thread each loaded plugin's registered intrinsics (e.g. AWS's
262
288
  // `Sub`) through to the fold path, so a file using a registered intrinsic
263
289
  // tagged template folds instead of unconditionally falling back to run.
@@ -362,6 +388,66 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
362
388
  // finding just as it suppresses a pre-synth one. A finding it suppresses is
363
389
  // counted, not dropped silently — see `suppressedPostSynthCount` below.
364
390
  let suppressedPostSynthCount = 0;
391
+
392
+ // Core-owned post-synth checks over effect receipts (#1833). Receipts are
393
+ // recognized by marker, lexicon-independently, so this set runs over the
394
+ // FULL build result — unscoped, and regardless of which plugins loaded.
395
+ // Same severity-resolution funnel as every other post-synth finding.
396
+ if (result.errors.length === 0) {
397
+ const receiptDiags = runPostSynthChecks(coreReceiptChecks(), result, env);
398
+ const { diagnostics: activeDiags, suppressed } = applyConfiguredSeverity(receiptDiags, config.lint?.rules);
399
+ suppressedPostSynthCount += suppressed.length;
400
+ for (const diag of activeDiags) {
401
+ const prefix = diag.entity ? `[${diag.entity}] ` : "";
402
+ const lexiconSuffix = diag.lexicon ? ` (${diag.lexicon})` : "";
403
+ if (diag.severity === "error") {
404
+ errors.push(formatError({ message: `${prefix}${diag.message}${lexiconSuffix}` }));
405
+ } else {
406
+ warnings.push(formatWarning({ message: `${prefix}${diag.message}${lexiconSuffix}` }));
407
+ }
408
+ }
409
+ }
410
+
411
+ // Core-owned post-synth check over serialized output text (#1526). The
412
+ // "[object Object]" marker means a reference got stringified instead of
413
+ // resolved; it can appear in any lexicon's emitted text, so this runs over
414
+ // the FULL build result the same way the receipt checks above do.
415
+ if (result.errors.length === 0) {
416
+ const outputDiags = runPostSynthChecks(coreOutputChecks(), result, env);
417
+ const { diagnostics: activeDiags, suppressed } = applyConfiguredSeverity(outputDiags, config.lint?.rules);
418
+ suppressedPostSynthCount += suppressed.length;
419
+ for (const diag of activeDiags) {
420
+ const prefix = diag.entity ? `[${diag.entity}] ` : "";
421
+ const lexiconSuffix = diag.lexicon ? ` (${diag.lexicon})` : "";
422
+ if (diag.severity === "error") {
423
+ errors.push(formatError({ message: `${prefix}${diag.message}${lexiconSuffix}` }));
424
+ } else {
425
+ warnings.push(formatWarning({ message: `${prefix}${diag.message}${lexiconSuffix}` }));
426
+ }
427
+ }
428
+ }
429
+
430
+ // Core-owned post-synth check over the project's OKF knowledge bundle
431
+ // (#1865, design #1059): a concept's `binds` naming no discovered entity is
432
+ // a stale binding, surfaced as a COR026 warning. `resolveKnowledgeDir` +
433
+ // `loadOkfBundle` never fail — a missing/absent `knowledge/` directory
434
+ // loads an empty bundle, so this is a no-op for a project that has none.
435
+ if (result.errors.length === 0) {
436
+ const bundle = await loadOkfBundle(resolveKnowledgeDir(config, configDir));
437
+ const knowledgeDiags = runPostSynthChecks(coreKnowledgeChecks(bundle), result, env);
438
+ const { diagnostics: activeDiags, suppressed } = applyConfiguredSeverity(knowledgeDiags, config.lint?.rules);
439
+ suppressedPostSynthCount += suppressed.length;
440
+ for (const diag of activeDiags) {
441
+ const prefix = diag.entity ? `[${diag.entity}] ` : "";
442
+ const lexiconSuffix = diag.lexicon ? ` (${diag.lexicon})` : "";
443
+ if (diag.severity === "error") {
444
+ errors.push(formatError({ message: `${prefix}${diag.message}${lexiconSuffix}` }));
445
+ } else {
446
+ warnings.push(formatWarning({ message: `${prefix}${diag.message}${lexiconSuffix}` }));
447
+ }
448
+ }
449
+ }
450
+
365
451
  if (result.errors.length === 0 && options.plugins) {
366
452
  for (const plugin of options.plugins) {
367
453
  if (!plugin.postSynthChecks) continue;
@@ -462,6 +548,20 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
462
548
  // Extract primary content and collect additional files from SerializerResult
463
549
  const additionalFiles = new Map<string, string>();
464
550
 
551
+ // Sidecar files that must reach disk BYTE-FOR-BYTE: committed ciphertext
552
+ // (`declareSecret({ provenance: "committed-encrypted", file })`). The
553
+ // additional-file writer below round-trips anything JSON.parse accepts,
554
+ // key-sorting it and possibly re-emitting it as YAML — which would break
555
+ // the `sops` MAC. `file` is restricted to .yaml/.yml, so that parse fails
556
+ // in practice, but naming the files here makes byte identity structural
557
+ // rather than a lucky accident.
558
+ const verbatimFiles = new Set<string>();
559
+ for (const decl of collectSecretDeclarations(result.entities).values()) {
560
+ if (decl.provenance === "committed-encrypted") {
561
+ verbatimFiles.add(decl.file.split(/[\\/]/).pop()!);
562
+ }
563
+ }
564
+
465
565
  function getPrimaryContent(raw: string | SerializerResult): string {
466
566
  if (typeof raw === "string") return raw;
467
567
  if (raw.files) {
@@ -567,15 +667,18 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
567
667
  const outputDir = dirname(outputPath);
568
668
  for (const [filename, content] of additionalFiles) {
569
669
  let fileContent = content;
570
- // Format additional files consistently
571
- try {
572
- const fileParsed = JSON.parse(content);
573
- fileContent = JSON.stringify(fileParsed, sortedJsonReplacer, 2);
574
- if (options.format === "yaml") {
575
- fileContent = jsonToYaml(JSON.parse(fileContent));
670
+ // Format additional files consistently — except the ones declared
671
+ // verbatim, which are copied exactly as they were committed.
672
+ if (!verbatimFiles.has(filename)) {
673
+ try {
674
+ const fileParsed = JSON.parse(content);
675
+ fileContent = JSON.stringify(fileParsed, sortedJsonReplacer, 2);
676
+ if (options.format === "yaml") {
677
+ fileContent = jsonToYaml(JSON.parse(fileContent));
678
+ }
679
+ } catch {
680
+ // If not JSON, write as-is
576
681
  }
577
- } catch {
578
- // If not JSON, write as-is
579
682
  }
580
683
  const targetPath = join(outputDir, filename);
581
684
  mkdirSync(dirname(targetPath), { recursive: true });
@@ -589,6 +692,21 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
589
692
  })
590
693
  );
591
694
  }
695
+ } else if (verbatimFiles.size > 0) {
696
+ // A build carrying committed ciphertext and no --output has nowhere to
697
+ // put the sidecar. Echoing it to a terminal is not useful and dropping
698
+ // it silently is worse: the primary output would reference a Secret
699
+ // whose file never got written, and the miss would surface as a pod
700
+ // failing to start, far from its cause. Refuse, naming the flag.
701
+ const names = [...verbatimFiles].sort().join(", ");
702
+ errors.push(
703
+ formatError({
704
+ message:
705
+ `This build emits committed-encrypted secret file(s) (${names}) as sidecars, ` +
706
+ `which need a directory to be written into. Re-run with --output <path> ` +
707
+ `(the sidecars land beside it).`,
708
+ }),
709
+ );
592
710
  } else {
593
711
  // Print to stdout
594
712
  console.log(output);
@@ -1,7 +1,9 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { join, dirname } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { checkLexicon } from "./check-lexicon";
4
+ import { checkLexicon, coverageReportCheck } from "./check-lexicon";
5
+ import { loadLexiconFromDir } from "./check-lexicon-plugin";
6
+ import type { LexiconPlugin } from "../../lexicon";
5
7
 
6
8
  // chant #1067 — check-lexicon.ts had zero tests before this issue, despite
7
9
  // being the tool meant to gate every lexicon's completeness. This locks in
@@ -32,3 +34,45 @@ describe("checkLexicon", () => {
32
34
  expect(matches?.pass).toBe(true);
33
35
  });
34
36
  });
37
+
38
+ // chant #1330 — fountain's spec-coverage gate lived only in its own
39
+ // coverage.test.ts, a convention rather than a check-lexicon contract. These
40
+ // lock in the tier-1 check over the plugin's coverageReport() member: red on
41
+ // an unaccounted kind, vacuous pass without the member, red on a throw, and
42
+ // green against the real fountain lexicon in this repo.
43
+ describe("coverageReportCheck", () => {
44
+ test("fails when the report leaves a kind unaccounted", async () => {
45
+ const plugin = {
46
+ coverageReport: async () => ({ unaccountedKinds: ["SandboxRequest"] }),
47
+ } as unknown as LexiconPlugin;
48
+ const item = await coverageReportCheck(plugin);
49
+ expect(item.tier).toBe(1);
50
+ expect(item.pass).toBe(false);
51
+ expect(item.detail).toContain("SandboxRequest");
52
+ });
53
+
54
+ test("passes vacuously when the plugin has no coverageReport", async () => {
55
+ const item = await coverageReportCheck({} as LexiconPlugin);
56
+ expect(item.pass).toBe(true);
57
+ expect(item.detail).toBeUndefined();
58
+ });
59
+
60
+ test("fails when the report throws", async () => {
61
+ const plugin = {
62
+ coverageReport: async () => {
63
+ throw new Error("snapshot missing");
64
+ },
65
+ } as unknown as LexiconPlugin;
66
+ const item = await coverageReportCheck(plugin);
67
+ expect(item.pass).toBe(false);
68
+ expect(item.detail).toContain("snapshot missing");
69
+ });
70
+
71
+ test("is green on the current fountain lexicon", async () => {
72
+ const loaded = await loadLexiconFromDir(join(repoRoot, "lexicons", "fountain"));
73
+ expect(loaded.plugin).toBeDefined();
74
+ const item = await coverageReportCheck(loaded.plugin);
75
+ expect(item.pass).toBe(true);
76
+ expect(item.detail).toBe("all spec kinds accounted for");
77
+ });
78
+ });
@@ -6,6 +6,7 @@ import { auditDocsClassification, auditDocsReachability } from "./check-lexicon-
6
6
  import { auditMcpNames, lexiconNameFor } from "./check-lexicon-mcp";
7
7
  import { loadLexiconFromDir, registers, safeList } from "./check-lexicon-plugin";
8
8
  import { RULE_CATALOG } from "../../audit/catalog";
9
+ import type { LexiconPlugin } from "../../lexicon";
9
10
 
10
11
  // ── Types ────────────────────────────────────────────────────────────
11
12
 
@@ -66,6 +67,45 @@ function countSubdirs(dir: string): number {
66
67
  .length;
67
68
  }
68
69
 
70
+ /**
71
+ * #1330 — gate on the plugin's own spec-coverage accounting.
72
+ *
73
+ * fountain's `coverage.test.ts` asserts `unaccountedKinds == []` in CI, but a
74
+ * lexicon-local test is a convention, not a check-lexicon contract — the same
75
+ * class of gap #1342 closed for LSP providers. `coverageReport()` gives core
76
+ * the one fact to gate on: which upstream spec kinds are neither modeled nor
77
+ * on the lexicon's exclusion list. A lexicon without the member passes
78
+ * vacuously, the same conditional shape as the docs-reachability and
79
+ * Diátaxis checks; a report that throws fails, for the same reason `safeList`
80
+ * treats a throw as worse than absence.
81
+ */
82
+ export async function coverageReportCheck(plugin: LexiconPlugin | undefined): Promise<CheckItem> {
83
+ const report = plugin?.coverageReport;
84
+ const hasReport = typeof report === "function";
85
+ let unaccounted: string[] = [];
86
+ let error: string | undefined;
87
+ if (hasReport) {
88
+ try {
89
+ unaccounted = (await report.call(plugin))?.unaccountedKinds ?? [];
90
+ } catch (e) {
91
+ error = e instanceof Error ? e.message : String(e);
92
+ }
93
+ }
94
+ return {
95
+ name: "coverageReport() leaves no spec kind unaccounted",
96
+ tier: 1,
97
+ pass: !hasReport || (error === undefined && unaccounted.length === 0),
98
+ detail:
99
+ error !== undefined
100
+ ? `threw: ${error}`
101
+ : unaccounted.length > 0
102
+ ? `${unaccounted.length} unaccounted: ${unaccounted.join(", ")}`
103
+ : hasReport
104
+ ? "all spec kinds accounted for"
105
+ : undefined,
106
+ };
107
+ }
108
+
69
109
  // ── Check runner ─────────────────────────────────────────────────────
70
110
 
71
111
  /**
@@ -389,6 +429,11 @@ export async function checkLexicon(dir: string): Promise<CheckResult> {
389
429
  : undefined,
390
430
  });
391
431
 
432
+ // #1330 — fountain's spec-coverage gate lived in a lexicon-local vitest
433
+ // assertion, a convention rather than a check-lexicon contract. The plugin
434
+ // now states the fact directly via `coverageReport()`.
435
+ items.push(await coverageReportCheck(plugin));
436
+
392
437
  // ── Tier 2: Recommended ────────────────────────────────────────
393
438
 
394
439
  const pluginContent = readOr(join(dir, "src/plugin.ts"));
@@ -9,6 +9,12 @@
9
9
  *
10
10
  * The snapshot file is written to `<lexicon-dir>/surface.snapshot.json`.
11
11
  * Pass `--update-snapshot` to commit the fresh snapshot after a successful run.
12
+ *
13
+ * An update run is exempt from the `surface-matches-snapshot` validate check
14
+ * and from nothing else (#1825): that check fails on the stale baseline the
15
+ * run exists to replace, and with an `"always"` gate (#1475) the documented
16
+ * re-baseline flow would deadlock. Any other failing step still refuses the
17
+ * write, so a broken generate cannot be baselined.
12
18
  */
13
19
 
14
20
  import { existsSync, readFileSync } from "fs";
@@ -78,6 +84,9 @@ export async function runSurfaceDiff(opts: SurfaceDiffOptions): Promise<RegenRes
78
84
  skipLint: opts.skipLint,
79
85
  skipExamples: !opts.runExamples,
80
86
  pinnedDigestPath: opts.pinnedDigestPath,
87
+ // The update run skips only the surface-matches-snapshot validate check —
88
+ // the staleness it is about to fix (#1825).
89
+ updatingSnapshot: opts.updateSnapshot,
81
90
  });
82
91
 
83
92
  // Update snapshot when requested and the run succeeded
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `--update-snapshot` against a failing snapshot gate (#1825).
3
+ *
4
+ * Since #1475 the k8s/azure gates run in "always" mode, so a stale baseline
5
+ * fails validate on every run — including the update run whose whole purpose
6
+ * is to replace that baseline. These tests run the real pipeline (no mocks)
7
+ * against a fixture lexicon whose validate script behaves like an "always"
8
+ * gate: it fails while the committed snapshot is stale, unless the run is
9
+ * exempted via CHANT_SNAPSHOT_UPDATE.
10
+ */
11
+
12
+ import { describe, test, expect } from "vitest";
13
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "fs";
14
+ import { join } from "path";
15
+ import { tmpdir } from "os";
16
+ import { runSurfaceDiff } from "./lexicon-surface-diff";
17
+ import { SNAPSHOT_FILENAME } from "../../codegen/lexicon-regen";
18
+ import { parseSnapshot } from "../../codegen/surface-snapshot";
19
+
20
+ const STALE_SNAPSHOT = JSON.stringify({
21
+ schemaVersion: 1,
22
+ generatedAt: "2026-01-01T00:00:00.000Z",
23
+ entries: { Queue: { kind: "resource", resourceType: "X::Y::Queue", attrs: [], props: [] } },
24
+ });
25
+
26
+ const GENERATE_SCRIPT = `node -e "
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const dir = path.join(process.cwd(), 'src', 'generated');
30
+ fs.mkdirSync(dir, { recursive: true });
31
+ fs.writeFileSync(path.join(dir, 'lexicon-test.json'), JSON.stringify({Widget:{kind:'resource',resourceType:'X::Y::Widget',attrs:{Id:'Id'}}}));
32
+ fs.writeFileSync(path.join(dir, 'index.d.ts'), 'export declare class Widget { constructor(props: { Name?: string; }); readonly Id: string; }');
33
+ "`;
34
+
35
+ // Emulates a checkSurfaceSnapshot: "always" gate: fail while the committed
36
+ // snapshot does not describe the generated surface, unless the run carries the
37
+ // update exemption.
38
+ const GATED_VALIDATE_SCRIPT = `node -e "
39
+ if (process.env.CHANT_SNAPSHOT_UPDATE === '1') process.exit(0);
40
+ const fs = require('fs');
41
+ const path = require('path');
42
+ const snap = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'surface.snapshot.json'), 'utf-8'));
43
+ process.exit(snap.entries && snap.entries.Widget ? 0 : 1);
44
+ "`;
45
+
46
+ function makeLexiconDir(validateScript: string): string {
47
+ const dir = mkdtempSync(join(tmpdir(), "chant-sd-update-"));
48
+ const pkg = {
49
+ name: "@intentius/chant-lexicon-test",
50
+ version: "0.1.0",
51
+ type: "module",
52
+ scripts: {
53
+ generate: GENERATE_SCRIPT,
54
+ validate: validateScript,
55
+ },
56
+ };
57
+ writeFileSync(join(dir, "package.json"), JSON.stringify(pkg, null, 2));
58
+ writeFileSync(join(dir, SNAPSHOT_FILENAME), STALE_SNAPSHOT);
59
+ return dir;
60
+ }
61
+
62
+ const baseOpts = { skipBundle: true, skipBuild: true, skipLint: true };
63
+
64
+ describe("surface-diff --update-snapshot vs. an \"always\" snapshot gate (#1825)", () => {
65
+ test("without --update-snapshot the stale snapshot still fails validate", async () => {
66
+ const dir = makeLexiconDir(GATED_VALIDATE_SCRIPT);
67
+ try {
68
+ const result = await runSurfaceDiff({ lexiconDir: dir, ...baseOpts });
69
+ expect(result.ok).toBe(false);
70
+ expect(result.failures.some((f) => f.step === "validate")).toBe(true);
71
+ } finally {
72
+ rmSync(dir, { recursive: true, force: true });
73
+ }
74
+ });
75
+
76
+ test("stale snapshot + healthy codegen: the update succeeds and the snapshot matches after", async () => {
77
+ const dir = makeLexiconDir(GATED_VALIDATE_SCRIPT);
78
+ try {
79
+ const result = await runSurfaceDiff({ lexiconDir: dir, ...baseOpts, updateSnapshot: true });
80
+ expect(result.ok).toBe(true);
81
+ expect(result.failures).toEqual([]);
82
+
83
+ // The baseline now describes the generated surface, not the stale one.
84
+ const written = parseSnapshot(readFileSync(join(dir, SNAPSHOT_FILENAME), "utf-8"));
85
+ expect(written.entries.Widget).toBeDefined();
86
+ expect(written.entries.Queue).toBeUndefined();
87
+
88
+ // A second run against the fresh baseline is green with no exemption.
89
+ const rerun = await runSurfaceDiff({ lexiconDir: dir, ...baseOpts });
90
+ expect(rerun.ok).toBe(true);
91
+ expect(rerun.changed).toBe(false);
92
+ } finally {
93
+ rmSync(dir, { recursive: true, force: true });
94
+ }
95
+ });
96
+
97
+ test("stale snapshot + another failing validate check: the update refuses", async () => {
98
+ // A validate that fails even with the exemption stands in for any check
99
+ // other than surface-matches-snapshot. The exemption covers only the
100
+ // staleness check, so a broken generate cannot be baselined.
101
+ const dir = makeLexiconDir("node -e \"process.exit(1)\"");
102
+ try {
103
+ const result = await runSurfaceDiff({ lexiconDir: dir, ...baseOpts, updateSnapshot: true });
104
+ expect(result.ok).toBe(false);
105
+ expect(result.failures.some((f) => f.step === "validate")).toBe(true);
106
+ // The stale baseline is untouched.
107
+ expect(readFileSync(join(dir, SNAPSHOT_FILENAME), "utf-8")).toBe(STALE_SNAPSHOT);
108
+ } finally {
109
+ rmSync(dir, { recursive: true, force: true });
110
+ }
111
+ });
112
+ });
@@ -20,6 +20,9 @@ import { GENERATED_MARKER } from "../../discovery/files";
20
20
 
21
21
  // Import config loader
22
22
  import { loadConfig, resolveRulesForFile, resolveConfiguredSeverity, findProjectRoot } from "../../lint/config";
23
+ import { loadChantConfig, resolveKnowledgeDir } from "../../config";
24
+ import type { LintProjectConfig } from "../../lint/rule";
25
+ import { loadOkfBundle, type OkfBundle } from "../../okf-read";
23
26
 
24
27
  /**
25
28
  * Type guard to check if a value conforms to the LintRule interface.
@@ -447,6 +450,28 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
447
450
  const config = loadConfig(projectRoot);
448
451
  const hasOverrides = config.overrides && config.overrides.length > 0;
449
452
 
453
+ // chant #1221 — the project's chant.config slice for config-aware rules
454
+ // (COR021 reads `environments` + `ownership`), threaded into every
455
+ // runLint() call below via LintContext.projectConfig. Best-effort: a
456
+ // directory with no project config lints with those rules silent.
457
+ let projectConfig: LintProjectConfig | undefined;
458
+ // chant #1866 — the loaded OKF knowledge bundle, threaded into
459
+ // `formatStylish` below so its suppressed section can resolve `okf:`
460
+ // citations. Best-effort like `projectConfig` above (same
461
+ // `loadChantConfig` call): a directory with no chant config, or no
462
+ // `knowledge/` bundle, lints with citations rendering unresolved rather
463
+ // than a failure — `loadOkfBundle` already treats a missing directory as
464
+ // an empty bundle.
465
+ let knowledgeBundle: OkfBundle | undefined;
466
+ try {
467
+ const chantConfig = (await loadChantConfig(projectRoot)).config;
468
+ projectConfig = chantConfig as LintProjectConfig;
469
+ knowledgeBundle = await loadOkfBundle(resolveKnowledgeDir(chantConfig, projectRoot));
470
+ } catch {
471
+ projectConfig = undefined;
472
+ knowledgeBundle = undefined;
473
+ }
474
+
450
475
  // Load all rules from lexicon plugins (core "chant" + lexicon-specific)
451
476
  const loaded = await loadAllPluginRules(projectRoot);
452
477
  let allRules = loaded.rules;
@@ -470,7 +495,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
470
495
  let diagnostics: LintDiagnostic[];
471
496
  let suppressed: Array<LintDiagnostic & { reason?: string }> = [];
472
497
  if (options.rules) {
473
- const result = await runLint(files, options.rules, undefined, intrinsics);
498
+ const result = await runLint(files, options.rules, undefined, intrinsics, projectConfig);
474
499
  diagnostics = result.diagnostics;
475
500
  suppressed = result.suppressed;
476
501
  } else if (hasOverrides) {
@@ -478,13 +503,13 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
478
503
  for (const file of files) {
479
504
  const relativePath = relative(projectRoot, file);
480
505
  const { rules: fileRules, ruleOptions } = getDefaultRules(projectRoot, relativePath, allRules);
481
- const result = await runLint([file], fileRules, ruleOptions, intrinsics);
506
+ const result = await runLint([file], fileRules, ruleOptions, intrinsics, projectConfig);
482
507
  diagnostics.push(...result.diagnostics);
483
508
  suppressed.push(...result.suppressed);
484
509
  }
485
510
  } else {
486
511
  const { rules, ruleOptions } = getDefaultRules(projectRoot, undefined, allRules);
487
- const result = await runLint(files, rules, ruleOptions, intrinsics);
512
+ const result = await runLint(files, rules, ruleOptions, intrinsics, projectConfig);
488
513
  diagnostics = result.diagnostics;
489
514
  suppressed = result.suppressed;
490
515
  }
@@ -518,7 +543,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
518
543
 
519
544
  // Re-lint after fixes to get updated diagnostics
520
545
  if (options.rules) {
521
- const postResult = await runLint(files, options.rules, undefined, intrinsics);
546
+ const postResult = await runLint(files, options.rules, undefined, intrinsics, projectConfig);
522
547
  diagnostics = postResult.diagnostics;
523
548
  suppressed = postResult.suppressed;
524
549
  } else if (hasOverrides) {
@@ -527,13 +552,13 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
527
552
  for (const file of files) {
528
553
  const relativePath = relative(projectRoot, file);
529
554
  const { rules: fileRules, ruleOptions } = getDefaultRules(projectRoot, relativePath, allRules);
530
- const postResult = await runLint([file], fileRules, ruleOptions, intrinsics);
555
+ const postResult = await runLint([file], fileRules, ruleOptions, intrinsics, projectConfig);
531
556
  diagnostics.push(...postResult.diagnostics);
532
557
  suppressed.push(...postResult.suppressed);
533
558
  }
534
559
  } else {
535
560
  const { rules, ruleOptions } = getDefaultRules(projectRoot, undefined, allRules);
536
- const postResult = await runLint(files, rules, ruleOptions, intrinsics);
561
+ const postResult = await runLint(files, rules, ruleOptions, intrinsics, projectConfig);
537
562
  diagnostics = postResult.diagnostics;
538
563
  suppressed = postResult.suppressed;
539
564
  }
@@ -575,7 +600,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
575
600
  break;
576
601
  case "stylish":
577
602
  default:
578
- output = formatStylish(diagnostics);
603
+ output = formatStylish(diagnostics, suppressed, knowledgeBundle);
579
604
  break;
580
605
  }
581
606
 
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
@@ -90,4 +90,73 @@ describe("runExplain", () => {
90
90
  const code = await runExplain(ctx({ format: "okf" }));
91
91
  expect(code).toBe(1);
92
92
  });
93
+
94
+ describe("knowledge sections (#1867)", () => {
95
+ let projectDir: string;
96
+
97
+ beforeEach(async () => {
98
+ projectDir = await mkdtemp(join(tmpdir(), "chant-explain-knowledge-"));
99
+ });
100
+
101
+ afterEach(async () => {
102
+ await rm(projectDir, { recursive: true, force: true });
103
+ });
104
+
105
+ test("omits the Knowledge section cleanly when no bundle exists", async () => {
106
+ const code = await runExplain(ctx({ path: projectDir }));
107
+ expect(code).toBe(0);
108
+ expect(logSpy.mock.calls[0][0]).not.toContain("## Knowledge");
109
+ });
110
+
111
+ test("json omits the knowledge field cleanly when no bundle exists", async () => {
112
+ const code = await runExplain(ctx({ path: projectDir, format: "json" }));
113
+ expect(code).toBe(0);
114
+ const parsed = JSON.parse(logSpy.mock.calls[0][0] as string);
115
+ expect(parsed.knowledge).toBeUndefined();
116
+ });
117
+
118
+ test("markdown includes a per-entity Knowledge section for bound concepts", async () => {
119
+ await mkdir(join(projectDir, "knowledge", "decisions"), { recursive: true });
120
+ await writeFile(
121
+ join(projectDir, "knowledge", "decisions", "public-vpc.md"),
122
+ "---\ntype: decision\ntitle: Public VPC\nbinds: vpc\n---\nWhy the VPC is public.\n",
123
+ );
124
+
125
+ const code = await runExplain(ctx({ path: projectDir }));
126
+ expect(code).toBe(0);
127
+ const out = logSpy.mock.calls[0][0] as string;
128
+ expect(out).toContain("## Knowledge");
129
+ expect(out).toContain("### `vpc`");
130
+ expect(out).toContain("decision: Public VPC (`decisions/public-vpc.md`)");
131
+ expect(out).not.toContain("### `subnet`");
132
+ });
133
+
134
+ test("json carries type/title/path per bound entity", async () => {
135
+ await mkdir(join(projectDir, "knowledge", "decisions"), { recursive: true });
136
+ await writeFile(
137
+ join(projectDir, "knowledge", "decisions", "public-vpc.md"),
138
+ "---\ntype: decision\ntitle: Public VPC\nbinds: vpc\n---\nWhy the VPC is public.\n",
139
+ );
140
+
141
+ const code = await runExplain(ctx({ path: projectDir, format: "json" }));
142
+ expect(code).toBe(0);
143
+ const parsed = JSON.parse(logSpy.mock.calls[0][0] as string);
144
+ expect(parsed.knowledge).toEqual({
145
+ vpc: [{ type: "decision", title: "Public VPC", path: "decisions/public-vpc.md" }],
146
+ });
147
+ });
148
+
149
+ test("--format okf output is untouched by a present bundle", async () => {
150
+ await mkdir(join(projectDir, "knowledge"), { recursive: true });
151
+ await writeFile(
152
+ join(projectDir, "knowledge", "note.md"),
153
+ "---\ntype: decision\ntitle: Note\nbinds: vpc\n---\nBody.\n",
154
+ );
155
+
156
+ const code = await runExplain(ctx({ path: projectDir, format: "okf" }));
157
+ expect(code).toBe(0);
158
+ const parsed = JSON.parse(logSpy.mock.calls[0][0] as string);
159
+ expect(Object.keys(parsed.files).sort()).toEqual(["gcp/subnet.md", "gcp/vpc.md", "index.md"]);
160
+ });
161
+ });
93
162
  });
@@ -8,7 +8,7 @@ import { mergeProjectOps } from "../../graph-ops";
8
8
  import { reconstructEdges, mergeCatalogs, containmentGroups, type ReferenceCatalog, type ContainmentPair } from "../../graph-refs";
9
9
  import { observeResources } from "../../lifecycle/observe";
10
10
  import { replaySnapshots, hasSnapshot } from "../../lifecycle/replay";
11
- import { loadChantConfig, environmentNames, loadChantConfigUpward, type ChantConfig } from "../../config";
11
+ import { loadChantConfig, environmentNames, matchesDeclaredEnvironment, loadChantConfigUpward, type ChantConfig } from "../../config";
12
12
  import { applyLiveEndpoint } from "../../live-endpoint";
13
13
  import { applyDetail, detailInertNotice, type DetailLevel } from "../../graph-detail";
14
14
  import { applyLens, parseLens } from "../../graph-lens";
@@ -77,7 +77,7 @@ async function graphBuildParams(
77
77
  async function graphBuildRootContributors(
78
78
  ctx: CommandContext,
79
79
  projectPath: string,
80
- ): Promise<Array<() => Promise<import("../../lexicon").BuildRootContribution>>> {
80
+ ): Promise<Array<import("../../lexicon").BuildRootContributor>> {
81
81
  const { config, configPath } = await loadChantConfigUpward(projectPath).catch(
82
82
  () => ({ config: {} as ChantConfig, configPath: undefined }),
83
83
  );
@@ -109,7 +109,7 @@ async function graphBuildRootContributors(
109
109
  */
110
110
  async function mergeGraphBuildRoots(
111
111
  entities: Map<string, import("../../declarable").Declarable>,
112
- contributors: Array<() => Promise<import("../../lexicon").BuildRootContribution>>,
112
+ contributors: Array<import("../../lexicon").BuildRootContributor>,
113
113
  ): Promise<boolean> {
114
114
  if (contributors.length === 0) return true;
115
115
  const merged = await mergeBuildRootEntities(entities, contributors);
@@ -210,7 +210,7 @@ async function runGraphLive(
210
210
  // observation plugins — load them here, mirroring the lifecycle handlers.
211
211
  const plugins = ctx.plugins.length > 0 ? ctx.plugins : await loadPlugins(await resolveProjectLexicons(projectPath));
212
212
  const declaredEnvNames = environmentNames(config.environments);
213
- if (declaredEnvNames && !declaredEnvNames.includes(environment)) {
213
+ if (declaredEnvNames && !matchesDeclaredEnvironment(config.environments, environment)) {
214
214
  console.error(formatError({
215
215
  message: `Unknown environment "${environment}"`,
216
216
  hint: `Defined environments: ${declaredEnvNames.join(", ")}`,