@intentius/chant 0.41.19 → 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 (150) hide show
  1. package/dist/cli/build-params-cli.d.ts +7 -4
  2. package/dist/cli/build-params-cli.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/carve-apply.d.ts +9 -0
  5. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  6. package/dist/cli/commands/carve-bridge.d.ts +7 -0
  7. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  8. package/dist/cli/commands/carve-emit.d.ts +7 -0
  9. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  10. package/dist/cli/commands/import.d.ts +16 -0
  11. package/dist/cli/commands/import.d.ts.map +1 -1
  12. package/dist/cli/commands/init.d.ts +2 -0
  13. package/dist/cli/commands/init.d.ts.map +1 -1
  14. package/dist/cli/handlers/carve-apply.d.ts.map +1 -1
  15. package/dist/cli/handlers/components.d.ts.map +1 -1
  16. package/dist/cli/handlers/explain.d.ts +11 -0
  17. package/dist/cli/handlers/explain.d.ts.map +1 -0
  18. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  19. package/dist/cli/handlers/misc.d.ts.map +1 -1
  20. package/dist/cli/main.d.ts.map +1 -1
  21. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  22. package/dist/cli/registry.d.ts +4 -0
  23. package/dist/cli/registry.d.ts.map +1 -1
  24. package/dist/codegen/docs-rule-scanning.d.ts +11 -0
  25. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  26. package/dist/codegen/okf-lexicon.d.ts +36 -0
  27. package/dist/codegen/okf-lexicon.d.ts.map +1 -0
  28. package/dist/codegen/package.d.ts +2 -1
  29. package/dist/codegen/package.d.ts.map +1 -1
  30. package/dist/components/deploy-units.d.ts.map +1 -1
  31. package/dist/components/verbs/sbom.d.ts +1 -1
  32. package/dist/components/verbs/sbom.d.ts.map +1 -1
  33. package/dist/components/verbs/vuln-gate.d.ts +15 -4
  34. package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
  35. package/dist/components/verbs/vuln-scan.d.ts +18 -6
  36. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  37. package/dist/config.d.ts +12 -0
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/deep-observation.d.ts +13 -0
  40. package/dist/deep-observation.d.ts.map +1 -1
  41. package/dist/governance.d.ts +37 -0
  42. package/dist/governance.d.ts.map +1 -0
  43. package/dist/lexicon.d.ts +3 -0
  44. package/dist/lexicon.d.ts.map +1 -1
  45. package/dist/lifecycle/observe.d.ts.map +1 -1
  46. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  47. package/dist/okf.d.ts +57 -0
  48. package/dist/okf.d.ts.map +1 -0
  49. package/dist/reconcile.d.ts +15 -0
  50. package/dist/reconcile.d.ts.map +1 -1
  51. package/dist/terraform/__fixtures__/build-graph.d.ts +13 -0
  52. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -0
  53. package/dist/terraform/adopt-state.d.ts +33 -1
  54. package/dist/terraform/adopt-state.d.ts.map +1 -1
  55. package/dist/terraform/bridge.d.ts +7 -1
  56. package/dist/terraform/bridge.d.ts.map +1 -1
  57. package/dist/terraform/carve.d.ts +12 -0
  58. package/dist/terraform/carve.d.ts.map +1 -1
  59. package/dist/terraform/excise.d.ts +31 -0
  60. package/dist/terraform/excise.d.ts.map +1 -0
  61. package/dist/terraform/graduate.d.ts +12 -0
  62. package/dist/terraform/graduate.d.ts.map +1 -1
  63. package/dist/terraform/graph.d.ts +35 -5
  64. package/dist/terraform/graph.d.ts.map +1 -1
  65. package/dist/terraform/manifest.d.ts +89 -0
  66. package/dist/terraform/manifest.d.ts.map +1 -0
  67. package/dist/terraform/parse.d.ts +9 -4
  68. package/dist/terraform/parse.d.ts.map +1 -1
  69. package/dist/terraform/types.d.ts +7 -0
  70. package/dist/terraform/types.d.ts.map +1 -1
  71. package/dist/terraform/unified-diff.d.ts +11 -0
  72. package/dist/terraform/unified-diff.d.ts.map +1 -0
  73. package/package.json +1 -1
  74. package/src/__snapshots__/okf.test.ts.snap +381 -0
  75. package/src/cli/build-params-cli.test.ts +47 -3
  76. package/src/cli/build-params-cli.ts +22 -5
  77. package/src/cli/commands/build.test.ts +44 -0
  78. package/src/cli/commands/build.ts +15 -2
  79. package/src/cli/commands/carve-apply.test.ts +94 -2
  80. package/src/cli/commands/carve-apply.ts +62 -11
  81. package/src/cli/commands/carve-bridge.test.ts +82 -4
  82. package/src/cli/commands/carve-bridge.ts +79 -11
  83. package/src/cli/commands/carve-emit-state.test.ts +110 -1
  84. package/src/cli/commands/carve-emit.ts +184 -8
  85. package/src/cli/commands/import.ts +53 -5
  86. package/src/cli/commands/init.ts +1 -1
  87. package/src/cli/handlers/build.test.ts +3 -1
  88. package/src/cli/handlers/carve-apply.ts +1 -0
  89. package/src/cli/handlers/components.ts +4 -2
  90. package/src/cli/handlers/explain.test.ts +93 -0
  91. package/src/cli/handlers/explain.ts +60 -0
  92. package/src/cli/handlers/lifecycle.ts +9 -7
  93. package/src/cli/handlers/misc.ts +40 -1
  94. package/src/cli/handlers/run.test.ts +3 -1
  95. package/src/cli/main.ts +25 -5
  96. package/src/cli/mcp/server.test.ts +17 -0
  97. package/src/cli/mcp/tools/explain.ts +15 -3
  98. package/src/cli/registry.ts +4 -0
  99. package/src/codegen/__snapshots__/okf-lexicon.test.ts.snap +170 -0
  100. package/src/codegen/docs-rule-scanning.ts +35 -14
  101. package/src/codegen/okf-lexicon.test.ts +248 -0
  102. package/src/codegen/okf-lexicon.ts +303 -0
  103. package/src/codegen/package.ts +29 -4
  104. package/src/codegen/publish-order.test.ts +1 -1
  105. package/src/codegen/release-wiring.test.ts +7 -3
  106. package/src/components/config-defaults.test.ts +30 -0
  107. package/src/components/deploy-units.ts +3 -0
  108. package/src/components/verbs/__fixtures__/grype-with-kev-epss.json +1 -0
  109. package/src/components/verbs/__fixtures__/trivy-with-kev-epss.json +631 -0
  110. package/src/components/verbs/exploitability-roundtrip.test.ts +190 -0
  111. package/src/components/verbs/sbom.ts +1 -1
  112. package/src/components/verbs/vuln-gate.test.ts +144 -1
  113. package/src/components/verbs/vuln-gate.ts +61 -10
  114. package/src/components/verbs/vuln-scan.test.ts +94 -0
  115. package/src/components/verbs/vuln-scan.ts +44 -9
  116. package/src/config.test.ts +21 -0
  117. package/src/config.ts +18 -0
  118. package/src/deep-observation.test.ts +19 -0
  119. package/src/deep-observation.ts +32 -13
  120. package/src/governance.test.ts +72 -0
  121. package/src/governance.ts +54 -0
  122. package/src/lexicon.ts +3 -0
  123. package/src/lifecycle/observe.test.ts +18 -0
  124. package/src/lifecycle/observe.ts +10 -3
  125. package/src/lifecycle/snapshot.ts +5 -4
  126. package/src/okf.test.ts +169 -0
  127. package/src/okf.ts +308 -0
  128. package/src/reconcile.ts +32 -1
  129. package/src/terraform/__fixtures__/build-graph.ts +25 -0
  130. package/src/terraform/adopt-state.test.ts +34 -1
  131. package/src/terraform/adopt-state.ts +69 -2
  132. package/src/terraform/bridge.test.ts +38 -14
  133. package/src/terraform/bridge.ts +48 -14
  134. package/src/terraform/carve.test.ts +9 -7
  135. package/src/terraform/carve.ts +0 -0
  136. package/src/terraform/excise.test.ts +92 -0
  137. package/src/terraform/excise.ts +139 -0
  138. package/src/terraform/graduate.test.ts +73 -6
  139. package/src/terraform/graduate.ts +49 -0
  140. package/src/terraform/graph.test.ts +56 -10
  141. package/src/terraform/graph.ts +112 -45
  142. package/src/terraform/manifest.test.ts +105 -0
  143. package/src/terraform/manifest.ts +162 -0
  144. package/src/terraform/parse.test.ts +39 -1
  145. package/src/terraform/parse.ts +36 -9
  146. package/src/terraform/score.test.ts +8 -8
  147. package/src/terraform/state.test.ts +4 -4
  148. package/src/terraform/types.ts +7 -0
  149. package/src/terraform/unified-diff.test.ts +77 -0
  150. package/src/terraform/unified-diff.ts +146 -0
@@ -4,6 +4,7 @@ import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import { carveEmit, formatCarveEmit } from "./carve-emit";
6
6
  import { loadHcl2json } from "../../terraform/parse";
7
+ import { readCarveManifest } from "../../terraform/manifest";
7
8
  import type { ImportResult, LiveImportOptions } from "./import";
8
9
  import type { LexiconPlugin } from "../../lexicon";
9
10
 
@@ -89,7 +90,7 @@ describe("carve emit --state (real adoption from tfstate)", () => {
89
90
  expect(liveImport).not.toHaveBeenCalled(); // offline — no cloud
90
91
 
91
92
  // A real .ts file with the native constructor + props from real state.
92
- expect(res.emittedFiles).toEqual([join(out, "assets.ts")]);
93
+ expect(res.emittedFiles).toEqual([join(out, "src", "assets.ts")]);
93
94
  const emitted = readFileSync(res.emittedFiles![0], "utf-8");
94
95
  expect(emitted).toContain("new Bucket({");
95
96
  expect(emitted).toContain('BucketName: "myapp-assets-prod"');
@@ -98,11 +99,119 @@ describe("carve emit --state (real adoption from tfstate)", () => {
98
99
  // Boundary still classified (the Lambda inbound).
99
100
  expect(res.report!.inbound.map((e) => e.survivor)).toEqual(["aws_lambda_function.api"]);
100
101
 
102
+ // The carve state manifest persists boundary + selector for bridge/apply.
103
+ expect(res.manifestPath).toBe(join(out, "aws_s3_bucket-assets.carve.json"));
104
+ const manifest = readCarveManifest(res.manifestPath!)!;
105
+ expect(manifest.target).toBe("aws_s3_bucket.assets");
106
+ expect(manifest.statePath).toBe(join(dir, "terraform.tfstate"));
107
+ expect(manifest.emit!.source).toBe("tfstate");
108
+ expect(manifest.emit!.files).toEqual([join(out, "src", "assets.ts")]);
109
+ expect(manifest.boundary.inbound.map((e) => e.survivor)).toEqual(["aws_lambda_function.api"]);
110
+
101
111
  const text = formatCarveEmit(res);
102
112
  expect(text).toContain("Adopted from Terraform state (offline)");
113
+ expect(text).toContain("State manifest");
114
+ expect(text).toContain("Scaffolded a buildable chant project");
103
115
  });
104
116
  });
105
117
 
118
+ test("scaffolds a buildable chant project around the emitted source, never overwriting", async () => {
119
+ if (!parserAvailable) return;
120
+ await withEstate(async (dir) => {
121
+ const out = join(dir, "carveout");
122
+ const res = await carveEmit(
123
+ { from: dir, select: "aws_s3_bucket.assets", statePath: join(dir, "terraform.tfstate"), output: out },
124
+ { plugins: [], liveImport },
125
+ );
126
+ expect(res.ok).toBe(true);
127
+ expect(res.scaffolded!.map((f) => f.slice(out.length + 1)).sort()).toEqual([
128
+ "chant.config.ts",
129
+ "package.json",
130
+ "tsconfig.json",
131
+ ]);
132
+
133
+ const pkg = JSON.parse(readFileSync(join(out, "package.json"), "utf-8"));
134
+ expect(pkg.scripts.build).toBe("chant build src --lexicon aws");
135
+ expect(Object.keys(pkg.dependencies)).toEqual(["@intentius/chant", "@intentius/chant-lexicon-aws"]);
136
+ expect(readFileSync(join(out, "chant.config.ts"), "utf-8")).toContain('lexicons: ["aws"]');
137
+
138
+ // A second emit into the same dir leaves the scaffold (and edits) alone.
139
+ writeFileSync(join(out, "package.json"), '{"name":"edited"}');
140
+ const again = await carveEmit(
141
+ { from: dir, select: "aws_s3_bucket.assets", statePath: join(dir, "terraform.tfstate"), output: out },
142
+ { plugins: [], liveImport },
143
+ );
144
+ expect(again.ok).toBe(true);
145
+ expect(again.scaffolded).toEqual([]);
146
+ expect(readFileSync(join(out, "package.json"), "utf-8")).toBe('{"name":"edited"}');
147
+ });
148
+ });
149
+
150
+ test("deferred outbound inputs become declared build params (#998)", async () => {
151
+ if (!parserAvailable) return;
152
+ const dir = mkdtempSync(join(tmpdir(), "chant-emit-params-"));
153
+ try {
154
+ writeFileSync(
155
+ join(dir, "main.tf"),
156
+ `
157
+ resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" }
158
+ resource "aws_subnet" "a" {
159
+ vpc_id = aws_vpc.main.id
160
+ cidr_block = "10.0.1.0/24"
161
+ }
162
+ `,
163
+ );
164
+ writeFileSync(
165
+ join(dir, "terraform.tfstate"),
166
+ JSON.stringify({
167
+ version: 4,
168
+ resources: [
169
+ {
170
+ mode: "managed",
171
+ type: "aws_subnet",
172
+ name: "a",
173
+ instances: [{ attributes: { id: "subnet-0aa", vpc_id: "vpc-0abc", cidr_block: "10.0.1.0/24" } }],
174
+ },
175
+ {
176
+ mode: "managed",
177
+ type: "aws_vpc",
178
+ name: "main",
179
+ instances: [{ attributes: { id: "vpc-0abc", cidr_block: "10.0.0.0/16" } }],
180
+ },
181
+ ],
182
+ }),
183
+ );
184
+ const out = join(dir, "carveout");
185
+ const res = await carveEmit(
186
+ { from: dir, select: "aws_subnet.a", statePath: join(dir, "terraform.tfstate"), output: out },
187
+ { plugins: [], liveImport },
188
+ );
189
+ expect(res.ok).toBe(true);
190
+
191
+ // The survivor-fed prop is a params reference; the rest stay literal.
192
+ const emitted = readFileSync(join(out, "src", "a.ts"), "utf-8");
193
+ expect(emitted).toContain('import { params } from "@intentius/chant/params";');
194
+ expect(emitted).toContain("VpcId: params.vpc_id as string,");
195
+ expect(emitted).toContain('CidrBlock: "10.0.1.0/24"');
196
+
197
+ // The scaffolded config declares the param, defaulted from state.
198
+ const config = readFileSync(join(out, "chant.config.ts"), "utf-8");
199
+ expect(config).toContain("buildParams: {");
200
+ expect(config).toContain("vpc_id: {");
201
+ expect(config).toContain('default: "vpc-0abc",');
202
+ expect(config).toContain("was aws_vpc.main.id in Terraform");
203
+
204
+ // Recorded in the manifest, reported in the summary.
205
+ const manifest = readCarveManifest(res.manifestPath!)!;
206
+ expect(manifest.emit!.params).toEqual({
207
+ vpc_id: { tfAttr: "vpc_id", survivor: "aws_vpc.main", attrs: ["id"], default: "vpc-0abc" },
208
+ });
209
+ expect(formatCarveEmit(res)).toContain("vpc_id — was aws_vpc.main.id");
210
+ } finally {
211
+ rmSync(dir, { recursive: true, force: true });
212
+ }
213
+ });
214
+
106
215
  test("without --state or --env it explains both adoption sources", async () => {
107
216
  if (!parserAvailable) return;
108
217
  await withEstate(async (dir) => {
@@ -13,12 +13,14 @@
13
13
  */
14
14
 
15
15
  import { existsSync, statSync, writeFileSync, mkdirSync } from "fs";
16
- import { join } from "path";
16
+ import { basename, join, resolve } from "path";
17
17
  import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
18
- import { boundaryReport, type CarveReport } from "../../terraform/carve";
18
+ import { boundaryReport, deferredParamName, type CarveReport } from "../../terraform/carve";
19
19
  import { resolveTier } from "../../terraform/tier-map";
20
20
  import { readStateResource } from "../../terraform/state";
21
- import { adoptFromState, canAdoptFromState, supportedStateAdoptionTypes } from "../../terraform/adopt-state";
21
+ import { writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
22
+ import { adoptFromState, canAdoptFromState, supportedStateAdoptionTypes, type DeferredParam } from "../../terraform/adopt-state";
23
+ import { getChantVersion } from "./init";
22
24
  import type { LexiconPlugin, ResourceSelector } from "../../lexicon";
23
25
  import type { ImportResult, LiveImportOptions } from "./import";
24
26
 
@@ -61,6 +63,12 @@ export interface CarveEmitResult {
61
63
  source?: "tfstate" | "live";
62
64
  /** Emitted file path(s). */
63
65
  emittedFiles?: string[];
66
+ /** Project files scaffolded around the emitted source (config, package.json). */
67
+ scaffolded?: string[];
68
+ /** Deferred outbound inputs declared as build parameters in the emitted project (#998). */
69
+ params?: DeferredParam[];
70
+ /** The persisted carve state manifest bridge/apply compose with. */
71
+ manifestPath?: string;
64
72
  }
65
73
 
66
74
  export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Promise<CarveEmitResult> {
@@ -113,15 +121,25 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
113
121
  if (!stateResource) {
114
122
  return { ok: false, error: `${opts.select} not found in state ${opts.statePath} (or is a data source / module-nested).` };
115
123
  }
116
- const adopted = adoptFromState(stateResource);
124
+ // Deferred outbound inputs → real build parameters (#998): each survivor
125
+ // value the carved block read enters the emitted project as a declared
126
+ // param, defaulted to the value the state resolved.
127
+ const params = deferredParams(report, stateResource.attributes);
128
+ const adopted = adoptFromState(stateResource, params);
117
129
  if (!adopted) return { ok: false, error: `Could not adopt ${opts.select} from state.` };
118
130
 
131
+ // The output dir is a buildable chant project: source in src/, plus the
132
+ // config + package.json scaffold (written once, never overwritten).
119
133
  const outDir = opts.output ?? join(opts.from, "carveout");
120
- mkdirSync(outDir, { recursive: true });
121
- const outPath = join(outDir, adopted.fileName);
134
+ const srcDir = join(outDir, "src");
135
+ mkdirSync(srcDir, { recursive: true });
136
+ const outPath = join(srcDir, adopted.fileName);
122
137
  writeFileSync(outPath, adopted.content);
138
+ const lexicon = tier.mapsTo.split("::")[0]?.toLowerCase() ?? "aws";
139
+ const scaffolded = scaffoldProject(outDir, lexicon, params);
123
140
 
124
- return { ok: true, report, source: "tfstate", emittedFiles: [outPath] };
141
+ const manifestPath = persistManifest(outDir, opts, report, tfType, "tfstate", [outPath], params);
142
+ return { ok: true, report, source: "tfstate", emittedFiles: [outPath], scaffolded, manifestPath, params };
125
143
  }
126
144
 
127
145
  // ── Adoption path 2: live import (cloud→code) ──
@@ -140,7 +158,152 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
140
158
  lexicon: tier.mapsTo.split("::")[0]?.toLowerCase() === "aws" ? "aws" : undefined,
141
159
  });
142
160
 
143
- return { ok: true, report, emit, selector, source: "live", emittedFiles: emit.generatedFiles };
161
+ const outDir = opts.output ?? join(opts.from, "carveout");
162
+ const manifestPath = persistManifest(outDir, opts, report, tfType, "live", emit.generatedFiles ?? []);
163
+ return { ok: true, report, emit, selector, source: "live", emittedFiles: emit.generatedFiles, manifestPath };
164
+ }
165
+
166
+ /**
167
+ * Turn the boundary report's outbound edges into build parameters (#998): one
168
+ * per carved-block attribute that read a survivor (`via`), named after that
169
+ * attribute, defaulted to the value the state resolved for it. A non-scalar
170
+ * value (a nested block) is declared without a default (`required: false`) —
171
+ * it documents the deferred input without blocking an out-of-the-box build.
172
+ */
173
+ function deferredParams(report: CarveReport, attributes: Record<string, unknown>): DeferredParam[] {
174
+ const byName = new Map<string, DeferredParam>();
175
+ for (const edge of report.outbound) {
176
+ for (const tfAttr of edge.via ?? []) {
177
+ const name = deferredParamName(tfAttr);
178
+ const raw = attributes[tfAttr];
179
+ const scalar = typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean" ? raw : undefined;
180
+ const prev = byName.get(name);
181
+ byName.set(name, {
182
+ name,
183
+ tfAttr,
184
+ survivor: prev && prev.survivor !== edge.survivor ? `${prev.survivor}, ${edge.survivor}` : edge.survivor,
185
+ attrs: [...new Set([...(prev?.attrs ?? []), ...edge.attrs])].sort(),
186
+ default: scalar,
187
+ });
188
+ }
189
+ }
190
+ return [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
191
+ }
192
+
193
+ /**
194
+ * Scaffold the emitted source into a buildable chant project: chant.config.ts,
195
+ * package.json, tsconfig.json — same shape `chant init` produces, so
196
+ * `npm install && npm run build` works in the output dir as-is. Existing files
197
+ * are never overwritten (re-emits and user edits survive). Deferred inputs are
198
+ * declared as `buildParams` (#998), defaults taken from the state's resolved
199
+ * values, so the first build reproduces what is live while every survivor-fed
200
+ * value stays overridable (`--param <name>=<value>`).
201
+ */
202
+ function scaffoldProject(outDir: string, lexicon: string, params: DeferredParam[] = []): string[] {
203
+ const ver = getChantVersion();
204
+ const packageJson = {
205
+ name: "chant-carveout",
206
+ version: "0.1.0",
207
+ type: "module" as const,
208
+ scripts: {
209
+ build: `chant build src --lexicon ${lexicon}`,
210
+ lint: "chant lint src",
211
+ },
212
+ dependencies: {
213
+ "@intentius/chant": `^${ver}`,
214
+ [`@intentius/chant-lexicon-${lexicon}`]: `^${ver}`,
215
+ },
216
+ devDependencies: {
217
+ typescript: "^5.0.0",
218
+ },
219
+ };
220
+ const tsconfig = {
221
+ compilerOptions: {
222
+ target: "ES2022",
223
+ module: "NodeNext",
224
+ moduleResolution: "NodeNext",
225
+ strict: true,
226
+ esModuleInterop: true,
227
+ skipLibCheck: true,
228
+ },
229
+ include: ["src"],
230
+ exclude: ["node_modules"],
231
+ };
232
+ const files: Array<[string, string]> = [
233
+ ["package.json", JSON.stringify(packageJson, null, 2) + "\n"],
234
+ ["chant.config.ts", renderConfig(lexicon, params)],
235
+ ["tsconfig.json", JSON.stringify(tsconfig, null, 2) + "\n"],
236
+ ];
237
+
238
+ const written: string[] = [];
239
+ for (const [name, content] of files) {
240
+ const path = join(outDir, name);
241
+ if (existsSync(path)) continue;
242
+ writeFileSync(path, content);
243
+ written.push(path);
244
+ }
245
+ return written;
246
+ }
247
+
248
+ /** Render the scaffolded `chant.config.ts`, declaring deferred inputs as `buildParams` (#998). */
249
+ function renderConfig(lexicon: string, params: DeferredParam[]): string {
250
+ const L: string[] = [];
251
+ L.push(`import type { ChantConfig } from "@intentius/chant";`);
252
+ L.push("");
253
+ L.push("export default {");
254
+ L.push(` lexicons: ["${lexicon}"],`);
255
+ if (params.length) {
256
+ L.push(" // Deferred deploy-time inputs — values the Terraform source read from");
257
+ L.push(" // surviving resources. Defaults are the values the tfstate resolved;");
258
+ L.push(" // override per build with --param <name>=<value>.");
259
+ L.push(" buildParams: {");
260
+ for (const p of params) {
261
+ const type = typeof p.default === "number" ? "number" : typeof p.default === "boolean" ? "boolean" : "string";
262
+ L.push(` ${p.name}: {`);
263
+ L.push(` type: ${JSON.stringify(type)},`);
264
+ if (p.default !== undefined) {
265
+ L.push(` default: ${JSON.stringify(p.default)},`);
266
+ } else {
267
+ L.push(` required: false,`);
268
+ }
269
+ L.push(` description: ${JSON.stringify(`was ${p.survivor}.${p.attrs.join("/")} in Terraform (deferred deploy-time input)`)},`);
270
+ L.push(" },");
271
+ }
272
+ L.push(" },");
273
+ }
274
+ L.push("} satisfies ChantConfig;");
275
+ return L.join("\n") + "\n";
276
+ }
277
+
278
+ /** Persist the carve state manifest so bridge/apply compose with this emit. */
279
+ function persistManifest(
280
+ outDir: string,
281
+ opts: CarveEmitOptions,
282
+ report: CarveReport,
283
+ tfType: string | undefined,
284
+ source: "tfstate" | "live",
285
+ files: string[],
286
+ params: DeferredParam[] = [],
287
+ ): string {
288
+ const manifest: CarveManifest = {
289
+ version: 1,
290
+ target: report.target,
291
+ tfType,
292
+ from: resolve(opts.from!),
293
+ statePath: opts.statePath ? resolve(opts.statePath) : undefined,
294
+ boundary: report,
295
+ emit: {
296
+ source,
297
+ files: files.map((f) => resolve(f)),
298
+ params: params.length
299
+ ? Object.fromEntries(
300
+ params.map((p) => [p.name, { tfAttr: p.tfAttr, survivor: p.survivor, attrs: p.attrs, default: p.default }]),
301
+ )
302
+ : undefined,
303
+ at: new Date().toISOString(),
304
+ },
305
+ };
306
+ return writeCarveManifest(outDir, manifest);
144
307
  }
145
308
 
146
309
  /** Human-readable emit summary: what was adopted and what boundary work remains. */
@@ -158,6 +321,19 @@ export function formatCarveEmit(result: CarveEmitResult): string {
158
321
  if (result.emittedFiles?.length) {
159
322
  lines.push(` Emitted: ${result.emittedFiles.join(", ")}`);
160
323
  }
324
+ if (result.scaffolded?.length) {
325
+ lines.push(` Scaffolded a buildable chant project: ${result.scaffolded.map((f) => basename(f)).join(", ")} (npm install && npm run build).`);
326
+ }
327
+ if (result.params?.length) {
328
+ lines.push(` Deferred input(s) declared as build params (override with --param <name>=<value>):`);
329
+ for (const p of result.params) {
330
+ const def = p.default !== undefined ? `default ${JSON.stringify(p.default)} from state` : "no default (required: false)";
331
+ lines.push(` ${p.name} — was ${p.survivor}.${p.attrs.join("/")}; ${def}`);
332
+ }
333
+ }
334
+ if (result.manifestPath) {
335
+ lines.push(` State manifest: ${result.manifestPath} — carve bridge/apply pick the target up from here.`);
336
+ }
161
337
  if (r.carveSet.length > 1) {
162
338
  const folded = r.carveSet.filter((m) => m.foldedInto).map((m) => m.address);
163
339
  lines.push(` Folded in: ${folded.join(", ")}`);
@@ -241,20 +241,68 @@ export async function importCommand(options: ImportOptions): Promise<ImportResul
241
241
 
242
242
  const lexicon = plugin.name;
243
243
 
244
+ return parseAndWrite(plugin, content, outputDir, options.force, warnings, generatedFiles, lexicon);
245
+ }
246
+
247
+ /**
248
+ * Import from an in-memory template string through a KNOWN plugin — no
249
+ * detection, no JSON assumption (#1548). This is the seam
250
+ * `chant import --kustomize <dir>` drives with `kustomize build` output
251
+ * through the k8s plugin's YAML parser; `importCommand` above is the same
252
+ * pipeline behind file reading + JSON detection.
253
+ */
254
+ export interface ContentImportOptions {
255
+ /** The raw template content (YAML or JSON — the plugin's parser decides). */
256
+ content: string;
257
+ /** The lexicon whose parser/generator handle it, e.g. "k8s". */
258
+ lexicon: string;
259
+ output?: string;
260
+ force?: boolean;
261
+ }
262
+
263
+ export async function importFromContent(options: ContentImportOptions): Promise<ImportResult> {
264
+ const outputDir = resolve(options.output ?? "./infra/");
265
+ let plugins: LexiconPlugin[];
266
+ try {
267
+ plugins = await loadPlugins([options.lexicon]);
268
+ } catch (err) {
269
+ return {
270
+ success: false,
271
+ generatedFiles: [],
272
+ warnings: [],
273
+ error: `Could not load lexicon "${options.lexicon}": ${err}`,
274
+ };
275
+ }
276
+ const plugin = plugins[0];
277
+ if (!plugin) {
278
+ return { success: false, generatedFiles: [], warnings: [], error: `Lexicon "${options.lexicon}" not available.` };
279
+ }
244
280
  if (!plugin.templateParser || !plugin.templateGenerator) {
245
281
  return {
246
282
  success: false,
247
283
  generatedFiles: [],
248
284
  warnings: [],
249
285
  error: `Lexicon "${plugin.name}" does not support template import.`,
250
- lexicon,
286
+ lexicon: plugin.name,
251
287
  };
252
288
  }
289
+ return parseAndWrite(plugin, options.content, outputDir, options.force, [], [], plugin.name);
290
+ }
253
291
 
292
+ /** The shared tail of every template import: parse → generate → write. */
293
+ function parseAndWrite(
294
+ plugin: LexiconPlugin,
295
+ content: string,
296
+ outputDir: string,
297
+ force: boolean | undefined,
298
+ warnings: string[],
299
+ generatedFiles: string[],
300
+ lexicon: string,
301
+ ): ImportResult {
254
302
  // Parse template
255
303
  let ir: TemplateIR;
256
304
  try {
257
- const parser = plugin.templateParser();
305
+ const parser = plugin.templateParser!();
258
306
  ir = parser.parse(content);
259
307
  } catch (err) {
260
308
  return {
@@ -265,10 +313,10 @@ export async function importCommand(options: ImportOptions): Promise<ImportResul
265
313
  };
266
314
  }
267
315
 
268
- const generator = plugin.templateGenerator();
316
+ const generator = plugin.templateGenerator!();
269
317
 
270
318
  // Check output directory
271
- if (existsSync(outputDir) && !options.force) {
319
+ if (existsSync(outputDir) && !force) {
272
320
  const files = readdirSync(outputDir);
273
321
  if (files.length > 0) {
274
322
  warnings.push(`Output directory ${outputDir} is not empty. Use --force to overwrite.`);
@@ -293,7 +341,7 @@ export async function importCommand(options: ImportOptions): Promise<ImportResul
293
341
  }
294
342
 
295
343
  // Check for existing file
296
- if (existsSync(filePath) && !options.force) {
344
+ if (existsSync(filePath) && !force) {
297
345
  warnings.push(`File ${file.path} already exists, skipping`);
298
346
  continue;
299
347
  }
@@ -7,7 +7,7 @@ import { formatSuccess, formatWarning } from "../format";
7
7
  import { loadPlugin } from "../plugins";
8
8
 
9
9
  /** Read the current chant package version from our own package.json. */
10
- function getChantVersion(): string {
10
+ export function getChantVersion(): string {
11
11
  try {
12
12
  const pkgDir = dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url)))));
13
13
  const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf-8"));
@@ -88,7 +88,9 @@ describe("runBuild --components --generate (chant #1108 build-time parameters)",
88
88
  undefined,
89
89
  [{ name: "tier", value: "light", source: "default" }],
90
90
  );
91
- expect(stderr.join("\n")).toContain("[param] tier");
91
+ // A parameter at its default collapses into the count line now; the
92
+ // provenance forwarded above is where its value is asserted.
93
+ expect(stderr.join("\n")).toContain("at their defaults");
92
94
  vi.restoreAllMocks();
93
95
  });
94
96
 
@@ -19,6 +19,7 @@ export async function runCarveApply(ctx: CommandContext): Promise<number> {
19
19
  statePath: args.statePath,
20
20
  output: args.output,
21
21
  write: args.write,
22
+ writeSource: args.writeSource,
22
23
  });
23
24
 
24
25
  if (!result.ok) {
@@ -42,6 +42,7 @@ import { findBuildManifestByArtifactDigest } from "../../lifecycle/build-ledger-
42
42
  import type { ComponentBomSummary } from "../../lifecycle/build-ledger";
43
43
  import { loadChantConfig } from "../../config";
44
44
  import { applyLiveEndpoint } from "../../live-endpoint";
45
+ import { isResourceDeclarable } from "../../declarable";
45
46
  import { build } from "../../build";
46
47
  import { discoverComponents } from "../../components/discover";
47
48
  import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
@@ -397,11 +398,12 @@ export async function runComponentsStatus(ctx: CommandContext): Promise<number>
397
398
  const declared = new Set<string>();
398
399
  const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
399
400
  for (const [name, entity] of buildResult.entities) {
400
- if (entity.lexicon === plugin.name) {
401
+ // Resource declarables only (see lifecycle/observe.ts).
402
+ if (entity.lexicon === plugin.name && isResourceDeclarable(entity)) {
401
403
  declared.add(name);
402
404
  entities.set(name, {
403
405
  entityType: entity.entityType,
404
- props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
406
+ props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
405
407
  });
406
408
  }
407
409
  }
@@ -0,0 +1,93 @@
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
6
+ import type { ParsedArgs } from "../registry";
7
+
8
+ const discoverMock = vi.fn();
9
+ vi.mock("../../discovery/index", () => ({
10
+ discover: (...a: unknown[]) => discoverMock(...a),
11
+ }));
12
+
13
+ import { runExplain } from "./explain";
14
+
15
+ function decl<T extends object>(base: T): Declarable & T {
16
+ return { [DECLARABLE_MARKER]: true, ...base } as Declarable & T;
17
+ }
18
+
19
+ function ctx(overrides: Partial<ParsedArgs>): Parameters<typeof runExplain>[0] {
20
+ return {
21
+ args: { command: "explain", path: ".", format: "", fix: false, watch: false, verbose: false, help: false, live: false, ...overrides } as ParsedArgs,
22
+ plugins: [],
23
+ serializers: [],
24
+ };
25
+ }
26
+
27
+ function discoveryResult() {
28
+ const vpc = decl({ lexicon: "gcp", entityType: "Vpc" });
29
+ const subnet = decl({ lexicon: "gcp", entityType: "Subnet" });
30
+ return {
31
+ entities: new Map<string, Declarable>([
32
+ ["vpc", vpc],
33
+ ["subnet", subnet],
34
+ ]),
35
+ dependencies: new Map([["subnet", new Set(["vpc"])]]),
36
+ sourceFiles: ["net.ts"],
37
+ errors: [],
38
+ foldDecisions: [],
39
+ };
40
+ }
41
+
42
+ describe("runExplain", () => {
43
+ let outDir: string;
44
+ let logSpy: ReturnType<typeof vi.spyOn>;
45
+
46
+ beforeEach(async () => {
47
+ outDir = await mkdtemp(join(tmpdir(), "chant-explain-okf-"));
48
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
49
+ vi.spyOn(console, "error").mockImplementation(() => {});
50
+ discoverMock.mockResolvedValue(discoveryResult());
51
+ });
52
+
53
+ afterEach(async () => {
54
+ vi.restoreAllMocks();
55
+ await rm(outDir, { recursive: true, force: true });
56
+ });
57
+
58
+ test("--format okf -o <dir> writes the bundle as a directory tree", async () => {
59
+ const code = await runExplain(ctx({ format: "okf", output: outDir }));
60
+ expect(code).toBe(0);
61
+
62
+ expect((await stat(join(outDir, "index.md"))).isFile()).toBe(true);
63
+ const subnet = await readFile(join(outDir, "gcp", "subnet.md"), "utf-8");
64
+ expect(subnet).toContain("type: Subnet");
65
+ expect(subnet).toContain("- [vpc](/gcp/vpc.md)");
66
+ });
67
+
68
+ test("--format okf without -o prints the bundle as JSON path -> content", async () => {
69
+ const code = await runExplain(ctx({ format: "okf" }));
70
+ expect(code).toBe(0);
71
+
72
+ const parsed = JSON.parse(logSpy.mock.calls[0][0] as string);
73
+ expect(parsed.okf_version).toBe("0.2");
74
+ expect(Object.keys(parsed.files).sort()).toEqual(["gcp/subnet.md", "gcp/vpc.md", "index.md"]);
75
+ });
76
+
77
+ test("default format renders the markdown summary", async () => {
78
+ const code = await runExplain(ctx({}));
79
+ expect(code).toBe(0);
80
+ expect(logSpy.mock.calls[0][0]).toContain("# Project Summary");
81
+ });
82
+
83
+ test("an unknown format is rejected", async () => {
84
+ const code = await runExplain(ctx({ format: "xml" }));
85
+ expect(code).toBe(1);
86
+ });
87
+
88
+ test("discovery errors surface as a non-zero exit for okf", async () => {
89
+ discoverMock.mockResolvedValue({ ...discoveryResult(), errors: [new Error("boom")] });
90
+ const code = await runExplain(ctx({ format: "okf" }));
91
+ expect(code).toBe(1);
92
+ });
93
+ });
@@ -0,0 +1,60 @@
1
+ import { resolve, join, dirname } from "node:path";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { discover } from "../../discovery/index";
4
+ import { buildOkfBundle, OKF_VERSION, type OkfFile } from "../../okf";
5
+ import { handleExplain } from "../mcp/tools/explain";
6
+ import { formatError, formatSuccess } from "../format";
7
+ import type { CommandContext } from "../registry";
8
+
9
+ const EXPLAIN_FORMATS = ["markdown", "json", "okf"] as const;
10
+ type ExplainFormat = (typeof EXPLAIN_FORMATS)[number];
11
+
12
+ /**
13
+ * `chant explain [path] [--format markdown|json|okf] [-o <dir>]` — the CLI
14
+ * path of the MCP `explain` tool (#1058): a structured summary of every
15
+ * discovered entity. `--format okf` emits an OKF v0.2 knowledge bundle
16
+ * instead — one markdown concept per entity plus an `index.md` — written as a
17
+ * directory tree under `-o <dir>`, or printed as JSON (path → content) when
18
+ * no output directory is given.
19
+ */
20
+ export async function runExplain(ctx: CommandContext): Promise<number> {
21
+ const { args } = ctx;
22
+ const format = (args.format || "markdown") as ExplainFormat;
23
+ if (!EXPLAIN_FORMATS.includes(format)) {
24
+ console.error(formatError({
25
+ message: `Invalid --format: ${format}. Expected one of ${EXPLAIN_FORMATS.join(", ")}.`,
26
+ }));
27
+ return 1;
28
+ }
29
+
30
+ const projectPath = resolve(args.path === "." ? "." : args.path);
31
+
32
+ if (format === "okf") {
33
+ const result = await discover(projectPath);
34
+ for (const err of result.errors) console.error(formatError({ message: err.message }));
35
+ const bundle = buildOkfBundle(result, projectPath);
36
+ if (args.output) {
37
+ await writeBundle(bundle, resolve(args.output));
38
+ console.log(formatSuccess(`Wrote OKF v${OKF_VERSION} bundle: ${bundle.length} file(s) under ${args.output}`));
39
+ } else {
40
+ console.log(JSON.stringify({
41
+ okf_version: OKF_VERSION,
42
+ files: Object.fromEntries(bundle.map((f) => [f.path, f.content])),
43
+ }, null, 2));
44
+ }
45
+ return result.errors.length > 0 ? 1 : 0;
46
+ }
47
+
48
+ const summary = await handleExplain({ path: projectPath, format });
49
+ console.log(typeof summary === "string" ? summary : JSON.stringify(summary, null, 2));
50
+ return 0;
51
+ }
52
+
53
+ /** Write the bundle's files under `outDir`, creating subdirectories as needed. */
54
+ async function writeBundle(bundle: OkfFile[], outDir: string): Promise<void> {
55
+ for (const file of bundle) {
56
+ const target = join(outDir, file.path);
57
+ await mkdir(dirname(target), { recursive: true });
58
+ await writeFile(target, file.content, "utf-8");
59
+ }
60
+ }