@intentius/chant 0.41.20 → 0.44.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.
- package/dist/build.d.ts +15 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/carve-apply.d.ts +9 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts +7 -0
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts +7 -0
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/handlers/carve-apply.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/explain.d.ts +11 -0
- package/dist/cli/handlers/explain.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +8 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-rule-scanning.d.ts +11 -0
- package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
- package/dist/codegen/okf-lexicon.d.ts +36 -0
- package/dist/codegen/okf-lexicon.d.ts.map +1 -0
- package/dist/codegen/package.d.ts +2 -1
- package/dist/codegen/package.d.ts.map +1 -1
- package/dist/components/deploy-units.d.ts.map +1 -1
- package/dist/components/verbs/sbom.d.ts +1 -1
- package/dist/components/verbs/sbom.d.ts.map +1 -1
- package/dist/components/verbs/vuln-gate.d.ts +15 -4
- package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
- package/dist/components/verbs/vuln-scan.d.ts +18 -6
- package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
- package/dist/config.d.ts +12 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +13 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/lexicon.d.ts +47 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/okf.d.ts +57 -0
- package/dist/okf.d.ts.map +1 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts +13 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -0
- package/dist/terraform/adopt-state.d.ts +33 -1
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/aws-resources.d.ts.map +1 -1
- package/dist/terraform/bridge.d.ts +7 -1
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/carve.d.ts +12 -0
- package/dist/terraform/carve.d.ts.map +1 -1
- package/dist/terraform/excise.d.ts +31 -0
- package/dist/terraform/excise.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +12 -0
- package/dist/terraform/graduate.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +35 -5
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/manifest.d.ts +89 -0
- package/dist/terraform/manifest.d.ts.map +1 -0
- package/dist/terraform/parse.d.ts +9 -4
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +4 -3
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/dist/terraform/unified-diff.d.ts +11 -0
- package/dist/terraform/unified-diff.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/__snapshots__/okf.test.ts.snap +381 -0
- package/src/audit/rules-doc.ts +1 -1
- package/src/build.test.ts +95 -0
- package/src/build.ts +46 -1
- package/src/cli/commands/build.ts +9 -1
- package/src/cli/commands/carve-apply.test.ts +94 -2
- package/src/cli/commands/carve-apply.ts +62 -11
- package/src/cli/commands/carve-bridge.test.ts +82 -4
- package/src/cli/commands/carve-bridge.ts +79 -11
- package/src/cli/commands/carve-emit-state.test.ts +110 -1
- package/src/cli/commands/carve-emit.ts +184 -8
- package/src/cli/commands/init.ts +1 -1
- package/src/cli/handlers/carve-apply.ts +1 -0
- package/src/cli/handlers/components.ts +4 -2
- package/src/cli/handlers/explain.test.ts +93 -0
- package/src/cli/handlers/explain.ts +60 -0
- package/src/cli/handlers/lifecycle.ts +16 -9
- package/src/cli/main.ts +21 -5
- package/src/cli/mcp/server.test.ts +17 -0
- package/src/cli/mcp/tools/explain.ts +15 -3
- package/src/cli/plugins.ts +17 -0
- package/src/cli/registry.ts +2 -0
- package/src/codegen/__snapshots__/okf-lexicon.test.ts.snap +170 -0
- package/src/codegen/docs-rule-scanning.ts +35 -14
- package/src/codegen/okf-lexicon.test.ts +248 -0
- package/src/codegen/okf-lexicon.ts +303 -0
- package/src/codegen/package.ts +29 -4
- package/src/components/config-defaults.test.ts +30 -0
- package/src/components/deploy-units.test.ts +13 -0
- package/src/components/deploy-units.ts +5 -0
- package/src/components/verbs/__fixtures__/grype-with-kev-epss.json +1 -0
- package/src/components/verbs/__fixtures__/trivy-with-kev-epss.json +631 -0
- package/src/components/verbs/exploitability-roundtrip.test.ts +190 -0
- package/src/components/verbs/sbom.ts +1 -1
- package/src/components/verbs/vuln-gate.test.ts +144 -1
- package/src/components/verbs/vuln-gate.ts +61 -10
- package/src/components/verbs/vuln-scan.test.ts +94 -0
- package/src/components/verbs/vuln-scan.ts +44 -9
- package/src/config.test.ts +21 -0
- package/src/config.ts +18 -0
- package/src/deep-observation.test.ts +19 -0
- package/src/deep-observation.ts +32 -13
- package/src/lexicon.ts +50 -0
- package/src/lifecycle/observe.test.ts +18 -0
- package/src/lifecycle/observe.ts +10 -3
- package/src/lifecycle/snapshot.ts +5 -4
- package/src/okf.test.ts +169 -0
- package/src/okf.ts +308 -0
- package/src/terraform/__fixtures__/build-graph.ts +25 -0
- package/src/terraform/adopt-state.test.ts +34 -1
- package/src/terraform/adopt-state.ts +69 -2
- package/src/terraform/aws-resources.test.ts +52 -2
- package/src/terraform/aws-resources.ts +95 -4
- package/src/terraform/bridge.test.ts +38 -14
- package/src/terraform/bridge.ts +48 -14
- package/src/terraform/carve.test.ts +9 -7
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/excise.test.ts +92 -0
- package/src/terraform/excise.ts +139 -0
- package/src/terraform/graduate.test.ts +73 -6
- package/src/terraform/graduate.ts +49 -0
- package/src/terraform/graph.test.ts +73 -10
- package/src/terraform/graph.ts +124 -47
- package/src/terraform/manifest.test.ts +105 -0
- package/src/terraform/manifest.ts +162 -0
- package/src/terraform/parse.test.ts +39 -1
- package/src/terraform/parse.ts +36 -9
- package/src/terraform/score.test.ts +8 -8
- package/src/terraform/state.test.ts +4 -4
- package/src/terraform/tier-map.ts +60 -8
- package/src/terraform/types.ts +7 -0
- package/src/terraform/unified-diff.test.ts +77 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(", ")}`);
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -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"));
|
|
@@ -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
|
-
|
|
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: (
|
|
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
|
+
}
|
|
@@ -31,7 +31,9 @@ import { cfnDeployStacks } from "./components";
|
|
|
31
31
|
import { affectedStacks } from "../../lifecycle/affected";
|
|
32
32
|
import { rollbackToRevision } from "../../lifecycle/rollback";
|
|
33
33
|
import { loadChantConfig, environmentNames } from "../../config";
|
|
34
|
+
import { collectBuildRootContributors } from "../plugins";
|
|
34
35
|
import { applyLiveEndpoint } from "../../live-endpoint";
|
|
36
|
+
import { isResourceDeclarable } from "../../declarable";
|
|
35
37
|
import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
|
|
36
38
|
import type { CommandContext } from "../registry";
|
|
37
39
|
import type { LifecycleSnapshot } from "../../lifecycle/types";
|
|
@@ -149,9 +151,10 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
|
|
|
149
151
|
// estate; a region whose stack declares no security group still has a default
|
|
150
152
|
// one, and scoping the bound per stack silently drops it.
|
|
151
153
|
const built: Array<{ target: (typeof targets)[number]; buildResult: Awaited<ReturnType<typeof build>> }> = [];
|
|
154
|
+
const buildRoots = collectBuildRootContributors(targetPlugins, config as unknown as Record<string, unknown>, projectPath);
|
|
152
155
|
for (const target of targets) {
|
|
153
156
|
const label = target.stack ? `stack "${target.stack}"` : "project";
|
|
154
|
-
const buildResult = await build(target.root, targetSerializers, undefined, { buildParams: declaredParams });
|
|
157
|
+
const buildResult = await build(target.root, targetSerializers, undefined, { buildParams: declaredParams, buildRoots });
|
|
155
158
|
if (buildResult.errors.length > 0) {
|
|
156
159
|
console.error(formatError({ message: `Build failed for ${label} — fix errors before taking a snapshot` }));
|
|
157
160
|
anyHardError = true;
|
|
@@ -362,9 +365,11 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
|
|
|
362
365
|
const endpointResult = applyLiveEndpoint(config.environments, environment, liveLexicons);
|
|
363
366
|
if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
|
|
364
367
|
|
|
368
|
+
const diffBuildRoots = collectBuildRootContributors(plugins, config as unknown as Record<string, unknown>, resolve("."));
|
|
369
|
+
|
|
365
370
|
try {
|
|
366
371
|
for (const target of targets) {
|
|
367
|
-
const buildResult = await build(target.root, targetSerializers, undefined, { buildParams: declaredParams });
|
|
372
|
+
const buildResult = await build(target.root, targetSerializers, undefined, { buildParams: declaredParams, buildRoots: diffBuildRoots });
|
|
368
373
|
if (buildResult.errors.length > 0) {
|
|
369
374
|
const label = target.stack ? `stack "${target.stack}"` : "project";
|
|
370
375
|
console.error(formatError({ message: `Build failed for ${label} — fix errors before diffing` }));
|
|
@@ -758,17 +763,18 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
|
|
|
758
763
|
continue;
|
|
759
764
|
}
|
|
760
765
|
|
|
761
|
-
// Build per-lexicon entity index
|
|
766
|
+
// Build per-lexicon entity index. Resource declarables only — outputs,
|
|
767
|
+
// parameters and serializer directives have no live counterpart, and a
|
|
768
|
+
// declared name the reader can never resolve reads as missing (see
|
|
769
|
+
// lifecycle/observe.ts).
|
|
762
770
|
const declared = new Set<string>();
|
|
763
771
|
const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
|
|
764
772
|
for (const [name, entity] of args.buildResult.entities) {
|
|
765
|
-
if (entity.lexicon === lexiconName) {
|
|
773
|
+
if (entity.lexicon === lexiconName && isResourceDeclarable(entity)) {
|
|
766
774
|
declared.add(name);
|
|
767
775
|
entities.set(name, {
|
|
768
776
|
entityType: entity.entityType,
|
|
769
|
-
props: (
|
|
770
|
-
? entity.props
|
|
771
|
-
: {}) as Record<string, unknown>,
|
|
777
|
+
props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
|
|
772
778
|
});
|
|
773
779
|
}
|
|
774
780
|
}
|
|
@@ -1061,6 +1067,7 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1061
1067
|
if (!declaredParams) return 1;
|
|
1062
1068
|
const buildResult = await build(resolveBuildRoot(args, config), targetSerializers, undefined, {
|
|
1063
1069
|
buildParams: declaredParams,
|
|
1070
|
+
buildRoots: collectBuildRootContributors(plugins, config as unknown as Record<string, unknown>, resolve(".")),
|
|
1064
1071
|
});
|
|
1065
1072
|
if (buildResult.errors.length > 0) {
|
|
1066
1073
|
console.error(formatError({ message: "Build failed — fix errors before planning" }));
|
|
@@ -1119,11 +1126,11 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1119
1126
|
const declared = new Set<string>();
|
|
1120
1127
|
const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
|
|
1121
1128
|
for (const [name, entity] of buildResult.entities) {
|
|
1122
|
-
if (entity.lexicon === lexiconName) {
|
|
1129
|
+
if (entity.lexicon === lexiconName && isResourceDeclarable(entity)) {
|
|
1123
1130
|
declared.add(name);
|
|
1124
1131
|
entities.set(name, {
|
|
1125
1132
|
entityType: entity.entityType,
|
|
1126
|
-
props: (
|
|
1133
|
+
props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
|
|
1127
1134
|
});
|
|
1128
1135
|
}
|
|
1129
1136
|
}
|