@intentius/chant 0.41.20 → 0.42.1

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 (125) hide show
  1. package/dist/cli/commands/carve-apply.d.ts +9 -0
  2. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  3. package/dist/cli/commands/carve-bridge.d.ts +7 -0
  4. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  5. package/dist/cli/commands/carve-emit.d.ts +7 -0
  6. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  7. package/dist/cli/commands/init.d.ts +2 -0
  8. package/dist/cli/commands/init.d.ts.map +1 -1
  9. package/dist/cli/handlers/carve-apply.d.ts.map +1 -1
  10. package/dist/cli/handlers/components.d.ts.map +1 -1
  11. package/dist/cli/handlers/explain.d.ts +11 -0
  12. package/dist/cli/handlers/explain.d.ts.map +1 -0
  13. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  14. package/dist/cli/main.d.ts.map +1 -1
  15. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  16. package/dist/cli/registry.d.ts +2 -0
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/codegen/docs-rule-scanning.d.ts +11 -0
  19. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  20. package/dist/codegen/okf-lexicon.d.ts +36 -0
  21. package/dist/codegen/okf-lexicon.d.ts.map +1 -0
  22. package/dist/codegen/package.d.ts +2 -1
  23. package/dist/codegen/package.d.ts.map +1 -1
  24. package/dist/components/verbs/sbom.d.ts +1 -1
  25. package/dist/components/verbs/sbom.d.ts.map +1 -1
  26. package/dist/components/verbs/vuln-gate.d.ts +15 -4
  27. package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
  28. package/dist/components/verbs/vuln-scan.d.ts +18 -6
  29. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  30. package/dist/config.d.ts +12 -0
  31. package/dist/config.d.ts.map +1 -1
  32. package/dist/deep-observation.d.ts +13 -0
  33. package/dist/deep-observation.d.ts.map +1 -1
  34. package/dist/lexicon.d.ts +3 -0
  35. package/dist/lexicon.d.ts.map +1 -1
  36. package/dist/lifecycle/observe.d.ts.map +1 -1
  37. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  38. package/dist/okf.d.ts +57 -0
  39. package/dist/okf.d.ts.map +1 -0
  40. package/dist/terraform/__fixtures__/build-graph.d.ts +13 -0
  41. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -0
  42. package/dist/terraform/adopt-state.d.ts +33 -1
  43. package/dist/terraform/adopt-state.d.ts.map +1 -1
  44. package/dist/terraform/bridge.d.ts +7 -1
  45. package/dist/terraform/bridge.d.ts.map +1 -1
  46. package/dist/terraform/carve.d.ts +12 -0
  47. package/dist/terraform/carve.d.ts.map +1 -1
  48. package/dist/terraform/excise.d.ts +31 -0
  49. package/dist/terraform/excise.d.ts.map +1 -0
  50. package/dist/terraform/graduate.d.ts +12 -0
  51. package/dist/terraform/graduate.d.ts.map +1 -1
  52. package/dist/terraform/graph.d.ts +35 -5
  53. package/dist/terraform/graph.d.ts.map +1 -1
  54. package/dist/terraform/manifest.d.ts +89 -0
  55. package/dist/terraform/manifest.d.ts.map +1 -0
  56. package/dist/terraform/parse.d.ts +9 -4
  57. package/dist/terraform/parse.d.ts.map +1 -1
  58. package/dist/terraform/types.d.ts +7 -0
  59. package/dist/terraform/types.d.ts.map +1 -1
  60. package/dist/terraform/unified-diff.d.ts +11 -0
  61. package/dist/terraform/unified-diff.d.ts.map +1 -0
  62. package/package.json +1 -1
  63. package/src/__snapshots__/okf.test.ts.snap +381 -0
  64. package/src/cli/commands/carve-apply.test.ts +94 -2
  65. package/src/cli/commands/carve-apply.ts +62 -11
  66. package/src/cli/commands/carve-bridge.test.ts +82 -4
  67. package/src/cli/commands/carve-bridge.ts +79 -11
  68. package/src/cli/commands/carve-emit-state.test.ts +110 -1
  69. package/src/cli/commands/carve-emit.ts +184 -8
  70. package/src/cli/commands/init.ts +1 -1
  71. package/src/cli/handlers/carve-apply.ts +1 -0
  72. package/src/cli/handlers/components.ts +4 -2
  73. package/src/cli/handlers/explain.test.ts +93 -0
  74. package/src/cli/handlers/explain.ts +60 -0
  75. package/src/cli/handlers/lifecycle.ts +9 -7
  76. package/src/cli/main.ts +21 -5
  77. package/src/cli/mcp/server.test.ts +17 -0
  78. package/src/cli/mcp/tools/explain.ts +15 -3
  79. package/src/cli/registry.ts +2 -0
  80. package/src/codegen/__snapshots__/okf-lexicon.test.ts.snap +170 -0
  81. package/src/codegen/docs-rule-scanning.ts +35 -14
  82. package/src/codegen/okf-lexicon.test.ts +248 -0
  83. package/src/codegen/okf-lexicon.ts +303 -0
  84. package/src/codegen/package.ts +29 -4
  85. package/src/components/config-defaults.test.ts +30 -0
  86. package/src/components/verbs/__fixtures__/grype-with-kev-epss.json +1 -0
  87. package/src/components/verbs/__fixtures__/trivy-with-kev-epss.json +631 -0
  88. package/src/components/verbs/exploitability-roundtrip.test.ts +190 -0
  89. package/src/components/verbs/sbom.ts +1 -1
  90. package/src/components/verbs/vuln-gate.test.ts +144 -1
  91. package/src/components/verbs/vuln-gate.ts +61 -10
  92. package/src/components/verbs/vuln-scan.test.ts +94 -0
  93. package/src/components/verbs/vuln-scan.ts +44 -9
  94. package/src/config.test.ts +21 -0
  95. package/src/config.ts +18 -0
  96. package/src/deep-observation.test.ts +19 -0
  97. package/src/deep-observation.ts +32 -13
  98. package/src/lexicon.ts +3 -0
  99. package/src/lifecycle/observe.test.ts +18 -0
  100. package/src/lifecycle/observe.ts +10 -3
  101. package/src/lifecycle/snapshot.ts +5 -4
  102. package/src/okf.test.ts +169 -0
  103. package/src/okf.ts +308 -0
  104. package/src/terraform/__fixtures__/build-graph.ts +25 -0
  105. package/src/terraform/adopt-state.test.ts +34 -1
  106. package/src/terraform/adopt-state.ts +69 -2
  107. package/src/terraform/bridge.test.ts +38 -14
  108. package/src/terraform/bridge.ts +48 -14
  109. package/src/terraform/carve.test.ts +9 -7
  110. package/src/terraform/carve.ts +0 -0
  111. package/src/terraform/excise.test.ts +92 -0
  112. package/src/terraform/excise.ts +139 -0
  113. package/src/terraform/graduate.test.ts +73 -6
  114. package/src/terraform/graduate.ts +49 -0
  115. package/src/terraform/graph.test.ts +56 -10
  116. package/src/terraform/graph.ts +112 -45
  117. package/src/terraform/manifest.test.ts +105 -0
  118. package/src/terraform/manifest.ts +162 -0
  119. package/src/terraform/parse.test.ts +39 -1
  120. package/src/terraform/parse.ts +36 -9
  121. package/src/terraform/score.test.ts +8 -8
  122. package/src/terraform/state.test.ts +4 -4
  123. package/src/terraform/types.ts +7 -0
  124. package/src/terraform/unified-diff.test.ts +77 -0
  125. package/src/terraform/unified-diff.ts +146 -0
@@ -0,0 +1,381 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`buildOkfBundle over shipped examples > aws lambda-s3 emits a conformant, snapshot-stable bundle 1`] = `
4
+ [
5
+ {
6
+ "content": "---
7
+ type: AWS::S3::Bucket
8
+ title: appBucket
9
+ description: aws resource of type AWS::S3::Bucket
10
+ name: appBucket
11
+ lexicon: aws
12
+ kind: resource
13
+ source: main.ts
14
+ composite: LambdaS3
15
+ composite_instance: app
16
+ ---
17
+ Declared in \`main.ts\`.
18
+
19
+ ## Referenced by
20
+
21
+ - [appFunc](/aws/appFunc.md)
22
+ - [appRole](/aws/appRole.md)
23
+ ",
24
+ "path": "aws/appBucket.md",
25
+ },
26
+ {
27
+ "content": "---
28
+ type: AWS::Lambda::Function
29
+ title: appFunc
30
+ description: aws resource of type AWS::Lambda::Function
31
+ name: appFunc
32
+ lexicon: aws
33
+ kind: resource
34
+ source: main.ts
35
+ composite: LambdaS3
36
+ composite_instance: app
37
+ ---
38
+ Declared in \`main.ts\`.
39
+
40
+ ## Depends on
41
+
42
+ - [appBucket](/aws/appBucket.md)
43
+ - [appRole](/aws/appRole.md)
44
+ ",
45
+ "path": "aws/appFunc.md",
46
+ },
47
+ {
48
+ "content": "---
49
+ type: AWS::IAM::Role
50
+ title: appRole
51
+ description: aws resource of type AWS::IAM::Role
52
+ name: appRole
53
+ lexicon: aws
54
+ kind: resource
55
+ source: main.ts
56
+ composite: LambdaS3
57
+ composite_instance: app
58
+ ---
59
+ Declared in \`main.ts\`.
60
+
61
+ ## Depends on
62
+
63
+ - [appBucket](/aws/appBucket.md)
64
+
65
+ ## Referenced by
66
+
67
+ - [appFunc](/aws/appFunc.md)
68
+ ",
69
+ "path": "aws/appRole.md",
70
+ },
71
+ {
72
+ "content": "---
73
+ type: Output
74
+ title: bucketArn
75
+ description: aws output of type Output
76
+ name: bucketArn
77
+ lexicon: aws
78
+ kind: output
79
+ source: outputs.ts
80
+ ---
81
+ Declared in \`outputs.ts\`.
82
+ ",
83
+ "path": "aws/bucketArn.md",
84
+ },
85
+ {
86
+ "content": "---
87
+ type: AWS::CloudFormation::Parameter
88
+ title: environment
89
+ description: aws resource of type AWS::CloudFormation::Parameter
90
+ name: environment
91
+ lexicon: aws
92
+ kind: resource
93
+ source: params.ts
94
+ ---
95
+ Declared in \`params.ts\`.
96
+ ",
97
+ "path": "aws/environment.md",
98
+ },
99
+ {
100
+ "content": "---
101
+ type: chant:aws:defaultTags
102
+ title: tags
103
+ description: aws resource of type chant:aws:defaultTags
104
+ name: tags
105
+ lexicon: aws
106
+ kind: resource
107
+ source: tags.ts
108
+ ---
109
+ Declared in \`tags.ts\`.
110
+ ",
111
+ "path": "aws/tags.md",
112
+ },
113
+ {
114
+ "content": "---
115
+ okf_version: '0.2'
116
+ ---
117
+
118
+ # Lexicon: aws
119
+
120
+ * [appBucket](/aws/appBucket.md) - aws resource of type AWS::S3::Bucket
121
+ * [appFunc](/aws/appFunc.md) - aws resource of type AWS::Lambda::Function
122
+ * [appRole](/aws/appRole.md) - aws resource of type AWS::IAM::Role
123
+ * [bucketArn](/aws/bucketArn.md) - aws output of type Output
124
+ * [environment](/aws/environment.md) - aws resource of type AWS::CloudFormation::Parameter
125
+ * [tags](/aws/tags.md) - aws resource of type chant:aws:defaultTags
126
+ ",
127
+ "path": "index.md",
128
+ },
129
+ ]
130
+ `;
131
+
132
+ exports[`buildOkfBundle over shipped examples > gitlab node-pipeline emits a conformant, snapshot-stable bundle 1`] = `
133
+ [
134
+ {
135
+ "content": "---
136
+ type: GitLab::CI::Job
137
+ title: appBuild
138
+ description: gitlab resource of type GitLab::CI::Job
139
+ name: appBuild
140
+ lexicon: gitlab
141
+ kind: resource
142
+ source: pipeline.ts
143
+ composite: NodePipeline
144
+ composite_instance: app
145
+ ---
146
+ Declared in \`pipeline.ts\`.
147
+ ",
148
+ "path": "gitlab/appBuild.md",
149
+ },
150
+ {
151
+ "content": "---
152
+ type: GitLab::CI::Default
153
+ title: appDefaults
154
+ description: gitlab resource of type GitLab::CI::Default
155
+ name: appDefaults
156
+ lexicon: gitlab
157
+ kind: resource
158
+ source: pipeline.ts
159
+ composite: NodePipeline
160
+ composite_instance: app
161
+ ---
162
+ Declared in \`pipeline.ts\`.
163
+ ",
164
+ "path": "gitlab/appDefaults.md",
165
+ },
166
+ {
167
+ "content": "---
168
+ type: GitLab::CI::Job
169
+ title: appTest
170
+ description: gitlab resource of type GitLab::CI::Job
171
+ name: appTest
172
+ lexicon: gitlab
173
+ kind: resource
174
+ source: pipeline.ts
175
+ composite: NodePipeline
176
+ composite_instance: app
177
+ ---
178
+ Declared in \`pipeline.ts\`.
179
+ ",
180
+ "path": "gitlab/appTest.md",
181
+ },
182
+ {
183
+ "content": "---
184
+ okf_version: '0.2'
185
+ ---
186
+
187
+ # Lexicon: gitlab
188
+
189
+ * [appBuild](/gitlab/appBuild.md) - gitlab resource of type GitLab::CI::Job
190
+ * [appDefaults](/gitlab/appDefaults.md) - gitlab resource of type GitLab::CI::Default
191
+ * [appTest](/gitlab/appTest.md) - gitlab resource of type GitLab::CI::Job
192
+ ",
193
+ "path": "index.md",
194
+ },
195
+ ]
196
+ `;
197
+
198
+ exports[`buildOkfBundle over shipped examples > k8s layered-config emits a conformant, snapshot-stable bundle 1`] = `
199
+ [
200
+ {
201
+ "content": "---
202
+ okf_version: '0.2'
203
+ ---
204
+
205
+ # Lexicon: k8s
206
+
207
+ * [devAppDeployment](/k8s/devAppDeployment.md) - k8s resource of type K8s::Apps::Deployment
208
+ * [devAppService](/k8s/devAppService.md) - k8s resource of type K8s::Core::Service
209
+ * [prodAppDeployment](/k8s/prodAppDeployment.md) - k8s resource of type K8s::Apps::Deployment
210
+ * [prodAppIngress](/k8s/prodAppIngress.md) - k8s resource of type K8s::Networking::Ingress
211
+ * [prodAppPdb](/k8s/prodAppPdb.md) - k8s resource of type K8s::Policy::PodDisruptionBudget
212
+ * [prodAppService](/k8s/prodAppService.md) - k8s resource of type K8s::Core::Service
213
+ * [stagingAppDeployment](/k8s/stagingAppDeployment.md) - k8s resource of type K8s::Apps::Deployment
214
+ * [stagingAppIngress](/k8s/stagingAppIngress.md) - k8s resource of type K8s::Networking::Ingress
215
+ * [stagingAppPdb](/k8s/stagingAppPdb.md) - k8s resource of type K8s::Policy::PodDisruptionBudget
216
+ * [stagingAppService](/k8s/stagingAppService.md) - k8s resource of type K8s::Core::Service
217
+ ",
218
+ "path": "index.md",
219
+ },
220
+ {
221
+ "content": "---
222
+ type: K8s::Apps::Deployment
223
+ title: devAppDeployment
224
+ description: k8s resource of type K8s::Apps::Deployment
225
+ name: devAppDeployment
226
+ lexicon: k8s
227
+ kind: resource
228
+ source: web.ts
229
+ composite: WebApp
230
+ composite_instance: devApp
231
+ ---
232
+ Declared in \`web.ts\`.
233
+ ",
234
+ "path": "k8s/devAppDeployment.md",
235
+ },
236
+ {
237
+ "content": "---
238
+ type: K8s::Core::Service
239
+ title: devAppService
240
+ description: k8s resource of type K8s::Core::Service
241
+ name: devAppService
242
+ lexicon: k8s
243
+ kind: resource
244
+ source: web.ts
245
+ composite: WebApp
246
+ composite_instance: devApp
247
+ ---
248
+ Declared in \`web.ts\`.
249
+ ",
250
+ "path": "k8s/devAppService.md",
251
+ },
252
+ {
253
+ "content": "---
254
+ type: K8s::Apps::Deployment
255
+ title: prodAppDeployment
256
+ description: k8s resource of type K8s::Apps::Deployment
257
+ name: prodAppDeployment
258
+ lexicon: k8s
259
+ kind: resource
260
+ source: web.ts
261
+ composite: WebApp
262
+ composite_instance: prodApp
263
+ ---
264
+ Declared in \`web.ts\`.
265
+ ",
266
+ "path": "k8s/prodAppDeployment.md",
267
+ },
268
+ {
269
+ "content": "---
270
+ type: K8s::Networking::Ingress
271
+ title: prodAppIngress
272
+ description: k8s resource of type K8s::Networking::Ingress
273
+ name: prodAppIngress
274
+ lexicon: k8s
275
+ kind: resource
276
+ source: web.ts
277
+ composite: WebApp
278
+ composite_instance: prodApp
279
+ ---
280
+ Declared in \`web.ts\`.
281
+ ",
282
+ "path": "k8s/prodAppIngress.md",
283
+ },
284
+ {
285
+ "content": "---
286
+ type: K8s::Policy::PodDisruptionBudget
287
+ title: prodAppPdb
288
+ description: k8s resource of type K8s::Policy::PodDisruptionBudget
289
+ name: prodAppPdb
290
+ lexicon: k8s
291
+ kind: resource
292
+ source: web.ts
293
+ composite: WebApp
294
+ composite_instance: prodApp
295
+ ---
296
+ Declared in \`web.ts\`.
297
+ ",
298
+ "path": "k8s/prodAppPdb.md",
299
+ },
300
+ {
301
+ "content": "---
302
+ type: K8s::Core::Service
303
+ title: prodAppService
304
+ description: k8s resource of type K8s::Core::Service
305
+ name: prodAppService
306
+ lexicon: k8s
307
+ kind: resource
308
+ source: web.ts
309
+ composite: WebApp
310
+ composite_instance: prodApp
311
+ ---
312
+ Declared in \`web.ts\`.
313
+ ",
314
+ "path": "k8s/prodAppService.md",
315
+ },
316
+ {
317
+ "content": "---
318
+ type: K8s::Apps::Deployment
319
+ title: stagingAppDeployment
320
+ description: k8s resource of type K8s::Apps::Deployment
321
+ name: stagingAppDeployment
322
+ lexicon: k8s
323
+ kind: resource
324
+ source: web.ts
325
+ composite: WebApp
326
+ composite_instance: stagingApp
327
+ ---
328
+ Declared in \`web.ts\`.
329
+ ",
330
+ "path": "k8s/stagingAppDeployment.md",
331
+ },
332
+ {
333
+ "content": "---
334
+ type: K8s::Networking::Ingress
335
+ title: stagingAppIngress
336
+ description: k8s resource of type K8s::Networking::Ingress
337
+ name: stagingAppIngress
338
+ lexicon: k8s
339
+ kind: resource
340
+ source: web.ts
341
+ composite: WebApp
342
+ composite_instance: stagingApp
343
+ ---
344
+ Declared in \`web.ts\`.
345
+ ",
346
+ "path": "k8s/stagingAppIngress.md",
347
+ },
348
+ {
349
+ "content": "---
350
+ type: K8s::Policy::PodDisruptionBudget
351
+ title: stagingAppPdb
352
+ description: k8s resource of type K8s::Policy::PodDisruptionBudget
353
+ name: stagingAppPdb
354
+ lexicon: k8s
355
+ kind: resource
356
+ source: web.ts
357
+ composite: WebApp
358
+ composite_instance: stagingApp
359
+ ---
360
+ Declared in \`web.ts\`.
361
+ ",
362
+ "path": "k8s/stagingAppPdb.md",
363
+ },
364
+ {
365
+ "content": "---
366
+ type: K8s::Core::Service
367
+ title: stagingAppService
368
+ description: k8s resource of type K8s::Core::Service
369
+ name: stagingAppService
370
+ lexicon: k8s
371
+ kind: resource
372
+ source: web.ts
373
+ composite: WebApp
374
+ composite_instance: stagingApp
375
+ ---
376
+ Declared in \`web.ts\`.
377
+ ",
378
+ "path": "k8s/stagingAppService.md",
379
+ },
380
+ ]
381
+ `;
@@ -4,6 +4,7 @@ import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import { carveApply, formatCarveApply } from "./carve-apply";
6
6
  import { loadHcl2json } from "../../terraform/parse";
7
+ import { readCarveManifest, writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
7
8
 
8
9
  let parserAvailable = false;
9
10
  try {
@@ -30,10 +31,101 @@ async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
30
31
  }
31
32
  }
32
33
 
34
+ function manifestFor(target: string, dir: string): CarveManifest {
35
+ return {
36
+ version: 1,
37
+ target,
38
+ from: dir,
39
+ boundary: {
40
+ target,
41
+ carveSet: [{ address: target, type: target.split(".")[0] }],
42
+ peelability: 90,
43
+ inbound: [],
44
+ outbound: [],
45
+ reversible: true,
46
+ diagnostics: [],
47
+ },
48
+ };
49
+ }
50
+
33
51
  describe("carveApply", () => {
34
- test("requires --from and --select", async () => {
52
+ test("requires --from; without --select it needs a carve manifest", async () => {
35
53
  expect((await carveApply({})).error).toContain("--from");
36
- expect((await carveApply({ from: "/x" })).error).toContain("--select");
54
+ await withEstate(async (dir) => {
55
+ const res = await carveApply({ from: dir });
56
+ expect(res.ok).toBe(false);
57
+ expect(res.error).toContain("--select");
58
+ });
59
+ });
60
+
61
+ test("composes with the carve manifest: target resolved without --select, graduation recorded", async () => {
62
+ if (!parserAvailable) return;
63
+ await withEstate(async (dir) => {
64
+ const out = join(dir, "carveout");
65
+ writeCarveManifest(out, manifestFor("aws_s3_bucket.assets", dir));
66
+
67
+ const res = await carveApply({ from: dir, output: out, env: "prod", stack: "assets" });
68
+ expect(res.ok).toBe(true);
69
+ expect(res.selectFromManifest).toBe(true);
70
+ expect(formatCarveApply(res)).toContain("target from the carve manifest");
71
+
72
+ const m = readCarveManifest(res.manifestPath!)!;
73
+ expect(m.apply!.marker).toEqual({ stack: "assets", env: "prod" });
74
+ expect(m.apply!.ownershipTags["chant:managed-by"]).toBe("chant");
75
+ });
76
+ });
77
+
78
+ test("--write-source stamps the ownership marker into the emitted source and records it", async () => {
79
+ if (!parserAvailable) return;
80
+ await withEstate(async (dir) => {
81
+ const out = join(dir, "carveout");
82
+ const emitted = join(out, "assets.ts");
83
+ const manifest = manifestFor("aws_s3_bucket.assets", dir);
84
+ manifest.emit = { source: "tfstate", files: [emitted], at: "t" };
85
+ writeCarveManifest(out, manifest);
86
+ writeFileSync(emitted, 'export const assets = new Bucket({\n BucketName: "myapp-assets-prod",\n});\n');
87
+
88
+ const res = await carveApply({ from: dir, output: out, env: "prod", stack: "assets", writeSource: true });
89
+ expect(res.ok).toBe(true);
90
+ expect(res.stamped).toEqual([emitted]);
91
+
92
+ const stamped = readFileSync(emitted, "utf-8");
93
+ expect(stamped).toContain('{"Key":"chant:managed-by","Value":"chant"}');
94
+ expect(stamped).toContain('{"Key":"chant:stack","Value":"assets"}');
95
+ expect(stamped).toContain('{"Key":"chant:env","Value":"prod"}');
96
+
97
+ const m = readCarveManifest(res.manifestPath!)!;
98
+ expect(m.apply!.stampedFiles).toEqual([emitted]);
99
+ expect(formatCarveApply(res)).toContain("Stamped the ownership marker into");
100
+
101
+ // Idempotent: a second graduation leaves the source stable.
102
+ const again = await carveApply({ from: dir, output: out, env: "prod", stack: "assets", writeSource: true });
103
+ expect(again.ok).toBe(true);
104
+ expect(readFileSync(emitted, "utf-8")).toBe(stamped);
105
+ });
106
+ });
107
+
108
+ test("--write-source without an emit record in the manifest is a clear error", async () => {
109
+ if (!parserAvailable) return;
110
+ await withEstate(async (dir) => {
111
+ const out = join(dir, "carveout");
112
+ writeCarveManifest(out, manifestFor("aws_s3_bucket.assets", dir));
113
+ const res = await carveApply({ from: dir, output: out, env: "prod", writeSource: true });
114
+ expect(res.ok).toBe(false);
115
+ expect(res.error).toContain("carve emit");
116
+ });
117
+ });
118
+
119
+ test("falls back to the manifest's persisted boundary when the block is already excised", async () => {
120
+ if (!parserAvailable) return;
121
+ await withEstate(async (dir) => {
122
+ const out = join(dir, "carveout");
123
+ writeCarveManifest(out, manifestFor("aws_s3_bucket.gone", dir));
124
+
125
+ const res = await carveApply({ from: dir, output: out, env: "prod" });
126
+ expect(res.ok).toBe(true);
127
+ expect(res.plan!.target).toBe("aws_s3_bucket.gone");
128
+ });
37
129
  });
38
130
 
39
131
  test("plans graduation with ownership marker; writes nothing by default", async () => {
@@ -9,14 +9,16 @@
9
9
  * lifecycle you brought.
10
10
  */
11
11
 
12
- import { existsSync, statSync, mkdirSync, writeFileSync } from "fs";
12
+ import { existsSync, statSync, mkdirSync, readFileSync, writeFileSync } from "fs";
13
13
  import { join } from "path";
14
14
  import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
15
15
  import { boundaryReport } from "../../terraform/carve";
16
- import { graduationPlan, type GraduationPlan } from "../../terraform/graduate";
16
+ import { graduationPlan, stampOwnershipIntoSource, type GraduationPlan } from "../../terraform/graduate";
17
+ import { resolveCarveManifest, updateCarveManifest } from "../../terraform/manifest";
17
18
 
18
19
  export interface CarveApplyOptions {
19
20
  from?: string;
21
+ /** Optional when the output dir holds a single carve manifest from `carve emit`. */
20
22
  select?: string;
21
23
  env?: string;
22
24
  stack?: string;
@@ -24,6 +26,8 @@ export interface CarveApplyOptions {
24
26
  output?: string;
25
27
  /** Write the graduation doc to the output dir. */
26
28
  write?: boolean;
29
+ /** Stamp the ownership marker into the emitted chant source. */
30
+ writeSource?: boolean;
27
31
  }
28
32
 
29
33
  export interface CarveApplyResult {
@@ -31,36 +35,77 @@ export interface CarveApplyResult {
31
35
  error?: string;
32
36
  plan?: GraduationPlan;
33
37
  written?: string;
38
+ /** Emitted source files the ownership marker was stamped into. */
39
+ stamped?: string[];
40
+ /** The carve state manifest this graduation was recorded into. */
41
+ manifestPath?: string;
42
+ /** True when the target came from the manifest, not --select. */
43
+ selectFromManifest?: boolean;
34
44
  }
35
45
 
36
46
  export async function carveApply(opts: CarveApplyOptions): Promise<CarveApplyResult> {
37
47
  if (!opts.from) return { ok: false, error: "chant carve apply requires --from <terraform-dir>" };
38
- if (!opts.select) return { ok: false, error: "chant carve apply requires --select <tf-address>" };
39
48
  if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
40
49
  return { ok: false, error: `Not a directory: ${opts.from}` };
41
50
  }
42
51
 
52
+ // Compose with the manifest emit/bridge persisted: it supplies the target
53
+ // (and tfstate) when --select is absent, and its persisted boundary stands in
54
+ // when the carved block is already excised from the estate.
55
+ const outDir = opts.output ?? join(opts.from, "carveout");
56
+ const resolved = resolveCarveManifest(outDir, opts.select);
57
+ if (!opts.select && resolved.error) return { ok: false, error: resolved.error };
58
+ const select = opts.select ?? resolved.manifest!.target;
59
+ const statePath = opts.statePath ?? resolved.manifest?.statePath;
60
+
43
61
  let plan: GraduationPlan;
44
62
  try {
45
- const graph = await parseTerraformDir(opts.from, { statePath: opts.statePath });
46
- const report = boundaryReport(graph, opts.select);
47
- if (!report) return { ok: false, error: `${opts.select} not found in ${opts.from}` };
63
+ const graph = await parseTerraformDir(opts.from, { statePath });
64
+ const report = boundaryReport(graph, select) ?? resolved.manifest?.boundary ?? null;
65
+ if (!report) return { ok: false, error: `${select} not found in ${opts.from}` };
48
66
  plan = graduationPlan(report, { stack: opts.stack, env: opts.env });
49
67
  } catch (err) {
50
68
  if (err instanceof Hcl2JsonNotInstalled) return { ok: false, error: err.message };
51
69
  return { ok: false, error: `Failed to plan graduation: ${err instanceof Error ? err.message : String(err)}` };
52
70
  }
53
71
 
72
+ // Stamp the marker into the emitted source (`--write-source`) — the emitted
73
+ // files are known from the manifest, so the tags the plan promises are the
74
+ // tags the source actually carries into the apply.
75
+ let stamped: string[] | undefined;
76
+ if (opts.writeSource) {
77
+ const files = resolved.manifest?.emit?.files ?? [];
78
+ if (files.length === 0) {
79
+ return {
80
+ ok: false,
81
+ error: "--write-source needs the emitted source recorded in the carve manifest — run `chant carve emit` first.",
82
+ };
83
+ }
84
+ stamped = [];
85
+ for (const file of files) {
86
+ if (!existsSync(file)) return { ok: false, error: `Emitted source not found: ${file}` };
87
+ const result = stampOwnershipIntoSource(readFileSync(file, "utf-8"), plan.ownershipTags);
88
+ if (!result) return { ok: false, error: `No constructor to stamp in ${file}.` };
89
+ if (result.changed) writeFileSync(file, result.content);
90
+ stamped.push(file);
91
+ }
92
+ }
93
+
54
94
  let written: string | undefined;
55
95
  if (opts.write) {
56
- const outDir = opts.output ?? join(opts.from, "carveout");
57
96
  mkdirSync(outDir, { recursive: true });
58
- const slug = opts.select.replace(/[^A-Za-z0-9_]+/g, "-");
97
+ const slug = select.replace(/[^A-Za-z0-9_]+/g, "-");
59
98
  written = join(outDir, `${slug}-graduation.md`);
60
99
  writeFileSync(written, renderGraduationDoc(plan) + "\n");
61
100
  }
62
101
 
63
- return { ok: true, plan, written };
102
+ // Record the graduation into an existing manifest; never create one here —
103
+ // plan-only stays plan-only unless a carve flow is already in progress.
104
+ const manifestPath = updateCarveManifest(outDir, select, {
105
+ apply: { marker: plan.marker, ownershipTags: plan.ownershipTags, stampedFiles: stamped, at: new Date().toISOString() },
106
+ });
107
+
108
+ return { ok: true, plan, written, stamped, manifestPath, selectFromManifest: !opts.select };
64
109
  }
65
110
 
66
111
  function renderGraduationDoc(plan: GraduationPlan): string {
@@ -86,7 +131,7 @@ export function formatCarveApply(result: CarveApplyResult): string {
86
131
  if (!result.ok || !result.plan) return result.error ?? "carve apply failed";
87
132
  const p = result.plan;
88
133
  const L: string[] = [];
89
- L.push(`Apply graduation for ${p.target} (dial-turn: observe → apply).`);
134
+ L.push(`Apply graduation for ${p.target} (dial-turn: observe → apply)${result.selectFromManifest ? " — target from the carve manifest" : ""}.`);
90
135
  L.push(` Ownership marker: stack=${p.marker.stack}${p.marker.env ? `, env=${p.marker.env}` : ""}`);
91
136
  L.push(" Ownership tags (stamped on apply):");
92
137
  for (const [k, v] of Object.entries(p.ownershipTags)) L.push(` ${k} = ${v}`);
@@ -97,10 +142,16 @@ export function formatCarveApply(result: CarveApplyResult): string {
97
142
  for (const w of p.warnings) L.push(` ! ${w}`);
98
143
  }
99
144
  L.push("");
145
+ if (result.stamped?.length) {
146
+ L.push(`Stamped the ownership marker into: ${result.stamped.join(", ")}`);
147
+ }
100
148
  if (result.written) {
101
149
  L.push(`Wrote graduation doc: ${result.written}`);
102
- } else {
150
+ } else if (!result.stamped?.length) {
103
151
  L.push("Plan only — no cloud call, nothing written. The apply is your lifecycle (BYOL). Add --write to save this as a doc.");
104
152
  }
153
+ if (result.manifestPath) {
154
+ L.push(`Recorded in the carve manifest: ${result.manifestPath}`);
155
+ }
105
156
  return L.join("\n");
106
157
  }