@intentius/chant 0.25.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/discovery/entity-wire-codec.d.ts +14 -9
- 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-wire.d.ts +16 -0
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts +29 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +18 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- 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/intrinsic-interpolation.d.ts.map +1 -1
- package/dist/lexicon-output.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 +77 -1
- package/src/build.ts +15 -2
- package/src/cli/commands/build.ts +33 -5
- package/src/cli/main.test.ts +93 -17
- package/src/cli/main.ts +101 -11
- package/src/discovery/entity-wire-codec.ts +14 -9
- package/src/discovery/graph.test.ts +40 -1
- package/src/discovery/graph.ts +8 -2
- package/src/discovery/sandbox/config-wire.ts +21 -0
- package/src/discovery/sandbox/driver.ts +132 -0
- package/src/discovery/sandbox/fork.ts +39 -1
- 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/intrinsic-interpolation.test.ts +27 -1
- package/src/intrinsic-interpolation.ts +10 -2
- package/src/lexicon-output.test.ts +36 -0
- package/src/lexicon-output.ts +12 -2
- 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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stack-output.d.ts","sourceRoot":"","sources":["../src/stack-output.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,iBAAiB,EAAE,KAAK,UAAU,EAAE,MAAM,cAAc,CAAC;AAClE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"stack-output.d.ts","sourceRoot":"","sources":["../src/stack-output.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,iBAAiB,EAAE,KAAK,UAAU,EAAE,MAAM,cAAc,CAAC;AAClE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAG1D;;GAEG;AACH,eAAO,MAAM,mBAAmB,eAAkC,CAAC;AAEnE;;;GAGG;AACH,MAAM,WAAW,WAAY,SAAQ,UAAU;IAC7C,QAAQ,CAAC,CAAC,mBAAmB,CAAC,EAAE,IAAI,CAAC;IACrC,QAAQ,CAAC,CAAC,iBAAiB,CAAC,EAAE,IAAI,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB;mDAC+C;IAC/C,QAAQ,CAAC,SAAS,EAAE,OAAO,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAqBD;;GAEG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,WAAW,CAOlE;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,WAAW,CACzB,GAAG,EAAE,OAAO,GAAG,SAAS,EACxB,OAAO,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GACjC,WAAW,CAiCb"}
|
package/package.json
CHANGED
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";
|
|
@@ -599,6 +599,58 @@ describe("detectCrossLexiconRefs", () => {
|
|
|
599
599
|
const detected = detectCrossLexiconRefs(entities);
|
|
600
600
|
expect(detected).toHaveLength(0);
|
|
601
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
|
+
});
|
|
602
654
|
});
|
|
603
655
|
|
|
604
656
|
describe("computeStackGraph (#200 — cross-stack apply ordering)", () => {
|
|
@@ -646,4 +698,28 @@ describe("computeStackGraph (#200 — cross-stack apply ordering)", () => {
|
|
|
646
698
|
);
|
|
647
699
|
expect(g.waves).toEqual([["base"], ["left", "right"], ["top"]]);
|
|
648
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
|
+
});
|
|
649
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);
|
|
@@ -370,7 +377,13 @@ export function detectCrossLexiconRefs(
|
|
|
370
377
|
if (visited.has(value)) return;
|
|
371
378
|
visited.add(value);
|
|
372
379
|
|
|
373
|
-
|
|
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)) {
|
|
374
387
|
const parent = value.parent.deref();
|
|
375
388
|
if (!parent) return;
|
|
376
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,22 @@ 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
|
+
|
|
161
176
|
// #1113 — the bootstrap limit, surfaced rather than left implicit. Reading
|
|
162
177
|
// `build.sandbox` out of `chant.config.ts` requires evaluating that file, so
|
|
163
178
|
// a config-only opt-in cannot have covered its own evaluation; only the CLI
|
|
@@ -298,8 +313,21 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
298
313
|
|
|
299
314
|
// Project-authored organizational policy — cross-cutting, so it sees every
|
|
300
315
|
// lexicon's output at once (not scoped per-plugin), with the current env.
|
|
301
|
-
|
|
302
|
-
|
|
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
|
+
});
|
|
303
331
|
for (const diag of policyDiags) {
|
|
304
332
|
const prefix = diag.entity ? `[${diag.entity}] ` : "";
|
|
305
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
|
@@ -7,6 +7,7 @@ import { loadPlugins, resolveProjectLexicons } from "./plugins";
|
|
|
7
7
|
import { resolveCommand, type CommandDef, type ParsedArgs } from "./registry";
|
|
8
8
|
import { loadChantConfigUpward } from "../config";
|
|
9
9
|
import { armSandboxConfigEvaluation } from "../config-sandbox";
|
|
10
|
+
import { armSandboxPolicyExecution } from "../lint/policy-import";
|
|
10
11
|
import { ENV_VAR, unknownEnvError } from "../env";
|
|
11
12
|
import { initRuntime } from "../runtime-adapter";
|
|
12
13
|
import { runBuild } from "./handlers/build";
|
|
@@ -27,10 +28,60 @@ import { runGraph } from "./handlers/graph";
|
|
|
27
28
|
import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
|
|
28
29
|
import { runEmulator } from "./handlers/emulator";
|
|
29
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
|
+
|
|
30
75
|
/**
|
|
31
76
|
* Parse command line arguments
|
|
32
77
|
*/
|
|
33
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
|
+
|
|
34
85
|
const result: ParsedArgs = {
|
|
35
86
|
command: "",
|
|
36
87
|
path: ".",
|
|
@@ -67,7 +118,29 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
67
118
|
|
|
68
119
|
let i = 0;
|
|
69
120
|
while (i < args.length) {
|
|
70
|
-
|
|
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
|
+
}
|
|
71
144
|
|
|
72
145
|
if (arg === "--help" || arg === "-h") {
|
|
73
146
|
result.help = true;
|
|
@@ -224,19 +297,28 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
224
297
|
} else if (arg === "--sandbox") {
|
|
225
298
|
result.sandbox = true;
|
|
226
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).
|
|
227
307
|
(result.param ??= []).push(args[++i]);
|
|
228
|
-
} else if (arg.startsWith("--param=")) {
|
|
229
|
-
// chant #1118 — this parser never supports an `--flag=value` joined
|
|
230
|
-
// form for any value-taking flag (every branch above is an exact `===`
|
|
231
|
-
// match, so a joined token falls through unrecognized and is silently
|
|
232
|
-
// dropped — see the "ignores unknown flags" case below). `--param
|
|
233
|
-
// name=value` (space-separated) is the only accepted form. Rather than
|
|
234
|
-
// teach the parser joined forms generally, `--param=name=value` is
|
|
235
|
-
// called out as a hard error instead of a silent no-op: a dropped
|
|
236
|
-
// `--param` can silently change what a build measures/deploys.
|
|
237
|
-
throw new Error(`${arg} is not supported. Use --param name=value (space-separated) instead.`);
|
|
238
308
|
} else if (arg === "--params-file") {
|
|
239
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.`);
|
|
240
322
|
} else if (!arg.startsWith("-")) {
|
|
241
323
|
if (!result.command) {
|
|
242
324
|
result.command = arg;
|
|
@@ -608,6 +690,14 @@ async function main(): Promise<void> {
|
|
|
608
690
|
// `../config-sandbox.ts`.
|
|
609
691
|
if (args.sandbox) armSandboxConfigEvaluation();
|
|
610
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
|
+
|
|
611
701
|
// Initialize runtime adapter early — before plugins or commands run.
|
|
612
702
|
// chant #1117 — walks up from `args.path` to the project root: for a
|
|
613
703
|
// subdirectory build/command (`chant build src/<stack> --env prod`) the
|
|
@@ -23,20 +23,25 @@
|
|
|
23
23
|
* value) becomes a name-keyed marker instead. {@link decodeEntitySet} is the
|
|
24
24
|
* inverse — it rebuilds a live `Map<string, Declarable>` whose entities are
|
|
25
25
|
* BEHAVIORALLY indistinguishable from what `discover()` would have produced
|
|
26
|
-
* in-process: real `AttrRef` instances
|
|
27
|
-
* `
|
|
26
|
+
* in-process: real `AttrRef` instances, not a duck-typed `{__attrRef}`
|
|
27
|
+
* envelope alone. `new AttrRef(...)` here is plain, direct construction from
|
|
28
|
+
* this module's own class — this codec runs inside the same module graph as
|
|
29
|
+
* every downstream reader (`intrinsic-interpolation.ts`'s
|
|
28
30
|
* `defaultInterpolationSerializer`, `discovery/graph.ts`'s
|
|
29
31
|
* `buildDependencyGraph`, `build.ts`'s `detectCrossLexiconRefs`/
|
|
30
|
-
* `computeStackGraph
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
* `computeStackGraph`, all converted to `isAttrRefLike` duck-typing by chant
|
|
33
|
+
* #1137 for the OTHER hazard, a separately-loaded lexicon copy) — so there is
|
|
34
|
+
* no dual-package boundary to duck-type across here, and building the real
|
|
35
|
+
* class is simply less code than hand-assembling a shape-alike stand-in with
|
|
36
|
+
* matching methods, and whole-entity embeds restored to the SAME object
|
|
37
|
+
* reference (not a structurally-equal clone), so `entityNames.get(decl)`
|
|
38
|
+
* keeps working by identity exactly as it does today.
|
|
34
39
|
*
|
|
35
40
|
* `serializer-walker.ts`'s `walkValue` needs NO changes for this: it already
|
|
36
41
|
* falls back to reading a plain `{__attrRef}` envelope (added for intrinsics
|
|
37
42
|
* whose own `toJSON()` embeds one). `decodeEntitySet` goes further and
|
|
38
|
-
* reconstructs the real class
|
|
39
|
-
*
|
|
43
|
+
* reconstructs the real class, both simpler here and a belt-and-suspenders
|
|
44
|
+
* match for any call site that still checks `instanceof AttrRef` directly.
|
|
40
45
|
*
|
|
41
46
|
* Naming happens exactly once, inside the boundary — `resolveAttrRefs`
|
|
42
47
|
* (./resolve.ts) runs as part of `discover()`, before `encodeEntitySet` is
|
|
@@ -85,7 +90,7 @@ import { isChildProject } from "../child-project";
|
|
|
85
90
|
* form. `refs` additionally captures any `AttrRef`/whole-entity reference
|
|
86
91
|
* found while walking the intrinsic's OWN fields (not through `toJSON()`)
|
|
87
92
|
* — `buildDependencyGraph` and `detectCrossLexiconRefs`/`computeStackGraph`
|
|
88
|
-
* walk raw entity property trees looking for `
|
|
93
|
+
* walk raw entity property trees looking for an `AttrRef`-like value/a
|
|
89
94
|
* tracked `Declarable`, not through `toJSON()`, so a ref nested inside e.g.
|
|
90
95
|
* a `Sub` template needs to still be discoverable post-decode for
|
|
91
96
|
* cross-lexicon output auto-detection and dependency ordering to keep
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect } from "vitest";
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
2
|
import { buildDependencyGraph } from "./graph";
|
|
3
3
|
import { DECLARABLE_MARKER, type Declarable } from "../declarable";
|
|
4
4
|
import { AttrRef } from "../attrref";
|
|
@@ -513,4 +513,43 @@ describe("buildDependencyGraph", () => {
|
|
|
513
513
|
expect(graph.get("Entity3")?.has("Entity2")).toBe(true);
|
|
514
514
|
expect(graph.get("Entity3")?.size).toBe(1);
|
|
515
515
|
});
|
|
516
|
+
|
|
517
|
+
// chant #1137 — `findDependencies` used to check `value instanceof
|
|
518
|
+
// AttrRef`, which returns false for an AttrRef built by a SEPARATELY-
|
|
519
|
+
// LOADED copy of `../attrref` (the same dual-npm-copy hazard #1122 fixed
|
|
520
|
+
// for `LexiconOutput`). `vi.resetModules()` + a fresh dynamic import
|
|
521
|
+
// reproduces that split module graph exactly. Before the fix, a foreign
|
|
522
|
+
// AttrRef here recurses into the object's own (unhelpful) fields instead
|
|
523
|
+
// of being recorded as a dependency, silently dropping the edge — which
|
|
524
|
+
// can misorder the file-discovery build order this graph exists to compute.
|
|
525
|
+
test("detects dependency from an AttrRef built by a second, separately-loaded copy", async () => {
|
|
526
|
+
const parent: Declarable = {
|
|
527
|
+
lexicon: "test",
|
|
528
|
+
entityType: "parent",
|
|
529
|
+
[DECLARABLE_MARKER]: true,
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
vi.resetModules();
|
|
533
|
+
const secondCopy = await import("../attrref");
|
|
534
|
+
expect(secondCopy.AttrRef).not.toBe(AttrRef);
|
|
535
|
+
|
|
536
|
+
const foreignRef = new secondCopy.AttrRef(parent, "someAttr");
|
|
537
|
+
expect(foreignRef instanceof AttrRef).toBe(false); // the historic bug
|
|
538
|
+
|
|
539
|
+
const child: Declarable & { ref: AttrRef } = {
|
|
540
|
+
lexicon: "test",
|
|
541
|
+
entityType: "child",
|
|
542
|
+
[DECLARABLE_MARKER]: true,
|
|
543
|
+
ref: foreignRef,
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const entities = new Map([
|
|
547
|
+
["Parent", parent],
|
|
548
|
+
["Child", child],
|
|
549
|
+
]);
|
|
550
|
+
const graph = buildDependencyGraph(entities);
|
|
551
|
+
|
|
552
|
+
expect(graph.get("Child")?.has("Parent")).toBe(true);
|
|
553
|
+
vi.resetModules();
|
|
554
|
+
});
|
|
516
555
|
});
|
package/src/discovery/graph.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Declarable } from "../declarable";
|
|
2
2
|
import { isDeclarable } from "../declarable";
|
|
3
3
|
import { AttrRef } from "../attrref";
|
|
4
|
+
import { isAttrRefLike } from "../utils";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Builds a dependency graph from a collection of entities
|
|
@@ -95,8 +96,13 @@ function findDependencies(
|
|
|
95
96
|
return;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
// Check if this is an AttrRef
|
|
99
|
-
|
|
99
|
+
// Check if this is an AttrRef. Duck-type, not `instanceof` (chant #1137):
|
|
100
|
+
// a lexicon built against a separate copy of `@intentius/chant` produces
|
|
101
|
+
// AttrRefs that fail `instanceof AttrRef` here but carry the same shape.
|
|
102
|
+
// Without this, the dependency edge is silently dropped instead of
|
|
103
|
+
// recorded, which can misorder — or fail to detect a cycle in — the
|
|
104
|
+
// file-discovery build order this graph exists to compute.
|
|
105
|
+
if (isAttrRefLike(value)) {
|
|
100
106
|
if (visited.has(value)) {
|
|
101
107
|
return;
|
|
102
108
|
}
|