@intentius/chant 0.52.1 → 0.53.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 (149) hide show
  1. package/dist/agents/checks.d.ts +35 -0
  2. package/dist/agents/checks.d.ts.map +1 -0
  3. package/dist/agents/discover.d.ts +86 -0
  4. package/dist/agents/discover.d.ts.map +1 -0
  5. package/dist/agents/importer.d.ts +46 -0
  6. package/dist/agents/importer.d.ts.map +1 -0
  7. package/dist/agents/index.d.ts +14 -0
  8. package/dist/agents/index.d.ts.map +1 -0
  9. package/dist/agents/types.d.ts +196 -0
  10. package/dist/agents/types.d.ts.map +1 -0
  11. package/dist/audit/catalog.d.ts +4 -1
  12. package/dist/audit/catalog.d.ts.map +1 -1
  13. package/dist/audit/report.d.ts +8 -0
  14. package/dist/audit/report.d.ts.map +1 -1
  15. package/dist/audit/rules-doc.d.ts.map +1 -1
  16. package/dist/cdk/advise.d.ts +29 -0
  17. package/dist/cdk/advise.d.ts.map +1 -0
  18. package/dist/cdk/assembly.d.ts +38 -0
  19. package/dist/cdk/assembly.d.ts.map +1 -0
  20. package/dist/cdk/graph.d.ts +68 -0
  21. package/dist/cdk/graph.d.ts.map +1 -0
  22. package/dist/cdk/tier-map.d.ts +44 -0
  23. package/dist/cdk/tier-map.d.ts.map +1 -0
  24. package/dist/cdk/types.d.ts +114 -0
  25. package/dist/cdk/types.d.ts.map +1 -0
  26. package/dist/cli/commands/audit-agents.d.ts +83 -0
  27. package/dist/cli/commands/audit-agents.d.ts.map +1 -0
  28. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  29. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  30. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  31. package/dist/cli/commands/carve.d.ts +48 -6
  32. package/dist/cli/commands/carve.d.ts.map +1 -1
  33. package/dist/cli/commands/import-agents.d.ts +64 -0
  34. package/dist/cli/commands/import-agents.d.ts.map +1 -0
  35. package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
  36. package/dist/cli/handlers/carve.d.ts +5 -4
  37. package/dist/cli/handlers/carve.d.ts.map +1 -1
  38. package/dist/cli/handlers/lifecycle.d.ts +10 -0
  39. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  40. package/dist/cli/handlers/misc.d.ts.map +1 -1
  41. package/dist/cli/main.d.ts.map +1 -1
  42. package/dist/cli/registry.d.ts +15 -0
  43. package/dist/cli/registry.d.ts.map +1 -1
  44. package/dist/identity.d.ts +196 -0
  45. package/dist/identity.d.ts.map +1 -0
  46. package/dist/index.d.ts +1 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/lexicon.d.ts +52 -0
  49. package/dist/lexicon.d.ts.map +1 -1
  50. package/dist/terraform/adopt-state.d.ts +17 -63
  51. package/dist/terraform/adopt-state.d.ts.map +1 -1
  52. package/dist/terraform/aws-resources.d.ts +1 -1
  53. package/dist/terraform/bridge.d.ts.map +1 -1
  54. package/dist/terraform/carve-provider.d.ts +142 -0
  55. package/dist/terraform/carve-provider.d.ts.map +1 -0
  56. package/dist/terraform/carve.d.ts +36 -3
  57. package/dist/terraform/carve.d.ts.map +1 -1
  58. package/dist/terraform/emit-source.d.ts +25 -0
  59. package/dist/terraform/emit-source.d.ts.map +1 -0
  60. package/dist/terraform/graduate.d.ts +11 -1
  61. package/dist/terraform/graduate.d.ts.map +1 -1
  62. package/dist/terraform/providers/aws.d.ts +19 -0
  63. package/dist/terraform/providers/aws.d.ts.map +1 -0
  64. package/dist/terraform/providers/gcp.d.ts +41 -0
  65. package/dist/terraform/providers/gcp.d.ts.map +1 -0
  66. package/dist/terraform/providers/index.d.ts +15 -0
  67. package/dist/terraform/providers/index.d.ts.map +1 -0
  68. package/dist/terraform/providers/kubernetes.d.ts +29 -0
  69. package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
  70. package/dist/terraform/score.d.ts +70 -4
  71. package/dist/terraform/score.d.ts.map +1 -1
  72. package/dist/terraform/tier-map.d.ts +38 -26
  73. package/dist/terraform/tier-map.d.ts.map +1 -1
  74. package/dist/terraform/types.d.ts +6 -0
  75. package/dist/terraform/types.d.ts.map +1 -1
  76. package/dist/yaml.d.ts.map +1 -1
  77. package/package.json +6 -1
  78. package/src/agents/checks.test.ts +228 -0
  79. package/src/agents/checks.ts +429 -0
  80. package/src/agents/discover.test.ts +310 -0
  81. package/src/agents/discover.ts +939 -0
  82. package/src/agents/importer.ts +49 -0
  83. package/src/agents/index.ts +29 -0
  84. package/src/agents/types.ts +207 -0
  85. package/src/audit/catalog.ts +90 -1
  86. package/src/audit/report.ts +9 -1
  87. package/src/audit/rules-doc.ts +6 -0
  88. package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
  89. package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
  90. package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
  91. package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
  92. package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
  93. package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
  94. package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
  95. package/src/cdk/advise.test.ts +208 -0
  96. package/src/cdk/advise.ts +44 -0
  97. package/src/cdk/assembly.ts +133 -0
  98. package/src/cdk/graph.test.ts +206 -0
  99. package/src/cdk/graph.ts +525 -0
  100. package/src/cdk/tier-map.ts +71 -0
  101. package/src/cdk/types.ts +115 -0
  102. package/src/cli/commands/audit-agents.test.ts +260 -0
  103. package/src/cli/commands/audit-agents.ts +387 -0
  104. package/src/cli/commands/carve-apply.ts +20 -4
  105. package/src/cli/commands/carve-bridge.test.ts +30 -0
  106. package/src/cli/commands/carve-bridge.ts +24 -2
  107. package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
  108. package/src/cli/commands/carve-emit-provider.test.ts +207 -0
  109. package/src/cli/commands/carve-emit.test.ts +72 -1
  110. package/src/cli/commands/carve-emit.ts +55 -28
  111. package/src/cli/commands/carve.ts +139 -36
  112. package/src/cli/commands/import-agents.test.ts +208 -0
  113. package/src/cli/commands/import-agents.ts +196 -0
  114. package/src/cli/handlers/carve-emit.ts +8 -1
  115. package/src/cli/handlers/carve.ts +8 -7
  116. package/src/cli/handlers/lifecycle.test.ts +187 -1
  117. package/src/cli/handlers/lifecycle.ts +125 -1
  118. package/src/cli/handlers/misc.ts +111 -0
  119. package/src/cli/main.ts +29 -5
  120. package/src/cli/registry.ts +15 -0
  121. package/src/identity.test.ts +199 -0
  122. package/src/identity.ts +346 -0
  123. package/src/index.ts +1 -0
  124. package/src/lexicon.ts +65 -0
  125. package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
  126. package/src/terraform/adopt-state.test.ts +131 -0
  127. package/src/terraform/adopt-state.ts +22 -167
  128. package/src/terraform/aws-resources.test.ts +55 -16
  129. package/src/terraform/aws-resources.ts +1 -1
  130. package/src/terraform/bridge.test.ts +12 -0
  131. package/src/terraform/bridge.ts +4 -1
  132. package/src/terraform/carve-provider.test.ts +155 -0
  133. package/src/terraform/carve-provider.ts +237 -0
  134. package/src/terraform/carve.test.ts +55 -1
  135. package/src/terraform/carve.ts +0 -0
  136. package/src/terraform/emit-source.ts +39 -0
  137. package/src/terraform/graduate.test.ts +37 -0
  138. package/src/terraform/graduate.ts +55 -7
  139. package/src/terraform/graph.ts +3 -3
  140. package/src/terraform/providers/aws.ts +169 -0
  141. package/src/terraform/providers/gcp.test.ts +228 -0
  142. package/src/terraform/providers/gcp.ts +329 -0
  143. package/src/terraform/providers/index.ts +21 -0
  144. package/src/terraform/providers/kubernetes.ts +224 -0
  145. package/src/terraform/score.ts +111 -25
  146. package/src/terraform/tier-map.ts +55 -90
  147. package/src/terraform/types.ts +6 -0
  148. package/src/yaml.test.ts +54 -0
  149. package/src/yaml.ts +24 -3
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The carve provider seam (#2016).
3
+ *
4
+ * `carve advise|emit|bridge` used to reach into the AWS carve table directly:
5
+ * the tier map derived from it, the emit gate was `awsCarveType(t) !== undefined`,
6
+ * the adopter called `applyAwsMapper`, and the emitted project's lexicon was a
7
+ * constant. A second provider — Kubernetes emit (#999), GCP (#2017) — had
8
+ * nowhere to land except a parallel path beside the AWS one.
9
+ *
10
+ * A provider owns one or more Terraform type prefixes and answers everything
11
+ * the carve commands ask about the types under them: how each ranks against a
12
+ * native spec, which HCL attribute carries the physical name, which
13
+ * sub-resources fold into a parent, which types emit can produce source for,
14
+ * and how to adopt one from `.tfstate`.
15
+ *
16
+ * ── Why the registry lives in core, not behind a `LexiconPlugin` capability ──
17
+ *
18
+ * `carve advise` runs against a Terraform directory that is not a chant project:
19
+ * there is no `chant.config.ts` naming lexicons, so there is nothing to load
20
+ * plugins from, and `carve emit --state` is deliberately offline (see the
21
+ * handler). Behind a plugin capability both would have to load plugins to learn
22
+ * that a type ranks tier 1. Providers are also Terraform-provider knowledge
23
+ * (`terraform-provider-aws` attribute names), which belongs beside the HCL
24
+ * parser rather than inside a lexicon.
25
+ *
26
+ * `registerCarveProvider` is exported, so a plugin that wants to contribute a
27
+ * provider at runtime still can — the seam does not foreclose it, it just does
28
+ * not require plugin loading for the paths that run without one.
29
+ */
30
+
31
+ import { BUILTIN_CARVE_PROVIDERS } from "./providers";
32
+ import type { StateResource } from "./state";
33
+
34
+ export interface TierInfo {
35
+ tier: 1 | 2 | 3;
36
+ /** The native spec type a carve would target, for the report. */
37
+ mapsTo: string;
38
+ }
39
+
40
+ /**
41
+ * One deferred outbound input turned into a build parameter (#998). Derived
42
+ * from the boundary report's outbound edges + the state's resolved attributes
43
+ * in `carve-emit.ts`; consumed by a provider's `adopt` (source substitution)
44
+ * and by the scaffold (`chant.config.ts` `buildParams` declaration).
45
+ */
46
+ export interface DeferredParam {
47
+ /** Build-parameter name, source-referenceable (`params.<name>`). */
48
+ name: string;
49
+ /** The carved resource's own Terraform attribute the value enters through. */
50
+ tfAttr: string;
51
+ /** The survivor the Terraform source read, e.g. `aws_vpc.main`. */
52
+ survivor: string;
53
+ /** Survivor attribute(s) read, e.g. `["id"]`. */
54
+ attrs: string[];
55
+ /** The state-resolved value — the parameter's declared default. */
56
+ default?: string | number | boolean;
57
+ }
58
+
59
+ /** What one folded sub-resource contributed to the parent's emitted props (#1637). */
60
+ export interface FoldedContribution {
61
+ /** The sub-resource's Terraform address, e.g. `aws_s3_bucket_versioning.assets`. */
62
+ address: string;
63
+ /** Native properties it added to the parent, e.g. `["VersioningConfiguration"]`. */
64
+ props: string[];
65
+ }
66
+
67
+ export interface AdoptedSource {
68
+ fileName: string;
69
+ content: string;
70
+ /** True when at least one attribute was mapped to a native prop. */
71
+ mapped: boolean;
72
+ nativeType: string;
73
+ /** Deferred params actually substituted into the emitted props (#998). */
74
+ parameterized: string[];
75
+ /** Folded sub-resources and the props each one joined into the parent (#1637). */
76
+ folded: FoldedContribution[];
77
+ }
78
+
79
+ export interface CarveProvider {
80
+ /** Registry id, unique. Registering a second provider under it replaces the first. */
81
+ readonly name: string;
82
+ /**
83
+ * Terraform type prefixes this provider owns, e.g. `["aws_"]`. Resolution
84
+ * takes the longest match, so a narrower provider can claim a subset of a
85
+ * wider one's prefix.
86
+ */
87
+ readonly tfTypePrefixes: readonly string[];
88
+ /**
89
+ * The chant lexicon emitted source targets — the scaffolded project's
90
+ * dependency and `--lexicon` flag, and the lexicon the live import runs
91
+ * against.
92
+ */
93
+ readonly lexicon: string;
94
+ /** Terraform type → native tier, contributed to the advisor's tier map. */
95
+ readonly tiers: Readonly<Record<string, TierInfo>>;
96
+ /**
97
+ * Terraform type → the HCL attribute carrying the physical name. A dotted
98
+ * entry is a path into nested blocks (`manifest.metadata.name`); the bridge
99
+ * refuses those, since a data-source body is flat `attr = value`.
100
+ */
101
+ readonly identityAttrs?: Readonly<Record<string, string>>;
102
+ /**
103
+ * Sub-resource Terraform type → parent type, for types Terraform splits out
104
+ * of the resource the native spec keeps them in.
105
+ */
106
+ readonly foldsInto?: Readonly<Record<string, string>>;
107
+ /**
108
+ * Terraform types `carve emit` can produce chant source for — narrower than
109
+ * `tiers`, which advise also ranks types no emit path can adopt. Absent or
110
+ * empty means advise-only. Declaring types here obliges the provider to
111
+ * implement {@link adopt}: both emit paths gate on the same list (#2015), so
112
+ * a type the live path accepts must be adoptable from state too.
113
+ */
114
+ readonly emitTypes?: readonly string[];
115
+ /** Adopt a resource from Terraform state into chant source. */
116
+ adopt?(resource: StateResource, params: DeferredParam[], folded: StateResource[]): AdoptedSource | null;
117
+ /**
118
+ * The native selector type the live (cloud→code) import filters on, e.g.
119
+ * `AWS::S3::Bucket`. Undefined when this type has no live adoption path —
120
+ * `carve emit --env` refuses it rather than importing the wrong thing.
121
+ */
122
+ liveSelectorType?(tfType: string): string | undefined;
123
+ }
124
+
125
+ /**
126
+ * A provider declaring emit types without an adopter would accept a type on
127
+ * `--env` and fail it on `--state`, the advise↔emit cliff #2015 closed.
128
+ */
129
+ function validate(provider: CarveProvider): void {
130
+ if (provider.emitTypes?.length && !provider.adopt) {
131
+ throw new Error(`Carve provider "${provider.name}" declares emitTypes but no adopt() — the --state path would refuse what --env accepts.`);
132
+ }
133
+ if (!provider.tfTypePrefixes.length) {
134
+ throw new Error(`Carve provider "${provider.name}" claims no Terraform type prefix, so no type would ever resolve to it.`);
135
+ }
136
+ }
137
+
138
+ const providers: CarveProvider[] = [...BUILTIN_CARVE_PROVIDERS];
139
+ for (const p of providers) validate(p);
140
+
141
+ /** Bumped on every registry mutation, so the derived indexes rebuild. */
142
+ let revision = 0;
143
+
144
+ /**
145
+ * Register a carve provider. Returns a function that removes it again, so a
146
+ * test (or a plugin unloading) leaves the registry as it found it.
147
+ */
148
+ export function registerCarveProvider(provider: CarveProvider): () => void {
149
+ validate(provider);
150
+ const existing = providers.findIndex((p) => p.name === provider.name);
151
+ if (existing >= 0) providers.splice(existing, 1);
152
+ providers.push(provider);
153
+ revision++;
154
+ return () => {
155
+ const at = providers.indexOf(provider);
156
+ if (at < 0) return;
157
+ providers.splice(at, 1);
158
+ revision++;
159
+ };
160
+ }
161
+
162
+ /** Every registered provider, in registration order. */
163
+ export function carveProviders(): readonly CarveProvider[] {
164
+ return providers;
165
+ }
166
+
167
+ interface RegistryIndex {
168
+ /** Prefixes longest-first, so the narrowest claim wins. */
169
+ prefixes: Array<{ prefix: string; provider: CarveProvider }>;
170
+ tiers: Record<string, TierInfo>;
171
+ identityAttrs: Record<string, string>;
172
+ foldsInto: Record<string, string>;
173
+ /** Terraform type → the provider that emits it. */
174
+ emitters: Map<string, CarveProvider>;
175
+ }
176
+
177
+ let cached: { revision: number; index: RegistryIndex } | null = null;
178
+
179
+ function index(): RegistryIndex {
180
+ if (cached && cached.revision === revision) return cached.index;
181
+ const built: RegistryIndex = {
182
+ prefixes: [],
183
+ tiers: {},
184
+ identityAttrs: {},
185
+ foldsInto: {},
186
+ emitters: new Map(),
187
+ };
188
+ for (const provider of providers) {
189
+ for (const prefix of provider.tfTypePrefixes) built.prefixes.push({ prefix, provider });
190
+ Object.assign(built.tiers, provider.tiers);
191
+ if (provider.identityAttrs) Object.assign(built.identityAttrs, provider.identityAttrs);
192
+ if (provider.foldsInto) Object.assign(built.foldsInto, provider.foldsInto);
193
+ for (const tfType of provider.emitTypes ?? []) built.emitters.set(tfType, provider);
194
+ }
195
+ built.prefixes.sort((a, b) => b.prefix.length - a.prefix.length);
196
+ cached = { revision, index: built };
197
+ return built;
198
+ }
199
+
200
+ /** The provider owning this Terraform type, by longest prefix match. */
201
+ export function resolveCarveProvider(tfType: string): CarveProvider | undefined {
202
+ return index().prefixes.find((p) => tfType.startsWith(p.prefix))?.provider;
203
+ }
204
+
205
+ /**
206
+ * The provider that can emit chant source for this Terraform type. Narrower
207
+ * than {@link resolveCarveProvider}: a provider owns every type under its
208
+ * prefix for advise, and only the ones on its `emitTypes` list for emit.
209
+ */
210
+ export function resolveEmitProvider(tfType: string): CarveProvider | undefined {
211
+ return index().emitters.get(tfType);
212
+ }
213
+
214
+ /** Every Terraform type any registered provider can emit, sorted, for user-facing hints. */
215
+ export function carveEmitTypes(): string[] {
216
+ return [...index().emitters.keys()].sort();
217
+ }
218
+
219
+ /** The lexicons emit can adopt into, sorted — what the CLI loads plugins for. */
220
+ export function carveEmitLexicons(): string[] {
221
+ return [...new Set([...index().emitters.values()].map((p) => p.lexicon))].sort();
222
+ }
223
+
224
+ /** Terraform resource type → native tier, merged over every provider. Read-only: it is the cached index. */
225
+ export function carveTierMap(): Readonly<Record<string, TierInfo>> {
226
+ return index().tiers;
227
+ }
228
+
229
+ /** The HCL attribute carrying this type's physical name, if its provider declares one. */
230
+ export function carveIdentityAttr(tfType: string): string | undefined {
231
+ return index().identityAttrs[tfType];
232
+ }
233
+
234
+ /** The parent Terraform type this sub-resource folds into, if any. */
235
+ export function carveFoldParent(tfType: string): string | undefined {
236
+ return index().foldsInto[tfType];
237
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { buildFixtureGraph } from "./__fixtures__/build-graph";
3
- import { resolveCarveSet, boundaryReport } from "./carve";
3
+ import { resolveCarveSet, boundaryReport, dedupeEdges, type BoundaryEdge } from "./carve";
4
4
  import type { Hcl2JsonTree } from "./types";
5
5
 
6
6
  // #197 worked example: a bucket a Lambda reads, with a versioning sub-resource.
@@ -131,3 +131,57 @@ describe("boundaryReport", () => {
131
131
  expect(boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.nope")).toBeNull();
132
132
  });
133
133
  });
134
+
135
+ // #2025: dedupeEdges joins survivor/carved with a separator no Terraform
136
+ // address can contain. A survivor/carved pair with no separator at all would
137
+ // collide across the two edges below (both concatenate to the same string),
138
+ // so this pins the separator's job, not just the byte that used to be a NUL.
139
+ describe("dedupeEdges", () => {
140
+ const edge = (over: Partial<BoundaryEdge>): BoundaryEdge => ({
141
+ direction: "inbound",
142
+ survivor: "a",
143
+ carved: "b",
144
+ attrs: [],
145
+ via: [],
146
+ bridge: "tf-data-source",
147
+ required: "immediately",
148
+ ...over,
149
+ });
150
+
151
+ test("does not merge distinct pairs whose plain concatenation collides", () => {
152
+ const edges = [
153
+ edge({ survivor: "aws_instance.ab", carved: "aws_s3_bucket.x", attrs: ["arn"] }),
154
+ edge({ survivor: "aws_instance.a", carved: "baws_s3_bucket.x", attrs: ["id"] }),
155
+ ];
156
+ // Both concatenate (survivor + carved, no separator) to the same string.
157
+ expect(edges[0].survivor + edges[0].carved).toBe(edges[1].survivor + edges[1].carved);
158
+
159
+ const result = dedupeEdges(edges);
160
+ expect(result).toHaveLength(2);
161
+ // Sorted by survivor: "aws_instance.a" sorts before "aws_instance.ab".
162
+ expect(result.map((e) => e.attrs)).toEqual([["id"], ["arn"]]);
163
+ });
164
+
165
+ test("merges edges that share the same survivor/carved pair, unioning attrs and via", () => {
166
+ const edges = [
167
+ edge({ survivor: "aws_lambda_function.api", carved: "aws_s3_bucket.assets", attrs: ["arn"], via: ["env"] }),
168
+ edge({
169
+ survivor: "aws_lambda_function.api",
170
+ carved: "aws_s3_bucket.assets",
171
+ attrs: ["bucket"],
172
+ via: ["tags"],
173
+ }),
174
+ ];
175
+ const result = dedupeEdges(edges);
176
+ expect(result).toHaveLength(1);
177
+ expect(result[0].attrs).toEqual(["arn", "bucket"]);
178
+ expect(result[0].via).toEqual(["env", "tags"]);
179
+ });
180
+
181
+ test("the separator is outside the alphabet a Terraform address component can use", () => {
182
+ // Mirrors graph.ts's NAME (`/^[A-Za-z_][A-Za-z0-9_-]*$/`), the grammar an
183
+ // address segment must match, plus the literal `.` that joins segments.
184
+ const addressChars = /^[A-Za-z0-9_.-]*$/;
185
+ expect(addressChars.test("")).toBe(false);
186
+ });
187
+ });
Binary file
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Source rendering shared by constructor-shaped carve providers (#2016).
3
+ *
4
+ * A provider that adopts into a typed chant constructor call — aws today, gcp
5
+ * next (#2017) — renders the same literal: an import, an exported `new Ctor({
6
+ * ...props })`, and a reference comment carrying whatever the mapping could not
7
+ * place. Only the lexicon import path and the property mapping differ, so the
8
+ * rendering lives here rather than being copied per provider.
9
+ *
10
+ * A provider whose native shape is not a constructor call (a Kubernetes
11
+ * manifest, #999) renders its own source and does not use this.
12
+ */
13
+
14
+ /** The core subpath the emitted source reads build parameters from. */
15
+ export const PARAMS_IMPORT = "@intentius/chant/params";
16
+
17
+ /** Marker for a prop whose value is a `params.<name>` reference, not a literal. */
18
+ export class ParamRef {
19
+ constructor(
20
+ readonly name: string,
21
+ readonly type: "string" | "number" | "boolean",
22
+ ) {}
23
+ }
24
+
25
+ /** Render a JS object literal with stable key order and given indent. */
26
+ export function renderObject(obj: Record<string, unknown>, indent: number): string {
27
+ const keys = Object.keys(obj);
28
+ if (keys.length === 0) return "{}";
29
+ const pad = " ".repeat(indent);
30
+ const body = keys
31
+ .map((k) => `${pad}${k}: ${renderValue(obj[k])},`)
32
+ .join("\n");
33
+ return `{\n${body}\n}`;
34
+ }
35
+
36
+ /** A prop value: a `params.<name>` reference (cast keeps the project tsc-clean) or a literal. */
37
+ export function renderValue(value: unknown): string {
38
+ return value instanceof ParamRef ? `params.${value.name} as ${value.type}` : JSON.stringify(value);
39
+ }
@@ -2,6 +2,7 @@ import { describe, test, expect } from "vitest";
2
2
  import { buildFixtureGraph } from "./__fixtures__/build-graph";
3
3
  import { boundaryReport } from "./carve";
4
4
  import { graduationPlan, stampOwnershipIntoSource, DEFAULT_TAG_OWNERSHIP_KEYS } from "./graduate";
5
+ import { LABEL_OWNERSHIP_KEYS } from "../ownership";
5
6
  import type { Hcl2JsonTree } from "./types";
6
7
 
7
8
  const bucketTree: Hcl2JsonTree = {
@@ -40,6 +41,23 @@ describe("graduationPlan", () => {
40
41
  expect(runbook).toMatch(/lifecycle diff --live/);
41
42
  });
42
43
 
44
+ test("a k8s carve graduates in labels and kubectl, not tags and CloudFormation (#999)", () => {
45
+ const report = boundaryReport(buildFixtureGraph(bucketTree), "aws_s3_bucket.assets")!;
46
+ const plan = graduationPlan(report, { stack: "assets", env: "prod", lexicon: "k8s" });
47
+
48
+ // The channel the k8s serializer actually merges in, not the AWS tag keys.
49
+ expect(plan.markerKind).toBe("labels");
50
+ expect(plan.ownershipTags).toEqual({
51
+ [LABEL_OWNERSHIP_KEYS.managedBy]: "chant",
52
+ [LABEL_OWNERSHIP_KEYS.stack]: "assets",
53
+ [LABEL_OWNERSHIP_KEYS.env]: "prod",
54
+ });
55
+ const runbook = plan.steps.join("\n");
56
+ expect(runbook).toContain("kubectl apply -f");
57
+ expect(runbook).toContain("--lexicon k8s");
58
+ expect(runbook).not.toContain("cloudformation deploy");
59
+ });
60
+
43
61
  test("warns when outbound edges leave deferred inputs to wire", () => {
44
62
  // Carve the Lambda: it depends on the bucket (survivor) → outbound/deferred.
45
63
  const report = boundaryReport(buildFixtureGraph(bucketTree), "aws_lambda_function.api")!;
@@ -113,4 +131,23 @@ describe("stampOwnershipIntoSource", () => {
113
131
  test("returns null when there is no constructor to stamp", () => {
114
132
  expect(stampOwnershipIntoSource("// nothing here\n", TAGS)).toBeNull();
115
133
  });
134
+
135
+ test("refuses a carved manifest rather than inventing a top-level Tags field (#999)", () => {
136
+ // The props of a `k8sManifest` call ARE the object. A `Tags: [...]` line
137
+ // spliced before the closing `});` would be a field the API server never
138
+ // heard of, applied as if an author had written it.
139
+ const src = [
140
+ 'import { k8sManifest } from "@intentius/chant-lexicon-k8s";',
141
+ "",
142
+ "export const app_config = k8sManifest({",
143
+ ' apiVersion: "v1",',
144
+ ' kind: "ConfigMap",',
145
+ " metadata: {",
146
+ ' name: "app-config",',
147
+ " },",
148
+ "});",
149
+ "",
150
+ ].join("\n");
151
+ expect(stampOwnershipIntoSource(src, TAGS)).toBeNull();
152
+ });
116
153
  });
@@ -14,7 +14,7 @@
14
14
  * emitted source now carries.
15
15
  */
16
16
 
17
- import { ownershipEntries, type ChannelKeys, type OwnershipMarker } from "../ownership";
17
+ import { ownershipEntries, LABEL_OWNERSHIP_KEYS, type ChannelKeys, type OwnershipMarker } from "../ownership";
18
18
  import type { CarveReport } from "./carve";
19
19
 
20
20
  /**
@@ -32,8 +32,10 @@ export const DEFAULT_TAG_OWNERSHIP_KEYS: ChannelKeys = {
32
32
  export interface GraduationPlan {
33
33
  target: string;
34
34
  marker: OwnershipMarker;
35
- /** The ownership tags the emitted resource must carry to be chant-owned. */
35
+ /** The ownership entries the emitted resource must carry to be chant-owned. */
36
36
  ownershipTags: Record<string, string>;
37
+ /** What that channel is called on this target: `tags`, or `labels` for k8s. */
38
+ markerKind: string;
37
39
  /** The finalized, ordered apply runbook. */
38
40
  steps: string[];
39
41
  warnings: string[];
@@ -46,12 +48,48 @@ export interface GraduationOptions {
46
48
  env?: string;
47
49
  /** Override the tag channel (e.g. for a non-AWS lexicon). */
48
50
  channel?: ChannelKeys;
51
+ /**
52
+ * The lexicon the emitted source targets, from the carve provider that
53
+ * adopted the type. It decides how the build and the apply are spelled: a
54
+ * CloudFormation template and `aws cloudformation deploy` for aws, a manifest
55
+ * and `kubectl apply` for k8s (#999). Unknown falls back to the aws wording,
56
+ * which is what every carve was before there was a second provider.
57
+ */
58
+ lexicon?: string;
59
+ }
60
+
61
+ /**
62
+ * How the marker, the build output and the BYOL apply are spelled, per emitted
63
+ * lexicon. A Kubernetes object carries the marker as labels — the same
64
+ * `LABEL_OWNERSHIP_KEYS` the k8s serializer merges in — so a runbook promising
65
+ * `chant:managed-by` tags would name a channel nothing stamps.
66
+ */
67
+ function applyVocabulary(
68
+ lexicon: string | undefined,
69
+ target: string,
70
+ stack: string,
71
+ ): { channel: ChannelKeys; markerKind: string; build: string; apply: string } {
72
+ if (lexicon === "k8s") {
73
+ return {
74
+ channel: LABEL_OWNERSHIP_KEYS,
75
+ markerKind: "labels",
76
+ build: `chant build <emitted-src> --lexicon k8s > ${target}.yaml`,
77
+ apply: `kubectl apply -f ${target}.yaml`,
78
+ };
79
+ }
80
+ return {
81
+ channel: DEFAULT_TAG_OWNERSHIP_KEYS,
82
+ markerKind: "tags",
83
+ build: `chant build <emitted-src> -o ${target}.template.json`,
84
+ apply: `aws cloudformation deploy --template-file ${target}.template.json --stack-name ${stack} ...`,
85
+ };
49
86
  }
50
87
 
51
88
  export function graduationPlan(report: CarveReport, opts: GraduationOptions = {}): GraduationPlan {
52
89
  const stack = opts.stack ?? (report.target.split(".").slice(1).join(".") || report.target);
53
90
  const marker: OwnershipMarker = { stack, env: opts.env };
54
- const ownershipTags = ownershipEntries(opts.channel ?? DEFAULT_TAG_OWNERSHIP_KEYS, marker);
91
+ const vocabulary = applyVocabulary(opts.lexicon, report.target, stack);
92
+ const ownershipTags = ownershipEntries(opts.channel ?? vocabulary.channel, marker);
55
93
 
56
94
  const warnings: string[] = [];
57
95
  if (report.outbound.length) {
@@ -65,18 +103,27 @@ export function graduationPlan(report: CarveReport, opts: GraduationOptions = {}
65
103
  .join(", ");
66
104
 
67
105
  const steps = [
68
- `1. Confirm the emitted source builds spec-true: chant build <emitted-src> -o ${report.target}.template.json`,
69
- `2. Add the ownership marker so chant owns the resource — tags: ${tagList}`,
106
+ `1. Confirm the emitted source builds spec-true: ${vocabulary.build}`,
107
+ `2. Add the ownership marker so chant owns the resource — ${vocabulary.markerKind}: ${tagList}`,
70
108
  `3. Apply with your lifecycle (BYOL), e.g.:`,
71
- ` aws cloudformation deploy --template-file ${report.target}.template.json --stack-name ${stack} ...`,
109
+ ` ${vocabulary.apply}`,
72
110
  ` or graduate to an ApplyOp for a durable, gated apply.`,
73
111
  `4. Verify chant now owns it: chant lifecycle diff --live <env> (expect: unchanged, owned)`,
74
112
  `Rollback before this point: terraform import ${report.target} <physical-id>`,
75
113
  ];
76
114
 
77
- return { target: report.target, marker, ownershipTags, steps, warnings };
115
+ return { target: report.target, marker, ownershipTags, markerKind: vocabulary.markerKind, steps, warnings };
78
116
  }
79
117
 
118
+ /**
119
+ * A constructor-shaped emitted file: `export const x = new Ctor(`. Tag-based
120
+ * ownership is a property of that shape — a carved `kubernetes_manifest` is a
121
+ * `k8sManifest({ ... })` call whose props are the manifest, where a top-level
122
+ * `Tags` array would be an invented field on the object, not a marker (#999).
123
+ * Such a file is refused rather than stamped.
124
+ */
125
+ const CONSTRUCTOR_SHAPE = /^export const \w+ = new \w+\(/m;
126
+
80
127
  /**
81
128
  * Stamp the ownership tags into an emitted chant source file (`carve apply
82
129
  * --write-source`). The emitted source is machine-generated (`adoptFromState`),
@@ -89,6 +136,7 @@ export function stampOwnershipIntoSource(
89
136
  content: string,
90
137
  tags: Record<string, string>,
91
138
  ): { content: string; changed: boolean } | null {
139
+ if (!CONSTRUCTOR_SHAPE.test(content)) return null;
92
140
  const entries = Object.entries(tags).map(([Key, Value]) => ({ Key, Value }));
93
141
  const keys = new Set(Object.keys(tags));
94
142
  const lines = content.split("\n");
@@ -11,7 +11,7 @@
11
11
  * graph building stays unit-testable on hand-written fixtures.
12
12
  */
13
13
 
14
- import { IDENTITY_ATTR } from "./tier-map";
14
+ import { identityAttrOf } from "./tier-map";
15
15
  import type { Hcl2JsonTree, TfEdge, TfGraph, TfNode } from "./types";
16
16
 
17
17
  /**
@@ -143,12 +143,12 @@ function blockHasMeta(block: unknown, key: string): boolean {
143
143
 
144
144
  /**
145
145
  * The resource's physical name, if its identity attribute is a plain literal
146
- * (not interpolated). A dotted `IDENTITY_ATTR` entry walks nested blocks —
146
+ * (not interpolated). A dotted identity attribute walks nested blocks —
147
147
  * hcl2json renders a nested block as a one-element array, so arrays step
148
148
  * through their first element (`manifest.metadata.name`).
149
149
  */
150
150
  function literalIdentity(block: unknown, type: string): string | undefined {
151
- const attr = IDENTITY_ATTR[type];
151
+ const attr = identityAttrOf(type);
152
152
  if (!attr || !block || typeof block !== "object") return undefined;
153
153
  let value: unknown = block;
154
154
  for (const segment of attr.split(".")) {