@intentius/chant 0.44.13 → 0.45.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 (175) hide show
  1. package/dist/audit/discover.d.ts +26 -2
  2. package/dist/audit/discover.d.ts.map +1 -1
  3. package/dist/audit/report-model.d.ts +10 -0
  4. package/dist/audit/report-model.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/cli/build-params-cli.d.ts +16 -7
  7. package/dist/cli/build-params-cli.d.ts.map +1 -1
  8. package/dist/cli/commands/audit.d.ts +20 -0
  9. package/dist/cli/commands/audit.d.ts.map +1 -1
  10. package/dist/cli/commands/build.d.ts +9 -0
  11. package/dist/cli/commands/build.d.ts.map +1 -1
  12. package/dist/cli/commands/check-lexicon-docs.d.ts +13 -0
  13. package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -1
  14. package/dist/cli/commands/check-lexicon-examples.d.ts +24 -14
  15. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  16. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  17. package/dist/cli/commands/init-lexicon/templates/codegen.d.ts.map +1 -1
  18. package/dist/cli/commands/init-lexicon/templates/docs.d.ts +5 -0
  19. package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
  20. package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
  21. package/dist/cli/commands/onboard.d.ts +28 -0
  22. package/dist/cli/commands/onboard.d.ts.map +1 -1
  23. package/dist/cli/handlers/build.d.ts.map +1 -1
  24. package/dist/cli/handlers/graph.d.ts.map +1 -1
  25. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  26. package/dist/cli/handlers/run.d.ts.map +1 -1
  27. package/dist/cli/handlers/search.d.ts +27 -0
  28. package/dist/cli/handlers/search.d.ts.map +1 -1
  29. package/dist/cli/main.d.ts.map +1 -1
  30. package/dist/codegen/docs-pages.d.ts +39 -0
  31. package/dist/codegen/docs-pages.d.ts.map +1 -0
  32. package/dist/codegen/docs-sections.d.ts.map +1 -1
  33. package/dist/codegen/docs-sidebar.d.ts +13 -2
  34. package/dist/codegen/docs-sidebar.d.ts.map +1 -1
  35. package/dist/codegen/docs-types.d.ts +30 -10
  36. package/dist/codegen/docs-types.d.ts.map +1 -1
  37. package/dist/codegen/docs.d.ts +14 -2
  38. package/dist/codegen/docs.d.ts.map +1 -1
  39. package/dist/codegen/surface-snapshot.d.ts +27 -3
  40. package/dist/codegen/surface-snapshot.d.ts.map +1 -1
  41. package/dist/codegen/validate.d.ts +11 -3
  42. package/dist/codegen/validate.d.ts.map +1 -1
  43. package/dist/components/capability-plugin.d.ts +13 -2
  44. package/dist/components/capability-plugin.d.ts.map +1 -1
  45. package/dist/components/driver.d.ts +38 -0
  46. package/dist/components/driver.d.ts.map +1 -1
  47. package/dist/components/index.d.ts +1 -1
  48. package/dist/components/index.d.ts.map +1 -1
  49. package/dist/components/starter-plugin.d.ts.map +1 -1
  50. package/dist/config.d.ts +57 -5
  51. package/dist/config.d.ts.map +1 -1
  52. package/dist/discovery/fold-import.d.ts.map +1 -1
  53. package/dist/fold/fold.d.ts +100 -1
  54. package/dist/fold/fold.d.ts.map +1 -1
  55. package/dist/graph-ir.d.ts +12 -0
  56. package/dist/graph-ir.d.ts.map +1 -1
  57. package/dist/graph-ops.d.ts +17 -0
  58. package/dist/graph-ops.d.ts.map +1 -0
  59. package/dist/lifecycle/change-set.d.ts +23 -2
  60. package/dist/lifecycle/change-set.d.ts.map +1 -1
  61. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  62. package/dist/lifecycle/observe.d.ts +8 -0
  63. package/dist/lifecycle/observe.d.ts.map +1 -1
  64. package/dist/lifecycle/replay.d.ts.map +1 -1
  65. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  66. package/dist/lifecycle/types.d.ts +7 -0
  67. package/dist/lifecycle/types.d.ts.map +1 -1
  68. package/dist/lint/policy.d.ts.map +1 -1
  69. package/dist/managed-fields.d.ts +33 -21
  70. package/dist/managed-fields.d.ts.map +1 -1
  71. package/dist/observation.d.ts +23 -1
  72. package/dist/observation.d.ts.map +1 -1
  73. package/dist/op/index.d.ts +1 -1
  74. package/dist/op/index.d.ts.map +1 -1
  75. package/dist/yaml.d.ts +0 -8
  76. package/dist/yaml.d.ts.map +1 -1
  77. package/package.json +1 -1
  78. package/src/__snapshots__/okf.test.ts.snap +20 -0
  79. package/src/audit/discover.test.ts +55 -1
  80. package/src/audit/discover.ts +70 -2
  81. package/src/audit/edge-init-safety.test.ts +149 -18
  82. package/src/audit/report-model.ts +12 -1
  83. package/src/audit/rules-doc.ts +1 -0
  84. package/src/build-params.test.ts +52 -0
  85. package/src/cli/build-params-cli.test.ts +25 -8
  86. package/src/cli/build-params-cli.ts +37 -23
  87. package/src/cli/commands/__fixtures__/audit-coverage/.github/workflows/ci.yml +5 -0
  88. package/src/cli/commands/__fixtures__/audit-coverage/Dockerfile +2 -0
  89. package/src/cli/commands/__fixtures__/audit-coverage/infra/main.tf +3 -0
  90. package/src/cli/commands/__fixtures__/audit-coverage/infra/stack.json +6 -0
  91. package/src/cli/commands/__fixtures__/audit-coverage/k8s/deploy.yaml +12 -0
  92. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/pages/getting-started.mdx +16 -0
  93. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +10 -2
  94. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate-cli.ts +3 -1
  95. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate.ts +2 -1
  96. package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +2 -1
  97. package/src/cli/commands/audit.test.ts +83 -1
  98. package/src/cli/commands/audit.ts +121 -28
  99. package/src/cli/commands/build.test.ts +144 -5
  100. package/src/cli/commands/build.ts +66 -14
  101. package/src/cli/commands/check-lexicon-docs.ts +30 -0
  102. package/src/cli/commands/check-lexicon-examples.test.ts +73 -7
  103. package/src/cli/commands/check-lexicon-examples.ts +66 -17
  104. package/src/cli/commands/check-lexicon.test.ts +1 -1
  105. package/src/cli/commands/check-lexicon.ts +19 -4
  106. package/src/cli/commands/init-lexicon/templates/codegen.ts +5 -2
  107. package/src/cli/commands/init-lexicon/templates/docs.ts +35 -2
  108. package/src/cli/commands/init-lexicon.test.ts +42 -1
  109. package/src/cli/commands/init-lexicon.ts +3 -1
  110. package/src/cli/commands/lexicon-rolling-upgrade.ts +1 -1
  111. package/src/cli/commands/lexicon-surface-diff.test.ts +1 -1
  112. package/src/cli/commands/lexicon-surface-diff.ts +1 -1
  113. package/src/cli/commands/onboard.test.ts +319 -225
  114. package/src/cli/commands/onboard.ts +144 -58
  115. package/src/cli/handlers/build.test.ts +3 -3
  116. package/src/cli/handlers/build.ts +2 -0
  117. package/src/cli/handlers/components.ts +2 -2
  118. package/src/cli/handlers/graph.test.ts +44 -0
  119. package/src/cli/handlers/graph.ts +22 -2
  120. package/src/cli/handlers/lifecycle.test.ts +117 -0
  121. package/src/cli/handlers/lifecycle.ts +16 -0
  122. package/src/cli/handlers/run.test.ts +5 -5
  123. package/src/cli/handlers/run.ts +1 -0
  124. package/src/cli/handlers/search-live.test.ts +217 -0
  125. package/src/cli/handlers/search.test.ts +41 -1
  126. package/src/cli/handlers/search.ts +55 -6
  127. package/src/cli/main.ts +5 -3
  128. package/src/cli/security-boundary.test.ts +2 -1
  129. package/src/codegen/docs-pages.test.ts +194 -0
  130. package/src/codegen/docs-pages.ts +138 -0
  131. package/src/codegen/docs-sections.ts +0 -8
  132. package/src/codegen/docs-sidebar.ts +50 -37
  133. package/src/codegen/docs-types.ts +32 -4
  134. package/src/codegen/docs.ts +78 -40
  135. package/src/codegen/lexicon-regen.ts +3 -1
  136. package/src/codegen/rolling-upgrade.test.ts +1 -1
  137. package/src/codegen/rolling-upgrade.ts +1 -1
  138. package/src/codegen/surface-snapshot.test.ts +139 -0
  139. package/src/codegen/surface-snapshot.ts +297 -122
  140. package/src/codegen/validate.test.ts +16 -3
  141. package/src/codegen/validate.ts +17 -7
  142. package/src/components/auto-release.ts +1 -1
  143. package/src/components/capability-plugin.ts +23 -2
  144. package/src/components/driver.test.ts +45 -0
  145. package/src/components/driver.ts +64 -27
  146. package/src/components/index.ts +2 -0
  147. package/src/components/starter-plugin.ts +5 -2
  148. package/src/config.test.ts +56 -1
  149. package/src/config.ts +152 -8
  150. package/src/discovery/fold-import.test.ts +427 -2
  151. package/src/discovery/fold-import.ts +163 -7
  152. package/src/discovery/sandbox/driver.test.ts +11 -0
  153. package/src/fold/fold.test.ts +41 -0
  154. package/src/fold/fold.ts +372 -2
  155. package/src/graph-ir-live.test.ts +42 -0
  156. package/src/graph-ir.ts +42 -2
  157. package/src/graph-ops.test.ts +69 -0
  158. package/src/graph-ops.ts +42 -0
  159. package/src/lifecycle/change-set.test.ts +41 -0
  160. package/src/lifecycle/change-set.ts +30 -2
  161. package/src/lifecycle/deep-observe.ts +4 -0
  162. package/src/lifecycle/observe.ts +29 -2
  163. package/src/lifecycle/replay.ts +3 -0
  164. package/src/lifecycle/snapshot.ts +3 -0
  165. package/src/lifecycle/types.ts +7 -0
  166. package/src/lint/policy.ts +14 -3
  167. package/src/managed-fields.test.ts +15 -42
  168. package/src/managed-fields.ts +43 -26
  169. package/src/observation.test.ts +24 -8
  170. package/src/observation.ts +48 -4
  171. package/src/op/builders-exports.test.ts +60 -0
  172. package/src/op/index.ts +1 -1
  173. package/src/ownership.test.ts +63 -1
  174. package/src/yaml.test.ts +15 -0
  175. package/src/yaml.ts +19 -2
@@ -18,6 +18,8 @@ import {
18
18
  DriverGateUnsupportedError,
19
19
  DriverRunFailure,
20
20
  UnknownDependencyError,
21
+ accumulateComponentOutputs,
22
+ collectComponentOutputs,
21
23
  resolveComponentGraph,
22
24
  resolveWiring,
23
25
  runComponentDeploy,
@@ -411,6 +413,49 @@ describe("runComponentDeploy — output wiring", () => {
411
413
  });
412
414
  });
413
415
 
416
+ describe("collectComponentOutputs / accumulateComponentOutputs — the shared accumulator (#700)", () => {
417
+ // Exported so the durable path's `accumulateComponentOutputs` activity
418
+ // (lexicons/temporal/src/component-op/activities.ts) captures outputs via
419
+ // the same function `runComponentDeploy` does — the accumulation twin of
420
+ // the already-shared `resolveStepInput`.
421
+ const phaseOutputs = {
422
+ Apply: { stackStatus: "CREATE_COMPLETE", outputs: { ClusterArn: "arn:cluster", ApiRepoUri: "repo" } },
423
+ Publish: { uri: "repo@sha256:abc", digest: "sha256:abc" },
424
+ };
425
+
426
+ it("namespaces publish outputs under `publish` and merges stack outputs at the top level", () => {
427
+ expect(collectComponentOutputs(phaseOutputs)).toEqual({
428
+ ClusterArn: "arn:cluster",
429
+ ApiRepoUri: "repo",
430
+ publish: { uri: "repo@sha256:abc", digest: "sha256:abc" },
431
+ });
432
+ });
433
+
434
+ it("returns undefined when no phase output looks like a publish or stack result", () => {
435
+ expect(collectComponentOutputs({ Verify: { ok: true } })).toBeUndefined();
436
+ });
437
+
438
+ it("records under the component name, merging over a seeded entry, and returns the map", () => {
439
+ const seeded = { "shared-alb": { Seeded: "keep" }, other: { x: 1 } };
440
+ const out = accumulateComponentOutputs(seeded, "shared-alb", phaseOutputs);
441
+ expect(out).toBe(seeded);
442
+ expect(out["shared-alb"]).toEqual({
443
+ Seeded: "keep",
444
+ ClusterArn: "arn:cluster",
445
+ ApiRepoUri: "repo",
446
+ publish: { uri: "repo@sha256:abc", digest: "sha256:abc" },
447
+ });
448
+ expect(out.other).toEqual({ x: 1 });
449
+ // Plain data throughout — survives the Temporal activity JSON boundary unchanged.
450
+ expect(JSON.parse(JSON.stringify(out))).toEqual(out);
451
+ });
452
+
453
+ it("is a no-op when the component exposed nothing", () => {
454
+ const map = { other: { x: 1 } };
455
+ expect(accumulateComponentOutputs(map, "svc", { Verify: { ok: true } })).toEqual({ other: { x: 1 } });
456
+ });
457
+ });
458
+
414
459
  describe("runInterpretDriver — end to end", () => {
415
460
  it("runs a producer before its consumer and threads env/vars into every capability call", async () => {
416
461
  const registry = new CapabilityRegistry();
@@ -603,37 +603,77 @@ export async function runComponentDeploy(
603
603
  return { component: component.name, ok: false, records };
604
604
  }
605
605
 
606
- // Publish-family outputs (publish-image / publish-artifact / load-image-on-host all
607
- // return at least one of uri/digest/key see ../verbs/publish.ts) become this
608
- // component's `@<name>.publish.*` for downstream cross-component references.
609
- const publishOutput = findPublishOutput(phaseOutputs);
610
- if (publishOutput) {
611
- componentOutputs[component.name] = { ...componentOutputs[component.name], publish: publishOutput };
612
- }
606
+ // Publish-family and stack outputs become this component's entry in
607
+ // `componentOutputs` through the same exported accumulator the durable
608
+ // path's `accumulateComponentOutputs` activity calls (#700), so local and
609
+ // Temporal runs can never diverge on what downstream references see.
610
+ accumulateComponentOutputs(componentOutputs, component.name, phaseOutputs);
611
+
612
+ return { component: component.name, ok: true, records };
613
+ }
613
614
 
614
- // Stack outputs from an apply step (cfn-deploy returns `CfnDeployOutput.outputs`)
615
- // become resolvable by downstream components' `stackOutput(<name>, ...)`
616
- // references the cross-stack apply-order integration deferred in #556.
617
- // Merged at the top level of this component's entry (peer to `publish`, which
618
- // is namespaced under its own key), so `resolveWiring`'s stackOutput branch —
619
- // `resolvePath(componentOutputs[stack], name)` finds each output by name.
620
- // Keyed by the component's own name, which by convention is the stack name a
621
- // `stackOutput` reference targets (see the pilots and composition-and-wiring.mdx).
615
+ /**
616
+ * Collect the cross-component outputs one finished component exposes to its
617
+ * downstream consumers, from every phase output it produced:
618
+ *
619
+ * - publish-family outputs (publish-image / publish-artifact /
620
+ * load-image-on-host all return at least one of uri/digest/key — see
621
+ * ./verbs/publish.ts) are namespaced under `publish`, so downstream
622
+ * `@<name>.publish.*` references resolve;
623
+ * - stack outputs from an apply step (cfn-deploy returns
624
+ * `CfnDeployOutput.outputs`) are merged at the top level, peer to
625
+ * `publish`, so `resolveWiring`'s stackOutput branch —
626
+ * `resolvePath(componentOutputs[stack], name)` — finds each output by
627
+ * name (the cross-stack apply-order integration deferred in #556, closed
628
+ * in #699). The entry is keyed by the component's own name, which by
629
+ * convention is the stack name a `stackOutput` reference targets (see the
630
+ * pilots and composition-and-wiring.mdx).
631
+ *
632
+ * Both halves are generic by output *shape*, not by capability `kind`, keeping
633
+ * the driver free of per-capability branching. Returns `undefined` when the
634
+ * component exposed nothing.
635
+ *
636
+ * Exported, like `resolveStepInput`, so the durable Temporal path
637
+ * (lexicons/temporal/src/component-op/activities.ts) accumulates outputs via
638
+ * this exact function rather than re-deriving it (#700). The resolver and the
639
+ * accumulator are the two halves of one contract; sharing only the resolver
640
+ * is how the cross-stack gap #699 closed locally could reopen durably.
641
+ */
642
+ export function collectComponentOutputs(
643
+ phaseOutputs: Record<string, Record<string, unknown>>,
644
+ ): Record<string, unknown> | undefined {
645
+ const publishOutput = findPublishOutput(phaseOutputs);
622
646
  const stackOutputs = findStackOutputs(phaseOutputs);
623
- if (stackOutputs) {
624
- componentOutputs[component.name] = { ...componentOutputs[component.name], ...stackOutputs };
625
- }
647
+ if (!publishOutput && !stackOutputs) return undefined;
648
+ return { ...(stackOutputs ?? {}), ...(publishOutput ? { publish: publishOutput } : {}) };
649
+ }
626
650
 
627
- return { component: component.name, ok: true, records };
651
+ /**
652
+ * Record a finished component's outputs (`collectComponentOutputs`) into the
653
+ * shared `componentOutputs` map under the component's own name, merging over
654
+ * any seeded entry (`--seed-outputs`, or a durable parent workflow's
655
+ * thread-through). Mutates and returns `componentOutputs`, so a Temporal
656
+ * activity can hand the updated map back to its workflow over the JSON
657
+ * boundary — every value in it is plain activity-result data, so the map is
658
+ * serializable by construction. A no-op when the component exposed nothing.
659
+ */
660
+ export function accumulateComponentOutputs(
661
+ componentOutputs: Record<string, Record<string, unknown>>,
662
+ componentName: string,
663
+ phaseOutputs: Record<string, Record<string, unknown>>,
664
+ ): Record<string, Record<string, unknown>> {
665
+ const collected = collectComponentOutputs(phaseOutputs);
666
+ if (collected) {
667
+ componentOutputs[componentName] = { ...componentOutputs[componentName], ...collected };
668
+ }
669
+ return componentOutputs;
628
670
  }
629
671
 
630
672
  /**
631
673
  * Find the output of the last step that looks like a publish result (carries
632
- * `uri`, `digest`, or `key`) across every phase this component ran, so the
633
- * driver can populate `@<component>.publish.*` for a downstream consumer.
634
- * Generic by shape, not by capability `kind` — any capability whose output
635
- * carries one of these fields is eligible, keeping the driver free of
636
- * per-capability branching.
674
+ * `uri`, `digest`, or `key`) across every phase this component ran, so
675
+ * `collectComponentOutputs` can populate `@<component>.publish.*` for a
676
+ * downstream consumer.
637
677
  */
638
678
  function findPublishOutput(
639
679
  phaseOutputs: Record<string, Record<string, unknown>>,
@@ -651,9 +691,6 @@ function findPublishOutput(
651
691
  * Find the stack `outputs` map produced by an apply-style step (cfn-deploy
652
692
  * returns `CfnDeployOutput.outputs`) across every phase this component ran, so
653
693
  * a deployed stack's outputs can seed downstream `stackOutput()` resolution.
654
- * Generic by shape — any output carrying an `outputs` record is eligible —
655
- * keeping the driver free of per-capability branching, the same way
656
- * `findPublishOutput` is.
657
694
  */
658
695
  function findStackOutputs(
659
696
  phaseOutputs: Record<string, Record<string, unknown>>,
@@ -91,6 +91,8 @@ export {
91
91
  resolveComponentGraph,
92
92
  resolveWiring,
93
93
  resolveStepInput,
94
+ collectComponentOutputs,
95
+ accumulateComponentOutputs,
94
96
  runComponentDeploy,
95
97
  runInterpretDriver,
96
98
  DriverGateUnsupportedError,
@@ -100,8 +100,11 @@ function starterCapabilities(): Array<Capability<never, unknown>> {
100
100
  export const starterCapabilityPlugin: CapabilityPlugin = {
101
101
  name: "starter",
102
102
  // The core package's own version (#1505) — lockstep releases bump it, so a
103
- // literal here would go stale every `just release`.
104
- version: ownPackageVersion(import.meta.url),
103
+ // literal here would go stale every `just release`. A getter, so the
104
+ // package.json read happens on first access rather than at import time.
105
+ get version(): string {
106
+ return ownPackageVersion(import.meta.url);
107
+ },
105
108
  capabilities: starterCapabilities,
106
109
  families: () => STARTER_VERB_FAMILIES,
107
110
  };
@@ -1,4 +1,4 @@
1
- import { describe, test, expect, beforeEach, afterEach } from "vitest";
1
+ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import {
3
3
  loadChantConfig,
4
4
  loadChantConfigUpward,
@@ -176,6 +176,61 @@ describe("loadChantConfigUpward (#1502 — lint fragments do not end the walk)",
176
176
  expect(result.config.ownership?.stack).toBe("nested");
177
177
  });
178
178
 
179
+ // #1711 — JSON has no comments, so a project explaining its rule disables
180
+ // reaches for an underscore key. Both CockroachDB examples carried
181
+ // `_ruleNotes` in every stack's fragment, and that one key stopped the walk:
182
+ // `chant build src/east` resolved the fragment, found no `buildParams`, and
183
+ // emitted `...@undefined.iam.gserviceaccount.com` without a word.
184
+ test("an underscore comment key does not turn a fragment into a project config", async () => {
185
+ mkdirSync(SRC, { recursive: true });
186
+ writeFileSync(
187
+ join(SRC, "chant.config.json"),
188
+ JSON.stringify({
189
+ extends: ["@intentius/chant/lint/presets/strict"],
190
+ _ruleNotes: { COR001: "inline objects read better here" },
191
+ rules: { COR001: "off" },
192
+ }),
193
+ );
194
+ writeFileSync(
195
+ join(TEST_DIR, "chant.config.json"),
196
+ JSON.stringify({
197
+ ownership: { stack: "crdb-multi-region" },
198
+ buildParams: { projectId: { type: "string", default: "my-project" } },
199
+ }),
200
+ );
201
+
202
+ const result = await loadChantConfigUpward(SRC);
203
+ expect(result.config.ownership?.stack).toBe("crdb-multi-region");
204
+ expect(result.config.buildParams?.projectId?.default).toBe("my-project");
205
+ expect(result.configPath).toBe(join(TEST_DIR, "chant.config.json"));
206
+ });
207
+
208
+ test("a non-comment unknown key still stops the walk, but says so", async () => {
209
+ mkdirSync(SRC, { recursive: true });
210
+ writeFileSync(
211
+ join(SRC, "chant.config.json"),
212
+ JSON.stringify({ extends: ["@intentius/chant/lint/presets/strict"], ruleNotes: { a: "b" } }),
213
+ );
214
+ writeFileSync(
215
+ join(TEST_DIR, "chant.config.json"),
216
+ JSON.stringify({ ownership: { stack: "root" } }),
217
+ );
218
+
219
+ const warn = vi.spyOn(console, "error").mockImplementation(() => {});
220
+ try {
221
+ const result = await loadChantConfigUpward(SRC);
222
+ // Unchanged behaviour: the fragment is the project config. What is new is
223
+ // that the build no longer loses the root config in silence.
224
+ expect(result.configPath).toBe(join(SRC, "chant.config.json"));
225
+ expect(result.config.ownership).toBeUndefined();
226
+ const said = warn.mock.calls.flat().join("\n");
227
+ expect(said).toContain("declares no project-level key");
228
+ expect(said).toContain('"ruleNotes"');
229
+ } finally {
230
+ warn.mockRestore();
231
+ }
232
+ });
233
+
179
234
  test("a fragment-only project resolves to the default config at the boundary", async () => {
180
235
  mkdirSync(SRC, { recursive: true });
181
236
  writeFileSync(join(TEST_DIR, "package.json"), JSON.stringify({ name: "boundary" }));
package/src/config.ts CHANGED
@@ -7,6 +7,7 @@ import { DEFAULT_SBOM_FORMAT, type SbomFormat } from "./components/verbs/sbom-ge
7
7
  import type { Severity } from "./components/verbs/vuln-scan";
8
8
  import type { VulnPolicy } from "./components/verbs/vuln-gate";
9
9
  import type { BuildParamsConfig } from "./build-params";
10
+ import type { BuildParamProvenance } from "./provenance";
10
11
  import { findProjectConfig } from "./project-root";
11
12
  import { evaluateProjectConfig } from "./config-sandbox";
12
13
 
@@ -22,6 +23,12 @@ import { evaluateProjectConfig } from "./config-sandbox";
22
23
  */
23
24
  export type EnvironmentDeclaration = string | { name: string; endpoint?: string };
24
25
 
26
+ /**
27
+ * `ownership.env`: a literal environment identity, or a reference to a
28
+ * declared build parameter whose resolved value becomes the identity (#1396).
29
+ */
30
+ export type OwnershipEnv = string | { param: string };
31
+
25
32
  /** The declared name of one `environments` entry, whichever form it takes. */
26
33
  export function environmentName(entry: EnvironmentDeclaration): string {
27
34
  return typeof entry === "string" ? entry : entry.name;
@@ -69,7 +76,7 @@ export const ChantConfigSchema = z.object({
69
76
  lint: z.record(z.string(), z.unknown()).optional(),
70
77
  ownership: z.object({
71
78
  stack: z.string().min(1).optional(),
72
- env: z.string().min(1).optional(),
79
+ env: z.union([z.string().min(1), z.object({ param: z.string().min(1) })]).optional(),
73
80
  enabled: z.boolean().optional(),
74
81
  }).optional(),
75
82
  build: z.object({
@@ -193,8 +200,17 @@ export interface ChantConfig {
193
200
  ownership?: {
194
201
  /** Stack identity stamped onto resources (required to enable stamping). */
195
202
  stack?: string;
196
- /** Optional environment identity. */
197
- env?: string;
203
+ /**
204
+ * Optional environment identity. Either a literal, or a reference to one
205
+ * of this project's declared `buildParams` (`{ param: "env" }`), in which
206
+ * case the marker takes whatever value that parameter resolves to for the
207
+ * build — the same value `params.env` reads in source (#1396). A
208
+ * reference is the only way to make the marker follow `--param env=prod`:
209
+ * the config is evaluated before build parameters exist, so a literal
210
+ * computed from `process.env` here and a parameter mapped to the same
211
+ * variable are two sources of truth that nothing keeps in step.
212
+ */
213
+ env?: OwnershipEnv;
198
214
  /** Set false to disable stamping even when `stack` is present. */
199
215
  enabled?: boolean;
200
216
  };
@@ -414,6 +430,7 @@ export async function loadChantConfigUpward(startDir: string): Promise<ResolvedC
414
430
  if (parent === dir) break;
415
431
  ({ dir, configPath } = findProjectConfig(parent));
416
432
  }
433
+ if (configPath) warnIfFragmentShadowsProjectConfig(configPath);
417
434
  return loadChantConfig(dir);
418
435
  }
419
436
 
@@ -426,12 +443,25 @@ export async function loadChantConfigUpward(startDir: string): Promise<ResolvedC
426
443
  */
427
444
  const LINT_FRAGMENT_KEYS = new Set(["$schema", "extends", "rules", "overrides", "plugins", "policies"]);
428
445
 
446
+ /**
447
+ * JSON has no comments, so a project that wants to explain why it disabled a
448
+ * rule reaches for an underscore-prefixed key. chant #1711: both CockroachDB
449
+ * examples carried a `_ruleNotes` object in every stack's fragment, and one
450
+ * unrecognized key was enough to stop the walk — `chant build src/east`
451
+ * resolved the fragment as the project config, found no `buildParams`, no
452
+ * `ownership` and no `lexicons`, and emitted
453
+ * `gke-crdb-east-crdb@undefined.iam.gserviceaccount.com`. Silently.
454
+ *
455
+ * An underscore key is a comment. It does not make a fragment a project config.
456
+ */
457
+ const isCommentKey = (k: string): boolean => k.startsWith("_");
458
+
429
459
  function isLintOnlyFragment(configPath: string): boolean {
430
460
  if (!configPath.endsWith("chant.config.json")) return false;
431
461
  try {
432
462
  const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as unknown;
433
463
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
434
- const keys = Object.keys(parsed);
464
+ const keys = Object.keys(parsed).filter((k) => !isCommentKey(k));
435
465
  return keys.length > 0 && keys.every((k) => LINT_FRAGMENT_KEYS.has(k));
436
466
  } catch {
437
467
  // Unreadable/unparseable JSON: let loadChantConfig surface the real error
@@ -441,13 +471,127 @@ function isLintOnlyFragment(configPath: string): boolean {
441
471
  }
442
472
 
443
473
  /**
444
- * Resolve the ownership marker to stamp from project config, or undefined when
445
- * ownership marking is off (no `stack`, or `enabled: false`).
474
+ * The `chant.config.json` the walk stopped at declares no project-level key at
475
+ * all so it is about to be used as the project config while carrying none of
476
+ * the things a project config exists to carry.
477
+ *
478
+ * The comment-key rule above fixes the case that bit (chant #1711), but the
479
+ * hole it came from is general: any key outside {@link LINT_FRAGMENT_KEYS}
480
+ * turns a fragment into the project config, and the consequence is a build
481
+ * that silently loses every parameter, the ownership marker and the lexicon
482
+ * list. This says so, naming the key responsible, instead of leaving it to be
483
+ * discovered in the output.
484
+ */
485
+ function warnIfFragmentShadowsProjectConfig(configPath: string): void {
486
+ if (!configPath.endsWith("chant.config.json")) return;
487
+ try {
488
+ const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
489
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return;
490
+ const keys = Object.keys(parsed).filter((k) => !isCommentKey(k));
491
+ if (keys.some((k) => !LINT_FRAGMENT_KEYS.has(k)) && !keys.some((k) => PROJECT_CONFIG_KEYS.has(k))) {
492
+ const offenders = keys.filter((k) => !LINT_FRAGMENT_KEYS.has(k));
493
+ console.error(
494
+ `warning: ${configPath} is being used as the project config, but declares no project-level ` +
495
+ `key (${[...PROJECT_CONFIG_KEYS].slice(0, 4).join(", ")}, …). ` +
496
+ `${offenders.length === 1 ? "The key" : "The keys"} ${offenders.map((k) => `"${k}"`).join(", ")} ` +
497
+ `stopped the walk to the real one — any config above this file is being ignored. ` +
498
+ `Prefix a comment key with "_", or move the key to the project config. (chant #1711)`,
499
+ );
500
+ }
501
+ } catch {
502
+ // Unreadable/unparseable: loadChantConfig reports it properly.
503
+ }
504
+ }
505
+
506
+ /** Top-level keys that make a config a *project* config rather than a lint fragment. */
507
+ const PROJECT_CONFIG_KEYS = new Set(Object.keys(ChantConfigSchema.shape).filter((k) => k !== "lint"));
508
+
509
+ /** Whether an `ownership.env` entry is a build-parameter reference rather than a literal. */
510
+ export function isOwnershipParamRef(env: OwnershipEnv | undefined): env is { param: string } {
511
+ return typeof env === "object" && env !== null && typeof env.param === "string";
512
+ }
513
+
514
+ /**
515
+ * The ownership stack identity alone — `ownership.stack` when marking is on
516
+ * (no `stack`, or `enabled: false`, means off). For callers that need the
517
+ * stack and nothing else (a field manager, a prune selector) and so have no
518
+ * build parameters to resolve `env` against.
446
519
  */
447
- export function resolveOwnershipMarker(config: ChantConfig): OwnershipMarker | undefined {
520
+ export function resolveOwnershipStack(config: ChantConfig): string | undefined {
448
521
  const o = config.ownership;
449
522
  if (!o || !o.stack || o.enabled === false) return undefined;
450
- return { stack: o.stack, env: o.env };
523
+ return o.stack;
524
+ }
525
+
526
+ /**
527
+ * Resolve the `env` half of the ownership marker (#1396). A literal is
528
+ * returned as-is. A `{ param }` reference is looked up in this build's resolved
529
+ * build parameters and must find one: a reference to a parameter that was
530
+ * never declared, or that resolved to no value, throws rather than quietly
531
+ * stamping a marker without an env — an env-less marker is exactly what lets
532
+ * a prod deployment go unseen by `--owned` filtering scoped to prod.
533
+ */
534
+ export function resolveOwnershipEnv(
535
+ config: ChantConfig,
536
+ buildParams: readonly BuildParamProvenance[] | undefined,
537
+ ): string | undefined {
538
+ const env = config.ownership?.env;
539
+ if (!isOwnershipParamRef(env)) return env;
540
+ const name = env.param;
541
+ const declared = config.buildParams?.[name];
542
+ if (!declared) {
543
+ throw new Error(
544
+ `ownership.env references build parameter "${name}", which chant.config.ts's buildParams does not declare`,
545
+ );
546
+ }
547
+ const resolved = buildParams?.find((p) => p.name === name);
548
+ if (!resolved) {
549
+ throw new Error(
550
+ `ownership.env references build parameter "${name}", which resolved to no value for this build — pass --param ${name}=<value>${declared.env ? `, set ${declared.env}` : ""}, or give it a default`,
551
+ );
552
+ }
553
+ return String(resolved.value);
554
+ }
555
+
556
+ /**
557
+ * Resolve the ownership marker to stamp from project config, or undefined when
558
+ * ownership marking is off (no `stack`, or `enabled: false`). `buildParams` is
559
+ * this build's resolved parameters (`resolveBuildParams`'s provenance), which
560
+ * an `ownership.env: { param }` reference resolves against — see {@link
561
+ * resolveOwnershipEnv}; it throws when the reference cannot be satisfied.
562
+ */
563
+ export function resolveOwnershipMarker(
564
+ config: ChantConfig,
565
+ buildParams?: readonly BuildParamProvenance[],
566
+ ): OwnershipMarker | undefined {
567
+ const stack = resolveOwnershipStack(config);
568
+ if (stack === undefined) return undefined;
569
+ return { stack, env: resolveOwnershipEnv(config, buildParams) };
570
+ }
571
+
572
+ /**
573
+ * The divergence #1396 is about, made loud when it cannot be removed: a
574
+ * project whose `ownership.env` is a literal while it also declares a build
575
+ * parameter named `env` has two answers to "which environment is this", and
576
+ * the manifest carries both (the ownership marker from config, the labels
577
+ * from the parameter). Returns a warning naming both values when they differ;
578
+ * undefined when there is nothing to say (no literal, no `env` parameter, or
579
+ * the two agree). The fix is the `{ param: "env" }` reference.
580
+ */
581
+ export function ownershipEnvDisagreement(
582
+ config: ChantConfig,
583
+ buildParams: readonly BuildParamProvenance[] | undefined,
584
+ ): string | undefined {
585
+ const literal = config.ownership?.env;
586
+ if (typeof literal !== "string") return undefined;
587
+ if (resolveOwnershipStack(config) === undefined) return undefined;
588
+ const param = buildParams?.find((p) => p.name === "env");
589
+ if (!param || String(param.value) === literal) return undefined;
590
+ return (
591
+ `ownership.env is "${literal}" but the env build parameter resolved to ${JSON.stringify(param.value)} (${param.source}) — ` +
592
+ `the ownership marker and params.env disagree about which environment this build is for. ` +
593
+ `Use ownership: { env: { param: "env" } } so the marker follows the parameter.`
594
+ );
451
595
  }
452
596
 
453
597
  /**