@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.
Files changed (50) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/discovery/entity-wire-codec.d.ts +14 -9
  5. package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
  6. package/dist/discovery/graph.d.ts.map +1 -1
  7. package/dist/discovery/sandbox/config-wire.d.ts +16 -0
  8. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  9. package/dist/discovery/sandbox/driver.d.ts +29 -0
  10. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  11. package/dist/discovery/sandbox/fork.d.ts +18 -0
  12. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  13. package/dist/discovery/sandbox/policy-run.d.ts +33 -0
  14. package/dist/discovery/sandbox/policy-run.d.ts.map +1 -0
  15. package/dist/discovery/sandbox/policy-wire.d.ts +177 -0
  16. package/dist/discovery/sandbox/policy-wire.d.ts.map +1 -0
  17. package/dist/intrinsic-interpolation.d.ts.map +1 -1
  18. package/dist/lexicon-output.d.ts.map +1 -1
  19. package/dist/lint/policy-import.d.ts +50 -0
  20. package/dist/lint/policy-import.d.ts.map +1 -0
  21. package/dist/lint/policy-sandbox.d.ts +89 -0
  22. package/dist/lint/policy-sandbox.d.ts.map +1 -0
  23. package/dist/lint/policy.d.ts +12 -1
  24. package/dist/lint/policy.d.ts.map +1 -1
  25. package/dist/stack-output.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/build.test.ts +77 -1
  28. package/src/build.ts +15 -2
  29. package/src/cli/commands/build.ts +33 -5
  30. package/src/cli/main.test.ts +93 -17
  31. package/src/cli/main.ts +101 -11
  32. package/src/discovery/entity-wire-codec.ts +14 -9
  33. package/src/discovery/graph.test.ts +40 -1
  34. package/src/discovery/graph.ts +8 -2
  35. package/src/discovery/sandbox/config-wire.ts +21 -0
  36. package/src/discovery/sandbox/driver.ts +132 -0
  37. package/src/discovery/sandbox/fork.ts +39 -1
  38. package/src/discovery/sandbox/policy-boundary.test.ts +325 -0
  39. package/src/discovery/sandbox/policy-run.ts +180 -0
  40. package/src/discovery/sandbox/policy-wire.test.ts +310 -0
  41. package/src/discovery/sandbox/policy-wire.ts +277 -0
  42. package/src/intrinsic-interpolation.test.ts +27 -1
  43. package/src/intrinsic-interpolation.ts +10 -2
  44. package/src/lexicon-output.test.ts +36 -0
  45. package/src/lexicon-output.ts +12 -2
  46. package/src/lint/policy-import.ts +70 -0
  47. package/src/lint/policy-sandbox.ts +123 -0
  48. package/src/lint/policy.ts +20 -2
  49. package/src/stack-output.test.ts +118 -0
  50. 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;AAE1D;;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;AAgBD;;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,CAuBb"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
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
- if (value instanceof AttrRef) {
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
- if (value instanceof AttrRef) {
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 policyChecks = config.lint?.policies?.length
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
- if (policyChecks.length > 0) {
302
- const policyDiags = runPostSynthChecks(policyChecks, result, env);
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})` : "";
@@ -130,10 +130,23 @@ describe("parseArgs", () => {
130
130
  expect(result.help).toBe(false);
131
131
  });
132
132
 
133
- test("ignores unknown flags", () => {
134
- const result = parseArgs(["build", "--unknown", "value"]);
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
- // ── --param=name=value hard error (chant #1118) ──────────────────────────
275
- // The joined `--flag=value` form is not supported anywhere in this parser
276
- // (see "ignores unknown flags" above) — a dropped --param can silently
277
- // change what a build measures/deploys, so this form is rejected loudly
278
- // instead of silently accepted as a no-op.
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 throws instead of silently dropping", () => {
281
- expect(() => parseArgs(["build", "src", "--param=tier=production"])).toThrow(/--param=tier=production/);
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("--param=name=value error names the working form", () => {
285
- expect(() => parseArgs(["build", "src", "--param=tier=production"])).toThrow(/--param name=value/);
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("--param= (empty value) also throws", () => {
289
- expect(() => parseArgs(["build", "src", "--param="])).toThrow(/--param=/);
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("plain --param name=value is unaffected", () => {
293
- const result = parseArgs(["build", "src", "--param", "tier=production"]);
294
- expect(result.param).toEqual(["tier=production"]);
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
- const arg = args[i];
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 (several call sites downstream key off
27
- * `instanceof AttrRef`, not just duck typing `intrinsic-interpolation.ts`'s
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` so a plain `{__attrRef}` envelope alone is not
31
- * enough), and whole-entity embeds restored to the SAME object reference
32
- * (not a structurally-equal clone), so `entityNames.get(decl)` keeps working
33
- * by identity exactly as it does today.
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 so every OTHER `instanceof AttrRef` call site
39
- * keeps working too, not just the walker.
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 `instanceof AttrRef`/a
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
  });
@@ -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
- if (value instanceof AttrRef) {
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
  }