@intentius/chant 0.24.0 → 0.26.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/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/config-import.d.ts +33 -0
- package/dist/config-import.d.ts.map +1 -0
- package/dist/config-sandbox.d.ts +47 -0
- package/dist/config-sandbox.d.ts.map +1 -0
- package/dist/config.d.ts +11 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/entity-wire-codec.d.ts +15 -10
- package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
- package/dist/discovery/graph.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-run.d.ts +24 -0
- package/dist/discovery/sandbox/config-run.d.ts.map +1 -0
- package/dist/discovery/sandbox/config-wire.d.ts +84 -0
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -0
- package/dist/discovery/sandbox/driver.d.ts +49 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +70 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -0
- package/dist/discovery/sandbox/policy-run.d.ts +33 -0
- package/dist/discovery/sandbox/policy-run.d.ts.map +1 -0
- package/dist/discovery/sandbox/policy-wire.d.ts +177 -0
- package/dist/discovery/sandbox/policy-wire.d.ts.map +1 -0
- package/dist/discovery/sandbox/run.d.ts +10 -20
- package/dist/discovery/sandbox/run.d.ts.map +1 -1
- package/dist/intrinsic-interpolation.d.ts.map +1 -1
- package/dist/lexicon-output.d.ts +62 -6
- package/dist/lexicon-output.d.ts.map +1 -1
- package/dist/lint/config.d.ts +6 -0
- package/dist/lint/config.d.ts.map +1 -1
- package/dist/lint/policy-import.d.ts +50 -0
- package/dist/lint/policy-import.d.ts.map +1 -0
- package/dist/lint/policy-sandbox.d.ts +89 -0
- package/dist/lint/policy-sandbox.d.ts.map +1 -0
- package/dist/lint/policy.d.ts +12 -1
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.test.ts +96 -1
- package/src/build.ts +36 -12
- package/src/cli/commands/build.ts +46 -5
- package/src/cli/main.test.ts +93 -17
- package/src/cli/main.ts +111 -11
- package/src/config-import.ts +43 -0
- package/src/config-sandbox.ts +138 -0
- package/src/config.ts +14 -5
- package/src/discovery/entity-wire-codec.ts +35 -21
- package/src/discovery/entity-wire.test.ts +26 -0
- package/src/discovery/graph.test.ts +40 -1
- package/src/discovery/graph.ts +8 -2
- package/src/discovery/sandbox/config-boundary.test.ts +239 -0
- package/src/discovery/sandbox/config-run.ts +130 -0
- package/src/discovery/sandbox/config-wire.test.ts +110 -0
- package/src/discovery/sandbox/config-wire.ts +195 -0
- package/src/discovery/sandbox/driver.ts +200 -0
- package/src/discovery/sandbox/fork.ts +148 -0
- package/src/discovery/sandbox/policy-boundary.test.ts +325 -0
- package/src/discovery/sandbox/policy-run.ts +180 -0
- package/src/discovery/sandbox/policy-wire.test.ts +310 -0
- package/src/discovery/sandbox/policy-wire.ts +277 -0
- package/src/discovery/sandbox/run.ts +28 -85
- package/src/intrinsic-interpolation.test.ts +27 -1
- package/src/intrinsic-interpolation.ts +10 -2
- package/src/lexicon-output.test.ts +173 -1
- package/src/lexicon-output.ts +123 -14
- package/src/lint/config.ts +8 -4
- package/src/lint/policy-import.ts +70 -0
- package/src/lint/policy-sandbox.ts +123 -0
- package/src/lint/policy.ts +20 -2
- package/src/stack-output.test.ts +118 -0
- package/src/stack-output.ts +21 -5
package/src/build.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
2
|
import { build, partitionByLexicon, detectCrossLexiconRefs, collectLexiconOutputs, computeStackGraph } from "./build";
|
|
3
3
|
import { output } from "./lexicon-output";
|
|
4
4
|
import { AttrRef } from "./attrref";
|
|
@@ -467,6 +467,25 @@ describe("detectCrossLexiconRefs", () => {
|
|
|
467
467
|
expect(collected[0].getOutputValue()).toEqual({ "Fn::Sub": "http://example.com/path" });
|
|
468
468
|
});
|
|
469
469
|
|
|
470
|
+
// chant #1121 — `output(<already-resolved value>, name)`, exactly the
|
|
471
|
+
// shape of a top-level `export const oParamName = output(data.Name,
|
|
472
|
+
// "oParamName")`, must reach the serializer as a plain `Value`, never a
|
|
473
|
+
// `Fn::GetAtt` pointing at the output's own logical id.
|
|
474
|
+
test("literal-valued output() emits its value verbatim, not a self-referencing Fn::GetAtt", () => {
|
|
475
|
+
const literalOutput = output("fold-output-repro", "oParamName");
|
|
476
|
+
|
|
477
|
+
const entities = new Map<string, Declarable>([
|
|
478
|
+
["oParamName", literalOutput as unknown as Declarable],
|
|
479
|
+
]);
|
|
480
|
+
|
|
481
|
+
const collected = collectLexiconOutputs(entities);
|
|
482
|
+
expect(collected).toHaveLength(1);
|
|
483
|
+
expect(collected[0].outputName).toBe("oParamName");
|
|
484
|
+
expect(collected[0].sourceEntity).toBe("");
|
|
485
|
+
expect(collected[0].sourceAttribute).toBeNull();
|
|
486
|
+
expect(collected[0].getOutputValue()).toBe("fold-output-repro");
|
|
487
|
+
});
|
|
488
|
+
|
|
470
489
|
test("deduplicates when same cross-lexicon ref appears in multiple entities", () => {
|
|
471
490
|
const alphaBucket = {
|
|
472
491
|
lexicon: "alpha",
|
|
@@ -580,6 +599,58 @@ describe("detectCrossLexiconRefs", () => {
|
|
|
580
599
|
const detected = detectCrossLexiconRefs(entities);
|
|
581
600
|
expect(detected).toHaveLength(0);
|
|
582
601
|
});
|
|
602
|
+
|
|
603
|
+
// chant #1137 — `detectCrossLexiconRefs`'s walk used to check
|
|
604
|
+
// `value instanceof AttrRef`, which returns false for an AttrRef built by
|
|
605
|
+
// a SEPARATELY-LOADED copy of `./attrref` (the same dual-npm-copy hazard
|
|
606
|
+
// #1122 fixed for `LexiconOutput`: a lexicon pinned to a chant range that
|
|
607
|
+
// doesn't overlap the project's own gets its own nested
|
|
608
|
+
// `node_modules/@intentius/chant`). `vi.resetModules()` + a fresh dynamic
|
|
609
|
+
// import reproduces that split module graph exactly. Before the fix, a
|
|
610
|
+
// foreign AttrRef here falls through to the generic object walk instead
|
|
611
|
+
// of being recognized, and the auto-detected `Outputs` entry vanishes
|
|
612
|
+
// silently — no error, just a missing cross-lexicon output.
|
|
613
|
+
test("detects a cross-lexicon ref built by a second, separately-loaded copy of AttrRef", async () => {
|
|
614
|
+
const alphaBucket = {
|
|
615
|
+
lexicon: "alpha",
|
|
616
|
+
entityType: "Alpha::Storage::Bucket",
|
|
617
|
+
[DECLARABLE_MARKER]: true,
|
|
618
|
+
} as Declarable;
|
|
619
|
+
|
|
620
|
+
vi.resetModules();
|
|
621
|
+
const secondCopy = await import("./attrref");
|
|
622
|
+
|
|
623
|
+
// Sanity check that this really is a distinct module instance — the
|
|
624
|
+
// premise the rest of the test depends on.
|
|
625
|
+
expect(secondCopy.AttrRef).not.toBe(AttrRef);
|
|
626
|
+
|
|
627
|
+
const foreignRef = new secondCopy.AttrRef(alphaBucket, "Endpoint");
|
|
628
|
+
|
|
629
|
+
// The historic bug: instanceof fails across separately-loaded copies of
|
|
630
|
+
// chant-core, even though the two classes are structurally identical.
|
|
631
|
+
expect(foreignRef instanceof AttrRef).toBe(false);
|
|
632
|
+
|
|
633
|
+
const ghAction = {
|
|
634
|
+
lexicon: "github",
|
|
635
|
+
entityType: "Action",
|
|
636
|
+
[DECLARABLE_MARKER]: true,
|
|
637
|
+
props: { url: foreignRef },
|
|
638
|
+
} as unknown as Declarable;
|
|
639
|
+
|
|
640
|
+
const entities = new Map<string, Declarable>([
|
|
641
|
+
["dataBucket", alphaBucket],
|
|
642
|
+
["deployAction", ghAction],
|
|
643
|
+
]);
|
|
644
|
+
|
|
645
|
+
// The fix: `isAttrRefLike` duck-types on shape, so a foreign-copy
|
|
646
|
+
// AttrRef is still recognized and auto-detected as a cross-lexicon output.
|
|
647
|
+
const detected = detectCrossLexiconRefs(entities);
|
|
648
|
+
expect(detected).toHaveLength(1);
|
|
649
|
+
expect(detected[0].sourceLexicon).toBe("alpha");
|
|
650
|
+
expect(detected[0].sourceEntity).toBe("dataBucket");
|
|
651
|
+
expect(detected[0].sourceAttribute).toBe("Endpoint");
|
|
652
|
+
vi.resetModules();
|
|
653
|
+
});
|
|
583
654
|
});
|
|
584
655
|
|
|
585
656
|
describe("computeStackGraph (#200 — cross-stack apply ordering)", () => {
|
|
@@ -627,4 +698,28 @@ describe("computeStackGraph (#200 — cross-stack apply ordering)", () => {
|
|
|
627
698
|
);
|
|
628
699
|
expect(g.waves).toEqual([["base"], ["left", "right"], ["top"]]);
|
|
629
700
|
});
|
|
701
|
+
|
|
702
|
+
// chant #1137 — same dual-npm-copy hazard as detectCrossLexiconRefs above,
|
|
703
|
+
// this time for the cross-stack apply-ordering graph: a foreign-copy
|
|
704
|
+
// AttrRef that fails `instanceof` here used to fall through to the
|
|
705
|
+
// generic object walk instead of producing an edge, silently dropping a
|
|
706
|
+
// real cross-stack dependency (which can misorder — or fail to detect a
|
|
707
|
+
// cycle in — the apply order this graph exists to compute).
|
|
708
|
+
test("infers a consumer→producer edge from an AttrRef built by a second, separately-loaded copy", async () => {
|
|
709
|
+
const vpc = ent("aws");
|
|
710
|
+
|
|
711
|
+
vi.resetModules();
|
|
712
|
+
const secondCopy = await import("./attrref");
|
|
713
|
+
expect(secondCopy.AttrRef).not.toBe(AttrRef);
|
|
714
|
+
|
|
715
|
+
const foreignRef = new secondCopy.AttrRef(vpc, "id");
|
|
716
|
+
expect(foreignRef instanceof AttrRef).toBe(false); // the historic bug
|
|
717
|
+
|
|
718
|
+
const svc = ent("k8s", { vpcId: foreignRef });
|
|
719
|
+
const g = computeStackGraph(new Map([["vpc", vpc], ["svc", svc]]), ["aws", "k8s"]);
|
|
720
|
+
|
|
721
|
+
expect(g.edges).toEqual([{ from: "k8s", to: "aws" }]);
|
|
722
|
+
expect(g.order).toEqual(["aws", "k8s"]);
|
|
723
|
+
vi.resetModules();
|
|
724
|
+
});
|
|
630
725
|
});
|
package/src/build.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { BuildParamProvenance } from "./provenance";
|
|
|
7
7
|
import { DiscoveryError, BuildError as BuildErrorClass } from "./errors";
|
|
8
8
|
import { LexiconOutput, isLexiconOutput } from "./lexicon-output";
|
|
9
9
|
import { AttrRef } from "./attrref";
|
|
10
|
+
import { isAttrRefLike } from "./utils";
|
|
10
11
|
import { isChildProject, type ChildProjectInstance } from "./child-project";
|
|
11
12
|
import { discover, type DiscoveryResult, type FoldDecision } from "./discovery/index";
|
|
12
13
|
import { decodeEntitySet, type DiscoveredEntitiesJson } from "./discovery/entity-wire";
|
|
@@ -77,7 +78,13 @@ export function computeStackGraph(
|
|
|
77
78
|
if (value === null || value === undefined || typeof value !== "object") return;
|
|
78
79
|
if (visited.has(value)) return;
|
|
79
80
|
visited.add(value);
|
|
80
|
-
|
|
81
|
+
// Duck-type, not `instanceof` (chant #1137): a lexicon built against a
|
|
82
|
+
// separate copy of `@intentius/chant` produces AttrRefs that fail
|
|
83
|
+
// `instanceof AttrRef` here but carry the same shape. Without this, a
|
|
84
|
+
// real cross-stack dependency silently falls through to the generic
|
|
85
|
+
// object walk below instead of producing an edge, which can misorder —
|
|
86
|
+
// or entirely drop — the apply order this graph exists to compute.
|
|
87
|
+
if (isAttrRefLike(value)) {
|
|
81
88
|
const parent = value.parent.deref();
|
|
82
89
|
const producer = parent ? (parent as Record<string, unknown>).lexicon : undefined;
|
|
83
90
|
if (typeof producer === "string" && producer !== consumer) addEdge(consumer, producer);
|
|
@@ -296,18 +303,28 @@ export function collectLexiconOutputs(
|
|
|
296
303
|
for (const [name, entity] of entities) {
|
|
297
304
|
if (isLexiconOutput(entity as unknown)) {
|
|
298
305
|
const lexiconOutput = entity as unknown as LexiconOutput;
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
306
|
+
// A literal-valued output (chant #1121) has no source entity at all —
|
|
307
|
+
// it isn't a reference to anything, so it must not fall back to the
|
|
308
|
+
// output's OWN map key the way an AttrRef whose parent didn't resolve
|
|
309
|
+
// would. Leaving `sourceEntity` empty keeps `getOutputValue()`'s
|
|
310
|
+
// (never-reached, for a literal) `Fn::GetAtt` fallback from ever being
|
|
311
|
+
// handed a bogus source, and keeps consumers that read `sourceEntity`
|
|
312
|
+
// directly (e.g. `graph-ir.ts`'s cross-stack export, the build
|
|
313
|
+
// manifest) from reporting the output as its own source.
|
|
314
|
+
if (lexiconOutput._literalValue === null) {
|
|
315
|
+
// Resolve source entity name from the WeakRef parent identity
|
|
316
|
+
const parent = lexiconOutput._sourceParent?.deref();
|
|
317
|
+
let sourceName = name;
|
|
318
|
+
if (parent) {
|
|
319
|
+
for (const [entityName, e] of entities) {
|
|
320
|
+
if (e === parent) {
|
|
321
|
+
sourceName = entityName;
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
307
324
|
}
|
|
308
325
|
}
|
|
326
|
+
lexiconOutput._setSourceEntity(sourceName);
|
|
309
327
|
}
|
|
310
|
-
lexiconOutput._setSourceEntity(sourceName);
|
|
311
328
|
outputs.push(lexiconOutput);
|
|
312
329
|
continue;
|
|
313
330
|
}
|
|
@@ -317,7 +334,8 @@ export function collectLexiconOutputs(
|
|
|
317
334
|
const prevLength = outputs.length;
|
|
318
335
|
walk(entity.props);
|
|
319
336
|
for (let i = prevLength; i < outputs.length; i++) {
|
|
320
|
-
|
|
337
|
+
// Same #1121 guard as above — a literal has no source entity to name.
|
|
338
|
+
if (!outputs[i].sourceEntity && outputs[i]._literalValue === null) {
|
|
321
339
|
outputs[i]._setSourceEntity(name);
|
|
322
340
|
}
|
|
323
341
|
}
|
|
@@ -359,7 +377,13 @@ export function detectCrossLexiconRefs(
|
|
|
359
377
|
if (visited.has(value)) return;
|
|
360
378
|
visited.add(value);
|
|
361
379
|
|
|
362
|
-
|
|
380
|
+
// Duck-type, not `instanceof` (chant #1137): a lexicon built against a
|
|
381
|
+
// separate copy of `@intentius/chant` produces AttrRefs that fail
|
|
382
|
+
// `instanceof AttrRef` here but carry the same shape. Without this, a
|
|
383
|
+
// real cross-lexicon reference silently falls through to the generic
|
|
384
|
+
// object walk below instead of auto-creating a `LexiconOutput`, and the
|
|
385
|
+
// whole `Outputs` entry for it vanishes (same failure shape as #1122).
|
|
386
|
+
if (isAttrRefLike(value)) {
|
|
363
387
|
const parent = value.parent.deref();
|
|
364
388
|
if (!parent) return;
|
|
365
389
|
|
|
@@ -5,6 +5,7 @@ import type { Serializer, SerializerResult } from "../../serializer";
|
|
|
5
5
|
import type { LexiconPlugin } from "../../lexicon";
|
|
6
6
|
import { runPostSynthChecks } from "../../lint/post-synth";
|
|
7
7
|
import { loadPolicyChecks } from "../../lint/policy";
|
|
8
|
+
import { armSandboxPolicyExecution, runProjectPolicies } from "../../lint/policy-sandbox";
|
|
8
9
|
import { sortedJsonReplacer } from "../../utils";
|
|
9
10
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
10
11
|
import { writeFileSync, mkdirSync } from "fs";
|
|
@@ -144,9 +145,7 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
144
145
|
// Project-authored organizational policy checks (lint.policies), run over the
|
|
145
146
|
// resolved resources during build. Resolve paths relative to the config dir.
|
|
146
147
|
const configDir = loaded.configPath ? dirname(loaded.configPath) : infraPath;
|
|
147
|
-
const
|
|
148
|
-
? await loadPolicyChecks(config.lint.policies, configDir)
|
|
149
|
-
: [];
|
|
148
|
+
const policies = config.lint?.policies ?? [];
|
|
150
149
|
|
|
151
150
|
// #1022 — opt-in fold path: the CLI flag wins over `chant.config.ts`'s
|
|
152
151
|
// `build.fold`, which wins over the (unchanged) default of running every
|
|
@@ -158,6 +157,35 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
158
157
|
// as fold, resolved independently.
|
|
159
158
|
const sandbox = resolveSandboxEnabled(config, options.sandbox);
|
|
160
159
|
|
|
160
|
+
// #1131 — arm sandboxed policy execution from the RESOLVED value, before any
|
|
161
|
+
// policy module could be loaded. Resolved, not `options.sandbox`, because
|
|
162
|
+
// policies have none of the config's bootstrap limit: they are loaded long
|
|
163
|
+
// after `build.sandbox` is known, so a config-only opt-in sandboxes them just
|
|
164
|
+
// as the CLI flag does. Arming (rather than threading a flag to each caller)
|
|
165
|
+
// makes `loadPolicyChecks` refuse process-wide — a call site that forgot to
|
|
166
|
+
// ask gets a loud error instead of quietly running project code here.
|
|
167
|
+
if (sandbox) armSandboxPolicyExecution();
|
|
168
|
+
|
|
169
|
+
// Unsandboxed, the policy pack is still loaded HERE, before the build — a
|
|
170
|
+
// policy path that doesn't resolve has always failed the command up front,
|
|
171
|
+
// including when the build itself then fails, and #1131 does not change that.
|
|
172
|
+
// Sandboxed, there is nothing to load in this process at all.
|
|
173
|
+
const preloadedPolicyChecks =
|
|
174
|
+
!sandbox && policies.length > 0 ? await loadPolicyChecks([...policies], configDir) : undefined;
|
|
175
|
+
|
|
176
|
+
// #1113 — the bootstrap limit, surfaced rather than left implicit. Reading
|
|
177
|
+
// `build.sandbox` out of `chant.config.ts` requires evaluating that file, so
|
|
178
|
+
// a config-only opt-in cannot have covered its own evaluation; only the CLI
|
|
179
|
+
// flag, known before any config is touched, arms `../config-sandbox.ts`.
|
|
180
|
+
// Say so instead of letting two different boundaries share one word.
|
|
181
|
+
if (sandbox && !options.sandbox && loaded.configPath?.endsWith(".ts")) {
|
|
182
|
+
warnings.push(
|
|
183
|
+
formatWarning({
|
|
184
|
+
message: `build.sandbox is enabled by ${loaded.configPath}, but that file was itself evaluated in this process — reading the setting requires running the config. Pass --sandbox on the command line to evaluate chant.config.ts inside the boundary too.`,
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
161
189
|
// #1064 (factored into ../build-params-cli.ts's resolveCliBuildParams by
|
|
162
190
|
// #1108, so the component deploy driver runs the identical sequence) —
|
|
163
191
|
// resolve declared build-time parameters (chant.config.ts's buildParams)
|
|
@@ -285,8 +313,21 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
285
313
|
|
|
286
314
|
// Project-authored organizational policy — cross-cutting, so it sees every
|
|
287
315
|
// lexicon's output at once (not scoped per-plugin), with the current env.
|
|
288
|
-
|
|
289
|
-
|
|
316
|
+
//
|
|
317
|
+
// #1131 — under `--sandbox` this is where the LAST piece of project-
|
|
318
|
+
// authored code the CLI used to execute in its own process moves behind
|
|
319
|
+
// the boundary: `runProjectPolicies` hands the merged, serialized build
|
|
320
|
+
// result to a post-merge sandboxed child, which imports the policy modules
|
|
321
|
+
// and runs their checks there, and only plain `PostSynthDiagnostic`s come
|
|
322
|
+
// back. Unsandboxed, it is the same in-process load-and-run as before.
|
|
323
|
+
if (policies.length > 0) {
|
|
324
|
+
const policyDiags = await runProjectPolicies({
|
|
325
|
+
policies,
|
|
326
|
+
configDir,
|
|
327
|
+
buildResult: result,
|
|
328
|
+
env,
|
|
329
|
+
preloaded: preloadedPolicyChecks,
|
|
330
|
+
});
|
|
290
331
|
for (const diag of policyDiags) {
|
|
291
332
|
const prefix = diag.entity ? `[${diag.entity}] ` : "";
|
|
292
333
|
const where = diag.lexicon ? ` (${diag.lexicon})` : "";
|
package/src/cli/main.test.ts
CHANGED
|
@@ -130,10 +130,23 @@ describe("parseArgs", () => {
|
|
|
130
130
|
expect(result.help).toBe(false);
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
-
test("
|
|
134
|
-
|
|
133
|
+
test("throws on an unknown bare flag instead of silently ignoring it (chant #1127)", () => {
|
|
134
|
+
// Was "ignores unknown flags" — pinned the old silent-drop as intended
|
|
135
|
+
// behavior. #1127 flips it: an unrecognized `--flag` is a hard error.
|
|
136
|
+
expect(() => parseArgs(["build", "--unknown", "value"])).toThrow(/Unknown flag: --unknown/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("unknown flag error points at --help", () => {
|
|
140
|
+
expect(() => parseArgs(["build", "--unknown"])).toThrow(/--help/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("throws on an unknown joined flag (--unknown=value)", () => {
|
|
144
|
+
expect(() => parseArgs(["build", "--unknown=value"])).toThrow(/Unknown flag: --unknown/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("unknown short flags are still silently ignored (unchanged; out of #1127 scope)", () => {
|
|
148
|
+
const result = parseArgs(["build", "-x", "value"]);
|
|
135
149
|
expect(result.command).toBe("build");
|
|
136
|
-
// Unknown flags are silently ignored
|
|
137
150
|
});
|
|
138
151
|
|
|
139
152
|
test("parses --watch flag", () => {
|
|
@@ -271,27 +284,90 @@ describe("parseArgs", () => {
|
|
|
271
284
|
expect(result.paramsFile).toBe("./params.json");
|
|
272
285
|
});
|
|
273
286
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
287
|
+
test("plain --param name=value is unaffected", () => {
|
|
288
|
+
const result = parseArgs(["build", "src", "--param", "tier=production"]);
|
|
289
|
+
expect(result.param).toEqual(["tier=production"]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ── generic --flag=value joined form (chant #1127) ────────────────────────
|
|
293
|
+
// #1118 taught this parser to hard-error `--param=name=value` specifically,
|
|
294
|
+
// because it was the one flag known (from #1118's investigation) to sit
|
|
295
|
+
// behind a silent drop. #1127's audit found the drop was general — every
|
|
296
|
+
// value-taking flag shares it — so the fix is general too: split any
|
|
297
|
+
// `--flag=value` token at its first `=` and re-dispatch as `--flag` +
|
|
298
|
+
// `value`, the exact shape every branch below already handles. This
|
|
299
|
+
// supersedes #1118's `--param=` hard error entirely: the joined form is now
|
|
300
|
+
// just as valid as the space-separated one, for every flag, not a rejected
|
|
301
|
+
// special case for one flag.
|
|
279
302
|
|
|
280
|
-
test("--param=name=value
|
|
281
|
-
|
|
303
|
+
test("--param=name=value now works instead of throwing — joined form matches the space-separated form", () => {
|
|
304
|
+
const result = parseArgs(["build", "src", "--param=tier=production"]);
|
|
305
|
+
expect(result.param).toEqual(["tier=production"]);
|
|
282
306
|
});
|
|
283
307
|
|
|
284
|
-
test("--
|
|
285
|
-
|
|
308
|
+
test("--env=value joined form works", () => {
|
|
309
|
+
const result = parseArgs(["build", "src", "--env=staging"]);
|
|
310
|
+
expect(result.env).toBe("staging");
|
|
286
311
|
});
|
|
287
312
|
|
|
288
|
-
test("--
|
|
289
|
-
|
|
313
|
+
test("--format=value joined form works", () => {
|
|
314
|
+
const result = parseArgs(["build", "src", "--format=yaml"]);
|
|
315
|
+
expect(result.format).toBe("yaml");
|
|
290
316
|
});
|
|
291
317
|
|
|
292
|
-
test("
|
|
293
|
-
const result = parseArgs(["build", "src", "--
|
|
294
|
-
expect(result.
|
|
318
|
+
test("--lexicon=value joined form works", () => {
|
|
319
|
+
const result = parseArgs(["build", "src", "--lexicon=aws"]);
|
|
320
|
+
expect(result.lexicon).toBe("aws");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("repeated --param=name=value (joined) accumulates in order, same as space-separated", () => {
|
|
324
|
+
const result = parseArgs(["build", "src", "--param=tier=production", "--param=env=staging"]);
|
|
325
|
+
expect(result.param).toEqual(["tier=production", "env=staging"]);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("joined form only splits on the FIRST '=' — a value containing '=' is preserved whole", () => {
|
|
329
|
+
// --param's own value shape is `name=value`, so `--param=tier=production`
|
|
330
|
+
// must split into flag `--param` + value `tier=production`, not further
|
|
331
|
+
// fragment on the second `=`.
|
|
332
|
+
const result = parseArgs(["build", "src", "--param=tier=production=east"]);
|
|
333
|
+
expect(result.param).toEqual(["tier=production=east"]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("joined form works mixed with space-separated flags in the same invocation", () => {
|
|
337
|
+
const result = parseArgs(["build", "src", "--env=prod", "--format", "json", "--lexicon=k8s"]);
|
|
338
|
+
expect(result.env).toBe("prod");
|
|
339
|
+
expect(result.format).toBe("json");
|
|
340
|
+
expect(result.lexicon).toBe("k8s");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ── boolean-only flag given a joined value (chant #1127) ──────────────────
|
|
344
|
+
// Decision: reject it. A boolean flag (--fold, --watch, --json, ...) has no
|
|
345
|
+
// value slot — its branch just sets a field to `true` and never consumes a
|
|
346
|
+
// following token. Silently coercing "true"/"false" would need to invent
|
|
347
|
+
// parsing rules (what about "1", "yes", mixed case?) for a form none of
|
|
348
|
+
// this CLI's flags need; silently dropping the value and reinterpreting it
|
|
349
|
+
// as the next positional (a path, a component name, ...) is exactly the
|
|
350
|
+
// silent misparse #1127 closes. So it errors, naming the flag as boolean.
|
|
351
|
+
|
|
352
|
+
test("a boolean flag given a joined value throws, naming the flag as boolean", () => {
|
|
353
|
+
expect(() => parseArgs(["build", "src", "--fold=true"])).toThrow(/--fold is a boolean flag/);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("boolean-with-value error does not silently reinterpret the value as a positional", () => {
|
|
357
|
+
expect(() => parseArgs(["build", "src", "--watch=false"])).toThrow(/--watch is a boolean flag/);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("--json=1 (another boolean flag) also throws", () => {
|
|
361
|
+
expect(() => parseArgs(["run", "myop", "--json=1"])).toThrow(/--json is a boolean flag/);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("--report keeps its context-sensitive bare-vs-value behavior when joined", () => {
|
|
365
|
+
// --report is deliberately not in the boolean-reject set: bare --report is
|
|
366
|
+
// a boolean (`run`), but --report <path> is a SARIF destination (migrate).
|
|
367
|
+
// The joined form should resolve the same way the space-separated one does.
|
|
368
|
+
const result = parseArgs(["migrate", "wf.yml", "--report=out.sarif"]);
|
|
369
|
+
expect(result.reportFile).toBe("out.sarif");
|
|
370
|
+
expect(result.report).toBeUndefined();
|
|
295
371
|
});
|
|
296
372
|
});
|
|
297
373
|
|
package/src/cli/main.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { formatSuccess, formatError } from "./format";
|
|
|
6
6
|
import { loadPlugins, resolveProjectLexicons } from "./plugins";
|
|
7
7
|
import { resolveCommand, type CommandDef, type ParsedArgs } from "./registry";
|
|
8
8
|
import { loadChantConfigUpward } from "../config";
|
|
9
|
+
import { armSandboxConfigEvaluation } from "../config-sandbox";
|
|
10
|
+
import { armSandboxPolicyExecution } from "../lint/policy-import";
|
|
9
11
|
import { ENV_VAR, unknownEnvError } from "../env";
|
|
10
12
|
import { initRuntime } from "../runtime-adapter";
|
|
11
13
|
import { runBuild } from "./handlers/build";
|
|
@@ -26,10 +28,60 @@ import { runGraph } from "./handlers/graph";
|
|
|
26
28
|
import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
|
|
27
29
|
import { runEmulator } from "./handlers/emulator";
|
|
28
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Long-form flags that are pure booleans in {@link parseArgs} — their branch
|
|
33
|
+
* below sets a field to `true` and never consumes a following array element.
|
|
34
|
+
* Used only to reject a joined `--flag=value` form for these (chant #1127):
|
|
35
|
+
* a boolean has no value to assign, and silently reinterpreting the joined
|
|
36
|
+
* value as the next positional argument (path, component name, ...) would be
|
|
37
|
+
* exactly the kind of silent misparse this issue exists to close. `--report`
|
|
38
|
+
* is deliberately excluded — it's context-sensitive (bare boolean vs a SARIF
|
|
39
|
+
* path, decided by lookahead), so a joined value for it is legitimate and
|
|
40
|
+
* already handled correctly once split.
|
|
41
|
+
*/
|
|
42
|
+
const BOOLEAN_FLAGS = new Set([
|
|
43
|
+
"--help",
|
|
44
|
+
"--force",
|
|
45
|
+
"--fix",
|
|
46
|
+
"--watch",
|
|
47
|
+
"--verbose",
|
|
48
|
+
"--live",
|
|
49
|
+
"--overlay",
|
|
50
|
+
"--owned",
|
|
51
|
+
"--verbatim",
|
|
52
|
+
"--apply-rewrites",
|
|
53
|
+
"--write",
|
|
54
|
+
"--strict",
|
|
55
|
+
"--validate",
|
|
56
|
+
"--use-composites",
|
|
57
|
+
"--stacks",
|
|
58
|
+
"--components",
|
|
59
|
+
"--up",
|
|
60
|
+
"--down",
|
|
61
|
+
"--include-dependents",
|
|
62
|
+
"--local",
|
|
63
|
+
"--temporal",
|
|
64
|
+
"--json",
|
|
65
|
+
"--progress-json",
|
|
66
|
+
"--update-snapshot",
|
|
67
|
+
"--run-examples",
|
|
68
|
+
"--check",
|
|
69
|
+
"--bump",
|
|
70
|
+
"--no-release-record",
|
|
71
|
+
"--fold",
|
|
72
|
+
"--sandbox",
|
|
73
|
+
]);
|
|
74
|
+
|
|
29
75
|
/**
|
|
30
76
|
* Parse command line arguments
|
|
31
77
|
*/
|
|
32
78
|
export function parseArgs(args: string[]): ParsedArgs {
|
|
79
|
+
// Local mutable copy — chant #1127's joined-`--flag=value` splitting below
|
|
80
|
+
// rewrites the array in place (one token becomes two), so this must not
|
|
81
|
+
// mutate whatever array the caller passed in (e.g. `process.argv.slice(2)`
|
|
82
|
+
// is already a fresh copy, but callers shouldn't have to know that).
|
|
83
|
+
args = args.slice();
|
|
84
|
+
|
|
33
85
|
const result: ParsedArgs = {
|
|
34
86
|
command: "",
|
|
35
87
|
path: ".",
|
|
@@ -66,7 +118,29 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
66
118
|
|
|
67
119
|
let i = 0;
|
|
68
120
|
while (i < args.length) {
|
|
69
|
-
|
|
121
|
+
let arg = args[i];
|
|
122
|
+
|
|
123
|
+
// chant #1127 — generic joined `--flag=value` support. Every value-taking
|
|
124
|
+
// flag below is matched by an exact `arg === "--flag"` check and then
|
|
125
|
+
// consumes the *next* array element (`args[++i]`) as its value; a joined
|
|
126
|
+
// token like `--env=prod` never matches any of those, doesn't match the
|
|
127
|
+
// trailing positional branch either (it starts with `-`), and used to
|
|
128
|
+
// vanish with no error. Splitting the token at its FIRST `=` and
|
|
129
|
+
// re-dispatching as two array elements makes every flag below see the
|
|
130
|
+
// exact shape it already handles — including a flag like `--param`
|
|
131
|
+
// whose own value legitimately contains `=` (`--param=tier=production`
|
|
132
|
+
// splits to flag `--param`, value `tier=production`, not further split
|
|
133
|
+
// on the second `=`).
|
|
134
|
+
if (arg.startsWith("--") && arg.includes("=")) {
|
|
135
|
+
const eq = arg.indexOf("=");
|
|
136
|
+
const flag = arg.slice(0, eq);
|
|
137
|
+
const value = arg.slice(eq + 1);
|
|
138
|
+
if (BOOLEAN_FLAGS.has(flag)) {
|
|
139
|
+
throw new Error(`${arg} — ${flag} is a boolean flag and does not take a value. Pass ${flag} on its own.`);
|
|
140
|
+
}
|
|
141
|
+
args.splice(i, 1, flag, value);
|
|
142
|
+
arg = args[i];
|
|
143
|
+
}
|
|
70
144
|
|
|
71
145
|
if (arg === "--help" || arg === "-h") {
|
|
72
146
|
result.help = true;
|
|
@@ -223,19 +297,28 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
223
297
|
} else if (arg === "--sandbox") {
|
|
224
298
|
result.sandbox = true;
|
|
225
299
|
} else if (arg === "--param") {
|
|
300
|
+
// chant #1118/#1127 — `--param name=value` (space-separated) and
|
|
301
|
+
// `--param=name=value` (joined, split above at its first `=` into flag
|
|
302
|
+
// `--param` + value `name=value`) both land here and behave
|
|
303
|
+
// identically; there is no separate joined-form error anymore (the
|
|
304
|
+
// #1118 hard error this superseded only existed because the parser
|
|
305
|
+
// didn't support joined forms at all — now that it does, the joined
|
|
306
|
+
// form is just as valid as the space-separated one).
|
|
226
307
|
(result.param ??= []).push(args[++i]);
|
|
227
|
-
} else if (arg.startsWith("--param=")) {
|
|
228
|
-
// chant #1118 — this parser never supports an `--flag=value` joined
|
|
229
|
-
// form for any value-taking flag (every branch above is an exact `===`
|
|
230
|
-
// match, so a joined token falls through unrecognized and is silently
|
|
231
|
-
// dropped — see the "ignores unknown flags" case below). `--param
|
|
232
|
-
// name=value` (space-separated) is the only accepted form. Rather than
|
|
233
|
-
// teach the parser joined forms generally, `--param=name=value` is
|
|
234
|
-
// called out as a hard error instead of a silent no-op: a dropped
|
|
235
|
-
// `--param` can silently change what a build measures/deploys.
|
|
236
|
-
throw new Error(`${arg} is not supported. Use --param name=value (space-separated) instead.`);
|
|
237
308
|
} else if (arg === "--params-file") {
|
|
238
309
|
result.paramsFile = args[++i];
|
|
310
|
+
} else if (arg.startsWith("--")) {
|
|
311
|
+
// chant #1127 — every recognized flag is matched above; anything left
|
|
312
|
+
// starting with `--` is unrecognized, whether it arrived bare
|
|
313
|
+
// (`--bogus`) or joined (`--bogus=value`, already split into
|
|
314
|
+
// `--bogus` + `value` above). This used to fall through silently (the
|
|
315
|
+
// "ignores unknown flags" case) — a typo'd or misremembered flag would
|
|
316
|
+
// vanish with no diagnostic, exactly like the silent-drop this issue
|
|
317
|
+
// closes for joined values. Point at --help rather than enumerating
|
|
318
|
+
// every flag here: this parser's flag set is one flat list shared by
|
|
319
|
+
// every command, not scoped per-command, so "the command's known
|
|
320
|
+
// flags" isn't something this loop can name in isolation.
|
|
321
|
+
throw new Error(`Unknown flag: ${arg}\nRun "chant --help" to see supported flags.`);
|
|
239
322
|
} else if (!arg.startsWith("-")) {
|
|
240
323
|
if (!result.command) {
|
|
241
324
|
result.command = arg;
|
|
@@ -598,6 +681,23 @@ async function main(): Promise<void> {
|
|
|
598
681
|
// chant.config itself may branch on the env. (#505)
|
|
599
682
|
if (args.env) process.env[ENV_VAR] = args.env;
|
|
600
683
|
|
|
684
|
+
// chant #1113 — `--sandbox` is a property of the whole invocation, and it
|
|
685
|
+
// has to be known BEFORE the first config load, because `chant.config.ts` is
|
|
686
|
+
// itself project-authored code. Arming here, straight off the parsed flag,
|
|
687
|
+
// is the only ordering that works: the project's own `build.sandbox: true`
|
|
688
|
+
// cannot cover its own evaluation (reading it means running it), so the
|
|
689
|
+
// command-line flag is what puts the config inside the boundary. See
|
|
690
|
+
// `../config-sandbox.ts`.
|
|
691
|
+
if (args.sandbox) armSandboxConfigEvaluation();
|
|
692
|
+
|
|
693
|
+
// chant #1131 — the same for `lint.policies`. Armed from the flag here so
|
|
694
|
+
// the mode is set for the whole invocation, not just `chant build`; the build
|
|
695
|
+
// command arms it again from the RESOLVED value (a project's own
|
|
696
|
+
// `build.sandbox: true` also sandboxes its policies — unlike the config,
|
|
697
|
+
// policies have no bootstrap limit, since they load long after the config is
|
|
698
|
+
// known). See `../lint/policy-sandbox.ts`.
|
|
699
|
+
if (args.sandbox) armSandboxPolicyExecution();
|
|
700
|
+
|
|
601
701
|
// Initialize runtime adapter early — before plugins or commands run.
|
|
602
702
|
// chant #1117 — walks up from `args.path` to the project root: for a
|
|
603
703
|
// subdirectory build/command (`chant build src/<stack> --env prod`) the
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The single place chant evaluates a project's `chant.config.ts` **in the CLI's
|
|
6
|
+
* own process**.
|
|
7
|
+
*
|
|
8
|
+
* This is the config-side analogue of `./discovery/import.ts`'s `importModule`
|
|
9
|
+
* — one narrow module whose only job is "execute project-authored code here",
|
|
10
|
+
* so that the question "did any project code run in this process?" has one
|
|
11
|
+
* place to look and one place to instrument (see
|
|
12
|
+
* `examples/sandbox-execution-boundary.test.ts`, which spies on both).
|
|
13
|
+
*
|
|
14
|
+
* Callers must not `import()` a config file directly. `./config-sandbox.ts`
|
|
15
|
+
* decides whether a given load is allowed to come through here at all: under
|
|
16
|
+
* `chant build --sandbox` it routes the evaluation into the sandboxed child
|
|
17
|
+
* instead (chant #1113), and these functions are never reached.
|
|
18
|
+
*
|
|
19
|
+
* `chant.config.json` is pure data and is parsed, not executed — it never goes
|
|
20
|
+
* through this module.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The shape a config module evaluates to, before `default`/`config`/namespace selection. */
|
|
24
|
+
export type ConfigModuleNamespace = Record<string, unknown>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Import a `chant.config.ts` into THIS process and return its module namespace.
|
|
28
|
+
* Node's ESM registry caches it, so repeated loads within one CLI invocation
|
|
29
|
+
* evaluate the file once — the behavior `loadChantConfig` has always had.
|
|
30
|
+
*/
|
|
31
|
+
export async function importConfigModule(configPath: string): Promise<ConfigModuleNamespace> {
|
|
32
|
+
return (await import(configPath)) as ConfigModuleNamespace;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `require()` a `chant.config.ts` into THIS process from `dir`'s resolution
|
|
37
|
+
* context — the synchronous path `./lint/config.ts`'s `loadConfig` has used
|
|
38
|
+
* since before the async loader existed (`chant lint` is a sync pipeline).
|
|
39
|
+
*/
|
|
40
|
+
export function requireConfigModule(configPath: string, dir: string): ConfigModuleNamespace {
|
|
41
|
+
const req = createRequire(join(dir, "package.json"));
|
|
42
|
+
return req(configPath) as ConfigModuleNamespace;
|
|
43
|
+
}
|