@intentius/chant 0.25.0 → 0.27.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 +6 -5
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/config.d.ts +13 -10
- package/dist/config.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.test.ts +16 -2
- package/src/cli/commands/build.ts +42 -13
- package/src/cli/main.test.ts +99 -17
- package/src/cli/main.ts +111 -13
- package/src/config.test.ts +28 -1
- package/src/config.ts +16 -12
- 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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type PostSynthCheck, type PostSynthContext, type PostSynthDiagnostic } from "./post-synth.js";
|
|
2
|
+
import { armSandboxPolicyExecution, isSandboxPolicyExecutionArmed, resetSandboxPolicyExecutionForTests } from "./policy-import.js";
|
|
3
|
+
import type { EncodablePolicyBuildResult } from "../discovery/sandbox/policy-wire.js";
|
|
4
|
+
/**
|
|
5
|
+
* chant #1131 — decides WHERE a project's `lint.policies` checks run.
|
|
6
|
+
*
|
|
7
|
+
* A policy is project-authored code. Every other piece of project code moved
|
|
8
|
+
* behind the `--sandbox` boundary in chant #1045 (run-fallback source), #1093
|
|
9
|
+
* (composite factories, constructors, intrinsic tags) and #1113
|
|
10
|
+
* (`chant.config.ts`), and all three had to leave this one out: the config
|
|
11
|
+
* declares its policies as *paths*, so putting the config behind the boundary
|
|
12
|
+
* did not put them there with it. `chant build` imported each policy module and
|
|
13
|
+
* called its `check` function in the CLI's own process, after discovery was
|
|
14
|
+
* over, with the CLI's filesystem, network, environment and process-spawn
|
|
15
|
+
* access. This module closes it.
|
|
16
|
+
*
|
|
17
|
+
* ## Why an armed process mode rather than a threaded option
|
|
18
|
+
*
|
|
19
|
+
* The same reason `../config-sandbox.ts` gives. `./policy.ts`'s
|
|
20
|
+
* `loadPolicyChecks` is exported from chant-core's public surface and called
|
|
21
|
+
* from more than one place (`../cli/commands/build.ts` for a build, and
|
|
22
|
+
* `evaluateProjectPolicies` for the `policyGate` Op step). Threading a
|
|
23
|
+
* `sandbox` flag to each is fail-OPEN: miss one and project code executes in
|
|
24
|
+
* the CLI process with nothing to notice. Arming the process once, before any
|
|
25
|
+
* policy is loaded, is fail-CLOSED — `loadPolicyChecks` REFUSES while armed, so
|
|
26
|
+
* a call site nobody remembered gets a loud error rather than a silent
|
|
27
|
+
* execution.
|
|
28
|
+
*
|
|
29
|
+
* ## What arms it
|
|
30
|
+
*
|
|
31
|
+
* Both ways of turning sandboxing on, unlike `../config-sandbox.ts`. That
|
|
32
|
+
* asymmetry is not an oversight: the config has a bootstrap limit (reading
|
|
33
|
+
* `build.sandbox` out of `chant.config.ts` means running it, so only the CLI
|
|
34
|
+
* flag, known before any config is touched, can cover the config's own
|
|
35
|
+
* evaluation). Policies have no such limit — they are loaded long after the
|
|
36
|
+
* config is known — so `build.sandbox: true` in a project's config sandboxes
|
|
37
|
+
* them just as `--sandbox` does, and `../cli/commands/build.ts` arms this from
|
|
38
|
+
* the RESOLVED value.
|
|
39
|
+
*
|
|
40
|
+
* There is deliberately no disarm: a security mode that can be turned off
|
|
41
|
+
* partway through a process is not one.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* The mode itself lives on `./policy-import.ts` — the leaf module that actually
|
|
45
|
+
* performs an in-process policy import, so the refusal sits on the narrowest
|
|
46
|
+
* possible thing and `./policy.ts` can consult it without importing this file
|
|
47
|
+
* (which imports `./policy.ts`). Re-exported here because this is where the
|
|
48
|
+
* decision is documented and where callers look.
|
|
49
|
+
*
|
|
50
|
+
* `armSandboxPolicyExecution` is called from `../cli/commands/build.ts` once
|
|
51
|
+
* `build.sandbox`/`--sandbox` has resolved, and from `../cli/main.ts` off the
|
|
52
|
+
* parsed flag.
|
|
53
|
+
*/
|
|
54
|
+
export { armSandboxPolicyExecution, isSandboxPolicyExecutionArmed, resetSandboxPolicyExecutionForTests };
|
|
55
|
+
export interface ProjectPolicyRun {
|
|
56
|
+
/** `lint.policies` as declared in the config — relative paths, resolved against {@link configDir}. */
|
|
57
|
+
policies: readonly string[];
|
|
58
|
+
/** The `chant.config.*` directory (`lint.policies` paths are relative to it), also the child's read allowance. */
|
|
59
|
+
configDir: string;
|
|
60
|
+
/** The merged, serialized build result the checks run over. */
|
|
61
|
+
buildResult: EncodablePolicyBuildResult & PostSynthContext["buildResult"];
|
|
62
|
+
/** `--env`, else `ownership.env`. Becomes `ctx.env`. */
|
|
63
|
+
env?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Checks the caller already loaded into THIS process, before the build ran.
|
|
66
|
+
*
|
|
67
|
+
* `chant build` has always loaded `lint.policies` up front rather than at the
|
|
68
|
+
* point of use, so a policy path that doesn't resolve fails the command
|
|
69
|
+
* immediately — including when the build itself goes on to fail, which is
|
|
70
|
+
* exactly when a typo'd path would otherwise go unnoticed. Preserving that
|
|
71
|
+
* for the unsandboxed path is the whole reason this field exists.
|
|
72
|
+
*
|
|
73
|
+
* Ignored when armed: under `--sandbox` there is nothing loaded here to pass,
|
|
74
|
+
* and `loadPolicyChecks` refuses outright.
|
|
75
|
+
*/
|
|
76
|
+
preloaded?: PostSynthCheck[];
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Run a project's organizational policy checks over a finished build and return
|
|
80
|
+
* their diagnostics. Sandboxed in a post-merge child when armed, in-process
|
|
81
|
+
* otherwise.
|
|
82
|
+
*
|
|
83
|
+
* The unarmed path is byte-for-byte what `chant build` has always done: load
|
|
84
|
+
* the modules, hand every check one `PostSynthContext`, concatenate what they
|
|
85
|
+
* return. The armed path does the same thing on the other side of a process
|
|
86
|
+
* boundary — see `../discovery/sandbox/policy-run.ts`.
|
|
87
|
+
*/
|
|
88
|
+
export declare function runProjectPolicies(run: ProjectPolicyRun): Promise<PostSynthDiagnostic[]>;
|
|
89
|
+
//# sourceMappingURL=policy-sandbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"policy-sandbox.d.ts","sourceRoot":"","sources":["../../src/lint/policy-sandbox.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EACzB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,yBAAyB,EACzB,6BAA6B,EAC7B,mCAAmC,EACpC,MAAM,iBAAiB,CAAC;AACzB,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,kCAAkC,CAAC;AAEnF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH;;;;;;;;;;GAUG;AACH,OAAO,EAAE,yBAAyB,EAAE,6BAA6B,EAAE,mCAAmC,EAAE,CAAC;AAEzG,MAAM,WAAW,gBAAgB;IAC/B,sGAAsG;IACtG,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,kHAAkH;IAClH,SAAS,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,WAAW,EAAE,0BAA0B,GAAG,gBAAgB,CAAC,aAAa,CAAC,CAAC;IAC1E,wDAAwD;IACxD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;OAWG;IACH,SAAS,CAAC,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED;;;;;;;;;GASG;AACH,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAoB9F"}
|
package/dist/lint/policy.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import type { PostSynthCheck, PostSynthDiagnostic } from "./post-synth.js";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Load project policy checks (one or more `PostSynthCheck` exports) from files,
|
|
4
|
+
* **into this process**.
|
|
5
|
+
*
|
|
6
|
+
* chant #1131 — refuses while `./policy-sandbox.ts` is armed. Under `--sandbox`
|
|
7
|
+
* the policy modules are imported and their checks run inside a child process
|
|
8
|
+
* (`runProjectPolicies`); reaching this function anyway means a call site is
|
|
9
|
+
* about to execute project-authored code in the CLI's own process, which is
|
|
10
|
+
* precisely the thing the flag promises does not happen. Failing loudly is the
|
|
11
|
+
* only honest option — falling through would make `--sandbox` mean less than it
|
|
12
|
+
* says without anything visible to notice.
|
|
13
|
+
*/
|
|
3
14
|
export declare function loadPolicyChecks(paths: string[], configDir: string): Promise<PostSynthCheck[]>;
|
|
4
15
|
export interface PolicyEvaluation {
|
|
5
16
|
/** All policy diagnostics (errors + warnings). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/lint/policy.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/lint/policy.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAGxE;;;;;;;;;;;GAWG;AACH,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAuBpG;AAED,MAAM,WAAW,gBAAgB;IAC/B,kDAAkD;IAClD,WAAW,EAAE,mBAAmB,EAAE,CAAC;IACnC,2EAA2E;IAC3E,UAAU,EAAE,mBAAmB,EAAE,CAAC;IAClC,gEAAgE;IAChE,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;;GAKG;AACH,wBAAsB,uBAAuB,CAAC,IAAI,EAAE;IAClD,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA8B5B"}
|
|
@@ -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
|
|
|
@@ -322,7 +322,7 @@ export const testEntity = {
|
|
|
322
322
|
expect(byName.get("env")).toEqual({ name: "env", value: "from-file", source: "params-file" });
|
|
323
323
|
});
|
|
324
324
|
|
|
325
|
-
test("
|
|
325
|
+
test("fold is the default (#1134): omitting the flag folds; --no-fold restores the run path", async () => {
|
|
326
326
|
await writeFile(
|
|
327
327
|
join(testDir, "test.infra.ts"),
|
|
328
328
|
`
|
|
@@ -345,7 +345,21 @@ export const testEntity = {
|
|
|
345
345
|
expect(result.success).toBe(true);
|
|
346
346
|
expect(result.resourceCount).toBe(1);
|
|
347
347
|
const anyFoldLine = errorSpy.mock.calls.map((call) => String(call[0])).some((line) => line.includes("[fold:"));
|
|
348
|
-
expect(anyFoldLine).toBe(
|
|
348
|
+
expect(anyFoldLine).toBe(true);
|
|
349
|
+
|
|
350
|
+
errorSpy.mockClear();
|
|
351
|
+
const runResult = await buildCommand({
|
|
352
|
+
path: testDir,
|
|
353
|
+
format: "json",
|
|
354
|
+
serializers: [mockSerializer],
|
|
355
|
+
fold: false,
|
|
356
|
+
});
|
|
357
|
+
expect(runResult.success).toBe(true);
|
|
358
|
+
expect(runResult.resourceCount).toBe(1);
|
|
359
|
+
const anyFoldLineOff = errorSpy.mock.calls
|
|
360
|
+
.map((call) => String(call[0]))
|
|
361
|
+
.some((line) => line.includes("[fold:"));
|
|
362
|
+
expect(anyFoldLineOff).toBe(false);
|
|
349
363
|
} finally {
|
|
350
364
|
errorSpy.mockRestore();
|
|
351
365
|
}
|
|
@@ -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";
|
|
@@ -34,11 +35,12 @@ export interface BuildOptions {
|
|
|
34
35
|
*/
|
|
35
36
|
env?: string;
|
|
36
37
|
/**
|
|
37
|
-
* chant #1022 (epic #1019) —
|
|
38
|
-
*
|
|
39
|
-
* run per-file for anything the folder can't represent.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
38
|
+
* chant #1022/#1134 (epic #1019) — fold source modules statically instead
|
|
39
|
+
* of importing/running them; the DEFAULT build path since #1134. Falls
|
|
40
|
+
* back to run per-file for anything the folder can't represent. Tri-state:
|
|
41
|
+
* `--fold` → true, `--no-fold` → false, unset → the project config /
|
|
42
|
+
* default via {@link resolveFoldEnabled}. An explicit flag always wins for
|
|
43
|
+
* the invocation, in either direction.
|
|
42
44
|
*/
|
|
43
45
|
fold?: boolean;
|
|
44
46
|
|
|
@@ -144,13 +146,11 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
144
146
|
// Project-authored organizational policy checks (lint.policies), run over the
|
|
145
147
|
// resolved resources during build. Resolve paths relative to the config dir.
|
|
146
148
|
const configDir = loaded.configPath ? dirname(loaded.configPath) : infraPath;
|
|
147
|
-
const
|
|
148
|
-
? await loadPolicyChecks(config.lint.policies, configDir)
|
|
149
|
-
: [];
|
|
149
|
+
const policies = config.lint?.policies ?? [];
|
|
150
150
|
|
|
151
|
-
// #1022 —
|
|
152
|
-
//
|
|
153
|
-
//
|
|
151
|
+
// #1022/#1134 — fold is the default build path: an explicit CLI flag
|
|
152
|
+
// (--fold/--no-fold) wins over `chant.config.ts`'s `build.fold`, which
|
|
153
|
+
// wins over the default of `true`.
|
|
154
154
|
const fold = resolveFoldEnabled(config, options.fold);
|
|
155
155
|
|
|
156
156
|
// #1045 Phase 2 — opt-in sandboxed execution of run-fallback files (or,
|
|
@@ -158,6 +158,22 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
158
158
|
// as fold, resolved independently.
|
|
159
159
|
const sandbox = resolveSandboxEnabled(config, options.sandbox);
|
|
160
160
|
|
|
161
|
+
// #1131 — arm sandboxed policy execution from the RESOLVED value, before any
|
|
162
|
+
// policy module could be loaded. Resolved, not `options.sandbox`, because
|
|
163
|
+
// policies have none of the config's bootstrap limit: they are loaded long
|
|
164
|
+
// after `build.sandbox` is known, so a config-only opt-in sandboxes them just
|
|
165
|
+
// as the CLI flag does. Arming (rather than threading a flag to each caller)
|
|
166
|
+
// makes `loadPolicyChecks` refuse process-wide — a call site that forgot to
|
|
167
|
+
// ask gets a loud error instead of quietly running project code here.
|
|
168
|
+
if (sandbox) armSandboxPolicyExecution();
|
|
169
|
+
|
|
170
|
+
// Unsandboxed, the policy pack is still loaded HERE, before the build — a
|
|
171
|
+
// policy path that doesn't resolve has always failed the command up front,
|
|
172
|
+
// including when the build itself then fails, and #1131 does not change that.
|
|
173
|
+
// Sandboxed, there is nothing to load in this process at all.
|
|
174
|
+
const preloadedPolicyChecks =
|
|
175
|
+
!sandbox && policies.length > 0 ? await loadPolicyChecks([...policies], configDir) : undefined;
|
|
176
|
+
|
|
161
177
|
// #1113 — the bootstrap limit, surfaced rather than left implicit. Reading
|
|
162
178
|
// `build.sandbox` out of `chant.config.ts` requires evaluating that file, so
|
|
163
179
|
// a config-only opt-in cannot have covered its own evaluation; only the CLI
|
|
@@ -298,8 +314,21 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
298
314
|
|
|
299
315
|
// Project-authored organizational policy — cross-cutting, so it sees every
|
|
300
316
|
// lexicon's output at once (not scoped per-plugin), with the current env.
|
|
301
|
-
|
|
302
|
-
|
|
317
|
+
//
|
|
318
|
+
// #1131 — under `--sandbox` this is where the LAST piece of project-
|
|
319
|
+
// authored code the CLI used to execute in its own process moves behind
|
|
320
|
+
// the boundary: `runProjectPolicies` hands the merged, serialized build
|
|
321
|
+
// result to a post-merge sandboxed child, which imports the policy modules
|
|
322
|
+
// and runs their checks there, and only plain `PostSynthDiagnostic`s come
|
|
323
|
+
// back. Unsandboxed, it is the same in-process load-and-run as before.
|
|
324
|
+
if (policies.length > 0) {
|
|
325
|
+
const policyDiags = await runProjectPolicies({
|
|
326
|
+
policies,
|
|
327
|
+
configDir,
|
|
328
|
+
buildResult: result,
|
|
329
|
+
env,
|
|
330
|
+
preloaded: preloadedPolicyChecks,
|
|
331
|
+
});
|
|
303
332
|
for (const diag of policyDiags) {
|
|
304
333
|
const prefix = diag.entity ? `[${diag.entity}] ` : "";
|
|
305
334
|
const where = diag.lexicon ? ` (${diag.lexicon})` : "";
|
package/src/cli/main.test.ts
CHANGED
|
@@ -4,6 +4,12 @@ import { parseArgs, waitForStreamDrain } from "./main";
|
|
|
4
4
|
import { resolveCommand, type CommandDef, type ParsedArgs } from "./registry";
|
|
5
5
|
|
|
6
6
|
describe("parseArgs", () => {
|
|
7
|
+
test("--fold and --no-fold set the tri-state fold option (#1134)", () => {
|
|
8
|
+
expect(parseArgs(["build", "src"]).fold).toBeUndefined();
|
|
9
|
+
expect(parseArgs(["build", "src", "--fold"]).fold).toBe(true);
|
|
10
|
+
expect(parseArgs(["build", "src", "--no-fold"]).fold).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
7
13
|
test("parses command as first positional arg", () => {
|
|
8
14
|
const result = parseArgs(["build"]);
|
|
9
15
|
expect(result.command).toBe("build");
|
|
@@ -130,10 +136,23 @@ describe("parseArgs", () => {
|
|
|
130
136
|
expect(result.help).toBe(false);
|
|
131
137
|
});
|
|
132
138
|
|
|
133
|
-
test("
|
|
134
|
-
|
|
139
|
+
test("throws on an unknown bare flag instead of silently ignoring it (chant #1127)", () => {
|
|
140
|
+
// Was "ignores unknown flags" — pinned the old silent-drop as intended
|
|
141
|
+
// behavior. #1127 flips it: an unrecognized `--flag` is a hard error.
|
|
142
|
+
expect(() => parseArgs(["build", "--unknown", "value"])).toThrow(/Unknown flag: --unknown/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("unknown flag error points at --help", () => {
|
|
146
|
+
expect(() => parseArgs(["build", "--unknown"])).toThrow(/--help/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("throws on an unknown joined flag (--unknown=value)", () => {
|
|
150
|
+
expect(() => parseArgs(["build", "--unknown=value"])).toThrow(/Unknown flag: --unknown/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("unknown short flags are still silently ignored (unchanged; out of #1127 scope)", () => {
|
|
154
|
+
const result = parseArgs(["build", "-x", "value"]);
|
|
135
155
|
expect(result.command).toBe("build");
|
|
136
|
-
// Unknown flags are silently ignored
|
|
137
156
|
});
|
|
138
157
|
|
|
139
158
|
test("parses --watch flag", () => {
|
|
@@ -271,27 +290,90 @@ describe("parseArgs", () => {
|
|
|
271
290
|
expect(result.paramsFile).toBe("./params.json");
|
|
272
291
|
});
|
|
273
292
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
293
|
+
test("plain --param name=value is unaffected", () => {
|
|
294
|
+
const result = parseArgs(["build", "src", "--param", "tier=production"]);
|
|
295
|
+
expect(result.param).toEqual(["tier=production"]);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ── generic --flag=value joined form (chant #1127) ────────────────────────
|
|
299
|
+
// #1118 taught this parser to hard-error `--param=name=value` specifically,
|
|
300
|
+
// because it was the one flag known (from #1118's investigation) to sit
|
|
301
|
+
// behind a silent drop. #1127's audit found the drop was general — every
|
|
302
|
+
// value-taking flag shares it — so the fix is general too: split any
|
|
303
|
+
// `--flag=value` token at its first `=` and re-dispatch as `--flag` +
|
|
304
|
+
// `value`, the exact shape every branch below already handles. This
|
|
305
|
+
// supersedes #1118's `--param=` hard error entirely: the joined form is now
|
|
306
|
+
// just as valid as the space-separated one, for every flag, not a rejected
|
|
307
|
+
// special case for one flag.
|
|
279
308
|
|
|
280
|
-
test("--param=name=value
|
|
281
|
-
|
|
309
|
+
test("--param=name=value now works instead of throwing — joined form matches the space-separated form", () => {
|
|
310
|
+
const result = parseArgs(["build", "src", "--param=tier=production"]);
|
|
311
|
+
expect(result.param).toEqual(["tier=production"]);
|
|
282
312
|
});
|
|
283
313
|
|
|
284
|
-
test("--
|
|
285
|
-
|
|
314
|
+
test("--env=value joined form works", () => {
|
|
315
|
+
const result = parseArgs(["build", "src", "--env=staging"]);
|
|
316
|
+
expect(result.env).toBe("staging");
|
|
286
317
|
});
|
|
287
318
|
|
|
288
|
-
test("--
|
|
289
|
-
|
|
319
|
+
test("--format=value joined form works", () => {
|
|
320
|
+
const result = parseArgs(["build", "src", "--format=yaml"]);
|
|
321
|
+
expect(result.format).toBe("yaml");
|
|
290
322
|
});
|
|
291
323
|
|
|
292
|
-
test("
|
|
293
|
-
const result = parseArgs(["build", "src", "--
|
|
294
|
-
expect(result.
|
|
324
|
+
test("--lexicon=value joined form works", () => {
|
|
325
|
+
const result = parseArgs(["build", "src", "--lexicon=aws"]);
|
|
326
|
+
expect(result.lexicon).toBe("aws");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("repeated --param=name=value (joined) accumulates in order, same as space-separated", () => {
|
|
330
|
+
const result = parseArgs(["build", "src", "--param=tier=production", "--param=env=staging"]);
|
|
331
|
+
expect(result.param).toEqual(["tier=production", "env=staging"]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("joined form only splits on the FIRST '=' — a value containing '=' is preserved whole", () => {
|
|
335
|
+
// --param's own value shape is `name=value`, so `--param=tier=production`
|
|
336
|
+
// must split into flag `--param` + value `tier=production`, not further
|
|
337
|
+
// fragment on the second `=`.
|
|
338
|
+
const result = parseArgs(["build", "src", "--param=tier=production=east"]);
|
|
339
|
+
expect(result.param).toEqual(["tier=production=east"]);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("joined form works mixed with space-separated flags in the same invocation", () => {
|
|
343
|
+
const result = parseArgs(["build", "src", "--env=prod", "--format", "json", "--lexicon=k8s"]);
|
|
344
|
+
expect(result.env).toBe("prod");
|
|
345
|
+
expect(result.format).toBe("json");
|
|
346
|
+
expect(result.lexicon).toBe("k8s");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// ── boolean-only flag given a joined value (chant #1127) ──────────────────
|
|
350
|
+
// Decision: reject it. A boolean flag (--fold, --watch, --json, ...) has no
|
|
351
|
+
// value slot — its branch just sets a field to `true` and never consumes a
|
|
352
|
+
// following token. Silently coercing "true"/"false" would need to invent
|
|
353
|
+
// parsing rules (what about "1", "yes", mixed case?) for a form none of
|
|
354
|
+
// this CLI's flags need; silently dropping the value and reinterpreting it
|
|
355
|
+
// as the next positional (a path, a component name, ...) is exactly the
|
|
356
|
+
// silent misparse #1127 closes. So it errors, naming the flag as boolean.
|
|
357
|
+
|
|
358
|
+
test("a boolean flag given a joined value throws, naming the flag as boolean", () => {
|
|
359
|
+
expect(() => parseArgs(["build", "src", "--fold=true"])).toThrow(/--fold is a boolean flag/);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("boolean-with-value error does not silently reinterpret the value as a positional", () => {
|
|
363
|
+
expect(() => parseArgs(["build", "src", "--watch=false"])).toThrow(/--watch is a boolean flag/);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("--json=1 (another boolean flag) also throws", () => {
|
|
367
|
+
expect(() => parseArgs(["run", "myop", "--json=1"])).toThrow(/--json is a boolean flag/);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("--report keeps its context-sensitive bare-vs-value behavior when joined", () => {
|
|
371
|
+
// --report is deliberately not in the boolean-reject set: bare --report is
|
|
372
|
+
// a boolean (`run`), but --report <path> is a SARIF destination (migrate).
|
|
373
|
+
// The joined form should resolve the same way the space-separated one does.
|
|
374
|
+
const result = parseArgs(["migrate", "wf.yml", "--report=out.sarif"]);
|
|
375
|
+
expect(result.reportFile).toBe("out.sarif");
|
|
376
|
+
expect(result.report).toBeUndefined();
|
|
295
377
|
});
|
|
296
378
|
});
|
|
297
379
|
|