@intentius/chant 0.44.14 → 0.46.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 (220) 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 +14 -0
  17. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  18. package/dist/cli/commands/init-lexicon/templates/codegen.d.ts.map +1 -1
  19. package/dist/cli/commands/init-lexicon/templates/docs.d.ts +5 -0
  20. package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
  21. package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
  22. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  23. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  24. package/dist/cli/commands/lint.d.ts.map +1 -1
  25. package/dist/cli/commands/onboard.d.ts +28 -0
  26. package/dist/cli/commands/onboard.d.ts.map +1 -1
  27. package/dist/cli/handlers/build.d.ts.map +1 -1
  28. package/dist/cli/handlers/graph.d.ts.map +1 -1
  29. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  30. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  31. package/dist/cli/handlers/run.d.ts.map +1 -1
  32. package/dist/cli/handlers/search.d.ts +27 -0
  33. package/dist/cli/handlers/search.d.ts.map +1 -1
  34. package/dist/cli/main.d.ts.map +1 -1
  35. package/dist/cli/registry.d.ts +7 -0
  36. package/dist/cli/registry.d.ts.map +1 -1
  37. package/dist/codegen/docs-pages.d.ts +39 -0
  38. package/dist/codegen/docs-pages.d.ts.map +1 -0
  39. package/dist/codegen/docs-sections.d.ts.map +1 -1
  40. package/dist/codegen/docs-sidebar.d.ts +13 -2
  41. package/dist/codegen/docs-sidebar.d.ts.map +1 -1
  42. package/dist/codegen/docs-types.d.ts +30 -10
  43. package/dist/codegen/docs-types.d.ts.map +1 -1
  44. package/dist/codegen/docs.d.ts +14 -2
  45. package/dist/codegen/docs.d.ts.map +1 -1
  46. package/dist/codegen/lexicon-regen.d.ts +11 -0
  47. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  48. package/dist/codegen/surface-snapshot.d.ts +27 -3
  49. package/dist/codegen/surface-snapshot.d.ts.map +1 -1
  50. package/dist/codegen/validate.d.ts +21 -3
  51. package/dist/codegen/validate.d.ts.map +1 -1
  52. package/dist/components/capability-plugin.d.ts +13 -2
  53. package/dist/components/capability-plugin.d.ts.map +1 -1
  54. package/dist/components/driver.d.ts +38 -0
  55. package/dist/components/driver.d.ts.map +1 -1
  56. package/dist/components/index.d.ts +1 -1
  57. package/dist/components/index.d.ts.map +1 -1
  58. package/dist/components/starter-plugin.d.ts.map +1 -1
  59. package/dist/config.d.ts +85 -5
  60. package/dist/config.d.ts.map +1 -1
  61. package/dist/discovery/fold-import.d.ts.map +1 -1
  62. package/dist/env.d.ts +12 -1
  63. package/dist/env.d.ts.map +1 -1
  64. package/dist/fold/fold.d.ts +100 -1
  65. package/dist/fold/fold.d.ts.map +1 -1
  66. package/dist/graph-ir.d.ts +12 -0
  67. package/dist/graph-ir.d.ts.map +1 -1
  68. package/dist/graph-ops.d.ts +17 -0
  69. package/dist/graph-ops.d.ts.map +1 -0
  70. package/dist/lexicon.d.ts +182 -2
  71. package/dist/lexicon.d.ts.map +1 -1
  72. package/dist/lifecycle/change-set.d.ts +23 -2
  73. package/dist/lifecycle/change-set.d.ts.map +1 -1
  74. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  75. package/dist/lifecycle/index.d.ts +1 -0
  76. package/dist/lifecycle/index.d.ts.map +1 -1
  77. package/dist/lifecycle/observe.d.ts +8 -0
  78. package/dist/lifecycle/observe.d.ts.map +1 -1
  79. package/dist/lifecycle/replay.d.ts.map +1 -1
  80. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  81. package/dist/lifecycle/teardown.d.ts +130 -0
  82. package/dist/lifecycle/teardown.d.ts.map +1 -0
  83. package/dist/lifecycle/types.d.ts +7 -0
  84. package/dist/lifecycle/types.d.ts.map +1 -1
  85. package/dist/lint/engine.d.ts +6 -2
  86. package/dist/lint/engine.d.ts.map +1 -1
  87. package/dist/lint/policy.d.ts.map +1 -1
  88. package/dist/lint/rule.d.ts +31 -0
  89. package/dist/lint/rule.d.ts.map +1 -1
  90. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  91. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  92. package/dist/lint/rules/index.d.ts +2 -1
  93. package/dist/lint/rules/index.d.ts.map +1 -1
  94. package/dist/managed-fields.d.ts +33 -21
  95. package/dist/managed-fields.d.ts.map +1 -1
  96. package/dist/observation.d.ts +23 -1
  97. package/dist/observation.d.ts.map +1 -1
  98. package/dist/op/builders.d.ts +36 -7
  99. package/dist/op/builders.d.ts.map +1 -1
  100. package/dist/op/index.d.ts +1 -1
  101. package/dist/op/index.d.ts.map +1 -1
  102. package/dist/testing.d.ts +136 -0
  103. package/dist/testing.d.ts.map +1 -0
  104. package/dist/yaml.d.ts +0 -8
  105. package/dist/yaml.d.ts.map +1 -1
  106. package/package.json +6 -1
  107. package/src/__snapshots__/okf.test.ts.snap +20 -0
  108. package/src/audit/discover.test.ts +55 -1
  109. package/src/audit/discover.ts +70 -2
  110. package/src/audit/edge-init-safety.test.ts +149 -18
  111. package/src/audit/report-model.ts +12 -1
  112. package/src/audit/rules-doc.ts +1 -0
  113. package/src/build-params.test.ts +52 -0
  114. package/src/cli/build-params-cli.test.ts +25 -8
  115. package/src/cli/build-params-cli.ts +37 -23
  116. package/src/cli/commands/__fixtures__/audit-coverage/.github/workflows/ci.yml +5 -0
  117. package/src/cli/commands/__fixtures__/audit-coverage/Dockerfile +2 -0
  118. package/src/cli/commands/__fixtures__/audit-coverage/infra/main.tf +3 -0
  119. package/src/cli/commands/__fixtures__/audit-coverage/infra/stack.json +6 -0
  120. package/src/cli/commands/__fixtures__/audit-coverage/k8s/deploy.yaml +12 -0
  121. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/pages/getting-started.mdx +16 -0
  122. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +10 -2
  123. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate-cli.ts +3 -1
  124. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate.ts +2 -1
  125. package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +2 -1
  126. package/src/cli/commands/audit.test.ts +83 -1
  127. package/src/cli/commands/audit.ts +121 -28
  128. package/src/cli/commands/build.test.ts +275 -5
  129. package/src/cli/commands/build.ts +86 -14
  130. package/src/cli/commands/check-lexicon-docs.ts +30 -0
  131. package/src/cli/commands/check-lexicon-examples.test.ts +73 -7
  132. package/src/cli/commands/check-lexicon-examples.ts +66 -17
  133. package/src/cli/commands/check-lexicon.test.ts +46 -2
  134. package/src/cli/commands/check-lexicon.ts +64 -4
  135. package/src/cli/commands/init-lexicon/templates/codegen.ts +5 -2
  136. package/src/cli/commands/init-lexicon/templates/docs.ts +35 -2
  137. package/src/cli/commands/init-lexicon.test.ts +42 -1
  138. package/src/cli/commands/init-lexicon.ts +3 -1
  139. package/src/cli/commands/lexicon-rolling-upgrade.ts +1 -1
  140. package/src/cli/commands/lexicon-surface-diff.test.ts +1 -1
  141. package/src/cli/commands/lexicon-surface-diff.ts +10 -1
  142. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  143. package/src/cli/commands/lint.ts +19 -6
  144. package/src/cli/commands/onboard.test.ts +319 -225
  145. package/src/cli/commands/onboard.ts +144 -58
  146. package/src/cli/handlers/build.test.ts +3 -3
  147. package/src/cli/handlers/build.ts +2 -0
  148. package/src/cli/handlers/components.ts +2 -2
  149. package/src/cli/handlers/graph.test.ts +44 -0
  150. package/src/cli/handlers/graph.ts +24 -4
  151. package/src/cli/handlers/lifecycle.test.ts +348 -1
  152. package/src/cli/handlers/lifecycle.ts +235 -3
  153. package/src/cli/handlers/run.test.ts +5 -5
  154. package/src/cli/handlers/run.ts +1 -0
  155. package/src/cli/handlers/search-live.test.ts +217 -0
  156. package/src/cli/handlers/search.test.ts +41 -1
  157. package/src/cli/handlers/search.ts +60 -8
  158. package/src/cli/main.ts +17 -4
  159. package/src/cli/registry.ts +7 -0
  160. package/src/cli/security-boundary.test.ts +2 -1
  161. package/src/codegen/docs-pages.test.ts +194 -0
  162. package/src/codegen/docs-pages.ts +138 -0
  163. package/src/codegen/docs-sections.ts +0 -8
  164. package/src/codegen/docs-sidebar.ts +50 -37
  165. package/src/codegen/docs-types.ts +32 -4
  166. package/src/codegen/docs.ts +78 -40
  167. package/src/codegen/lexicon-regen.ts +22 -2
  168. package/src/codegen/rolling-upgrade.test.ts +1 -1
  169. package/src/codegen/rolling-upgrade.ts +1 -1
  170. package/src/codegen/surface-snapshot.test.ts +139 -0
  171. package/src/codegen/surface-snapshot.ts +297 -122
  172. package/src/codegen/validate.test.ts +49 -3
  173. package/src/codegen/validate.ts +36 -7
  174. package/src/components/auto-release.ts +1 -1
  175. package/src/components/capability-plugin.ts +23 -2
  176. package/src/components/driver.test.ts +45 -0
  177. package/src/components/driver.ts +64 -27
  178. package/src/components/index.ts +2 -0
  179. package/src/components/starter-plugin.ts +5 -2
  180. package/src/config.test.ts +40 -0
  181. package/src/config.ts +159 -8
  182. package/src/discovery/fold-import.test.ts +427 -2
  183. package/src/discovery/fold-import.ts +163 -7
  184. package/src/discovery/sandbox/driver.test.ts +11 -0
  185. package/src/env.test.ts +35 -1
  186. package/src/env.ts +17 -3
  187. package/src/fold/fold.test.ts +41 -0
  188. package/src/fold/fold.ts +372 -2
  189. package/src/graph-ir-live.test.ts +42 -0
  190. package/src/graph-ir.ts +42 -2
  191. package/src/graph-ops.test.ts +69 -0
  192. package/src/graph-ops.ts +42 -0
  193. package/src/lexicon.ts +182 -2
  194. package/src/lifecycle/change-set.test.ts +41 -0
  195. package/src/lifecycle/change-set.ts +30 -2
  196. package/src/lifecycle/deep-observe.ts +4 -0
  197. package/src/lifecycle/index.ts +1 -0
  198. package/src/lifecycle/observe.ts +29 -2
  199. package/src/lifecycle/replay.ts +3 -0
  200. package/src/lifecycle/snapshot.ts +3 -0
  201. package/src/lifecycle/teardown.test.ts +537 -0
  202. package/src/lifecycle/teardown.ts +357 -0
  203. package/src/lifecycle/types.ts +7 -0
  204. package/src/lint/engine.ts +7 -1
  205. package/src/lint/policy.ts +14 -3
  206. package/src/lint/rule.ts +23 -0
  207. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  208. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  209. package/src/lint/rules/index.ts +4 -1
  210. package/src/managed-fields.test.ts +15 -42
  211. package/src/managed-fields.ts +43 -26
  212. package/src/observation.test.ts +24 -8
  213. package/src/observation.ts +48 -4
  214. package/src/op/builders.ts +40 -7
  215. package/src/op/index.ts +1 -1
  216. package/src/ownership.test.ts +63 -1
  217. package/src/testing.test.ts +261 -0
  218. package/src/testing.ts +338 -0
  219. package/src/yaml.test.ts +15 -0
  220. package/src/yaml.ts +19 -2
@@ -0,0 +1,114 @@
1
+ import * as ts from "typescript";
2
+ import type { LintRule, LintContext, LintDiagnostic, LintProjectConfig } from "../rule";
3
+
4
+ /**
5
+ * COR021: Literal Name in a Multi-Environment Project (#1221)
6
+ *
7
+ * A project that declares two or more `environments` and binds its ownership
8
+ * marker to a build parameter (`ownership.env: { param: "env" }`) is built
9
+ * once per environment — and every physical name that does not vary with
10
+ * that parameter is the SAME name in every build. The collision is silent at
11
+ * build time and only surfaces at apply time, when the second environment's
12
+ * deploy walks over the first's resources.
13
+ *
14
+ * This rule warns on the declaration: a name-bearing property (`name`, or a
15
+ * `*Name` property like `bucketName`) whose value is a bare string literal,
16
+ * in a project shaped for per-environment builds. The fix is interpolation —
17
+ * `` `billing-${params.env}-uploads` `` — which folds to a per-environment
18
+ * literal because build parameters resolve before any file is imported
19
+ * (#1064). See the resource-naming guide's multi-environment section.
20
+ *
21
+ * Deliberately silent when:
22
+ * - no project config was threaded (a bare unit test, the LSP single-file path),
23
+ * - the project declares fewer than two environments (nothing to collide),
24
+ * - `ownership.env` is not param-bound — a literal `ownership.env` (or none)
25
+ * means the project is not doing per-environment builds from one source
26
+ * tree, so per-instance names are presumably managed another way (the
27
+ * layered-config all-in-one pattern hand-names each instance),
28
+ * - the value is anything other than a bare string literal — a template
29
+ * interpolating `params.<name>` is the fixed shape, and other non-literal
30
+ * values are EVL territory, not this rule's.
31
+ */
32
+
33
+ /** Does this property name carry a physical resource name — `name` or a camelCase `*Name`? */
34
+ function isNameBearingProperty(propName: string): boolean {
35
+ return propName === "name" || /^[a-z][A-Za-z0-9]*Name$/.test(propName);
36
+ }
37
+
38
+ /** The property's declared name, for Identifier and string-literal keys; undefined for computed keys. */
39
+ function propertyName(prop: ts.PropertyAssignment): string | undefined {
40
+ if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) return prop.name.text;
41
+ return undefined;
42
+ }
43
+
44
+ /** Whether the rule's preconditions hold: 2+ declared environments and a param-bound ownership.env. */
45
+ function projectIsMultiEnvParamBound(config: LintProjectConfig | undefined): string | undefined {
46
+ const env = config?.ownership?.env;
47
+ const paramBound = typeof env === "object" && env !== null && typeof env.param === "string";
48
+ if (!paramBound) return undefined;
49
+ if ((config?.environments?.length ?? 0) < 2) return undefined;
50
+ return env.param;
51
+ }
52
+
53
+ /** Walk an object literal (nested included, e.g. k8s `metadata: { name }`), flagging literal name-bearing values. */
54
+ function checkObjectLiteral(
55
+ obj: ts.ObjectLiteralExpression,
56
+ paramName: string,
57
+ context: LintContext,
58
+ diagnostics: LintDiagnostic[],
59
+ ): void {
60
+ for (const prop of obj.properties) {
61
+ if (!ts.isPropertyAssignment(prop)) continue;
62
+ const name = propertyName(prop);
63
+ const value = prop.initializer;
64
+ if (
65
+ name !== undefined &&
66
+ isNameBearingProperty(name) &&
67
+ (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value))
68
+ ) {
69
+ const { line, character } = context.sourceFile.getLineAndCharacterOfPosition(
70
+ value.getStart(context.sourceFile),
71
+ );
72
+ diagnostics.push({
73
+ file: context.filePath,
74
+ line: line + 1,
75
+ column: character + 1,
76
+ ruleId: "COR021",
77
+ severity: "warning",
78
+ message:
79
+ `Literal ${name}: "${value.text}" in a multi-environment project — every environment's build ` +
80
+ `produces this same physical name, so two deployed environments collide. Interpolate the env ` +
81
+ `parameter: \`${value.text}-\${params.${paramName}}\` (see the resource-naming guide's ` +
82
+ `multi-environment section).`,
83
+ });
84
+ }
85
+ if (ts.isObjectLiteralExpression(value)) {
86
+ checkObjectLiteral(value, paramName, context, diagnostics);
87
+ }
88
+ }
89
+ }
90
+
91
+ function checkNode(node: ts.Node, paramName: string, context: LintContext, diagnostics: LintDiagnostic[]): void {
92
+ if (ts.isNewExpression(node) && node.arguments && node.arguments.length > 0) {
93
+ const firstArg = node.arguments[0];
94
+ if (ts.isObjectLiteralExpression(firstArg)) {
95
+ checkObjectLiteral(firstArg, paramName, context, diagnostics);
96
+ }
97
+ }
98
+ ts.forEachChild(node, (child) => checkNode(child, paramName, context, diagnostics));
99
+ }
100
+
101
+ export const cor021EnvLiteralNameRule: LintRule = {
102
+ id: "COR021",
103
+ severity: "warning",
104
+ category: "correctness",
105
+ description:
106
+ "In a multi-environment project with a param-bound ownership.env, name-bearing properties should interpolate the env parameter, not hold a bare literal",
107
+ check(context: LintContext): LintDiagnostic[] {
108
+ const paramName = projectIsMultiEnvParamBound(context.projectConfig);
109
+ if (paramName === undefined) return [];
110
+ const diagnostics: LintDiagnostic[] = [];
111
+ checkNode(context.sourceFile, paramName, context, diagnostics);
112
+ return diagnostics;
113
+ },
114
+ };
@@ -25,6 +25,7 @@ export { evl009CompositeNoConstantRule } from "./evl009-composite-no-constant";
25
25
  export { evl010CompositeNoTransformRule } from "./evl010-composite-no-transform";
26
26
  export { cor017CompositeNameMatchRule } from "./cor017-composite-name-match";
27
27
  export { cor018CompositePreferLexiconTypeRule } from "./cor018-composite-prefer-lexicon-type";
28
+ export { cor021EnvLiteralNameRule } from "./cor021-env-literal-name";
28
29
  export { isInsideCompositeFactory } from "./composite-scope";
29
30
 
30
31
  import { flatDeclarationsRule } from "./flat-declarations";
@@ -48,9 +49,10 @@ import { evl009CompositeNoConstantRule } from "./evl009-composite-no-constant";
48
49
  import { evl010CompositeNoTransformRule } from "./evl010-composite-no-transform";
49
50
  import { cor017CompositeNameMatchRule } from "./cor017-composite-name-match";
50
51
  import { cor018CompositePreferLexiconTypeRule } from "./cor018-composite-prefer-lexicon-type";
52
+ import { cor021EnvLiteralNameRule } from "./cor021-env-literal-name";
51
53
 
52
54
  /**
53
- * Load all 21 core lint rules (COR + EVL).
55
+ * Load all 22 core lint rules (COR + EVL).
54
56
  */
55
57
  export function loadCoreRules(): LintRule[] {
56
58
  return [
@@ -75,5 +77,6 @@ export function loadCoreRules(): LintRule[] {
75
77
  evl010CompositeNoTransformRule,
76
78
  cor017CompositeNameMatchRule,
77
79
  cor018CompositePreferLexiconTypeRule,
80
+ cor021EnvLiteralNameRule,
78
81
  ];
79
82
  }
@@ -2,28 +2,16 @@ import { describe, test, expect } from "vitest";
2
2
  import {
3
3
  K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS,
4
4
  k8sListMapOrderKey,
5
+ K8S_SYSTEM_METADATA_PRUNE_PATTERNS,
5
6
  buildOwnershipSets,
6
- pruneByOwnership,
7
- type OwnershipSets,
8
7
  } from "./managed-fields";
9
- import { normalizeDeepProperties, type DeepNode } from "./deep-observation";
8
+ import { normalizeDeepProperties } from "./deep-observation";
10
9
 
11
10
  /** The naming scheme both the k8s lexicon (via `@intentius/chant-k8s-client`'s `isChantFieldManager`) and gcp restate use in their own tests. */
12
11
  function isChantManager(manager: string | undefined): boolean {
13
12
  return !!manager && (manager === "chant" || manager.startsWith("chant:"));
14
13
  }
15
14
 
16
- function node(partial: Partial<DeepNode> & Pick<DeepNode, "path" | "pattern">): DeepNode {
17
- return {
18
- entityType: "Test::Entity",
19
- key: partial.pattern,
20
- value: undefined,
21
- side: "live",
22
- counterpart: "unknown",
23
- ...partial,
24
- };
25
- }
26
-
27
15
  describe("K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS — the generic Kubernetes object envelope", () => {
28
16
  test("covers status and the server-minted metadata fields", () => {
29
17
  for (const p of [
@@ -150,36 +138,21 @@ describe("buildOwnershipSets — resolving managedFields against live and declar
150
138
  });
151
139
  });
152
140
 
153
- describe("pruneByOwnership — the shared three-question rule", () => {
154
- const sets: OwnershipSets = {
155
- chantOwned: new Set(["metadata.labels.tier"]),
156
- foreignOwned: new Set(["spec.replicas", "metadata.annotations.noise"]),
157
- foreignContested: new Set(["spec.replicas"]),
158
- owners: new Map([
159
- ["metadata.labels.tier", "chant"],
160
- ["spec.replicas", "hpa-controller"],
161
- ["metadata.annotations.noise", "kube-controller-manager"],
162
- ]),
163
- };
164
-
165
- test("never prunes the declared side", () => {
166
- expect(pruneByOwnership(node({ path: "spec.replicas", pattern: "spec.replicas", side: "declared" }), sets)).toBe(false);
167
- });
168
-
169
- test("never prunes a chant-owned path", () => {
170
- expect(pruneByOwnership(node({ path: "metadata.labels.tier", pattern: "metadata.labels.tier" }), sets)).toBe(false);
171
- });
172
-
173
- test("prunes a foreign-owned, uncontested (undeclared) path", () => {
174
- expect(pruneByOwnership(node({ path: "metadata.annotations.noise", pattern: "metadata.annotations.noise" }), sets)).toBe(true);
175
- });
176
-
177
- test("keeps a foreign-owned, contested (declared) path", () => {
178
- expect(pruneByOwnership(node({ path: "spec.replicas", pattern: "spec.replicas" }), sets)).toBe(false);
141
+ describe("K8S_SYSTEM_METADATA_PRUNE_PATTERNSwhat the controllers stamp, not what a human labels (#1191)", () => {
142
+ test("covers client-side apply bookkeeping, the Deployment controller's rollout counters and the controllers' selector labels", () => {
143
+ for (const p of [
144
+ "metadata.annotations.kubectl.kubernetes.io/last-applied-configuration",
145
+ "metadata.annotations.deployment.kubernetes.io/revision",
146
+ "metadata.labels.pod-template-hash",
147
+ "metadata.labels.controller-revision-hash",
148
+ ]) {
149
+ expect(K8S_SYSTEM_METADATA_PRUNE_PATTERNS.has(p)).toBe(true);
150
+ }
179
151
  });
180
152
 
181
- test("leaves a path with no ownership information alone (never pruned by this rule)", () => {
182
- expect(pruneByOwnership(node({ path: "spec.selector", pattern: "spec.selector" }), sets)).toBe(false);
153
+ test("an out-of-band user label is not on the list it has to reach the diff as undeclared", () => {
154
+ expect(K8S_SYSTEM_METADATA_PRUNE_PATTERNS.has("metadata.labels.team")).toBe(false);
155
+ expect(K8S_SYSTEM_METADATA_PRUNE_PATTERNS.has("metadata.annotations.kubernetes.io/change-cause")).toBe(false);
183
156
  });
184
157
  });
185
158
 
@@ -142,17 +142,18 @@ export interface OwnershipSets {
142
142
  chantOwned: ReadonlySet<string>;
143
143
  /** Paths owned by a manager that is not chant. */
144
144
  foreignOwned: ReadonlySet<string>;
145
- /** The subset of `foreignOwned` where the declared manifest also sets the path — drift-relevant despite foreign ownership. */
145
+ /** The subset of `foreignOwned` where the declared manifest also sets the path — a contested field. */
146
146
  foreignContested: ReadonlySet<string>;
147
147
  /**
148
148
  * Path → the name of the manager that owns it (#1189).
149
149
  *
150
- * The three sets above answer *which category* owns a path, which is all the
151
- * prune rule needs. A reader needs the other question — "owned by
152
- * `kubectl-client-side-apply`" and "owned by `hpa-controller`" are the same
153
- * category and mean very different things to an operator. Last writer wins
154
- * where several managers touch one path, matching what the API server itself
155
- * reports.
150
+ * The three sets above answer *which category* owns a path. A reader needs
151
+ * the other question — "owned by `kubectl-client-side-apply`" and "owned by
152
+ * `hpa-controller`" are the same category and mean very different things to
153
+ * an operator. Since chant #1191 no category is pruned from the diff, so
154
+ * this map is what tells the two apart in the report. Last writer wins
155
+ * where several managers touch one path, matching what the API server
156
+ * itself reports.
156
157
  */
157
158
  owners: ReadonlyMap<string, string>;
158
159
  }
@@ -323,25 +324,41 @@ export function buildOwnershipSets(
323
324
  }
324
325
 
325
326
  /**
326
- * The three-question managed-fields prune rule, as a predicate over a
327
- * {@link DeepNode} plus one object's precomputed {@link OwnershipSets}
328
- * shared by every lexicon layering a per-resource managed-fields prune on
329
- * top of its own static rules (k8s's `perResourceHooks`, gcp's equivalent):
327
+ * Well-known metadata the control plane writes on an object regardless of
328
+ * who applied it matched on the exact index-erased pattern, like
329
+ * {@link K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS}, but gated by the caller on
330
+ * `side === "live" && counterpart === "absent"` so a manifest that *does*
331
+ * declare one of these (a hand-pinned `change-cause`, say) is still compared.
330
332
  *
331
- * 1. Chant owns the path (any chant manager) never pruned by this rule.
332
- * 2. A different manager owns it, chant does not, and it is not declared →
333
- * controller-managed noise, pruned.
334
- * 3. It is declared, regardless of who owns it live → never pruned by this
335
- * rule, because chant's source is a statement of intent independent of
336
- * which write currently holds the field.
333
+ * This list is the noise valve that replaced the managed-fields prune
334
+ * (chant #1191). Until then a foreign-owned, undeclared path was dropped
335
+ * before the diff ever saw it, which silenced `kubectl label deploy web
336
+ * team=platform` the exact console-edit class the `undeclared` drift kind
337
+ * exists for. Now a foreign-owned path nobody declared is reported as
338
+ * `undeclared` (with its owner named, #1189) unless the accepted baseline
339
+ * already carries it — and what remains to subtract statically is only the
340
+ * handful of annotations and labels Kubernetes' own controllers stamp on
341
+ * every object of a kind. Widening this set is additive; each entry must be
342
+ * something *no* human writes out of band.
337
343
  *
338
- * Only applies to the live side — the declared tree carries no managedFields
339
- * to prune by.
344
+ * - `kubectl.kubernetes.io/last-applied-configuration` client-side apply's
345
+ * bookkeeping, a JSON copy of whatever was last applied.
346
+ * - `deployment.kubernetes.io/revision` (+ `revision-history`,
347
+ * `desired-replicas`, `max-replicas`) — the Deployment controller's
348
+ * rollout counters, written to Deployments and their ReplicaSets.
349
+ * - `pod-template-hash` / `controller-revision-hash` /
350
+ * `statefulset.kubernetes.io/pod-name` — the selector labels the
351
+ * ReplicaSet, DaemonSet and StatefulSet controllers add to what they own.
340
352
  */
341
- export function pruneByOwnership(node: DeepNode, sets: OwnershipSets): boolean {
342
- if (node.side !== "live") return false;
343
- if (sets.chantOwned.has(node.path)) return false;
344
- if (!sets.foreignOwned.has(node.path)) return false;
345
- if (sets.foreignContested.has(node.path)) return false;
346
- return true;
347
- }
353
+ export const K8S_SYSTEM_METADATA_PRUNE_PATTERNS: ReadonlySet<string> = new Set([
354
+ "metadata.annotations.kubectl.kubernetes.io/last-applied-configuration",
355
+ "metadata.annotations.deployment.kubernetes.io/revision",
356
+ "metadata.annotations.deployment.kubernetes.io/revision-history",
357
+ "metadata.annotations.deployment.kubernetes.io/desired-replicas",
358
+ "metadata.annotations.deployment.kubernetes.io/max-replicas",
359
+ "metadata.labels.pod-template-hash",
360
+ "metadata.labels.controller-revision-hash",
361
+ "metadata.labels.statefulset.kubernetes.io/pod-name",
362
+ "spec.template.metadata.labels.pod-template-hash",
363
+ "spec.template.metadata.labels.controller-revision-hash",
364
+ ]);
@@ -29,7 +29,7 @@ const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
29
29
 
30
30
  describe("normalizeObservation", () => {
31
31
  test("a bare map means 'I looked at everything'", () => {
32
- expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {}, queried: {} });
32
+ expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] });
33
33
  });
34
34
 
35
35
  test("the envelope carries both halves", () => {
@@ -38,11 +38,19 @@ describe("normalizeObservation", () => {
38
38
  resources: { a: meta() },
39
39
  unobserved: { b: { reason: "read-failed" } },
40
40
  queried: {},
41
+ notes: [],
41
42
  });
42
43
  });
43
44
 
45
+ test("run-level notes ride the envelope, distinct (#1265)", () => {
46
+ const value = observation({ a: meta() }, undefined, undefined, ["ownership unknown", "ownership unknown"]);
47
+ expect(value.notes).toEqual(["ownership unknown", "ownership unknown"]);
48
+ expect(normalizeObservation(value).notes).toEqual(["ownership unknown"]);
49
+ expect(observation({}).notes).toBeUndefined();
50
+ });
51
+
44
52
  test("undefined normalizes to empty maps", () => {
45
- expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {}, queried: {} });
53
+ expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {}, queried: {}, notes: [] });
46
54
  });
47
55
 
48
56
  test("the envelope carries the queried addresses through normalization (#1620)", () => {
@@ -84,25 +92,33 @@ describe("unobservedAll", () => {
84
92
  describe("mergeObservations (multi-stack)", () => {
85
93
  test("present beats not-observed beats absent", () => {
86
94
  const merged = mergeObservations([
87
- { resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } }, queried: {} },
88
- { resources: { a: meta() }, unobserved: {}, queried: {} },
95
+ { resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } }, queried: {}, notes: [] },
96
+ { resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] },
89
97
  ]);
90
98
  expect(Object.keys(merged.resources)).toEqual(["a"]);
91
99
  expect(Object.keys(merged.unobserved)).toEqual(["b"]);
92
100
  });
93
101
 
102
+ test("the same note from four stacks is one note (#1265)", () => {
103
+ const note = "ownership filter unavailable";
104
+ const merged = mergeObservations(
105
+ ["a", "b", "c", "d"].map((k) => ({ resources: { [k]: meta() }, unobserved: {}, queried: {}, notes: [note] })),
106
+ );
107
+ expect(merged.notes).toEqual([note]);
108
+ });
109
+
94
110
  test("an entity nobody looked for in any stack stays absent", () => {
95
111
  const merged = mergeObservations([
96
- { resources: { a: meta() }, unobserved: {}, queried: {} },
97
- { resources: { b: meta() }, unobserved: {}, queried: {} },
112
+ { resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] },
113
+ { resources: { b: meta() }, unobserved: {}, queried: {}, notes: [] },
98
114
  ]);
99
115
  expect(merged.unobserved).toEqual({});
100
116
  });
101
117
 
102
118
  test("queried addresses union across stacks (#1620)", () => {
103
119
  const merged = mergeObservations([
104
- { resources: {}, unobserved: {}, queried: { a: "stack-1/a" } },
105
- { resources: { b: meta() }, unobserved: {}, queried: { b: "stack-2/b" } },
120
+ { resources: {}, unobserved: {}, queried: { a: "stack-1/a" }, notes: [] },
121
+ { resources: { b: meta() }, unobserved: {}, queried: { b: "stack-2/b" }, notes: [] },
106
122
  ]);
107
123
  expect(merged.queried).toEqual({ a: "stack-1/a", b: "stack-2/b" });
108
124
  });
@@ -112,6 +112,24 @@ export interface ObservationResult {
112
112
  * namespace, endpoint or region was read, not the one the resource lives in.
113
113
  */
114
114
  queried?: Record<string, string>;
115
+ /**
116
+ * Notices about the read as a whole, not about any one entity (#1265) —
117
+ * "the ownership filter could not be applied on this surface" is the
118
+ * canonical one. A note is a property of the environment or the read path,
119
+ * so core says each distinct note once per run, after the answer, however
120
+ * many stacks or lexicons reported it. A lexicon returns it here instead of
121
+ * printing, so the note cannot land ahead of the rows it qualifies.
122
+ */
123
+ notes?: string[];
124
+ /**
125
+ * What the deployable unit publishes, keyed by the stack that publishes it
126
+ * (#1279) — a CloudFormation stack's outputs, for instance. Stack-level, so it
127
+ * lives here once rather than on every resource: a node's `attributes` are
128
+ * that resource's own properties, and a VPC that carried the stack's
129
+ * `expWebIp` beside no `CidrBlock` of its own was answering the wrong
130
+ * question. Values are already scrubbed of anything that looks secret.
131
+ */
132
+ stackExports?: Record<string, Record<string, unknown>>;
115
133
  }
116
134
 
117
135
  /**
@@ -126,6 +144,10 @@ export interface NormalizedObservation {
126
144
  unobserved: Record<string, UnobservedEntity>;
127
145
  /** Resolved query address per entity name (#1620). Empty when the lexicon reported none. */
128
146
  queried: Record<string, string>;
147
+ /** Run-level notices (#1265), distinct. Empty when the lexicon reported none. */
148
+ notes: string[];
149
+ /** Per-stack exports (#1279), keyed by stack name. Absent when the lexicon reported none. */
150
+ stackExports?: Record<string, Record<string, unknown>>;
129
151
  }
130
152
 
131
153
  /** True when `value` is the versioned {@link ObservationResult} envelope. */
@@ -145,12 +167,16 @@ export function observation(
145
167
  resources: Record<string, ResourceMetadata>,
146
168
  unobserved?: Record<string, UnobservedEntity>,
147
169
  queried?: Record<string, string>,
170
+ notes?: string[],
171
+ stackExports?: Record<string, Record<string, unknown>>,
148
172
  ): ObservationResult {
149
173
  return {
150
174
  observation: "v1",
151
175
  resources,
152
176
  ...(unobserved && Object.keys(unobserved).length > 0 ? { unobserved } : {}),
153
177
  ...(queried && Object.keys(queried).length > 0 ? { queried } : {}),
178
+ ...(notes && notes.length > 0 ? { notes } : {}),
179
+ ...(stackExports && Object.keys(stackExports).length > 0 ? { stackExports } : {}),
154
180
  };
155
181
  }
156
182
 
@@ -161,11 +187,17 @@ export function observation(
161
187
  * {@link unobservedAll} rather than returning nothing.
162
188
  */
163
189
  export function normalizeObservation(value: DescribeResourcesResult | undefined): NormalizedObservation {
164
- if (!value) return { resources: {}, unobserved: {}, queried: {} };
190
+ if (!value) return { resources: {}, unobserved: {}, queried: {}, notes: [] };
165
191
  if (isObservationResult(value)) {
166
- return { resources: value.resources ?? {}, unobserved: value.unobserved ?? {}, queried: value.queried ?? {} };
192
+ return {
193
+ resources: value.resources ?? {},
194
+ unobserved: value.unobserved ?? {},
195
+ queried: value.queried ?? {},
196
+ notes: [...new Set(value.notes ?? [])],
197
+ ...(value.stackExports && Object.keys(value.stackExports).length > 0 ? { stackExports: value.stackExports } : {}),
198
+ };
167
199
  }
168
- return { resources: value, unobserved: {}, queried: {} };
200
+ return { resources: value, unobserved: {}, queried: {}, notes: [] };
169
201
  }
170
202
 
171
203
  /**
@@ -205,15 +237,27 @@ export function mergeObservations(parts: Iterable<NormalizedObservation>): Norma
205
237
  const resources: Record<string, ResourceMetadata> = {};
206
238
  const unobserved: Record<string, UnobservedEntity> = {};
207
239
  const queried: Record<string, string> = {};
240
+ // A note is about the read, not a stack; four stacks saying the same thing
241
+ // is one note (#1265).
242
+ const notes = new Set<string>();
243
+ const stackExports: Record<string, Record<string, unknown>> = {};
208
244
  for (const part of parts) {
209
245
  Object.assign(resources, part.resources);
210
246
  Object.assign(unobserved, part.unobserved);
211
247
  Object.assign(queried, part.queried);
248
+ for (const n of part.notes) notes.add(n);
249
+ Object.assign(stackExports, part.stackExports ?? {});
212
250
  }
213
251
  // Present wins: a stack that could not be read does not un-observe a resource
214
252
  // another stack returned.
215
253
  for (const name of Object.keys(resources)) delete unobserved[name];
216
- return { resources, unobserved, queried };
254
+ return {
255
+ resources,
256
+ unobserved,
257
+ queried,
258
+ notes: [...notes],
259
+ ...(Object.keys(stackExports).length > 0 ? { stackExports } : {}),
260
+ };
217
261
  }
218
262
 
219
263
  /** One-line human phrasing of a reason, for CLI output. */
@@ -137,6 +137,37 @@ export const shell = (
137
137
  export const teardown = (path: string): ActivityStep =>
138
138
  activity("chantTeardown", { path }, "longInfra");
139
139
 
140
+ /**
141
+ * Tear down one environment's marker-owned resources — the durable form of
142
+ * `chant lifecycle teardown <env> --yes` (#1222). The activity runs core's
143
+ * teardown engine in-process: enumerate by ownership marker (this project's
144
+ * `ownership.stack` + `env`), delete through each lexicon's `executeTeardown`
145
+ * capability, retry failures once, and fail the step when any candidate is
146
+ * still failed. Distinct from {@link teardown}, which runs a project's own
147
+ * `npm run teardown` script.
148
+ *
149
+ * The CLI's guards apply unchanged, and a production-like environment name
150
+ * needs `confirmProd: true` in `opts` — the authored counterpart of
151
+ * `--confirm-prod`, since an Op never prompts. An ordinary {@link gate} step
152
+ * placed before this one composes as usual (steps run in authored order), so
153
+ * a human approval can precede the deletion:
154
+ *
155
+ * ```ts
156
+ * phase("Teardown", [
157
+ * gate("approve-teardown", { description: "Release the staging teardown" }),
158
+ * envTeardown("staging"),
159
+ * ]),
160
+ * ```
161
+ *
162
+ * `opts` also accepts `path` (the chant project directory, default the
163
+ * worker's cwd). Defaults to the `longInfra` profile (override via
164
+ * `opts.profile`).
165
+ */
166
+ export const envTeardown = (env: string, opts?: Record<string, unknown>): ActivityStep => {
167
+ const { args, profile } = takeProfile(opts);
168
+ return activity("envTeardown", { env, ...args }, profile ?? "longInfra");
169
+ };
170
+
140
171
  /**
141
172
  * Create a local k3d cluster (vanilla Kubernetes in Docker). Idempotent: skips
142
173
  * creation if a cluster of the same name already exists. Defaults to the
@@ -279,8 +310,8 @@ export const azDelete = (templatePath: string, opts?: Record<string, unknown>):
279
310
  * Deploy a built CloudFormation template by calling the CloudFormation API
280
311
  * directly (create-or-update + poll) — the direct twin of {@link azApply} /
281
312
  * {@link gcpApply} for AWS, targeting a local Floci emulator or real AWS by
282
- * endpoint override. Speaks the CFN API over HTTP rather than shelling `aws`
283
- * (that path is still `nativeApply({ target: "cloudformation" })`). Provided by
313
+ * endpoint override. Speaks the CFN API over HTTP rather than shelling `aws`
314
+ * `nativeApply({ target: "cloudformation" })` routes here too (#1449). Provided by
284
315
  * the aws lexicon; loaded when the project lists `aws`. Defaults to the
285
316
  * `longInfra` profile. `opts` requires `stackName`; accepts `endpoint`, `region`,
286
317
  * `capabilities`, `timeoutMs`, `intervalMs`.
@@ -297,13 +328,15 @@ export const awsDelete = (templatePath: string, opts?: Record<string, unknown>):
297
328
  };
298
329
 
299
330
  /**
300
- * Apply chant's built GCP (CNRM) resources directly to the GCS REST API,
331
+ * Apply chant's built GCP (CNRM) resources directly to their GCP REST APIs,
301
332
  * targeting a local floci-gcp emulator or real GCP by endpoint override — the
302
- * native GCP applier (#706 starter #711), currently handling `StorageBucket`.
303
- * Defaults to the `longInfra` profile (override via `opts.profile`).
333
+ * native GCP applier (#706 starter #711). `nativeApply({ target: "gcp" })`
334
+ * routes here too (#1449). Defaults to the `longInfra` profile (override via
335
+ * `opts.profile`).
304
336
  *
305
- * `opts` accepts `endpoint` (default `STORAGE_EMULATOR_HOST` env / real GCS) and
306
- * `project` (default `GOOGLE_CLOUD_PROJECT` env / the CNRM project-id annotation).
337
+ * `opts` accepts `endpoint` (default `GCP_ENDPOINT_URL` env / each kind's
338
+ * real-GCP host) and `project` (default `GOOGLE_CLOUD_PROJECT` env / the CNRM
339
+ * project-id annotation).
307
340
  */
308
341
  export const gcpApply = (manifestPath: string, opts?: Record<string, unknown>): ActivityStep => {
309
342
  const { args, profile } = takeProfile(opts);
package/src/op/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Op, phase, activity, gate, build, kubectlApply, helmInstall, waitForStack, waitForReady,
2
- gitlabPipeline, lifecycleSnapshot, shell, teardown, k3dUp, k3dDown, flociUp, flociDown,
2
+ gitlabPipeline, lifecycleSnapshot, shell, teardown, envTeardown, k3dUp, k3dDown, flociUp, flociDown,
3
3
  flociAzUp, flociAzDown, flociGcpUp, flociGcpDown, httpCheck,
4
4
  azGroupEnsure, azGroupDelete, azApply, azDelete, awsApply, awsDelete, gcpApply, gcpDelete, policyGate,
5
5
  spriteCreate, spriteExec, spriteCheckpoint, spriteRestore, listCheckpoints, spriteDestroy,
@@ -9,7 +9,12 @@ import {
9
9
  OWNERSHIP_MANAGED_BY_VALUE,
10
10
  type ChannelKeys,
11
11
  } from "./ownership";
12
- import { resolveOwnershipMarker } from "./config";
12
+ import {
13
+ resolveOwnershipMarker,
14
+ resolveOwnershipEnv,
15
+ resolveOwnershipStack,
16
+ ownershipEnvDisagreement,
17
+ } from "./config";
13
18
 
14
19
  // A stand-in for a lexicon-provided convention (e.g. AWS's colon keys), to
15
20
  // prove the core helpers are generic over any `ChannelKeys` — the per-provider
@@ -105,3 +110,60 @@ describe("resolveOwnershipMarker (config opt-in)", () => {
105
110
  expect(resolveOwnershipMarker({ ownership: { stack: "billing", enabled: false } })).toBeUndefined();
106
111
  });
107
112
  });
113
+
114
+ describe("ownership.env as a build-parameter reference (#1396)", () => {
115
+ const config = {
116
+ ownership: { stack: "fountain", env: { param: "env" } },
117
+ buildParams: { env: { type: "string" as const, default: "dev", env: "FOUNTAIN_ENV" } },
118
+ };
119
+
120
+ test("takes the resolved parameter value — --param env=prod drives the marker", () => {
121
+ expect(resolveOwnershipMarker(config, [{ name: "env", value: "prod", source: "cli" }])).toEqual({
122
+ stack: "fountain",
123
+ env: "prod",
124
+ });
125
+ expect(resolveOwnershipMarker(config, [{ name: "env", value: "dev", source: "default" }])).toEqual({
126
+ stack: "fountain",
127
+ env: "dev",
128
+ });
129
+ });
130
+
131
+ test("a reference to an undeclared parameter is an error, not an env-less marker", () => {
132
+ expect(() => resolveOwnershipMarker({ ownership: { stack: "s", env: { param: "env" } } }, [])).toThrow(
133
+ /does not declare/,
134
+ );
135
+ });
136
+
137
+ test("a reference to a parameter that resolved to nothing is an error", () => {
138
+ expect(() => resolveOwnershipMarker(config, [])).toThrow(
139
+ /resolved to no value.*--param env=<value>.*FOUNTAIN_ENV/,
140
+ );
141
+ });
142
+
143
+ test("the stack-only resolver needs no parameters", () => {
144
+ expect(resolveOwnershipStack(config)).toBe("fountain");
145
+ expect(resolveOwnershipStack({ ownership: { stack: "s", enabled: false } })).toBeUndefined();
146
+ });
147
+
148
+ test("a literal env is returned untouched, with or without parameters", () => {
149
+ expect(resolveOwnershipEnv({ ownership: { stack: "s", env: "prod" } }, undefined)).toBe("prod");
150
+ expect(resolveOwnershipEnv({ ownership: { stack: "s" } }, [])).toBeUndefined();
151
+ });
152
+
153
+ test("a literal env disagreeing with an env parameter is reported", () => {
154
+ const literal = {
155
+ ownership: { stack: "fountain", env: "dev" },
156
+ buildParams: { env: { type: "string" as const, default: "dev" } },
157
+ };
158
+ expect(ownershipEnvDisagreement(literal, [{ name: "env", value: "dev", source: "default" }])).toBeUndefined();
159
+ expect(ownershipEnvDisagreement(literal, [{ name: "env", value: "prod", source: "cli" }])).toMatch(
160
+ /ownership\.env is "dev" but the env build parameter resolved to "prod" \(cli\)/,
161
+ );
162
+ // A reference never disagrees — there is one source.
163
+ expect(ownershipEnvDisagreement(config, [{ name: "env", value: "prod", source: "cli" }])).toBeUndefined();
164
+ // Marking off — nothing is stamped, nothing to disagree with.
165
+ expect(
166
+ ownershipEnvDisagreement({ ownership: { env: "dev" } }, [{ name: "env", value: "prod", source: "cli" }]),
167
+ ).toBeUndefined();
168
+ });
169
+ });