@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
@@ -18,13 +18,20 @@
18
18
  * expression in a block that manages no infrastructure, the same order of work
19
19
  * as recording an outbound deferred input. Hence 4, not 12.
20
20
  *
21
- * Sub-resources that inline into a parent (see `FOLDS_INTO`) are folded into the
21
+ * Sub-resources that inline into a parent (see `foldParentOf`) are folded into the
22
22
  * parent's carve set: they are removed from the ranking and their edge to the
23
23
  * parent is not counted as inbound — inlining them is free, not boundary work.
24
+ *
25
+ * The model itself is not Terraform-specific — the CDK cloud-assembly advisor
26
+ * (#1056) scores construct units with the same arithmetic. What differs per
27
+ * source is the type→tier lookup and the extra signals that source can see, so
28
+ * both are injectable via `ScoreOptions` rather than forked into a second
29
+ * scorer. Everything else — the six penalty terms, the clamp, the bands — is
30
+ * shared verbatim.
24
31
  */
25
32
 
26
33
  import { inboundEdges, outboundEdges } from "./graph";
27
- import { FOLDS_INTO, resolveTier } from "./tier-map";
34
+ import { foldParentOf, resolveTier, type TierInfo } from "./tier-map";
28
35
  import type { TfGraph, TfNode } from "./types";
29
36
 
30
37
  export type PeelabilityBand = "clean leaf" | "carvable w/ edits" | "leave in Terraform";
@@ -38,7 +45,12 @@ export interface PeelabilityBreakdown {
38
45
  tier: 1 | 2 | 3 | null;
39
46
  hasDynamic: boolean;
40
47
  instances: number;
41
- /** Signed penalty contributions, for report transparency. */
48
+ /**
49
+ * Signed penalty contributions, for report transparency. The six shared terms
50
+ * are always present; a source-specific scorer may add its own keys (the CDK
51
+ * advisor's `asset`, say). A reader that sums the values reproduces the score;
52
+ * a reader that only knows the six named terms still reads those correctly.
53
+ */
42
54
  penalties: {
43
55
  inbound: number;
44
56
  outbound: number;
@@ -46,9 +58,28 @@ export interface PeelabilityBreakdown {
46
58
  tier: number;
47
59
  dynamic: number;
48
60
  instances: number;
61
+ [term: string]: number;
49
62
  };
50
63
  }
51
64
 
65
+ /**
66
+ * One unit of source a scored node stands for, when the node is a fold of
67
+ * several (#1056). A CDK construct emits 1..n CloudFormation resources and
68
+ * ranks once; this names the resources that carve with it. Absent on the
69
+ * Terraform path, where a fold is expressed by the child's absence from the
70
+ * ranking instead.
71
+ */
72
+ export interface CarveUnitMember {
73
+ /** Identifier within its template/estate — a CFN logical ID. */
74
+ id: string;
75
+ /** Resource type, e.g. `AWS::IAM::Role`. */
76
+ type: string;
77
+ /** The template/stack the member lives in. */
78
+ stack?: string;
79
+ /** The member's own source path, e.g. a CDK construct path. */
80
+ path?: string;
81
+ }
82
+
52
83
  export interface Peelability {
53
84
  address: string;
54
85
  kind: "resource" | "module";
@@ -57,10 +88,53 @@ export interface Peelability {
57
88
  /** Native spec type a carve would target; undefined for modules/unsupported. */
58
89
  mapsTo?: string;
59
90
  breakdown: PeelabilityBreakdown;
91
+ /**
92
+ * Source-specific facts about this node that the six penalty terms do not
93
+ * carry — why it was disqualified, what folded into it, what it is. Plain
94
+ * prose, for the report's benefit; nothing keys on it.
95
+ */
96
+ notes?: string[];
97
+ /** The source units folded into this node (#1056). */
98
+ members?: CarveUnitMember[];
99
+ }
100
+
101
+ /**
102
+ * Source-specific scoring input for one node, layered on the shared model.
103
+ * Everything here is optional: with no signals a node scores exactly as the
104
+ * Terraform path scores it.
105
+ */
106
+ export interface ScoreSignals {
107
+ /** Extra penalty terms, merged into `breakdown.penalties` and the total. */
108
+ penalties?: Record<string, number>;
109
+ notes?: string[];
110
+ members?: CarveUnitMember[];
111
+ /**
112
+ * When set, the node scores 0 and this is the stated reason. For claims the
113
+ * arithmetic cannot express — a CDK assembly synthesized from dummy lookup
114
+ * values is not a faithful picture of anything, so no score it produces is
115
+ * worth reading.
116
+ */
117
+ disqualified?: string;
118
+ }
119
+
120
+ export interface ScoreOptions {
121
+ /**
122
+ * Resource type → native tier. Defaults to the Terraform tier map; the CDK
123
+ * advisor passes a CloudFormation-keyed one. Modules keep the built-in
124
+ * {@link MODULE_TIER} treatment either way.
125
+ */
126
+ tierOf?: (type: string) => TierInfo | null;
127
+ signalsFor?: (node: TfNode) => ScoreSignals;
60
128
  }
61
129
 
62
130
  const clamp = (n: number): number => Math.max(0, Math.min(100, n));
63
131
 
132
+ /**
133
+ * `-12 * 0` is `-0`, and JSON has no notation for it — a report written to a
134
+ * file would not equal the one held in memory. A term that costs nothing is 0.
135
+ */
136
+ const term = (n: number): number => (n === 0 ? 0 : n);
137
+
64
138
  export function bandFor(score: number): PeelabilityBand {
65
139
  if (score >= 80) return "clean leaf";
66
140
  if (score >= 50) return "carvable w/ edits";
@@ -81,7 +155,7 @@ function computeFolds(graph: TfGraph): { folded: Set<string>; childrenOf: Map<st
81
155
  const childrenOf = new Map<string, Set<string>>();
82
156
  for (const node of graph.nodes) {
83
157
  if (node.kind !== "resource" || !node.type) continue;
84
- const parentType = FOLDS_INTO[node.type];
158
+ const parentType = foldParentOf(node.type);
85
159
  if (!parentType) continue;
86
160
  const parentAddress = `${parentType}.${node.name}`;
87
161
  if (!byAddress.has(parentAddress)) continue; // sub-resource without its parent → score it on its own
@@ -92,7 +166,12 @@ function computeFolds(graph: TfGraph): { folded: Set<string>; childrenOf: Map<st
92
166
  return { folded, childrenOf };
93
167
  }
94
168
 
95
- function scoreNode(node: TfNode, graph: TfGraph, foldedChildren: Set<string>): Peelability {
169
+ function scoreNode(
170
+ node: TfNode,
171
+ graph: TfGraph,
172
+ foldedChildren: Set<string>,
173
+ opts: ScoreOptions,
174
+ ): Peelability {
96
175
  // Inbound excludes edges from this node's own folded sub-resources, and
97
176
  // counts output blocks separately — they cost less to bridge (#1638).
98
177
  const inboundAll = inboundEdges(graph, node.address).filter((e) => !foldedChildren.has(e.from));
@@ -100,45 +179,51 @@ function scoreNode(node: TfNode, graph: TfGraph, foldedChildren: Set<string>): P
100
179
  const outputs = inboundAll.length - inbound;
101
180
  const outbound = outboundEdges(graph, node.address).length;
102
181
 
103
- const tierInfo = node.kind === "module" ? { tier: MODULE_TIER, mapsTo: undefined } : resolveTier(node.type!);
182
+ const signals = opts.signalsFor?.(node) ?? {};
183
+ const extra = { ...(signals.penalties ?? {}) };
184
+ const trimmings = {
185
+ ...(signals.notes?.length ? { notes: signals.notes } : {}),
186
+ ...(signals.members?.length ? { members: signals.members } : {}),
187
+ };
188
+
189
+ const tierOf = opts.tierOf ?? resolveTier;
190
+ const tierInfo = node.kind === "module" ? { tier: MODULE_TIER, mapsTo: undefined } : node.type ? tierOf(node.type) : null;
104
191
  const tier = tierInfo ? tierInfo.tier : null;
105
192
 
106
- // Unsupported provider/type → 0, no partial credit.
107
- if (tier === null) {
193
+ // Unsupported provider/type, or a source-specific disqualification → 0, no
194
+ // partial credit. The extra penalties are dropped with the rest of the
195
+ // arithmetic: there is no score left for them to reduce.
196
+ if (tier === null || signals.disqualified) {
108
197
  return {
109
198
  address: node.address,
110
199
  kind: node.kind,
111
200
  score: 0,
112
201
  band: "leave in Terraform",
202
+ ...(tier === null ? {} : { mapsTo: tierInfo && "mapsTo" in tierInfo ? tierInfo.mapsTo : undefined }),
113
203
  breakdown: {
114
204
  inbound,
115
205
  outbound,
116
206
  outputs,
117
- tier: null,
207
+ tier,
118
208
  hasDynamic: node.hasDynamic,
119
209
  instances: node.instances,
120
210
  penalties: { inbound: 0, outbound: 0, outputs: 0, tier: 0, dynamic: 0, instances: 0 },
121
211
  },
212
+ ...trimmings,
213
+ ...(signals.disqualified ? { notes: [signals.disqualified, ...(signals.notes ?? [])] } : {}),
122
214
  };
123
215
  }
124
216
 
125
217
  const penalties = {
126
- inbound: -12 * inbound,
127
- outbound: -4 * outbound,
128
- outputs: -4 * outputs,
129
- tier: -15 * (tier - 1),
218
+ inbound: term(-12 * inbound),
219
+ outbound: term(-4 * outbound),
220
+ outputs: term(-4 * outputs),
221
+ tier: term(-15 * (tier - 1)),
130
222
  dynamic: node.hasDynamic ? -10 : 0,
131
- instances: -3 * Math.max(0, node.instances - 1),
223
+ instances: term(-3 * Math.max(0, node.instances - 1)),
224
+ ...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, term(value)])),
132
225
  };
133
- const score = clamp(
134
- 100 +
135
- penalties.inbound +
136
- penalties.outbound +
137
- penalties.outputs +
138
- penalties.tier +
139
- penalties.dynamic +
140
- penalties.instances,
141
- );
226
+ const score = clamp(100 + Object.values(penalties).reduce((sum, n) => sum + n, 0));
142
227
 
143
228
  return {
144
229
  address: node.address,
@@ -155,6 +240,7 @@ function scoreNode(node: TfNode, graph: TfGraph, foldedChildren: Set<string>): P
155
240
  instances: node.instances,
156
241
  penalties,
157
242
  },
243
+ ...trimmings,
158
244
  };
159
245
  }
160
246
 
@@ -162,10 +248,10 @@ function scoreNode(node: TfNode, graph: TfGraph, foldedChildren: Set<string>): P
162
248
  * Score every carvable node in the estate, ranked most-peelable first. Folded
163
249
  * sub-resources are omitted (they carve with their parent, not on their own).
164
250
  */
165
- export function scoreEstate(graph: TfGraph): Peelability[] {
251
+ export function scoreEstate(graph: TfGraph, opts: ScoreOptions = {}): Peelability[] {
166
252
  const { folded, childrenOf } = computeFolds(graph);
167
253
  return graph.nodes
168
254
  .filter((n) => !folded.has(n.address))
169
- .map((n) => scoreNode(n, graph, childrenOf.get(n.address) ?? new Set()))
255
+ .map((n) => scoreNode(n, graph, childrenOf.get(n.address) ?? new Set(), opts))
170
256
  .sort((a, b) => b.score - a.score || (a.address < b.address ? -1 : a.address > b.address ? 1 : 0));
171
257
  }
@@ -1,112 +1,77 @@
1
1
  /**
2
- * Terraform-type → native-spec tier map for the carve-out advisor (#214 T3).
2
+ * Terraform-type → native-spec ranking for the carve-out advisor (#214 T3),
3
+ * resolved through the carve provider registry (`carve-provider.ts`, #2016).
3
4
  *
4
- * The AWS entries are derived from the single AWS carve-out table
5
- * (`aws-resources.ts`), so the advisor ranks exactly the AWS types `carve emit`
6
- * can produce — no advise↔emit cliff. Non-AWS entries (e.g. `kubernetes_manifest`)
7
- * are listed here directly; emit for those is a separate target.
5
+ * Every entry comes from a registered provider: the AWS ones from the single
6
+ * AWS carve-out table (`providers/aws.ts` over `aws-resources.ts`), so the
7
+ * advisor ranks exactly the AWS types `carve emit` can produce — no advise↔emit
8
+ * cliff — and the Kubernetes ones from `providers/kubernetes.ts`, which emits
9
+ * `kubernetes_manifest` and ranks the typed provider resources it does not.
8
10
  *
9
11
  * tier 1 — a clean 1:1 native resource
10
12
  * tier 2 — maps, but with reshaping
11
13
  * tier 3 — a hard/partial map
12
14
  * null — no known native mapping (unsupported provider/type) → score 0
15
+ *
16
+ * These are functions, not frozen constants: the registry is open, so a
17
+ * provider registered after module load has to be visible here.
13
18
  */
14
19
 
15
- import { AWS_CARVE_TYPES } from "./aws-resources";
20
+ import {
21
+ carveFoldParent,
22
+ carveIdentityAttr,
23
+ carveTierMap,
24
+ resolveEmitProvider,
25
+ type TierInfo,
26
+ } from "./carve-provider";
27
+
28
+ export { carveEmitTypes, type TierInfo } from "./carve-provider";
29
+
30
+ /** TF resource type → native tier, over every registered provider. Absent = unsupported (score 0). */
31
+ export function tierMap(): Readonly<Record<string, TierInfo>> {
32
+ return carveTierMap();
33
+ }
16
34
 
17
- export interface TierInfo {
18
- tier: 1 | 2 | 3;
19
- /** The native spec type a carve would target, for the report. */
20
- mapsTo: string;
35
+ export function resolveTier(tfType: string): TierInfo | null {
36
+ return carveTierMap()[tfType] ?? null;
21
37
  }
22
38
 
23
39
  /**
24
- * Kubernetes provider types with a native k8s-lexicon target. `kubernetes_manifest`
25
- * IS the manifest (tier 1). The typed provider resources (`kubernetes_deployment`,
26
- * ...) reshape their HCL schema back into a manifest, so they rank tier 2; the
27
- * common `_v1` aliases share their base type's entry.
40
+ * The HCL attribute carrying a resource's physical name. A dotted entry is a
41
+ * path into nested blocks (`manifest.metadata.name`). Used for the graph's
42
+ * identity and the live-import hint. Absent → fall back to the TF logical name.
28
43
  */
29
- const K8S_TIER_BASE: Record<string, TierInfo> = {
30
- kubernetes_manifest: { tier: 1, mapsTo: "k8s:manifest" },
31
- kubernetes_namespace: { tier: 2, mapsTo: "k8s:Namespace" },
32
- kubernetes_config_map: { tier: 2, mapsTo: "k8s:ConfigMap" },
33
- kubernetes_secret: { tier: 2, mapsTo: "k8s:Secret" },
34
- kubernetes_service: { tier: 2, mapsTo: "k8s:Service" },
35
- kubernetes_service_account: { tier: 2, mapsTo: "k8s:ServiceAccount" },
36
- kubernetes_deployment: { tier: 2, mapsTo: "k8s:Deployment" },
37
- kubernetes_stateful_set: { tier: 2, mapsTo: "k8s:StatefulSet" },
38
- kubernetes_daemon_set: { tier: 2, mapsTo: "k8s:DaemonSet" },
39
- kubernetes_job: { tier: 2, mapsTo: "k8s:Job" },
40
- kubernetes_cron_job: { tier: 2, mapsTo: "k8s:CronJob" },
41
- kubernetes_ingress: { tier: 2, mapsTo: "k8s:Ingress" },
42
- kubernetes_network_policy: { tier: 2, mapsTo: "k8s:NetworkPolicy" },
43
- kubernetes_persistent_volume_claim: { tier: 2, mapsTo: "k8s:PersistentVolumeClaim" },
44
- kubernetes_role: { tier: 2, mapsTo: "k8s:Role" },
45
- kubernetes_role_binding: { tier: 2, mapsTo: "k8s:RoleBinding" },
46
- kubernetes_cluster_role: { tier: 2, mapsTo: "k8s:ClusterRole" },
47
- kubernetes_cluster_role_binding: { tier: 2, mapsTo: "k8s:ClusterRoleBinding" },
48
- kubernetes_resource_quota: { tier: 2, mapsTo: "k8s:ResourceQuota" },
49
- kubernetes_limit_range: { tier: 2, mapsTo: "k8s:LimitRange" },
50
- kubernetes_priority_class: { tier: 2, mapsTo: "k8s:PriorityClass" },
51
- kubernetes_pod_disruption_budget: { tier: 2, mapsTo: "k8s:PodDisruptionBudget" },
52
- kubernetes_horizontal_pod_autoscaler: { tier: 2, mapsTo: "k8s:HorizontalPodAutoscaler" },
53
- };
54
-
55
- const K8S_TIER: Record<string, TierInfo> = Object.fromEntries(
56
- Object.entries(K8S_TIER_BASE).flatMap(([type, info]) =>
57
- type === "kubernetes_manifest" ? [[type, info]] : [[type, info], [`${type}_v1`, info]],
58
- ),
59
- );
60
- // The one provider type whose current alias is _v2, not _v1.
61
- K8S_TIER.kubernetes_horizontal_pod_autoscaler_v2 = K8S_TIER_BASE.kubernetes_horizontal_pod_autoscaler;
62
-
63
- /** TF resource type → native tier. Absent = unsupported (score 0). */
64
- export const TIER_MAP: Record<string, TierInfo> = {
65
- // AWS: derived from the carve-out table so advise and emit stay in lockstep.
66
- ...Object.fromEntries(AWS_CARVE_TYPES.map((t) => [t.tfType, { tier: t.tier, mapsTo: t.nativeType }])),
67
- // Kubernetes — ranked by advise; emit support is a separate target.
68
- ...K8S_TIER,
69
- };
44
+ export function identityAttrOf(tfType: string): string | undefined {
45
+ return carveIdentityAttr(tfType);
46
+ }
70
47
 
71
48
  /**
72
- * TF "sub-resource" types that inline into a parent resource rather than
73
- * standing alone (TF splits config the native spec keeps in one resource).
74
- * A sub-resource that shares its parent's name is folded into the parent's
75
- * carve set: it is not ranked on its own, and its edge to the parent does not
76
- * count as inbound boundary work — inlining it is free.
77
- *
78
- * Maps sub-resource TF type → parent TF type.
49
+ * The parent Terraform type a sub-resource folds into, if any. Terraform splits
50
+ * configuration the native spec keeps in one resource; a sub-resource sharing
51
+ * its parent's name carves with the parent rather than being ranked on its own.
79
52
  */
80
- export const FOLDS_INTO: Record<string, string> = {
81
- aws_s3_bucket_versioning: "aws_s3_bucket",
82
- aws_s3_bucket_acl: "aws_s3_bucket",
83
- aws_s3_bucket_policy: "aws_s3_bucket",
84
- aws_s3_bucket_public_access_block: "aws_s3_bucket",
85
- aws_s3_bucket_server_side_encryption_configuration: "aws_s3_bucket",
86
- aws_s3_bucket_lifecycle_configuration: "aws_s3_bucket",
87
- aws_s3_bucket_website_configuration: "aws_s3_bucket",
88
- aws_s3_bucket_cors_configuration: "aws_s3_bucket",
89
- aws_s3_bucket_logging: "aws_s3_bucket",
90
- aws_s3_bucket_ownership_controls: "aws_s3_bucket",
91
- aws_s3_bucket_notification: "aws_s3_bucket",
92
- aws_s3_bucket_accelerate_configuration: "aws_s3_bucket",
93
- aws_s3_bucket_request_payment_configuration: "aws_s3_bucket",
94
- aws_s3_bucket_intelligent_tiering_configuration: "aws_s3_bucket",
95
- aws_ecr_lifecycle_policy: "aws_ecr_repository",
96
- aws_ecr_repository_policy: "aws_ecr_repository",
97
- };
53
+ export function foldParentOf(tfType: string): string | undefined {
54
+ return carveFoldParent(tfType);
55
+ }
98
56
 
99
57
  /**
100
- * The HCL attribute carrying a resource's physical name, per type — AWS entries
101
- * derived from the carve-out table, non-AWS listed directly. A dotted entry is
102
- * a path into nested blocks (`manifest.metadata.name`). Used for the graph's
103
- * identity and the live-import hint. Absent → fall back to the TF logical name.
58
+ * Can `chant carve emit` produce chant source for this type? Narrower than
59
+ * `resolveTier`: providers also rank types no emit path can adopt (the typed
60
+ * `kubernetes_*` resources, whose manifest only the provider schema knows how
61
+ * to reassemble). Both emit paths — `--state` and `--env` — gate on this, so a
62
+ * type either command refuses is refused by the other with the same message.
104
63
  */
105
- export const IDENTITY_ATTR: Record<string, string> = {
106
- ...Object.fromEntries(AWS_CARVE_TYPES.filter((t) => t.identityAttr).map((t) => [t.tfType, t.identityAttr!])),
107
- kubernetes_manifest: "manifest.metadata.name",
108
- };
64
+ export function canCarveEmit(tfType: string): boolean {
65
+ return resolveEmitProvider(tfType) !== undefined;
66
+ }
109
67
 
110
- export function resolveTier(tfType: string): TierInfo | null {
111
- return TIER_MAP[tfType] ?? null;
68
+ /**
69
+ * Can `chant carve bridge` render a Terraform `data` source for this type? A
70
+ * dotted identity attribute is a path into nested blocks, and a data source
71
+ * body is flat `attr = value` — `manifest.metadata.name = "x"` is not valid
72
+ * HCL. An absent entry is fine: the bridge writes a TODO comment instead.
73
+ */
74
+ export function canBridge(tfType: string): boolean {
75
+ const attr = carveIdentityAttr(tfType);
76
+ return attr === undefined || !attr.includes(".");
112
77
  }
@@ -64,6 +64,12 @@ export interface TfEdge {
64
64
  * parameter. Empty when the reference came from a non-object block shape.
65
65
  */
66
66
  via: string[];
67
+ /**
68
+ * The two ends live in different templates — a CloudFormation
69
+ * `Fn::ImportValue` resolved through the exporting stack's `Outputs` (#1056).
70
+ * Never set on the Terraform path, where a single estate is one namespace.
71
+ */
72
+ crossStack?: boolean;
67
73
  }
68
74
 
69
75
  export interface TfGraph {
package/src/yaml.test.ts CHANGED
@@ -484,3 +484,57 @@ describe("parseYAML — sibling keys after a nested block in a sequence item (#1
484
484
  });
485
485
  });
486
486
  });
487
+
488
+ // #2013 — a colon opens a mapping only when whitespace or the end of the line
489
+ // follows it (YAML 1.2 §7.4.2). The item/key disambiguation used to accept a
490
+ // colon anywhere, which turned a bare URL in a sequence into a single-key
491
+ // object and made the CRD-schema check reject a string list the emitter here
492
+ // had written itself.
493
+ describe("parseYAML — a colon without following space is not a mapping (#2013)", () => {
494
+ test("a bare URL in a sequence is a string", () => {
495
+ expect(parseYAML("sourceRepos:\n- https://github.com/INTENTIUS/behold\n")).toEqual({
496
+ sourceRepos: ["https://github.com/INTENTIUS/behold"],
497
+ });
498
+ });
499
+
500
+ test("an AppProject's sourceRepos round-trips through emit and parse", () => {
501
+ const repos = ["https://github.com/INTENTIUS/behold", "ssh://git@github.com:22/INTENTIUS/behold.git"];
502
+ expect(parseYAML(`spec:${emitYAML({ sourceRepos: repos }, 1)}\n`)).toEqual({ spec: { sourceRepos: repos } });
503
+ });
504
+
505
+ test("a real `- key: value` item is still a mapping", () => {
506
+ expect(parseYAML("items:\n- name: x\n url: https://example.com/a\n")).toEqual({
507
+ items: [{ name: "x", url: "https://example.com/a" }],
508
+ });
509
+ });
510
+
511
+ test("a mapping item whose first value is a URL keeps its siblings", () => {
512
+ expect(parseYAML("sources:\n- repoURL: https://github.com/o/r\n path: ./chart\n")).toEqual({
513
+ sources: [{ repoURL: "https://github.com/o/r", path: "./chart" }],
514
+ });
515
+ });
516
+
517
+ test("a quoted URL in a sequence still works", () => {
518
+ expect(parseYAML("sourceRepos:\n- 'https://github.com/o/r'\n- \"https://github.com/o/s\"\n")).toEqual({
519
+ sourceRepos: ["https://github.com/o/r", "https://github.com/o/s"],
520
+ });
521
+ });
522
+
523
+ test("`- key:value` with no space after the colon is a scalar, per the spec", () => {
524
+ expect(parseYAML("items:\n- key:value\n")).toEqual({ items: ["key:value"] });
525
+ });
526
+
527
+ test("`- key:` with nothing after the colon is still a mapping with a null value", () => {
528
+ expect(parseYAML("items:\n- key:\n")).toEqual({ items: [{ key: null }] });
529
+ });
530
+
531
+ test("a URL on a mapping line, not a sequence item, is unaffected", () => {
532
+ expect(parseYAML("url: https://example.com/a\n")).toEqual({ url: "https://example.com/a" });
533
+ });
534
+
535
+ test("a bare URL among mapping-shaped siblings inside one item", () => {
536
+ expect(parseYAML("items:\n- name: x\n args:\n - --endpoint\n - https://example.com/a\n image: nginx\n")).toEqual({
537
+ items: [{ name: "x", args: ["--endpoint", "https://example.com/a"], image: "nginx" }],
538
+ });
539
+ });
540
+ });
package/src/yaml.ts CHANGED
@@ -155,6 +155,27 @@ export interface ParseResult {
155
155
  endIndex: number;
156
156
  }
157
157
 
158
+ /**
159
+ * `key: value` on a line of its own (`KEY_LINE`, capturing the indent) and the
160
+ * same after a sequence item's `- ` (`ITEM_KEY`).
161
+ *
162
+ * The lookahead is the rule both patterns used to miss: a `:` opens a mapping
163
+ * only when what follows it is whitespace or the end of the line, and a plain
164
+ * scalar may carry any other colon (YAML 1.2 §7.4.2). Written as `:\s*`, which
165
+ * matches zero whitespace, EVERY colon opened one — so a bare URL in a
166
+ * sequence,
167
+ *
168
+ * sourceRepos:
169
+ * - https://github.com/INTENTIUS/behold
170
+ *
171
+ * came back as `{ https: "//github.com/INTENTIUS/behold" }`, and the CRD-schema
172
+ * post-synth check (#1372) re-reading emitted YAML reported the string list
173
+ * this file's own emitter had just written as a list of objects (#2013). By the
174
+ * same rule `- key:value` is the scalar `"key:value"`, not a mapping.
175
+ */
176
+ const KEY_LINE = /^(\s*)([^\s:][^:]*?):(?=\s|$)\s*(.*)$/;
177
+ const ITEM_KEY = /^([^\s:][^:]*?):(?=\s|$)\s*(.*)$/;
178
+
158
179
  /**
159
180
  * Parse a YAML document (or JSON document) into a plain object.
160
181
  *
@@ -274,7 +295,7 @@ export function parseYAMLLines(
274
295
  if (indent < baseIndent) break; // Dedented — done with this block
275
296
  if (indent > baseIndent && startIndex > 0) break; // Unexpected indent
276
297
 
277
- const keyMatch = line.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
298
+ const keyMatch = line.match(KEY_LINE);
278
299
  if (keyMatch) {
279
300
  const key = keyMatch[2].trim();
280
301
  const inlineValue = keyMatch[3].trim();
@@ -472,7 +493,7 @@ export function parseYAMLArray(
472
493
  const isQuotedScalar =
473
494
  (itemValue.startsWith('"') && itemValue.endsWith('"')) ||
474
495
  (itemValue.startsWith("'") && itemValue.endsWith("'"));
475
- const kvMatch = !isQuotedScalar && itemValue.match(/^([^\s:][^:]*?):\s*(.*)$/);
496
+ const kvMatch = !isQuotedScalar && itemValue.match(ITEM_KEY);
476
497
  if (kvMatch) {
477
498
  const obj: Record<string, unknown> = {};
478
499
  obj[kvMatch[1].trim()] = parseArrayItemValue(kvMatch[2].trim(), lines, i, indent + 2);
@@ -506,7 +527,7 @@ export function parseYAMLArray(
506
527
  const ni = nextLine.search(/\S/);
507
528
  if (ni < nextIndent) break;
508
529
  if (ni > nextIndent) break; // belongs to a nested block already consumed
509
- const nextKV = nextLine.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
530
+ const nextKV = nextLine.match(KEY_LINE);
510
531
  if (nextKV) {
511
532
  const nextVal = nextKV[3].trim();
512
533
  obj[nextKV[2].trim()] = parseArrayItemValue(nextVal, lines, j, ni);