@intentius/chant 0.52.1 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/checks.d.ts +35 -0
- package/dist/agents/checks.d.ts.map +1 -0
- package/dist/agents/discover.d.ts +86 -0
- package/dist/agents/discover.d.ts.map +1 -0
- package/dist/agents/importer.d.ts +46 -0
- package/dist/agents/importer.d.ts.map +1 -0
- package/dist/agents/index.d.ts +14 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/types.d.ts +196 -0
- package/dist/agents/types.d.ts.map +1 -0
- package/dist/audit/catalog.d.ts +4 -1
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/report.d.ts +8 -0
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/cdk/advise.d.ts +29 -0
- package/dist/cdk/advise.d.ts.map +1 -0
- package/dist/cdk/assembly.d.ts +38 -0
- package/dist/cdk/assembly.d.ts.map +1 -0
- package/dist/cdk/graph.d.ts +68 -0
- package/dist/cdk/graph.d.ts.map +1 -0
- package/dist/cdk/tier-map.d.ts +44 -0
- package/dist/cdk/tier-map.d.ts.map +1 -0
- package/dist/cdk/types.d.ts +114 -0
- package/dist/cdk/types.d.ts.map +1 -0
- package/dist/cli/commands/audit-agents.d.ts +83 -0
- package/dist/cli/commands/audit-agents.d.ts.map +1 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/carve.d.ts +48 -6
- package/dist/cli/commands/carve.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts +64 -0
- package/dist/cli/commands/import-agents.d.ts.map +1 -0
- package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
- package/dist/cli/handlers/carve.d.ts +5 -4
- package/dist/cli/handlers/carve.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +10 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +15 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/identity.d.ts +196 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +52 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +17 -63
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/aws-resources.d.ts +1 -1
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/carve-provider.d.ts +142 -0
- package/dist/terraform/carve-provider.d.ts.map +1 -0
- package/dist/terraform/carve.d.ts +36 -3
- package/dist/terraform/carve.d.ts.map +1 -1
- package/dist/terraform/emit-source.d.ts +25 -0
- package/dist/terraform/emit-source.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +11 -1
- package/dist/terraform/graduate.d.ts.map +1 -1
- package/dist/terraform/providers/aws.d.ts +19 -0
- package/dist/terraform/providers/aws.d.ts.map +1 -0
- package/dist/terraform/providers/gcp.d.ts +41 -0
- package/dist/terraform/providers/gcp.d.ts.map +1 -0
- package/dist/terraform/providers/index.d.ts +15 -0
- package/dist/terraform/providers/index.d.ts.map +1 -0
- package/dist/terraform/providers/kubernetes.d.ts +29 -0
- package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
- package/dist/terraform/score.d.ts +70 -4
- package/dist/terraform/score.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +38 -26
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +6 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/agents/checks.test.ts +228 -0
- package/src/agents/checks.ts +429 -0
- package/src/agents/discover.test.ts +310 -0
- package/src/agents/discover.ts +939 -0
- package/src/agents/importer.ts +49 -0
- package/src/agents/index.ts +29 -0
- package/src/agents/types.ts +207 -0
- package/src/audit/catalog.ts +90 -1
- package/src/audit/report.ts +9 -1
- package/src/audit/rules-doc.ts +6 -0
- package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
- package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
- package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
- package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
- package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
- package/src/cdk/advise.test.ts +208 -0
- package/src/cdk/advise.ts +44 -0
- package/src/cdk/assembly.ts +133 -0
- package/src/cdk/graph.test.ts +206 -0
- package/src/cdk/graph.ts +525 -0
- package/src/cdk/tier-map.ts +71 -0
- package/src/cdk/types.ts +115 -0
- package/src/cli/commands/audit-agents.test.ts +260 -0
- package/src/cli/commands/audit-agents.ts +387 -0
- package/src/cli/commands/carve-apply.ts +20 -4
- package/src/cli/commands/carve-bridge.test.ts +30 -0
- package/src/cli/commands/carve-bridge.ts +24 -2
- package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
- package/src/cli/commands/carve-emit-provider.test.ts +207 -0
- package/src/cli/commands/carve-emit.test.ts +72 -1
- package/src/cli/commands/carve-emit.ts +55 -28
- package/src/cli/commands/carve.ts +139 -36
- package/src/cli/commands/import-agents.test.ts +208 -0
- package/src/cli/commands/import-agents.ts +196 -0
- package/src/cli/handlers/carve-emit.ts +8 -1
- package/src/cli/handlers/carve.ts +8 -7
- package/src/cli/handlers/lifecycle.test.ts +187 -1
- package/src/cli/handlers/lifecycle.ts +125 -1
- package/src/cli/handlers/misc.ts +111 -0
- package/src/cli/main.ts +29 -5
- package/src/cli/registry.ts +15 -0
- package/src/identity.test.ts +199 -0
- package/src/identity.ts +346 -0
- package/src/index.ts +1 -0
- package/src/lexicon.ts +65 -0
- package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
- package/src/terraform/adopt-state.test.ts +131 -0
- package/src/terraform/adopt-state.ts +22 -167
- package/src/terraform/aws-resources.test.ts +55 -16
- package/src/terraform/aws-resources.ts +1 -1
- package/src/terraform/bridge.test.ts +12 -0
- package/src/terraform/bridge.ts +4 -1
- package/src/terraform/carve-provider.test.ts +155 -0
- package/src/terraform/carve-provider.ts +237 -0
- package/src/terraform/carve.test.ts +55 -1
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/emit-source.ts +39 -0
- package/src/terraform/graduate.test.ts +37 -0
- package/src/terraform/graduate.ts +55 -7
- package/src/terraform/graph.ts +3 -3
- package/src/terraform/providers/aws.ts +169 -0
- package/src/terraform/providers/gcp.test.ts +228 -0
- package/src/terraform/providers/gcp.ts +329 -0
- package/src/terraform/providers/index.ts +21 -0
- package/src/terraform/providers/kubernetes.ts +224 -0
- package/src/terraform/score.ts +111 -25
- package/src/terraform/tier-map.ts +55 -90
- package/src/terraform/types.ts +6 -0
- package/src/yaml.test.ts +54 -0
- package/src/yaml.ts +24 -3
|
@@ -14,18 +14,14 @@
|
|
|
14
14
|
|
|
15
15
|
import { existsSync, statSync, writeFileSync, mkdirSync } from "fs";
|
|
16
16
|
import { basename, join, resolve } from "path";
|
|
17
|
+
import { cdkNotSupported, isCloudAssembly } from "../../cdk/assembly";
|
|
17
18
|
import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
|
|
18
19
|
import { boundaryReport, deferredParamName, type CarveReport } from "../../terraform/carve";
|
|
19
|
-
import { resolveTier } from "../../terraform/tier-map";
|
|
20
|
+
import { carveEmitTypes, resolveTier } from "../../terraform/tier-map";
|
|
21
|
+
import { resolveEmitProvider } from "../../terraform/carve-provider";
|
|
20
22
|
import { readStateResource, type StateResource } from "../../terraform/state";
|
|
21
23
|
import { writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
|
|
22
|
-
import {
|
|
23
|
-
adoptFromState,
|
|
24
|
-
canAdoptFromState,
|
|
25
|
-
supportedStateAdoptionTypes,
|
|
26
|
-
type DeferredParam,
|
|
27
|
-
type FoldedContribution,
|
|
28
|
-
} from "../../terraform/adopt-state";
|
|
24
|
+
import { adoptFromState, type DeferredParam, type FoldedContribution } from "../../terraform/adopt-state";
|
|
29
25
|
import { getChantVersion } from "./init";
|
|
30
26
|
import type { LexiconPlugin, ResourceSelector } from "../../lexicon";
|
|
31
27
|
import type { ImportResult, LiveImportOptions } from "./import";
|
|
@@ -90,6 +86,7 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
|
|
|
90
86
|
if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
|
|
91
87
|
return { ok: false, error: `Not a directory: ${opts.from}` };
|
|
92
88
|
}
|
|
89
|
+
if (isCloudAssembly(opts.from)) return { ok: false, error: cdkNotSupported(opts.from, "emit") };
|
|
93
90
|
|
|
94
91
|
let report: CarveReport | null;
|
|
95
92
|
let tfType: string | undefined;
|
|
@@ -104,27 +101,32 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
|
|
|
104
101
|
|
|
105
102
|
if (!report) return { ok: false, error: `${opts.select} not found in ${opts.from}` };
|
|
106
103
|
|
|
107
|
-
|
|
108
|
-
if (!tier) {
|
|
104
|
+
if (!tfType || !resolveTier(tfType)) {
|
|
109
105
|
return {
|
|
110
106
|
ok: false,
|
|
111
107
|
error: `${opts.select} (${tfType ?? "unknown type"}) has no known native mapping, so it cannot be emitted. Advisor bands it "leave in Terraform".`,
|
|
112
108
|
};
|
|
113
109
|
}
|
|
114
110
|
|
|
111
|
+
// `carve advise` ranks every type a provider declares; emit produces source
|
|
112
|
+
// only for the ones that provider can adopt. Both adoption paths refuse the
|
|
113
|
+
// rest here — before any file is written or any cloud call is made — so
|
|
114
|
+
// neither path can proceed on a type the other rejects.
|
|
115
|
+
const provider = resolveEmitProvider(tfType);
|
|
116
|
+
if (!provider) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
error:
|
|
120
|
+
`${tfType} cannot be emitted yet (no carve provider adopts it).\n` +
|
|
121
|
+
`Supported types: ${carveEmitTypes().join(", ")}. ` +
|
|
122
|
+
`Coverage is expanding — see chant issue #1001.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
115
126
|
if (opts.reportFile) writeFileSync(opts.reportFile, JSON.stringify(report, null, 2));
|
|
116
127
|
|
|
117
128
|
// ── Adoption path 1: from .tfstate (offline, correct for TF-managed) ──
|
|
118
129
|
if (opts.statePath) {
|
|
119
|
-
if (!canAdoptFromState(tfType!)) {
|
|
120
|
-
return {
|
|
121
|
-
ok: false,
|
|
122
|
-
error:
|
|
123
|
-
`${tfType} cannot be adopted from state yet (no native constructor mapping).\n` +
|
|
124
|
-
`Supported types: ${supportedStateAdoptionTypes().join(", ")}. ` +
|
|
125
|
-
`Coverage is expanding — see chant issue #1001.`,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
130
|
const stateResource = readStateResource(opts.statePath, opts.select);
|
|
129
131
|
if (!stateResource) {
|
|
130
132
|
return { ok: false, error: `${opts.select} not found in state ${opts.statePath} (or is a data source / module-nested).` };
|
|
@@ -149,8 +151,7 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
|
|
|
149
151
|
mkdirSync(srcDir, { recursive: true });
|
|
150
152
|
const outPath = join(srcDir, adopted.fileName);
|
|
151
153
|
writeFileSync(outPath, adopted.content);
|
|
152
|
-
const
|
|
153
|
-
const scaffolded = scaffoldProject(outDir, lexicon, params);
|
|
154
|
+
const scaffolded = scaffoldProject(outDir, provider.lexicon, params);
|
|
154
155
|
|
|
155
156
|
const manifestPath = persistManifest(outDir, opts, report, tfType, "tfstate", [outPath], params);
|
|
156
157
|
return {
|
|
@@ -166,21 +167,47 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
|
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
// ── Adoption path 2: live import (cloud→code) ──
|
|
169
|
-
// The
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
170
|
+
// The provider names the selector type its live export filters on — for aws a
|
|
171
|
+
// CloudFormation type, filtered by logical ID rather than the Terraform
|
|
172
|
+
// physical name, so `--live-name` (not `identity`) is what narrows a stack
|
|
173
|
+
// holding several of that type.
|
|
174
|
+
const selectorType = provider.liveSelectorType?.(tfType);
|
|
175
|
+
if (!selectorType) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
error:
|
|
179
|
+
`${tfType} has no live adoption path — the ${provider.name} carve provider adopts it from state only.\n` +
|
|
180
|
+
`Re-run with --state <tfstate>.`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
173
183
|
const selector: ResourceSelector = opts.liveName
|
|
174
|
-
? { type:
|
|
175
|
-
: { type:
|
|
184
|
+
? { type: selectorType, name: opts.liveName }
|
|
185
|
+
: { type: selectorType };
|
|
176
186
|
|
|
177
187
|
const emit = await deps.liveImport(deps.plugins, {
|
|
178
188
|
environment: opts.env!,
|
|
179
189
|
selector,
|
|
180
190
|
output: opts.output,
|
|
181
|
-
lexicon:
|
|
191
|
+
lexicon: provider.lexicon,
|
|
182
192
|
});
|
|
183
193
|
|
|
194
|
+
// A failed import emitted no source. Reporting success here would write a
|
|
195
|
+
// carve manifest that `carve bridge` composes from, and bridge excises the
|
|
196
|
+
// survivor's `.tf` block for the resource — so the estate would lose a
|
|
197
|
+
// declaration that was never adopted. Fail, and persist nothing.
|
|
198
|
+
if (!emit.success) {
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
error:
|
|
202
|
+
`Live import of ${selector.type} from ${opts.env} failed: ${emit.error ?? "no error reported"}\n` +
|
|
203
|
+
`Nothing was emitted and no carve manifest was written.`,
|
|
204
|
+
report,
|
|
205
|
+
emit,
|
|
206
|
+
selector,
|
|
207
|
+
source: "live",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
184
211
|
const outDir = opts.output ?? join(opts.from, "carveout");
|
|
185
212
|
const manifestPath = persistManifest(outDir, opts, report, tfType, "live", emit.generatedFiles ?? []);
|
|
186
213
|
return { ok: true, report, emit, selector, source: "live", emittedFiles: emit.generatedFiles, manifestPath };
|
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `chant carve advise` — the read-only
|
|
2
|
+
* `chant carve advise` — the read-only peelability advisor (#214, #1056).
|
|
3
3
|
*
|
|
4
|
-
* Points at
|
|
5
|
-
*
|
|
4
|
+
* Points at foreign infrastructure and reports which pieces are cheap to carve
|
|
5
|
+
* into native chant later and which should stay where they are. It emits
|
|
6
6
|
* nothing, patches nothing, and touches no live resource — pure analysis. The
|
|
7
|
-
* emit/boundary/apply phases
|
|
7
|
+
* emit/boundary/apply phases (`carve emit|bridge|apply`, #197) read Terraform
|
|
8
|
+
* only, and refuse a cloud assembly by name.
|
|
9
|
+
*
|
|
10
|
+
* Two sources, one report. A Terraform estate is parsed from `.tf`; a CDK cloud
|
|
11
|
+
* assembly is read from `cdk.out` (#1056). Which one is in `--from` is decided
|
|
12
|
+
* by looking, not by a second flag: a directory holding `manifest.json` plus a
|
|
13
|
+
* synthesized `*.template.json` is an assembly. Both produce the same bands,
|
|
14
|
+
* the same score arithmetic, and the same JSON shape.
|
|
8
15
|
*/
|
|
9
16
|
|
|
10
17
|
import { existsSync, statSync, writeFileSync } from "fs";
|
|
18
|
+
import { adviseCloudAssembly, CDK_DIALECT } from "../../cdk/advise";
|
|
19
|
+
import { isCloudAssembly } from "../../cdk/assembly";
|
|
11
20
|
import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
|
|
12
|
-
import { scoreEstate, type Peelability, type PeelabilityBand } from "../../terraform/score";
|
|
13
|
-
import { boundaryReport, type BoundaryEdge } from "../../terraform/carve";
|
|
21
|
+
import { scoreEstate, type Peelability, type PeelabilityBand, type ScoreOptions } from "../../terraform/score";
|
|
22
|
+
import { boundaryReport, type BoundaryEdge, type CarveDialect } from "../../terraform/carve";
|
|
14
23
|
import type { TfGraph } from "../../terraform/types";
|
|
15
24
|
|
|
16
25
|
export interface CarveAdviseOptions {
|
|
17
|
-
/** Terraform estate directory (from `--from`). */
|
|
26
|
+
/** Terraform estate directory or CDK cloud assembly (from `--from`). */
|
|
18
27
|
from?: string;
|
|
19
28
|
/** Opt-in `.tfstate` path (from `--state`): accurate fan-out instance counts. */
|
|
20
29
|
statePath?: string;
|
|
@@ -22,10 +31,19 @@ export interface CarveAdviseOptions {
|
|
|
22
31
|
reportFile?: string;
|
|
23
32
|
}
|
|
24
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Which source the ranking came from. Terraform addresses and CDK construct
|
|
36
|
+
* paths are different namespaces, and the bridge patches have different names,
|
|
37
|
+
* so a report says which it is rather than leaving a reader to guess from the
|
|
38
|
+
* shape of an address.
|
|
39
|
+
*/
|
|
40
|
+
export type CarveSource = "terraform" | "cdk";
|
|
41
|
+
|
|
25
42
|
export interface CarveAdviseResult {
|
|
26
43
|
ok: boolean;
|
|
27
44
|
error?: string;
|
|
28
45
|
from?: string;
|
|
46
|
+
source?: CarveSource;
|
|
29
47
|
results?: Peelability[];
|
|
30
48
|
/**
|
|
31
49
|
* The parsed dependency graph the scores came from. Kept so the JSON report
|
|
@@ -33,37 +51,69 @@ export interface CarveAdviseResult {
|
|
|
33
51
|
* Not part of the JSON payload — `carveJson` derives from it.
|
|
34
52
|
*/
|
|
35
53
|
graph?: TfGraph;
|
|
54
|
+
/** The hooks the scores were produced with, so the boundary pass agrees. */
|
|
55
|
+
scoreOptions?: ScoreOptions;
|
|
56
|
+
/** Anything about the read itself worth saying out loud. */
|
|
57
|
+
diagnostics?: string[];
|
|
36
58
|
}
|
|
37
59
|
|
|
38
60
|
const BAND_ORDER: PeelabilityBand[] = ["clean leaf", "carvable w/ edits", "leave in Terraform"];
|
|
39
61
|
|
|
40
62
|
export async function carveAdvise(opts: CarveAdviseOptions): Promise<CarveAdviseResult> {
|
|
41
63
|
if (!opts.from) {
|
|
42
|
-
return { ok: false, error: "chant carve advise requires --from <terraform-dir>" };
|
|
64
|
+
return { ok: false, error: "chant carve advise requires --from <terraform-dir | cdk.out>" };
|
|
43
65
|
}
|
|
44
66
|
if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
|
|
45
67
|
return { ok: false, error: `Not a directory: ${opts.from}` };
|
|
46
68
|
}
|
|
69
|
+
|
|
70
|
+
const result = isCloudAssembly(opts.from) ? adviseCdk(opts) : await adviseTerraform(opts);
|
|
71
|
+
if (result.ok && opts.reportFile) {
|
|
72
|
+
writeFileSync(opts.reportFile, JSON.stringify(carveJson(result), null, 2));
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Rank a CDK cloud assembly. No parser to install, no state file to read. */
|
|
78
|
+
function adviseCdk(opts: CarveAdviseOptions): CarveAdviseResult {
|
|
79
|
+
if (opts.statePath) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error:
|
|
83
|
+
`${opts.from} is a CDK cloud assembly, and --state is a Terraform option. ` +
|
|
84
|
+
"A synthesized template already carries its own instance counts.",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const advice = adviseCloudAssembly(opts.from!);
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
from: opts.from,
|
|
92
|
+
source: "cdk",
|
|
93
|
+
results: advice.results,
|
|
94
|
+
graph: advice.graph,
|
|
95
|
+
scoreOptions: advice.scoreOptions,
|
|
96
|
+
diagnostics: advice.diagnostics,
|
|
97
|
+
};
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: `Failed to read the CDK cloud assembly in ${opts.from}: ${err instanceof Error ? err.message : String(err)}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function adviseTerraform(opts: CarveAdviseOptions): Promise<CarveAdviseResult> {
|
|
47
107
|
if (opts.statePath && !existsSync(opts.statePath)) {
|
|
48
108
|
return { ok: false, error: `State file not found: ${opts.statePath}` };
|
|
49
109
|
}
|
|
50
|
-
|
|
51
|
-
let graph: TfGraph;
|
|
52
|
-
let results: Peelability[];
|
|
53
110
|
try {
|
|
54
|
-
graph = await parseTerraformDir(opts.from
|
|
55
|
-
results
|
|
111
|
+
const graph = await parseTerraformDir(opts.from!, { statePath: opts.statePath });
|
|
112
|
+
return { ok: true, from: opts.from, source: "terraform", results: scoreEstate(graph), graph };
|
|
56
113
|
} catch (err) {
|
|
57
114
|
if (err instanceof Hcl2JsonNotInstalled) return { ok: false, error: err.message };
|
|
58
115
|
return { ok: false, error: `Failed to parse Terraform in ${opts.from}: ${err instanceof Error ? err.message : String(err)}` };
|
|
59
116
|
}
|
|
60
|
-
|
|
61
|
-
const result: CarveAdviseResult = { ok: true, from: opts.from, results, graph };
|
|
62
|
-
if (opts.reportFile) {
|
|
63
|
-
writeFileSync(opts.reportFile, JSON.stringify(carveJson(result), null, 2));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return result;
|
|
67
117
|
}
|
|
68
118
|
|
|
69
119
|
/**
|
|
@@ -81,6 +131,16 @@ export async function carveAdvise(opts: CarveAdviseOptions): Promise<CarveAdvise
|
|
|
81
131
|
* not).
|
|
82
132
|
* - A reader that does not know the version it is handed should refuse the
|
|
83
133
|
* report rather than half-read it.
|
|
134
|
+
*
|
|
135
|
+
* The CDK source (#1056) stays inside that promise and does not bump the
|
|
136
|
+
* version. Every field an existing reader keys on keeps its type and meaning:
|
|
137
|
+
* `address` is still an opaque string identifier (a construct path instead of a
|
|
138
|
+
* Terraform address), `kind` is still `"resource"` or `"module"` (an L3
|
|
139
|
+
* construct subtree is a Composite candidate, which is what `module` already
|
|
140
|
+
* means), `score`/`band`/`breakdown` are the same arithmetic. What is new is
|
|
141
|
+
* additive and ignorable: the top-level `source` discriminator, per-resource
|
|
142
|
+
* `notes` and `members`, and two more `bridge` values in a list that was
|
|
143
|
+
* already declared open-ended.
|
|
84
144
|
*/
|
|
85
145
|
export const CARVE_REPORT_VERSION = 1;
|
|
86
146
|
|
|
@@ -113,10 +173,23 @@ export interface CarveJsonResource extends Peelability {
|
|
|
113
173
|
export interface CarveJsonReport {
|
|
114
174
|
version: number;
|
|
115
175
|
from?: string;
|
|
176
|
+
/**
|
|
177
|
+
* Which source was read (#1056). `terraform` — addresses are Terraform
|
|
178
|
+
* addresses, bridges are `data` sources. `cdk` — addresses are construct
|
|
179
|
+
* paths and bridges are reimports. Absent on a chant that predates the CDK
|
|
180
|
+
* advisor, which a reader should take as `terraform`.
|
|
181
|
+
*/
|
|
182
|
+
source: CarveSource;
|
|
116
183
|
advisory: string;
|
|
117
184
|
count: number;
|
|
118
185
|
/** Band name -> how many resources landed in it. */
|
|
119
186
|
bands: Record<string, number>;
|
|
187
|
+
/**
|
|
188
|
+
* Anything about the read the scores alone do not say — a missing `tree.json`,
|
|
189
|
+
* an assembly synthesized from unresolved context lookups. Present only when
|
|
190
|
+
* there is something to report.
|
|
191
|
+
*/
|
|
192
|
+
diagnostics?: string[];
|
|
120
193
|
resources: CarveJsonResource[];
|
|
121
194
|
}
|
|
122
195
|
|
|
@@ -126,20 +199,29 @@ export function carveJson(result: CarveAdviseResult): CarveJsonReport {
|
|
|
126
199
|
const counts = Object.fromEntries(
|
|
127
200
|
BAND_ORDER.map((b) => [b, results.filter((r) => r.band === b).length]),
|
|
128
201
|
);
|
|
202
|
+
const source = result.source ?? "terraform";
|
|
203
|
+
const dialect: CarveDialect = source === "cdk" ? CDK_DIALECT : "terraform";
|
|
129
204
|
return {
|
|
130
205
|
version: CARVE_REPORT_VERSION,
|
|
131
206
|
from: result.from,
|
|
207
|
+
source,
|
|
132
208
|
advisory: "read-only — emits nothing, patches nothing, touches no live resource",
|
|
133
209
|
count: results.length,
|
|
134
210
|
bands: counts,
|
|
135
|
-
|
|
211
|
+
...(result.diagnostics?.length ? { diagnostics: result.diagnostics } : {}),
|
|
212
|
+
resources: results.map((r) => withBoundary(r, result.graph, dialect, result.scoreOptions)),
|
|
136
213
|
};
|
|
137
214
|
}
|
|
138
215
|
|
|
139
216
|
/** A scored resource plus its carve boundary, when the graph is at hand (#1636). */
|
|
140
|
-
function withBoundary(
|
|
217
|
+
function withBoundary(
|
|
218
|
+
r: Peelability,
|
|
219
|
+
graph: TfGraph | undefined,
|
|
220
|
+
dialect: CarveDialect,
|
|
221
|
+
score: ScoreOptions | undefined,
|
|
222
|
+
): CarveJsonResource {
|
|
141
223
|
if (!graph) return r;
|
|
142
|
-
const report = boundaryReport(graph, r.address);
|
|
224
|
+
const report = boundaryReport(graph, r.address, { dialect, score });
|
|
143
225
|
if (!report) return r;
|
|
144
226
|
return { ...r, boundary: { inbound: report.inbound, outbound: report.outbound } };
|
|
145
227
|
}
|
|
@@ -147,52 +229,73 @@ function withBoundary(r: Peelability, graph?: TfGraph): CarveJsonResource {
|
|
|
147
229
|
/** Human-readable banded, ranked summary. */
|
|
148
230
|
export function formatCarveReport(result: CarveAdviseResult): string {
|
|
149
231
|
const results = result.results ?? [];
|
|
232
|
+
const source = result.source ?? "terraform";
|
|
233
|
+
const cdk = source === "cdk";
|
|
234
|
+
|
|
150
235
|
if (results.length === 0) {
|
|
151
|
-
return
|
|
236
|
+
return cdk
|
|
237
|
+
? `No carvable constructs found in ${result.from}\n(the assembly synthesized no infrastructure beyond CDK's own scaffolding).`
|
|
238
|
+
: `No carvable resources found in ${result.from}\n(only data sources, providers, or unsupported types were present).`;
|
|
152
239
|
}
|
|
153
240
|
|
|
154
241
|
const lines: string[] = [];
|
|
155
|
-
lines.push(
|
|
156
|
-
lines.push(
|
|
242
|
+
lines.push(`${cdk ? "CDK cloud-assembly" : "Terraform"} carve-out advisory for ${result.from}`);
|
|
243
|
+
lines.push(
|
|
244
|
+
` ${results.length} ${cdk ? "construct(s)" : "resource(s)/module(s)"} scored. ` +
|
|
245
|
+
"Advises only — nothing is emitted or changed.",
|
|
246
|
+
);
|
|
247
|
+
for (const note of result.diagnostics ?? []) lines.push(` ! ${note}`);
|
|
157
248
|
lines.push("");
|
|
158
249
|
|
|
159
250
|
for (const band of BAND_ORDER) {
|
|
160
251
|
const inBand = results.filter((r) => r.band === band);
|
|
161
252
|
if (inBand.length === 0) continue;
|
|
162
|
-
lines.push(`${bandLabel(band)} (${inBand.length})`);
|
|
253
|
+
lines.push(`${bandLabel(band, cdk)} (${inBand.length})`);
|
|
163
254
|
for (const r of inBand) {
|
|
164
255
|
const target = r.mapsTo ? ` -> ${r.mapsTo}` : "";
|
|
165
256
|
lines.push(` ${String(r.score).padStart(3)} ${r.address}${target}`);
|
|
166
|
-
lines.push(` ${reasons(r)}`);
|
|
257
|
+
lines.push(` ${reasons(r, cdk)}`);
|
|
258
|
+
for (const note of r.notes ?? []) lines.push(` ${note}`);
|
|
167
259
|
}
|
|
168
260
|
lines.push("");
|
|
169
261
|
}
|
|
170
262
|
|
|
171
|
-
lines.push(
|
|
263
|
+
lines.push(
|
|
264
|
+
`Bands: 80-100 carve now | 50-79 carve with boundary edits | 0-49 leave in ${cdk ? "CDK" : "Terraform"}`,
|
|
265
|
+
);
|
|
172
266
|
return lines.join("\n").trimEnd();
|
|
173
267
|
}
|
|
174
268
|
|
|
175
|
-
function bandLabel(band: PeelabilityBand): string {
|
|
269
|
+
function bandLabel(band: PeelabilityBand, cdk: boolean): string {
|
|
176
270
|
switch (band) {
|
|
177
271
|
case "clean leaf":
|
|
178
272
|
return "CLEAN LEAF — carve now";
|
|
179
273
|
case "carvable w/ edits":
|
|
180
274
|
return "CARVABLE — has boundary work";
|
|
181
275
|
case "leave in Terraform":
|
|
182
|
-
|
|
276
|
+
// The band's name is part of the JSON contract and does not change per
|
|
277
|
+
// source; only the heading a person reads does.
|
|
278
|
+
return cdk ? "LEAVE IN CDK" : "LEAVE IN TERRAFORM";
|
|
183
279
|
}
|
|
184
280
|
}
|
|
185
281
|
|
|
186
282
|
/** One-line explanation of what drove a score, from its breakdown. */
|
|
187
|
-
function reasons(r: Peelability): string {
|
|
283
|
+
function reasons(r: Peelability, cdk = false): string {
|
|
188
284
|
const b = r.breakdown;
|
|
189
|
-
if (b.tier === null)
|
|
285
|
+
if (b.tier === null) {
|
|
286
|
+
return cdk ? "no chant AWS lexicon target for this type" : "no known native mapping (unsupported provider/type)";
|
|
287
|
+
}
|
|
288
|
+
// A node that scored 0 with every penalty at 0 was disqualified outright, not
|
|
289
|
+
// scored down. Reciting the arithmetic would credit terms that never applied;
|
|
290
|
+
// the stated reason follows on the next line.
|
|
291
|
+
if (r.score === 0 && Object.values(b.penalties).every((p) => p === 0)) return "not carvable as it stands";
|
|
190
292
|
const parts: string[] = [];
|
|
191
|
-
if (b.inbound) parts.push(`${b.inbound} inbound (data-source patch each)`);
|
|
192
|
-
if (b.outputs) parts.push(`${b.outputs} output block(s) reading it (one-line rewrite each)`);
|
|
293
|
+
if (b.inbound) parts.push(`${b.inbound} inbound (${cdk ? "a reimport each" : "data-source patch each"})`);
|
|
294
|
+
if (b.outputs) parts.push(`${b.outputs} ${cdk ? "stack output(s)" : "output block(s)"} reading it (one-line rewrite each)`);
|
|
193
295
|
if (b.outbound) parts.push(`${b.outbound} outbound (deferred input each)`);
|
|
194
296
|
if (b.tier > 1) parts.push(`tier ${b.tier} map`);
|
|
195
|
-
if (b.hasDynamic) parts.push("count/for_each/data present");
|
|
297
|
+
if (b.hasDynamic) parts.push(cdk ? "condition/parameter present" : "count/for_each/data present");
|
|
196
298
|
if (b.instances > 1) parts.push(`${b.instances} instances`);
|
|
299
|
+
if (b.penalties.asset) parts.push("asset-backed");
|
|
197
300
|
return parts.length ? parts.join(", ") : "clean 1:1 native map, no boundary edges";
|
|
198
301
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The import command's own contract, separate from the mapping it delegates to
|
|
3
|
+
* (covered by the fountain lexicon's `local-agents.test.ts`).
|
|
4
|
+
*
|
|
5
|
+
* What matters here is the parts a user hits when something is off: a lexicon
|
|
6
|
+
* that can't be loaded or can't express agents, an output directory that
|
|
7
|
+
* already has files in it, and — above all — that every lossy step the mapper
|
|
8
|
+
* reports actually reaches the user. Re-expression silently dropping a config
|
|
9
|
+
* or defaulting a required field is the failure mode that would make the
|
|
10
|
+
* generated code untrustworthy.
|
|
11
|
+
*
|
|
12
|
+
* The plugin is injected rather than loaded, so these run without a built
|
|
13
|
+
* lexicon on disk.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
17
|
+
import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from "fs";
|
|
18
|
+
import { join } from "path";
|
|
19
|
+
import { tmpdir } from "os";
|
|
20
|
+
import { importAgentsCommand, DEFAULT_AGENT_LEXICON, type PluginLoader } from "./import-agents";
|
|
21
|
+
import type { LexiconPlugin } from "../../lexicon";
|
|
22
|
+
import type { AgentImportOutcome } from "../../agents/importer";
|
|
23
|
+
import type { TemplateIR } from "../../import/parser";
|
|
24
|
+
|
|
25
|
+
let home: string;
|
|
26
|
+
let out: string;
|
|
27
|
+
|
|
28
|
+
function writeIn(root: string, rel: string, content: string): void {
|
|
29
|
+
const full = join(root, rel);
|
|
30
|
+
mkdirSync(join(full, ".."), { recursive: true });
|
|
31
|
+
writeFileSync(full, content, "utf-8");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
home = join(tmpdir(), `chant-ia-home-${Math.random().toString(36).slice(2)}`);
|
|
36
|
+
out = join(tmpdir(), `chant-ia-out-${Math.random().toString(36).slice(2)}`);
|
|
37
|
+
mkdirSync(home, { recursive: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
rmSync(home, { recursive: true, force: true });
|
|
42
|
+
rmSync(out, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** A config with one MCP server, so the scan finds something to import. */
|
|
46
|
+
function seedConfig(): void {
|
|
47
|
+
writeIn(home, ".claude/mcp.json", JSON.stringify({ mcpServers: { a: { command: "x" } } }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const EMPTY_OUTCOME: AgentImportOutcome = { ir: { resources: [], parameters: [] }, skipped: [], unmappedModel: [], redactedSecrets: [] };
|
|
51
|
+
|
|
52
|
+
/** A plugin whose importer and generator are both stubbed. */
|
|
53
|
+
function fakePlugin(outcome: Partial<AgentImportOutcome> = {}, overrides: Partial<LexiconPlugin> = {}): LexiconPlugin {
|
|
54
|
+
const resolved: AgentImportOutcome = { ...EMPTY_OUTCOME, ...outcome };
|
|
55
|
+
return {
|
|
56
|
+
name: "fountain",
|
|
57
|
+
agentConfigImporter: () => ({ toTemplateIR: () => resolved }),
|
|
58
|
+
templateGenerator: () => ({
|
|
59
|
+
generate: (ir: TemplateIR) => [{ path: "main.ts", content: ir.resources.map((r) => `export const ${r.logicalId} = {};`).join("\n") }],
|
|
60
|
+
}),
|
|
61
|
+
...overrides,
|
|
62
|
+
} as unknown as LexiconPlugin;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** An outcome with one resource, so the command reaches the write step. */
|
|
66
|
+
const ONE_RESOURCE: Partial<AgentImportOutcome> = {
|
|
67
|
+
ir: { resources: [{ logicalId: "userClaude", type: "Fountain::V1::Agent", properties: {} }], parameters: [] },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const loaderFor = (plugin: LexiconPlugin): PluginLoader => async () => plugin;
|
|
71
|
+
|
|
72
|
+
const run = (opts: Parameters<typeof importAgentsCommand>[0] = {}) =>
|
|
73
|
+
importAgentsCommand({ home, platform: "linux", projectRoots: [], output: out, pluginLoader: loaderFor(fakePlugin(ONE_RESOURCE)), ...opts });
|
|
74
|
+
|
|
75
|
+
describe("nothing to import", () => {
|
|
76
|
+
test("fails with a clear message when no agent config exists", async () => {
|
|
77
|
+
const result = await run();
|
|
78
|
+
expect(result.success).toBe(false);
|
|
79
|
+
expect(result.error).toContain("No agent configuration found");
|
|
80
|
+
expect(result.generatedFiles).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("lexicon resolution", () => {
|
|
85
|
+
test("defaults to the fountain lexicon", async () => {
|
|
86
|
+
seedConfig();
|
|
87
|
+
let asked: string | undefined;
|
|
88
|
+
await run({ pluginLoader: async (name) => { asked = name; return fakePlugin(ONE_RESOURCE); } });
|
|
89
|
+
expect(asked).toBe(DEFAULT_AGENT_LEXICON);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("reports an install hint when the lexicon package is missing", async () => {
|
|
93
|
+
seedConfig();
|
|
94
|
+
const result = await run({
|
|
95
|
+
lexicon: "nope",
|
|
96
|
+
pluginLoader: async () => { throw new Error("Cannot find module"); },
|
|
97
|
+
});
|
|
98
|
+
expect(result.success).toBe(false);
|
|
99
|
+
expect(result.error).toContain("npm i @intentius/chant-lexicon-nope");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("names the fallback when the lexicon cannot express agent config", async () => {
|
|
103
|
+
seedConfig();
|
|
104
|
+
const result = await run({ lexicon: "k8s", pluginLoader: loaderFor(fakePlugin({}, { agentConfigImporter: undefined })) });
|
|
105
|
+
expect(result.success).toBe(false);
|
|
106
|
+
expect(result.error).toContain("cannot express agent configuration");
|
|
107
|
+
expect(result.error).toContain(`--lexicon ${DEFAULT_AGENT_LEXICON}`);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("fails when the lexicon has no generator to emit TypeScript", async () => {
|
|
111
|
+
seedConfig();
|
|
112
|
+
const result = await run({ pluginLoader: loaderFor(fakePlugin(ONE_RESOURCE, { templateGenerator: undefined })) });
|
|
113
|
+
expect(result.success).toBe(false);
|
|
114
|
+
expect(result.error).toContain("no templateGenerator");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("reporting what was lost", () => {
|
|
119
|
+
test("surfaces a skipped site with the mapper's reason", async () => {
|
|
120
|
+
seedConfig();
|
|
121
|
+
const result = await run({
|
|
122
|
+
pluginLoader: loaderFor(fakePlugin({ ...ONE_RESOURCE, skipped: [{ siteId: "user-cursor", reason: 'fountain has no "cursor" runtime' }] })),
|
|
123
|
+
});
|
|
124
|
+
expect(result.warnings.some((w) => w.includes("user-cursor") && w.includes("cursor"))).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("warns that a defaulted model needs editing before it is applied", async () => {
|
|
128
|
+
seedConfig();
|
|
129
|
+
const result = await run({ pluginLoader: loaderFor(fakePlugin({ ...ONE_RESOURCE, unmappedModel: ["user-claude"] })) });
|
|
130
|
+
expect(result.warnings.some((w) => w.includes("user-claude") && w.includes("Edit it before applying"))).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("states that redacted credentials were not copied into the generated code", async () => {
|
|
134
|
+
// The user has to know a secret was *removed*, not carried over — otherwise
|
|
135
|
+
// they'd assume the generated config works as-is.
|
|
136
|
+
seedConfig();
|
|
137
|
+
const result = await run({ pluginLoader: loaderFor(fakePlugin({ ...ONE_RESOURCE, redactedSecrets: ["user-claude"] })) });
|
|
138
|
+
const warning = result.warnings.find((w) => w.includes("Literal credentials"));
|
|
139
|
+
expect(warning).toContain("were NOT copied");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("fails, rather than writing an empty file, when nothing could be mapped", async () => {
|
|
143
|
+
seedConfig();
|
|
144
|
+
const result = await run({ pluginLoader: loaderFor(fakePlugin({ skipped: [{ siteId: "user-cursor", reason: "no runtime" }] })) });
|
|
145
|
+
expect(result.success).toBe(false);
|
|
146
|
+
expect(result.error).toContain("none could be expressed");
|
|
147
|
+
expect(existsSync(join(out, "main.ts"))).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("writing output", () => {
|
|
152
|
+
test("writes the generated files and reports what it counted", async () => {
|
|
153
|
+
seedConfig();
|
|
154
|
+
const result = await run();
|
|
155
|
+
expect(result.success).toBe(true);
|
|
156
|
+
expect(result.generatedFiles).toEqual([join(out, "main.ts")]);
|
|
157
|
+
expect(readFileSync(join(out, "main.ts"), "utf-8")).toContain("export const userClaude");
|
|
158
|
+
expect(result.summary).toEqual({ discovered: 1, mapped: 1 });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("refuses to overwrite an existing file without --force", async () => {
|
|
162
|
+
// Generated agent code gets hand-edited after the first run; silently
|
|
163
|
+
// reverting those edits would be the worst outcome for this command.
|
|
164
|
+
seedConfig();
|
|
165
|
+
mkdirSync(out, { recursive: true });
|
|
166
|
+
writeFileSync(join(out, "main.ts"), "// hand-edited", "utf-8");
|
|
167
|
+
|
|
168
|
+
const result = await run();
|
|
169
|
+
expect(result.success).toBe(false);
|
|
170
|
+
expect(result.error).toContain("--force");
|
|
171
|
+
expect(readFileSync(join(out, "main.ts"), "utf-8")).toBe("// hand-edited");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("overwrites with --force", async () => {
|
|
175
|
+
seedConfig();
|
|
176
|
+
mkdirSync(out, { recursive: true });
|
|
177
|
+
writeFileSync(join(out, "main.ts"), "// hand-edited", "utf-8");
|
|
178
|
+
|
|
179
|
+
const result = await run({ force: true });
|
|
180
|
+
expect(result.success).toBe(true);
|
|
181
|
+
expect(readFileSync(join(out, "main.ts"), "utf-8")).toContain("export const userClaude");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("creates the output directory when it does not exist", async () => {
|
|
185
|
+
seedConfig();
|
|
186
|
+
const nested = join(out, "deep", "nested");
|
|
187
|
+
const result = await run({ output: nested });
|
|
188
|
+
expect(result.success).toBe(true);
|
|
189
|
+
expect(existsSync(join(nested, "main.ts"))).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("scope and runtime filtering", () => {
|
|
194
|
+
test("passes the scope filter through to the scan", async () => {
|
|
195
|
+
seedConfig();
|
|
196
|
+
// user scope holds the config; restricting to project finds nothing.
|
|
197
|
+
const result = await run({ scopes: ["project"] });
|
|
198
|
+
expect(result.success).toBe(false);
|
|
199
|
+
expect(result.error).toContain("No agent configuration found");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("passes the runtime filter through to the scan", async () => {
|
|
203
|
+
seedConfig();
|
|
204
|
+
const result = await run({ runtimes: ["codex"] });
|
|
205
|
+
expect(result.success).toBe(false);
|
|
206
|
+
expect(result.error).toContain("No agent configuration found");
|
|
207
|
+
});
|
|
208
|
+
});
|