@intentius/chant 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/config-import.d.ts +33 -0
- package/dist/config-import.d.ts.map +1 -0
- package/dist/config-sandbox.d.ts +47 -0
- package/dist/config-sandbox.d.ts.map +1 -0
- package/dist/config.d.ts +11 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/entity-wire-codec.d.ts +15 -10
- package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
- package/dist/discovery/graph.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-run.d.ts +24 -0
- package/dist/discovery/sandbox/config-run.d.ts.map +1 -0
- package/dist/discovery/sandbox/config-wire.d.ts +84 -0
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -0
- package/dist/discovery/sandbox/driver.d.ts +49 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +70 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -0
- package/dist/discovery/sandbox/policy-run.d.ts +33 -0
- package/dist/discovery/sandbox/policy-run.d.ts.map +1 -0
- package/dist/discovery/sandbox/policy-wire.d.ts +177 -0
- package/dist/discovery/sandbox/policy-wire.d.ts.map +1 -0
- package/dist/discovery/sandbox/run.d.ts +10 -20
- package/dist/discovery/sandbox/run.d.ts.map +1 -1
- package/dist/intrinsic-interpolation.d.ts.map +1 -1
- package/dist/lexicon-output.d.ts +62 -6
- package/dist/lexicon-output.d.ts.map +1 -1
- package/dist/lint/config.d.ts +6 -0
- package/dist/lint/config.d.ts.map +1 -1
- package/dist/lint/policy-import.d.ts +50 -0
- package/dist/lint/policy-import.d.ts.map +1 -0
- package/dist/lint/policy-sandbox.d.ts +89 -0
- package/dist/lint/policy-sandbox.d.ts.map +1 -0
- package/dist/lint/policy.d.ts +12 -1
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.test.ts +96 -1
- package/src/build.ts +36 -12
- package/src/cli/commands/build.ts +46 -5
- package/src/cli/main.test.ts +93 -17
- package/src/cli/main.ts +111 -11
- package/src/config-import.ts +43 -0
- package/src/config-sandbox.ts +138 -0
- package/src/config.ts +14 -5
- package/src/discovery/entity-wire-codec.ts +35 -21
- package/src/discovery/entity-wire.test.ts +26 -0
- package/src/discovery/graph.test.ts +40 -1
- package/src/discovery/graph.ts +8 -2
- package/src/discovery/sandbox/config-boundary.test.ts +239 -0
- package/src/discovery/sandbox/config-run.ts +130 -0
- package/src/discovery/sandbox/config-wire.test.ts +110 -0
- package/src/discovery/sandbox/config-wire.ts +195 -0
- package/src/discovery/sandbox/driver.ts +200 -0
- package/src/discovery/sandbox/fork.ts +148 -0
- package/src/discovery/sandbox/policy-boundary.test.ts +325 -0
- package/src/discovery/sandbox/policy-run.ts +180 -0
- package/src/discovery/sandbox/policy-wire.test.ts +310 -0
- package/src/discovery/sandbox/policy-wire.ts +277 -0
- package/src/discovery/sandbox/run.ts +28 -85
- package/src/intrinsic-interpolation.test.ts +27 -1
- package/src/intrinsic-interpolation.ts +10 -2
- package/src/lexicon-output.test.ts +173 -1
- package/src/lexicon-output.ts +123 -14
- package/src/lint/config.ts +8 -4
- package/src/lint/policy-import.ts +70 -0
- package/src/lint/policy-sandbox.ts +123 -0
- package/src/lint/policy.ts +20 -2
- package/src/stack-output.test.ts +118 -0
- package/src/stack-output.ts +21 -5
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single place chant imports a project's `lint.policies` module **in the
|
|
3
|
+
* CLI's own process** — and the flag that says it may not.
|
|
4
|
+
*
|
|
5
|
+
* The third member of the same family as `../discovery/import.ts`'s
|
|
6
|
+
* `importModule` (project source) and `../config-import.ts`'s
|
|
7
|
+
* `importConfigModule` (`chant.config.ts`): one narrow module whose only job is
|
|
8
|
+
* "execute project-authored code here", so that "did any project code run in
|
|
9
|
+
* this process?" has one place to look and one place to instrument — see
|
|
10
|
+
* `examples/sandbox-execution-boundary.test.ts`, which spies on all three.
|
|
11
|
+
*
|
|
12
|
+
* Before chant #1131 `./policy.ts` called `await import(resolved)` inline,
|
|
13
|
+
* which is why the corpus boundary gate could not see it even after #1113 put
|
|
14
|
+
* the config behind the boundary: there was nothing named to wrap.
|
|
15
|
+
*
|
|
16
|
+
* The armed flag lives here rather than in `./policy-sandbox.ts` (which owns
|
|
17
|
+
* the *decision* and documents the reasoning, and re-exports these three
|
|
18
|
+
* functions as its public surface) for one structural reason: `./policy.ts`
|
|
19
|
+
* needs to consult it, `./policy-sandbox.ts` needs to call `./policy.ts`, and a
|
|
20
|
+
* flag on a leaf module with no imports of its own breaks what would otherwise
|
|
21
|
+
* be an import cycle. It also puts the check on the narrowest possible thing —
|
|
22
|
+
* the function that actually executes project code — rather than on a caller
|
|
23
|
+
* that might forget to ask.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Whether this process must run project policy modules inside the sandbox boundary instead of here. */
|
|
27
|
+
let armed = false;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Arm sandboxed policy execution for the rest of this process. See
|
|
31
|
+
* `./policy-sandbox.ts` for what arms it and why it is a process mode rather
|
|
32
|
+
* than a threaded option. Idempotent; there is deliberately no disarm.
|
|
33
|
+
*/
|
|
34
|
+
export function armSandboxPolicyExecution(): void {
|
|
35
|
+
armed = true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Whether {@link armSandboxPolicyExecution} has been called. */
|
|
39
|
+
export function isSandboxPolicyExecutionArmed(): boolean {
|
|
40
|
+
return armed;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Test-only reset — vitest gives each test file its own module registry, so this exists for suites that arm and disarm within one file. */
|
|
44
|
+
export function resetSandboxPolicyExecutionForTests(): void {
|
|
45
|
+
armed = false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The shape a policy module evaluates to — chant reads its exported values and keeps the `PostSynthCheck`-shaped ones. */
|
|
49
|
+
export type PolicyModuleNamespace = Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Import a policy module into THIS process and return its module namespace.
|
|
53
|
+
* Node's ESM registry caches it, so repeated loads within one CLI invocation
|
|
54
|
+
* evaluate the file once.
|
|
55
|
+
*
|
|
56
|
+
* Refuses while armed. Under `--sandbox` the policy modules are imported inside
|
|
57
|
+
* a child process (`../discovery/sandbox/policy-run.ts`); arriving here anyway
|
|
58
|
+
* means something is about to execute project-authored code in the CLI's own
|
|
59
|
+
* process, which is exactly what the flag promises does not happen. Falling
|
|
60
|
+
* through would make `--sandbox` mean less than it says with nothing visible to
|
|
61
|
+
* notice, so this throws instead.
|
|
62
|
+
*/
|
|
63
|
+
export async function importPolicyModule(policyPath: string): Promise<PolicyModuleNamespace> {
|
|
64
|
+
if (armed) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Refusing to import the policy module ${policyPath} into the chant process under --sandbox: it is project-authored code and must be imported inside the sandbox boundary (packages/core/src/lint/policy-sandbox.ts's runProjectPolicies).`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return (await import(policyPath)) as PolicyModuleNamespace;
|
|
70
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
runPostSynthChecks,
|
|
4
|
+
type PostSynthCheck,
|
|
5
|
+
type PostSynthContext,
|
|
6
|
+
type PostSynthDiagnostic,
|
|
7
|
+
} from "./post-synth";
|
|
8
|
+
import { loadPolicyChecks } from "./policy";
|
|
9
|
+
import {
|
|
10
|
+
armSandboxPolicyExecution,
|
|
11
|
+
isSandboxPolicyExecutionArmed,
|
|
12
|
+
resetSandboxPolicyExecutionForTests,
|
|
13
|
+
} from "./policy-import";
|
|
14
|
+
import type { EncodablePolicyBuildResult } from "../discovery/sandbox/policy-wire";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* chant #1131 — decides WHERE a project's `lint.policies` checks run.
|
|
18
|
+
*
|
|
19
|
+
* A policy is project-authored code. Every other piece of project code moved
|
|
20
|
+
* behind the `--sandbox` boundary in chant #1045 (run-fallback source), #1093
|
|
21
|
+
* (composite factories, constructors, intrinsic tags) and #1113
|
|
22
|
+
* (`chant.config.ts`), and all three had to leave this one out: the config
|
|
23
|
+
* declares its policies as *paths*, so putting the config behind the boundary
|
|
24
|
+
* did not put them there with it. `chant build` imported each policy module and
|
|
25
|
+
* called its `check` function in the CLI's own process, after discovery was
|
|
26
|
+
* over, with the CLI's filesystem, network, environment and process-spawn
|
|
27
|
+
* access. This module closes it.
|
|
28
|
+
*
|
|
29
|
+
* ## Why an armed process mode rather than a threaded option
|
|
30
|
+
*
|
|
31
|
+
* The same reason `../config-sandbox.ts` gives. `./policy.ts`'s
|
|
32
|
+
* `loadPolicyChecks` is exported from chant-core's public surface and called
|
|
33
|
+
* from more than one place (`../cli/commands/build.ts` for a build, and
|
|
34
|
+
* `evaluateProjectPolicies` for the `policyGate` Op step). Threading a
|
|
35
|
+
* `sandbox` flag to each is fail-OPEN: miss one and project code executes in
|
|
36
|
+
* the CLI process with nothing to notice. Arming the process once, before any
|
|
37
|
+
* policy is loaded, is fail-CLOSED — `loadPolicyChecks` REFUSES while armed, so
|
|
38
|
+
* a call site nobody remembered gets a loud error rather than a silent
|
|
39
|
+
* execution.
|
|
40
|
+
*
|
|
41
|
+
* ## What arms it
|
|
42
|
+
*
|
|
43
|
+
* Both ways of turning sandboxing on, unlike `../config-sandbox.ts`. That
|
|
44
|
+
* asymmetry is not an oversight: the config has a bootstrap limit (reading
|
|
45
|
+
* `build.sandbox` out of `chant.config.ts` means running it, so only the CLI
|
|
46
|
+
* flag, known before any config is touched, can cover the config's own
|
|
47
|
+
* evaluation). Policies have no such limit — they are loaded long after the
|
|
48
|
+
* config is known — so `build.sandbox: true` in a project's config sandboxes
|
|
49
|
+
* them just as `--sandbox` does, and `../cli/commands/build.ts` arms this from
|
|
50
|
+
* the RESOLVED value.
|
|
51
|
+
*
|
|
52
|
+
* There is deliberately no disarm: a security mode that can be turned off
|
|
53
|
+
* partway through a process is not one.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The mode itself lives on `./policy-import.ts` — the leaf module that actually
|
|
58
|
+
* performs an in-process policy import, so the refusal sits on the narrowest
|
|
59
|
+
* possible thing and `./policy.ts` can consult it without importing this file
|
|
60
|
+
* (which imports `./policy.ts`). Re-exported here because this is where the
|
|
61
|
+
* decision is documented and where callers look.
|
|
62
|
+
*
|
|
63
|
+
* `armSandboxPolicyExecution` is called from `../cli/commands/build.ts` once
|
|
64
|
+
* `build.sandbox`/`--sandbox` has resolved, and from `../cli/main.ts` off the
|
|
65
|
+
* parsed flag.
|
|
66
|
+
*/
|
|
67
|
+
export { armSandboxPolicyExecution, isSandboxPolicyExecutionArmed, resetSandboxPolicyExecutionForTests };
|
|
68
|
+
|
|
69
|
+
export interface ProjectPolicyRun {
|
|
70
|
+
/** `lint.policies` as declared in the config — relative paths, resolved against {@link configDir}. */
|
|
71
|
+
policies: readonly string[];
|
|
72
|
+
/** The `chant.config.*` directory (`lint.policies` paths are relative to it), also the child's read allowance. */
|
|
73
|
+
configDir: string;
|
|
74
|
+
/** The merged, serialized build result the checks run over. */
|
|
75
|
+
buildResult: EncodablePolicyBuildResult & PostSynthContext["buildResult"];
|
|
76
|
+
/** `--env`, else `ownership.env`. Becomes `ctx.env`. */
|
|
77
|
+
env?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Checks the caller already loaded into THIS process, before the build ran.
|
|
80
|
+
*
|
|
81
|
+
* `chant build` has always loaded `lint.policies` up front rather than at the
|
|
82
|
+
* point of use, so a policy path that doesn't resolve fails the command
|
|
83
|
+
* immediately — including when the build itself goes on to fail, which is
|
|
84
|
+
* exactly when a typo'd path would otherwise go unnoticed. Preserving that
|
|
85
|
+
* for the unsandboxed path is the whole reason this field exists.
|
|
86
|
+
*
|
|
87
|
+
* Ignored when armed: under `--sandbox` there is nothing loaded here to pass,
|
|
88
|
+
* and `loadPolicyChecks` refuses outright.
|
|
89
|
+
*/
|
|
90
|
+
preloaded?: PostSynthCheck[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Run a project's organizational policy checks over a finished build and return
|
|
95
|
+
* their diagnostics. Sandboxed in a post-merge child when armed, in-process
|
|
96
|
+
* otherwise.
|
|
97
|
+
*
|
|
98
|
+
* The unarmed path is byte-for-byte what `chant build` has always done: load
|
|
99
|
+
* the modules, hand every check one `PostSynthContext`, concatenate what they
|
|
100
|
+
* return. The armed path does the same thing on the other side of a process
|
|
101
|
+
* boundary — see `../discovery/sandbox/policy-run.ts`.
|
|
102
|
+
*/
|
|
103
|
+
export async function runProjectPolicies(run: ProjectPolicyRun): Promise<PostSynthDiagnostic[]> {
|
|
104
|
+
if (run.policies.length === 0) return [];
|
|
105
|
+
|
|
106
|
+
if (!isSandboxPolicyExecutionArmed()) {
|
|
107
|
+
const checks = run.preloaded ?? (await loadPolicyChecks([...run.policies], run.configDir));
|
|
108
|
+
if (checks.length === 0) return [];
|
|
109
|
+
return runPostSynthChecks(checks, run.buildResult, run.env);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Dynamic, not static, for the same reason `../config-sandbox.ts` imports
|
|
113
|
+
// `../discovery/sandbox/config-run` dynamically: this pulls in `esbuild`, a
|
|
114
|
+
// large CJS package no unsandboxed build should pay to load.
|
|
115
|
+
const { runPoliciesSandboxed } = await import("../discovery/sandbox/policy-run");
|
|
116
|
+
const { diagnostics } = await runPoliciesSandboxed({
|
|
117
|
+
policyPaths: run.policies.map((p) => resolve(run.configDir, p)),
|
|
118
|
+
buildResult: run.buildResult,
|
|
119
|
+
env: run.env,
|
|
120
|
+
projectRoot: run.configDir,
|
|
121
|
+
});
|
|
122
|
+
return diagnostics;
|
|
123
|
+
}
|
package/src/lint/policy.ts
CHANGED
|
@@ -9,15 +9,33 @@ import { resolveProjectLexicons, loadPlugins } from "../cli/plugins";
|
|
|
9
9
|
import { build } from "../build";
|
|
10
10
|
import { runPostSynthChecks, isPostSynthCheck } from "./post-synth";
|
|
11
11
|
import type { PostSynthCheck, PostSynthDiagnostic } from "./post-synth";
|
|
12
|
+
import { importPolicyModule, isSandboxPolicyExecutionArmed } from "./policy-import";
|
|
12
13
|
|
|
13
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Load project policy checks (one or more `PostSynthCheck` exports) from files,
|
|
16
|
+
* **into this process**.
|
|
17
|
+
*
|
|
18
|
+
* chant #1131 — refuses while `./policy-sandbox.ts` is armed. Under `--sandbox`
|
|
19
|
+
* the policy modules are imported and their checks run inside a child process
|
|
20
|
+
* (`runProjectPolicies`); reaching this function anyway means a call site is
|
|
21
|
+
* about to execute project-authored code in the CLI's own process, which is
|
|
22
|
+
* precisely the thing the flag promises does not happen. Failing loudly is the
|
|
23
|
+
* only honest option — falling through would make `--sandbox` mean less than it
|
|
24
|
+
* says without anything visible to notice.
|
|
25
|
+
*/
|
|
14
26
|
export async function loadPolicyChecks(paths: string[], configDir: string): Promise<PostSynthCheck[]> {
|
|
27
|
+
if (isSandboxPolicyExecutionArmed()) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Cannot load lint.policies (${paths.join(", ")}) in the chant process under --sandbox: a policy module is project-authored code, and it must be imported inside the sandbox boundary. This is a chant bug — the caller should route through runProjectPolicies() (packages/core/src/lint/policy-sandbox.ts).`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
const checks: PostSynthCheck[] = [];
|
|
16
34
|
for (const p of paths) {
|
|
17
35
|
const resolved = resolve(configDir, p);
|
|
18
36
|
let mod: Record<string, unknown>;
|
|
19
37
|
try {
|
|
20
|
-
mod =
|
|
38
|
+
mod = await importPolicyModule(resolved);
|
|
21
39
|
} catch (err) {
|
|
22
40
|
throw new Error(
|
|
23
41
|
`Failed to load policy "${p}": ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
|
+
import { stackOutput, isStackOutput, STACK_OUTPUT_MARKER } from "./stack-output";
|
|
3
|
+
import { AttrRef } from "./attrref";
|
|
4
|
+
import { INTRINSIC_MARKER } from "./intrinsic";
|
|
5
|
+
import { DECLARABLE_MARKER, type Declarable } from "./declarable";
|
|
6
|
+
|
|
7
|
+
const vpc: Declarable = {
|
|
8
|
+
lexicon: "aws",
|
|
9
|
+
entityType: "AWS::EC2::VPC",
|
|
10
|
+
[DECLARABLE_MARKER]: true,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe("stackOutput", () => {
|
|
14
|
+
test("wraps a bare AttrRef and derives lexicon from its parent", () => {
|
|
15
|
+
const ref = new AttrRef(vpc, "VpcId");
|
|
16
|
+
const out = stackOutput(ref);
|
|
17
|
+
|
|
18
|
+
expect(out[STACK_OUTPUT_MARKER]).toBe(true);
|
|
19
|
+
expect(out[DECLARABLE_MARKER]).toBe(true);
|
|
20
|
+
expect(out.lexicon).toBe("aws");
|
|
21
|
+
expect(out.kind).toBe("output");
|
|
22
|
+
expect(out.sourceRef).toBe(ref);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("wraps an intrinsic nesting an AttrRef and borrows the AttrRef's lexicon", () => {
|
|
26
|
+
const ref = new AttrRef(vpc, "VpcId");
|
|
27
|
+
const join = {
|
|
28
|
+
[INTRINSIC_MARKER]: true as const,
|
|
29
|
+
values: [ref, "-suffix"],
|
|
30
|
+
toJSON: () => ({ "Fn::Join": ["", ["VpcId", "-suffix"]] }),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const out = stackOutput(join);
|
|
34
|
+
expect(out.lexicon).toBe("aws");
|
|
35
|
+
expect(out.sourceRef).toBe(join);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("records an optional description", () => {
|
|
39
|
+
const ref = new AttrRef(vpc, "VpcId");
|
|
40
|
+
const out = stackOutput(ref, { description: "Primary VPC id" });
|
|
41
|
+
expect(out.description).toBe("Primary VPC id");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("throws for a value that is neither AttrRef-like nor an Intrinsic", () => {
|
|
45
|
+
expect(() => stackOutput("not-a-ref" as unknown as AttrRef)).toThrow(
|
|
46
|
+
"stackOutput(ref): ref must be an attribute reference or an intrinsic wrapping one",
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("falls back to lexicon 'unknown' when the anchor's parent has no lexicon field", () => {
|
|
51
|
+
const ref = new AttrRef({}, "attr");
|
|
52
|
+
const out = stackOutput(ref);
|
|
53
|
+
expect(out.lexicon).toBe("unknown");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// chant #1137 — `stackOutput()` used to check `ref instanceof AttrRef` (in
|
|
57
|
+
// both its input validation and its lexicon-deriving anchor selection),
|
|
58
|
+
// which returns false for an AttrRef built by a SEPARATELY-LOADED copy of
|
|
59
|
+
// `./attrref` (the same dual-npm-copy hazard #1122 fixed for
|
|
60
|
+
// `LexiconOutput`). `firstAttrRef` had the same raw `instanceof` check, so
|
|
61
|
+
// even the "wrapped in an intrinsic" fallback path missed a foreign
|
|
62
|
+
// AttrRef too. Before the fix, a foreign-copy AttrRef silently anchored on
|
|
63
|
+
// nothing and the output's `lexicon` field became `"unknown"` instead of
|
|
64
|
+
// the real producing lexicon — no error, just a wrong value.
|
|
65
|
+
// `vi.resetModules()` + a fresh dynamic import reproduces the split module
|
|
66
|
+
// graph exactly.
|
|
67
|
+
test("derives lexicon from a bare AttrRef built by a second, separately-loaded copy", async () => {
|
|
68
|
+
vi.resetModules();
|
|
69
|
+
const secondCopy = await import("./attrref");
|
|
70
|
+
expect(secondCopy.AttrRef).not.toBe(AttrRef);
|
|
71
|
+
|
|
72
|
+
const foreignRef = new secondCopy.AttrRef(vpc, "VpcId");
|
|
73
|
+
|
|
74
|
+
// The historic bug: instanceof fails across separately-loaded copies of
|
|
75
|
+
// chant-core, even though the two classes are structurally identical.
|
|
76
|
+
expect(foreignRef instanceof AttrRef).toBe(false);
|
|
77
|
+
|
|
78
|
+
const out = stackOutput(foreignRef);
|
|
79
|
+
expect(out.lexicon).toBe("aws");
|
|
80
|
+
expect(out.sourceRef).toBe(foreignRef);
|
|
81
|
+
vi.resetModules();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("derives lexicon from a foreign-copy AttrRef nested inside an intrinsic", async () => {
|
|
85
|
+
vi.resetModules();
|
|
86
|
+
const secondCopy = await import("./attrref");
|
|
87
|
+
const foreignRef = new secondCopy.AttrRef(vpc, "VpcId");
|
|
88
|
+
expect(foreignRef instanceof AttrRef).toBe(false);
|
|
89
|
+
|
|
90
|
+
const join = {
|
|
91
|
+
[INTRINSIC_MARKER]: true as const,
|
|
92
|
+
values: [foreignRef, "-suffix"],
|
|
93
|
+
toJSON: () => ({ "Fn::Join": ["", ["VpcId", "-suffix"]] }),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const out = stackOutput(join);
|
|
97
|
+
expect(out.lexicon).toBe("aws");
|
|
98
|
+
vi.resetModules();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("isStackOutput", () => {
|
|
103
|
+
test("returns true for a StackOutput", () => {
|
|
104
|
+
const ref = new AttrRef(vpc, "VpcId");
|
|
105
|
+
expect(isStackOutput(stackOutput(ref))).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("returns false for a plain AttrRef", () => {
|
|
109
|
+
const ref = new AttrRef(vpc, "VpcId");
|
|
110
|
+
expect(isStackOutput(ref)).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("returns false for null/primitives", () => {
|
|
114
|
+
expect(isStackOutput(null)).toBe(false);
|
|
115
|
+
expect(isStackOutput("x")).toBe(false);
|
|
116
|
+
expect(isStackOutput(42)).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
});
|
package/src/stack-output.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { DECLARABLE_MARKER, type Declarable } from "./declarable";
|
|
10
10
|
import { AttrRef } from "./attrref";
|
|
11
11
|
import { isIntrinsic, type Intrinsic } from "./intrinsic";
|
|
12
|
+
import { isAttrRefLike } from "./utils";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Marker symbol for stack output identification.
|
|
@@ -32,10 +33,15 @@ export interface StackOutput extends Declarable {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/** Find the first AttrRef anywhere inside a value (walking intrinsics/objects/
|
|
35
|
-
* arrays), so a wrapping intrinsic can still borrow its parent's lexicon.
|
|
36
|
+
* arrays), so a wrapping intrinsic can still borrow its parent's lexicon.
|
|
37
|
+
* Duck-type, not `instanceof` (chant #1137): a lexicon built against a
|
|
38
|
+
* separate copy of `@intentius/chant` produces AttrRefs that fail
|
|
39
|
+
* `instanceof AttrRef` here but carry the same shape — missing one would
|
|
40
|
+
* make the walk recurse into the AttrRef's own (unhelpful) fields instead
|
|
41
|
+
* of stopping on it, silently failing to find the anchor. */
|
|
36
42
|
function firstAttrRef(value: unknown, seen = new Set<unknown>()): AttrRef | undefined {
|
|
37
43
|
if (value === null || typeof value !== "object" || seen.has(value)) return undefined;
|
|
38
|
-
if (value
|
|
44
|
+
if (isAttrRefLike(value)) return value;
|
|
39
45
|
seen.add(value);
|
|
40
46
|
const children = Array.isArray(value) ? value : Object.values(value as Record<string, unknown>);
|
|
41
47
|
for (const child of children) {
|
|
@@ -79,12 +85,22 @@ export function stackOutput(
|
|
|
79
85
|
ref: AttrRef | Intrinsic,
|
|
80
86
|
options?: { description?: string },
|
|
81
87
|
): StackOutput {
|
|
82
|
-
|
|
88
|
+
// Duck-type, not `instanceof` (chant #1137): AttrRef also implements
|
|
89
|
+
// Intrinsic (a global-symbol marker), so `isIntrinsic(ref)` alone already
|
|
90
|
+
// happens to accept a foreign-copy AttrRef here — this check would not
|
|
91
|
+
// misfire even with the raw `instanceof` left in. It is converted anyway
|
|
92
|
+
// so the guard's own logic states the invariant explicitly ("ref must be
|
|
93
|
+
// AttrRef-like or Intrinsic") rather than relying on that coincidence,
|
|
94
|
+
// matching the anchor selection right below it, which does misbehave.
|
|
95
|
+
if (!isAttrRefLike(ref) && !isIntrinsic(ref)) {
|
|
83
96
|
throw new Error("stackOutput(ref): ref must be an attribute reference or an intrinsic wrapping one");
|
|
84
97
|
}
|
|
85
98
|
// Derive lexicon from the referenced entity — for a bare AttrRef, its parent;
|
|
86
|
-
// for an intrinsic (Join etc.), the first AttrRef nested inside it.
|
|
87
|
-
|
|
99
|
+
// for an intrinsic (Join etc.), the first AttrRef nested inside it. A
|
|
100
|
+
// foreign-copy AttrRef failing raw `instanceof` here would fall to
|
|
101
|
+
// `firstAttrRef`, which (before its own #1137 fix) would also miss it —
|
|
102
|
+
// silently anchoring on nothing and recording `lexicon: "unknown"`.
|
|
103
|
+
const anchor = isAttrRefLike(ref) ? ref : firstAttrRef(ref);
|
|
88
104
|
const parent = anchor?.parent.deref();
|
|
89
105
|
const lexicon = parent && typeof (parent as Record<string, unknown>).lexicon === "string"
|
|
90
106
|
? (parent as Record<string, unknown>).lexicon as string
|