@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
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { fork } from "node:child_process";
|
|
2
1
|
import { realpathSync, rmSync } from "node:fs";
|
|
3
2
|
import { resolve } from "node:path";
|
|
4
3
|
import type { Declarable } from "../../declarable";
|
|
@@ -7,6 +6,7 @@ import { decodeEntitySet, type EntitySetWire } from "../entity-wire-codec";
|
|
|
7
6
|
import { bundleDriver } from "./bundle";
|
|
8
7
|
import { classifyChildError } from "./child-errors";
|
|
9
8
|
import { generateDriverSource } from "./driver";
|
|
9
|
+
import { forkSandboxed } from "./fork";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* chant #1045 Phase 2 — runs every run-fallback file for a build TOGETHER, as
|
|
@@ -14,26 +14,16 @@ import { generateDriverSource } from "./driver";
|
|
|
14
14
|
* the same shape `discover()`'s own in-process run path would have produced:
|
|
15
15
|
* a named, ref-resolved entities map plus any errors.
|
|
16
16
|
*
|
|
17
|
-
* Isolation mechanics
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* unbundled and resolves them to their real, fixed location instead —
|
|
28
|
-
* project source never controls what's installed there.
|
|
29
|
-
* - The env is a spawn-time scrub (`env: {}` below, plus `PATH` — see the
|
|
30
|
-
* option below), not `--permission`: Node's Permission Model does not gate
|
|
31
|
-
* `process.env` at all (confirmed: every key stays readable even under
|
|
32
|
-
* `--permission`).
|
|
33
|
-
* - Network egress is NOT addressed here — Node has no flag for it. See the
|
|
34
|
-
* chant#1045 PR description / docs for the residual-risk statement and
|
|
35
|
-
* deployment guidance (a container with no egress, a network namespace).
|
|
36
|
-
* This function does not claim to close that gap.
|
|
17
|
+
* Isolation mechanics live in `./fork.ts` — the one function that spawns a
|
|
18
|
+
* sandboxed child, shared with chant #1113's config evaluation
|
|
19
|
+
* (`./config-run.ts`) so the two cannot drift apart. In short:
|
|
20
|
+
* `--permission --allow-fs-read=<bundle dir>,<project dir>[,<trusted external
|
|
21
|
+
* package dirs>]`, a spawn-time environment scrub, no writes, no spawning, no
|
|
22
|
+
* worker threads, and no network guarantee. The "trusted external package
|
|
23
|
+
* dirs" allowance is narrow and specific: `./bundle.ts` deliberately leaves a
|
|
24
|
+
* couple of chant/lexicon-internal dependencies (`typescript`) unbundled and
|
|
25
|
+
* resolves them to their real, fixed location instead — project source never
|
|
26
|
+
* controls what's installed there.
|
|
37
27
|
*
|
|
38
28
|
* What does NOT run inside the child: fold (`tryFoldFile`, `../fold-import`)
|
|
39
29
|
* stays exactly where it is today, in the parent, unsandboxed — fold already
|
|
@@ -108,7 +98,23 @@ export async function runFallbackFilesSandboxed(
|
|
|
108
98
|
projectRealpath = resolve(buildRoot);
|
|
109
99
|
}
|
|
110
100
|
|
|
111
|
-
const response = await
|
|
101
|
+
const response = await forkSandboxed(
|
|
102
|
+
{
|
|
103
|
+
bundlePath,
|
|
104
|
+
bundleDir,
|
|
105
|
+
projectRealpath,
|
|
106
|
+
externalReadPaths,
|
|
107
|
+
// chant #1045 Phase 2 — Node's Permission Model does not gate
|
|
108
|
+
// `process.env`; scrubbing it at spawn is the only way to keep the
|
|
109
|
+
// ambient environment out of untrusted project source's reach. `PATH`
|
|
110
|
+
// is kept only because some platforms' module resolution/dynamic
|
|
111
|
+
// linking consults it; it carries no project secrets.
|
|
112
|
+
env: { PATH: process.env.PATH ?? "" },
|
|
113
|
+
timeoutMs: CHILD_TIMEOUT_MS,
|
|
114
|
+
label: "sandboxed run",
|
|
115
|
+
},
|
|
116
|
+
isChildResponse,
|
|
117
|
+
);
|
|
112
118
|
|
|
113
119
|
const errors = (response.errors ?? []).map(
|
|
114
120
|
(e) => new DiscoveryError(e.file, e.message, e.type),
|
|
@@ -131,66 +137,3 @@ export async function runFallbackFilesSandboxed(
|
|
|
131
137
|
rmSync(bundleDir, { recursive: true, force: true });
|
|
132
138
|
}
|
|
133
139
|
}
|
|
134
|
-
|
|
135
|
-
/** Fork the bundle under `--permission`, with a scrubbed environment, and resolve with its one IPC message (or reject on crash/timeout/fork error). */
|
|
136
|
-
function runChildProcess(
|
|
137
|
-
bundlePath: string,
|
|
138
|
-
bundleDir: string,
|
|
139
|
-
projectRealpath: string,
|
|
140
|
-
externalReadPaths: readonly string[],
|
|
141
|
-
): Promise<ChildResponse> {
|
|
142
|
-
return new Promise((resolvePromise, reject) => {
|
|
143
|
-
const readAllowances = [bundleDir, projectRealpath, ...externalReadPaths].map(
|
|
144
|
-
(p) => `--allow-fs-read=${p}`,
|
|
145
|
-
);
|
|
146
|
-
const child = fork(bundlePath, [], {
|
|
147
|
-
execArgv: ["--permission", ...readAllowances],
|
|
148
|
-
// chant #1045 Phase 2 — Node's Permission Model does not gate
|
|
149
|
-
// `process.env`; scrubbing it here is the only way to keep the ambient
|
|
150
|
-
// environment out of untrusted project source's reach. `PATH` is kept
|
|
151
|
-
// only because some platforms' module resolution/dynamic linking
|
|
152
|
-
// consults it; it carries no project secrets.
|
|
153
|
-
env: { PATH: process.env.PATH ?? "" },
|
|
154
|
-
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
let settled = false;
|
|
158
|
-
let stderrBuf = "";
|
|
159
|
-
|
|
160
|
-
const timeout = setTimeout(() => {
|
|
161
|
-
if (settled) return;
|
|
162
|
-
settled = true;
|
|
163
|
-
child.kill();
|
|
164
|
-
reject(new Error(`sandboxed run timed out after ${CHILD_TIMEOUT_MS}ms`));
|
|
165
|
-
}, CHILD_TIMEOUT_MS);
|
|
166
|
-
|
|
167
|
-
child.stderr?.on("data", (chunk: Buffer) => {
|
|
168
|
-
stderrBuf += chunk.toString();
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
child.on("message", (msg: unknown) => {
|
|
172
|
-
if (settled || !isChildResponse(msg)) return;
|
|
173
|
-
settled = true;
|
|
174
|
-
clearTimeout(timeout);
|
|
175
|
-
resolvePromise(msg);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
child.on("error", (err) => {
|
|
179
|
-
if (settled) return;
|
|
180
|
-
settled = true;
|
|
181
|
-
clearTimeout(timeout);
|
|
182
|
-
reject(err);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
child.on("exit", (code, signal) => {
|
|
186
|
-
if (settled) return;
|
|
187
|
-
settled = true;
|
|
188
|
-
clearTimeout(timeout);
|
|
189
|
-
reject(
|
|
190
|
-
new Error(
|
|
191
|
-
`sandboxed child exited before reporting results (code ${code}, signal ${signal})${stderrBuf.trim() ? `: ${stderrBuf.trim()}` : ""}`,
|
|
192
|
-
),
|
|
193
|
-
);
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from "vitest";
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
2
|
import { buildInterpolatedString, defaultInterpolationSerializer } from "./intrinsic-interpolation";
|
|
3
3
|
import { AttrRef } from "./attrref";
|
|
4
4
|
import { INTRINSIC_MARKER } from "./intrinsic";
|
|
@@ -54,6 +54,32 @@ describe("defaultInterpolationSerializer", () => {
|
|
|
54
54
|
expect(() => serialize(ref)).toThrow("logical name not set");
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
// chant #1137 — this dispatch used to check `value instanceof AttrRef`,
|
|
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`). Because AttrRef also implements Intrinsic, the miss
|
|
61
|
+
// was not loud: a foreign AttrRef fell into the generic Intrinsic branch
|
|
62
|
+
// below instead, whose `toJSON()` returns the `{__attrRef}` wire envelope
|
|
63
|
+
// (not a `Ref`), silently stringified to `[object Object]` in the
|
|
64
|
+
// interpolated output. `vi.resetModules()` + a fresh dynamic import
|
|
65
|
+
// reproduces the split module graph exactly.
|
|
66
|
+
test("serializes an AttrRef built by a second, separately-loaded copy of AttrRef", async () => {
|
|
67
|
+
vi.resetModules();
|
|
68
|
+
const secondCopy = await import("./attrref");
|
|
69
|
+
expect(secondCopy.AttrRef).not.toBe(AttrRef);
|
|
70
|
+
|
|
71
|
+
const parent = {};
|
|
72
|
+
const foreignRef = new secondCopy.AttrRef(parent, "Arn");
|
|
73
|
+
foreignRef._setLogicalName("MyBucket");
|
|
74
|
+
|
|
75
|
+
// The historic bug: instanceof fails across separately-loaded copies of
|
|
76
|
+
// chant-core, even though the two classes are structurally identical.
|
|
77
|
+
expect(foreignRef instanceof AttrRef).toBe(false);
|
|
78
|
+
|
|
79
|
+
expect(serialize(foreignRef)).toBe("${MyBucket.Arn}");
|
|
80
|
+
vi.resetModules();
|
|
81
|
+
});
|
|
82
|
+
|
|
57
83
|
test("serializes Intrinsic with Ref toJSON", () => {
|
|
58
84
|
const intrinsic = {
|
|
59
85
|
[INTRINSIC_MARKER]: true as const,
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { AttrRef } from "./attrref";
|
|
10
10
|
import { INTRINSIC_MARKER } from "./intrinsic";
|
|
11
11
|
import { DECLARABLE_MARKER } from "./declarable";
|
|
12
|
+
import { isAttrRefLike } from "./utils";
|
|
12
13
|
|
|
13
14
|
export type InterpolationValueSerializer = (value: unknown) => string;
|
|
14
15
|
|
|
@@ -26,8 +27,15 @@ export function defaultInterpolationSerializer(
|
|
|
26
27
|
serializeRef: (refName: string) => string,
|
|
27
28
|
): InterpolationValueSerializer {
|
|
28
29
|
return (value: unknown): string => {
|
|
29
|
-
// Handle AttrRef
|
|
30
|
-
|
|
30
|
+
// Handle AttrRef. Duck-type, not `instanceof` (chant #1137): a lexicon
|
|
31
|
+
// built against a separate copy of `@intentius/chant` produces AttrRefs
|
|
32
|
+
// that fail `instanceof AttrRef` here but carry the same shape — and
|
|
33
|
+
// since AttrRef also implements Intrinsic, missing this branch does not
|
|
34
|
+
// fail loud. The value instead falls into the generic Intrinsic branch
|
|
35
|
+
// below, which calls `toJSON()` (returning the `{__attrRef}` wire
|
|
36
|
+
// envelope, not a `Ref`) and silently stringifies it to `[object
|
|
37
|
+
// Object]` in the interpolated output.
|
|
38
|
+
if (isAttrRefLike(value)) {
|
|
31
39
|
const logicalName = value.getLogicalName();
|
|
32
40
|
if (!logicalName) {
|
|
33
41
|
throw new Error(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect } from "vitest";
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
2
|
import { LexiconOutput, output, isLexiconOutput } from "./lexicon-output";
|
|
3
3
|
import { AttrRef } from "./attrref";
|
|
4
4
|
import { INTRINSIC_MARKER } from "./intrinsic";
|
|
@@ -87,11 +87,101 @@ describe("LexiconOutput", () => {
|
|
|
87
87
|
expect(lo.getOutputValue()).toEqual({ "Fn::GetAtt": ["myBucket", "Arn"] });
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
// chant #1137 — the constructor used to check `ref instanceof AttrRef`,
|
|
91
|
+
// which returns false for an AttrRef built by a SEPARATELY-LOADED copy of
|
|
92
|
+
// `./attrref` (the same dual-npm-copy hazard #1122 fixed for
|
|
93
|
+
// `isLexiconOutput` itself). Because AttrRef also implements Intrinsic,
|
|
94
|
+
// the miss was not loud: a foreign AttrRef fell into the `isIntrinsic(ref)`
|
|
95
|
+
// branch instead, stored as `_intrinsic` rather than recognized as an
|
|
96
|
+
// AttrRef-sourced output — so `getOutputValue()` called the foreign
|
|
97
|
+
// AttrRef's own `toJSON()` (the `{__attrRef}` wire envelope) instead of
|
|
98
|
+
// emitting `Fn::GetAtt`, a broken Output with no error at synth time.
|
|
99
|
+
// `vi.resetModules()` + a fresh dynamic import reproduces the split
|
|
100
|
+
// module graph exactly.
|
|
101
|
+
test("recognizes an AttrRef built by a second, separately-loaded copy of AttrRef", async () => {
|
|
102
|
+
const bucket = new MockResource();
|
|
103
|
+
|
|
104
|
+
vi.resetModules();
|
|
105
|
+
const secondCopy = await import("./attrref");
|
|
106
|
+
expect(secondCopy.AttrRef).not.toBe(AttrRef);
|
|
107
|
+
|
|
108
|
+
const foreignRef = new secondCopy.AttrRef(bucket, "Arn");
|
|
109
|
+
|
|
110
|
+
// The historic bug: instanceof fails across separately-loaded copies of
|
|
111
|
+
// chant-core, even though the two classes are structurally identical.
|
|
112
|
+
expect(foreignRef instanceof AttrRef).toBe(false);
|
|
113
|
+
|
|
114
|
+
const lo = new LexiconOutput(foreignRef, "BucketArn");
|
|
115
|
+
lo._setSourceEntity("myBucket");
|
|
116
|
+
|
|
117
|
+
// The fix: recognized as AttrRef-sourced, not misfiled as a generic
|
|
118
|
+
// Intrinsic — sourceAttribute is set and getOutputValue() emits a real
|
|
119
|
+
// Fn::GetAtt, not the foreign AttrRef's own wire-envelope toJSON().
|
|
120
|
+
expect(lo.sourceLexicon).toBe("testdom");
|
|
121
|
+
expect(lo.sourceAttribute).toBe("Arn");
|
|
122
|
+
expect(lo.getOutputValue()).toEqual({ "Fn::GetAtt": ["myBucket", "Arn"] });
|
|
123
|
+
vi.resetModules();
|
|
124
|
+
});
|
|
125
|
+
|
|
90
126
|
test("getOutputValue() returns intrinsic toJSON for Intrinsic-based output", () => {
|
|
91
127
|
const mockIntrinsic = { [INTRINSIC_MARKER]: true as const, toJSON: () => ({ "Fn::Sub": "http://${Param}/path" }) };
|
|
92
128
|
const lo = new LexiconOutput(mockIntrinsic, "MyUrl");
|
|
93
129
|
expect(lo.getOutputValue()).toEqual({ "Fn::Sub": "http://${Param}/path" });
|
|
94
130
|
});
|
|
131
|
+
|
|
132
|
+
// chant #1121 — an already-resolved plain value (not a reference to
|
|
133
|
+
// anything) must be emitted verbatim as the Output's Value, never coerced
|
|
134
|
+
// into a bogus Fn::GetAtt pointing at the output's own logical id.
|
|
135
|
+
describe("literal-valued output (chant #1121)", () => {
|
|
136
|
+
test("accepts a string literal and sets no source entity/attribute", () => {
|
|
137
|
+
const lo = new LexiconOutput("us-east-1", "Region");
|
|
138
|
+
expect(lo.sourceLexicon).toBe("");
|
|
139
|
+
expect(lo.sourceEntity).toBe("");
|
|
140
|
+
expect(lo.sourceAttribute).toBeNull();
|
|
141
|
+
expect(lo.outputName).toBe("Region");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("getOutputValue() returns a string literal verbatim", () => {
|
|
145
|
+
const lo = new LexiconOutput("fold-output-repro", "oParamName");
|
|
146
|
+
expect(lo.getOutputValue()).toBe("fold-output-repro");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("getOutputValue() returns a number literal verbatim", () => {
|
|
150
|
+
const lo = new LexiconOutput(42, "oCount");
|
|
151
|
+
expect(lo.getOutputValue()).toBe(42);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("getOutputValue() returns a boolean literal verbatim (including false)", () => {
|
|
155
|
+
expect(new LexiconOutput(true, "oEnabled").getOutputValue()).toBe(true);
|
|
156
|
+
expect(new LexiconOutput(false, "oDisabled").getOutputValue()).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("getOutputValue() returns 0 and empty string verbatim (falsy but valid)", () => {
|
|
160
|
+
expect(new LexiconOutput(0, "oZero").getOutputValue()).toBe(0);
|
|
161
|
+
expect(new LexiconOutput("", "oEmpty").getOutputValue()).toBe("");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("_setSourceEntity has no effect on getOutputValue() for a literal", () => {
|
|
165
|
+
const lo = new LexiconOutput("v1", "oVersion");
|
|
166
|
+
lo._setSourceEntity("someUnrelatedEntity");
|
|
167
|
+
expect(lo.getOutputValue()).toBe("v1");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("throws for a ref that is neither an AttrRef, an Intrinsic, nor a string/number/boolean literal", () => {
|
|
171
|
+
// Mirrors what a resource member access that resolves to `undefined`
|
|
172
|
+
// looks like at runtime (e.g. a typo, or a non-attribute field a
|
|
173
|
+
// generated resource class never echoes onto the instance).
|
|
174
|
+
expect(() => new LexiconOutput(undefined as unknown as string, "oBroken")).toThrow(
|
|
175
|
+
/must be an AttrRef, an Intrinsic, or an already-resolved string\/number\/boolean/,
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("throws for null", () => {
|
|
180
|
+
expect(() => new LexiconOutput(null as unknown as string, "oBroken")).toThrow(
|
|
181
|
+
/must be an AttrRef, an Intrinsic, or an already-resolved string\/number\/boolean/,
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
95
185
|
});
|
|
96
186
|
|
|
97
187
|
describe("LexiconOutput.auto", () => {
|
|
@@ -164,6 +254,20 @@ describe("output() helper", () => {
|
|
|
164
254
|
expect(result.sourceAttribute).toBe("Arn");
|
|
165
255
|
expect(result.outputName).toBe("DataBucketArn");
|
|
166
256
|
});
|
|
257
|
+
|
|
258
|
+
// chant #1121
|
|
259
|
+
test.each([
|
|
260
|
+
["string", "v1"],
|
|
261
|
+
["number", 42],
|
|
262
|
+
["boolean", true],
|
|
263
|
+
] as const)("creates a literal-valued LexiconOutput from a %s and emits it verbatim", (_kind, value) => {
|
|
264
|
+
const result = output(value, "oLiteral");
|
|
265
|
+
|
|
266
|
+
expect(result).toBeInstanceOf(LexiconOutput);
|
|
267
|
+
expect(result.sourceEntity).toBe("");
|
|
268
|
+
expect(result.sourceAttribute).toBeNull();
|
|
269
|
+
expect(result.getOutputValue()).toBe(value);
|
|
270
|
+
});
|
|
167
271
|
});
|
|
168
272
|
|
|
169
273
|
describe("isLexiconOutput", () => {
|
|
@@ -188,6 +292,41 @@ describe("isLexiconOutput", () => {
|
|
|
188
292
|
|
|
189
293
|
expect(isLexiconOutput(ref)).toBe(false);
|
|
190
294
|
});
|
|
295
|
+
|
|
296
|
+
// chant #1122 — the guard used to be `value instanceof LexiconOutput`,
|
|
297
|
+
// which returns false when the value was built by a SEPARATELY-LOADED
|
|
298
|
+
// copy of this module (a plain npm-dedupe outcome: a lexicon pinned to a
|
|
299
|
+
// chant range that doesn't overlap the project's own gets its own nested
|
|
300
|
+
// `node_modules/@intentius/chant`). `vi.resetModules()` + a fresh dynamic
|
|
301
|
+
// import reproduces that split module graph exactly, without needing an
|
|
302
|
+
// actual second install on disk — the resulting instance is structurally
|
|
303
|
+
// and behaviorally identical to a real LexiconOutput, just built from a
|
|
304
|
+
// distinct `LexiconOutput` class object.
|
|
305
|
+
test("recognizes a LexiconOutput built by a second, separately-loaded copy of this module", async () => {
|
|
306
|
+
vi.resetModules();
|
|
307
|
+
const secondCopy = await import("./lexicon-output");
|
|
308
|
+
|
|
309
|
+
// Sanity check that this really is a distinct module instance — the
|
|
310
|
+
// premise the rest of the test depends on.
|
|
311
|
+
expect(secondCopy.LexiconOutput).not.toBe(LexiconOutput);
|
|
312
|
+
|
|
313
|
+
const second = new secondCopy.LexiconOutput("v2", "oFromSecondCopy");
|
|
314
|
+
|
|
315
|
+
// The historic bug: instanceof fails across separately-loaded copies of
|
|
316
|
+
// chant-core, even though the two classes are structurally identical.
|
|
317
|
+
expect(second instanceof LexiconOutput).toBe(false);
|
|
318
|
+
|
|
319
|
+
// The fix: a Symbol.for global marker holds across copies the way
|
|
320
|
+
// DECLARABLE_MARKER/INTRINSIC_MARKER/STACK_OUTPUT_MARKER already do —
|
|
321
|
+
// isLexiconOutput (from EITHER copy) recognizes the other copy's output.
|
|
322
|
+
expect(isLexiconOutput(second)).toBe(true);
|
|
323
|
+
expect(secondCopy.isLexiconOutput(second)).toBe(true);
|
|
324
|
+
|
|
325
|
+
// And the callers that gate on this guard only ever read own-prototype
|
|
326
|
+
// members that survive the cross-copy split.
|
|
327
|
+
expect(second.getOutputValue()).toBe("v2");
|
|
328
|
+
vi.resetModules();
|
|
329
|
+
});
|
|
191
330
|
});
|
|
192
331
|
|
|
193
332
|
describe("collectLexiconOutputs", () => {
|
|
@@ -231,4 +370,37 @@ describe("collectLexiconOutputs", () => {
|
|
|
231
370
|
expect(collected).toHaveLength(1);
|
|
232
371
|
expect(collected[0].outputName).toBe("BucketArn");
|
|
233
372
|
});
|
|
373
|
+
|
|
374
|
+
// chant #1121 — a literal-valued output has no source entity. Before this
|
|
375
|
+
// fix, a top-level `export const x = output("literal", "x")` fell back to
|
|
376
|
+
// naming the output's OWN map key as its "source entity" (there being no
|
|
377
|
+
// `_sourceParent` to resolve), which `getOutputValue()`'s `Fn::GetAtt`
|
|
378
|
+
// fallback then read back out as a self-referencing, invalid reference.
|
|
379
|
+
test("does NOT fall back to the output's own key as sourceEntity for a literal-valued output", () => {
|
|
380
|
+
const literalOutput = output("fold-output-repro", "oParamName");
|
|
381
|
+
|
|
382
|
+
const entities = new Map<string, Declarable>();
|
|
383
|
+
entities.set("oParamName", literalOutput as unknown as Declarable);
|
|
384
|
+
|
|
385
|
+
const collected = collectLexiconOutputs(entities);
|
|
386
|
+
|
|
387
|
+
expect(collected).toHaveLength(1);
|
|
388
|
+
expect(collected[0].sourceEntity).toBe("");
|
|
389
|
+
expect(collected[0].getOutputValue()).toBe("fold-output-repro");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("does NOT fall back to the containing entity's name for a literal-valued output nested in props", () => {
|
|
393
|
+
const bucket = new MockResource();
|
|
394
|
+
const literalOutput = output(123, "oNested");
|
|
395
|
+
bucket.props.nested = literalOutput;
|
|
396
|
+
|
|
397
|
+
const entities = new Map<string, Declarable>();
|
|
398
|
+
entities.set("dataBucket", bucket as unknown as Declarable);
|
|
399
|
+
|
|
400
|
+
const collected = collectLexiconOutputs(entities);
|
|
401
|
+
|
|
402
|
+
expect(collected).toHaveLength(1);
|
|
403
|
+
expect(collected[0].sourceEntity).toBe("");
|
|
404
|
+
expect(collected[0].getOutputValue()).toBe(123);
|
|
405
|
+
});
|
|
234
406
|
});
|
package/src/lexicon-output.ts
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
|
-
import { INTRINSIC_MARKER, type Intrinsic } from "./intrinsic";
|
|
1
|
+
import { INTRINSIC_MARKER, isIntrinsic, type Intrinsic } from "./intrinsic";
|
|
2
2
|
import { AttrRef } from "./attrref";
|
|
3
|
+
import { isAttrRefLike } from "./utils";
|
|
4
|
+
|
|
5
|
+
/** A value `output()` accepts that is already fully resolved — not a
|
|
6
|
+
* reference to anything, just data the author computed (a literal, a prop,
|
|
7
|
+
* a template string). See chant #1121. */
|
|
8
|
+
export type LexiconOutputLiteral = string | number | boolean;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Marker symbol for LexiconOutput identification (chant #1122).
|
|
12
|
+
*
|
|
13
|
+
* A GLOBAL symbol (via `Symbol.for`), like every other chant-core brand
|
|
14
|
+
* check — `DECLARABLE_MARKER`, `STACK_OUTPUT_MARKER`, `INTRINSIC_MARKER` —
|
|
15
|
+
* holds across separately-loaded copies of chant-core the way `instanceof`
|
|
16
|
+
* does not. Two copies in one process is a plain npm-dedupe outcome: a
|
|
17
|
+
* lexicon pinned to a chant range that does not overlap the project's own
|
|
18
|
+
* gets a nested `node_modules/@intentius/chant`, and a project file
|
|
19
|
+
* importing `output` from that lexicon then holds a different `LexiconOutput`
|
|
20
|
+
* class than the CLI does. `instanceof LexiconOutput` returns false for a
|
|
21
|
+
* real output built by the other copy, and every `Outputs` entry vanishes
|
|
22
|
+
* silently.
|
|
23
|
+
*
|
|
24
|
+
* Installed non-enumerably in the constructor (not as a public class field)
|
|
25
|
+
* so a shallow spread/clone of a real instance — which would already lack
|
|
26
|
+
* its prototype methods (`getOutputValue()`, `_setSourceEntity()`, …) — does
|
|
27
|
+
* not silently pick up the marker and pass this guard too.
|
|
28
|
+
*/
|
|
29
|
+
export const LEXICON_OUTPUT_MARKER = Symbol.for("chant.lexiconOutput");
|
|
3
30
|
|
|
4
31
|
/**
|
|
5
32
|
* Sanitize auto-generated Output name parts into a valid CloudFormation
|
|
@@ -34,11 +61,17 @@ export function sanitizeLogicalId(...parts: string[]): string {
|
|
|
34
61
|
*
|
|
35
62
|
* Implements Intrinsic so it can be used as Value<string> anywhere.
|
|
36
63
|
*
|
|
37
|
-
* Accepts
|
|
38
|
-
*
|
|
64
|
+
* Accepts an AttrRef (resource attribute reference), any Intrinsic (e.g. Sub,
|
|
65
|
+
* Join) for computed output values like constructed URLs, or an already-
|
|
66
|
+
* resolved literal (string/number/boolean) — a constant the author's code
|
|
67
|
+
* computed rather than a reference to anything (chant #1121).
|
|
39
68
|
*/
|
|
40
69
|
export class LexiconOutput implements Intrinsic {
|
|
41
70
|
readonly [INTRINSIC_MARKER] = true as const;
|
|
71
|
+
/** @internal Brand marker — see {@link LEXICON_OUTPUT_MARKER}. Declared
|
|
72
|
+
* here only for type purposes; the real, non-enumerable property is
|
|
73
|
+
* installed by the constructor. */
|
|
74
|
+
readonly [LEXICON_OUTPUT_MARKER]!: true;
|
|
42
75
|
readonly sourceLexicon: string;
|
|
43
76
|
readonly sourceEntity: string;
|
|
44
77
|
readonly sourceAttribute: string | null;
|
|
@@ -52,9 +85,32 @@ export class LexiconOutput implements Intrinsic {
|
|
|
52
85
|
* checking on every field it reads (#1047).
|
|
53
86
|
*/
|
|
54
87
|
readonly _intrinsic: Intrinsic | null;
|
|
88
|
+
/**
|
|
89
|
+
* @internal The already-resolved literal (string/number/boolean) when
|
|
90
|
+
* constructed from neither an AttrRef nor an Intrinsic — non-null exactly
|
|
91
|
+
* when `_intrinsic` is null AND `sourceAttribute` is null. There is no
|
|
92
|
+
* source entity or attribute to reference, so `getOutputValue()` returns
|
|
93
|
+
* this value verbatim rather than fabricating a `Fn::GetAtt` out of an
|
|
94
|
+
* unset attribute (chant #1121). Readable outside the class for the same
|
|
95
|
+
* reason as `_intrinsic`/`_sourceParent` above.
|
|
96
|
+
*/
|
|
97
|
+
readonly _literalValue: LexiconOutputLiteral | null;
|
|
55
98
|
|
|
56
|
-
constructor(ref: AttrRef | Intrinsic |
|
|
57
|
-
|
|
99
|
+
constructor(ref: AttrRef | Intrinsic | LexiconOutputLiteral, name: string) {
|
|
100
|
+
Object.defineProperty(this, LEXICON_OUTPUT_MARKER, {
|
|
101
|
+
value: true,
|
|
102
|
+
enumerable: false,
|
|
103
|
+
});
|
|
104
|
+
// Duck-type, not `instanceof` (chant #1137): a lexicon built against a
|
|
105
|
+
// separate copy of `@intentius/chant` produces AttrRefs that fail
|
|
106
|
+
// `instanceof AttrRef` here but carry the same shape — and since AttrRef
|
|
107
|
+
// also implements Intrinsic, missing this branch does not fail loud.
|
|
108
|
+
// The value instead falls into the `isIntrinsic(ref)` branch below,
|
|
109
|
+
// stored as `_intrinsic` rather than an AttrRef-sourced output, so
|
|
110
|
+
// `getOutputValue()` later calls the foreign AttrRef's own `toJSON()`
|
|
111
|
+
// (the `{__attrRef}` wire envelope) instead of emitting `Fn::GetAtt` —
|
|
112
|
+
// a broken Output emitted with no error at synth time.
|
|
113
|
+
if (isAttrRefLike(ref)) {
|
|
58
114
|
const parent = ref.parent.deref();
|
|
59
115
|
if (!parent) {
|
|
60
116
|
throw new Error("Cannot create LexiconOutput: parent entity has been garbage collected");
|
|
@@ -70,16 +126,45 @@ export class LexiconOutput implements Intrinsic {
|
|
|
70
126
|
this.outputName = name;
|
|
71
127
|
this._sourceParent = ref.parent;
|
|
72
128
|
this._intrinsic = null;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
// TypeScript level (they are AttrRef at runtime, caught by instanceof above).
|
|
129
|
+
this._literalValue = null;
|
|
130
|
+
} else if (isIntrinsic(ref)) {
|
|
131
|
+
// Intrinsic (Sub, Join, Ref, etc.) — no parent entity tracking needed.
|
|
77
132
|
this.sourceLexicon = "";
|
|
78
133
|
this.sourceEntity = "";
|
|
79
134
|
this.sourceAttribute = null;
|
|
80
135
|
this.outputName = name;
|
|
81
136
|
this._sourceParent = null;
|
|
82
|
-
this._intrinsic =
|
|
137
|
+
this._intrinsic = ref;
|
|
138
|
+
this._literalValue = null;
|
|
139
|
+
} else if (typeof ref === "string" || typeof ref === "number" || typeof ref === "boolean") {
|
|
140
|
+
// An already-resolved literal — a real string/number/boolean the
|
|
141
|
+
// caller computed (a prop, a template string, a plain constant), not
|
|
142
|
+
// a reference to anything. The `string` arm of the exported type
|
|
143
|
+
// exists for a documented reason: a generated resource's attribute
|
|
144
|
+
// accessor is typed `string` at the TypeScript level but is a real
|
|
145
|
+
// `AttrRef` at runtime, caught by the `isAttrRefLike` check above — so
|
|
146
|
+
// anything that reaches this branch genuinely has no source entity
|
|
147
|
+
// or attribute, and is recorded to be emitted as a plain `Value`
|
|
148
|
+
// rather than a fabricated `Fn::GetAtt` (chant #1121).
|
|
149
|
+
this.sourceLexicon = "";
|
|
150
|
+
this.sourceEntity = "";
|
|
151
|
+
this.sourceAttribute = null;
|
|
152
|
+
this.outputName = name;
|
|
153
|
+
this._sourceParent = null;
|
|
154
|
+
this._intrinsic = null;
|
|
155
|
+
this._literalValue = ref;
|
|
156
|
+
} else {
|
|
157
|
+
// Neither a reference NOR a resolved value — most commonly `undefined`
|
|
158
|
+
// from accessing a resource member that looks like an attribute but
|
|
159
|
+
// isn't one (a typo, or a genuine prop that was never wired onto the
|
|
160
|
+
// instance as either an AttrRef or an echoed literal). Silently
|
|
161
|
+
// treating this as a literal would trade one invalid Output (a
|
|
162
|
+
// fabricated `Fn::GetAtt`) for another (a `Value` that is missing or
|
|
163
|
+
// `null`) — fail loudly instead, the same call `stackOutput()` already
|
|
164
|
+
// makes for a ref it cannot anchor (chant #1121).
|
|
165
|
+
throw new Error(
|
|
166
|
+
`output(ref, "${name}"): ref must be an AttrRef, an Intrinsic, or an already-resolved string/number/boolean — got ${ref === null ? "null" : typeof ref} instead. If this came from a resource member access (e.g. "resource.SomeField"), that member is neither a generated attribute nor a real value here — check for a typo or a property that CloudFormation does not expose via Fn::GetAtt.`,
|
|
167
|
+
);
|
|
83
168
|
}
|
|
84
169
|
}
|
|
85
170
|
|
|
@@ -94,10 +179,14 @@ export class LexiconOutput implements Intrinsic {
|
|
|
94
179
|
|
|
95
180
|
/**
|
|
96
181
|
* Returns the CloudFormation Output Value for this output.
|
|
182
|
+
* For a literal output: the resolved value itself, verbatim.
|
|
97
183
|
* For AttrRef-based outputs: emits Fn::GetAtt.
|
|
98
184
|
* For Intrinsic-based outputs: delegates to the intrinsic's toJSON().
|
|
99
185
|
*/
|
|
100
186
|
getOutputValue(): unknown {
|
|
187
|
+
if (this._literalValue !== null) {
|
|
188
|
+
return this._literalValue;
|
|
189
|
+
}
|
|
101
190
|
if (this._intrinsic) {
|
|
102
191
|
return this._intrinsic.toJSON();
|
|
103
192
|
}
|
|
@@ -130,7 +219,8 @@ export class LexiconOutput implements Intrinsic {
|
|
|
130
219
|
}
|
|
131
220
|
|
|
132
221
|
/**
|
|
133
|
-
* Create a LexiconOutput from an AttrRef
|
|
222
|
+
* Create a LexiconOutput from an AttrRef, an Intrinsic, or an already-
|
|
223
|
+
* resolved literal, and a user-provided output name.
|
|
134
224
|
*
|
|
135
225
|
* Usage with AttrRef:
|
|
136
226
|
* ```ts
|
|
@@ -141,14 +231,33 @@ export class LexiconOutput implements Intrinsic {
|
|
|
141
231
|
* ```ts
|
|
142
232
|
* const solrUrl = output(Sub`http://${Ref(albDnsName)}/solr`, "solrUrl");
|
|
143
233
|
* ```
|
|
234
|
+
*
|
|
235
|
+
* Usage with a literal (chant #1121) — a real value the caller already
|
|
236
|
+
* computed, not a reference:
|
|
237
|
+
* ```ts
|
|
238
|
+
* const apiVersion = output("v1", "ApiVersion");
|
|
239
|
+
* ```
|
|
144
240
|
*/
|
|
145
|
-
export function output(ref: AttrRef | Intrinsic |
|
|
241
|
+
export function output(ref: AttrRef | Intrinsic | LexiconOutputLiteral, name: string): LexiconOutput {
|
|
146
242
|
return new LexiconOutput(ref, name);
|
|
147
243
|
}
|
|
148
244
|
|
|
149
245
|
/**
|
|
150
|
-
* Type guard to check if a value is a LexiconOutput
|
|
246
|
+
* Type guard to check if a value is a LexiconOutput.
|
|
247
|
+
*
|
|
248
|
+
* Structural, not `instanceof` (chant #1122) — keys off {@link
|
|
249
|
+
* LEXICON_OUTPUT_MARKER}, a global symbol, so it holds across separately-
|
|
250
|
+
* loaded copies of chant-core the way `instanceof` does not. Every caller
|
|
251
|
+
* (`collect.ts`, `build.ts`, `graph-ir.ts`, `entity-wire-codec.ts`) reads
|
|
252
|
+
* only own-prototype members off the result (`outputName`, `_sourceParent`,
|
|
253
|
+
* `_setSourceEntity()`, `getOutputValue()`), all of which work identically
|
|
254
|
+
* on a cross-copy instance.
|
|
151
255
|
*/
|
|
152
256
|
export function isLexiconOutput(value: unknown): value is LexiconOutput {
|
|
153
|
-
return
|
|
257
|
+
return (
|
|
258
|
+
typeof value === "object" &&
|
|
259
|
+
value !== null &&
|
|
260
|
+
LEXICON_OUTPUT_MARKER in value &&
|
|
261
|
+
(value as Record<symbol, unknown>)[LEXICON_OUTPUT_MARKER] === true
|
|
262
|
+
);
|
|
154
263
|
}
|
package/src/lint/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from "fs";
|
|
2
2
|
import { join, dirname, resolve } from "path";
|
|
3
|
-
import { createRequire } from "module";
|
|
4
3
|
import { z } from "zod";
|
|
4
|
+
import { evaluateProjectConfigSync } from "../config-sandbox";
|
|
5
5
|
import type { Severity, RuleConfig } from "./rule";
|
|
6
6
|
import { moduleDir, getRuntime } from "../runtime-adapter";
|
|
7
7
|
import strictPreset from "./presets/strict.json";
|
|
@@ -337,6 +337,12 @@ function loadConfigFile(configPath: string, visited: Set<string> = new Set()): L
|
|
|
337
337
|
* then falls back to `chant.config.json` (legacy LintConfig format).
|
|
338
338
|
* Returns default configuration if neither exists.
|
|
339
339
|
*
|
|
340
|
+
* chant #1113 — the `chant.config.ts` branch executes project-authored code,
|
|
341
|
+
* so it goes through `../config-sandbox.ts` like every other config load
|
|
342
|
+
* rather than `require`-ing the file itself. Unarmed (which is every `chant
|
|
343
|
+
* lint` invocation today — `lint` has no `--sandbox` flag) that is the
|
|
344
|
+
* identical `createRequire` path this used before, moved one module over.
|
|
345
|
+
*
|
|
340
346
|
* @param dir - Directory path to search for config file
|
|
341
347
|
* @returns Loaded and merged configuration, or default config if not found
|
|
342
348
|
*/
|
|
@@ -345,9 +351,7 @@ export function loadConfig(dir: string): LintConfig {
|
|
|
345
351
|
const tsConfigPath = join(dir, "chant.config.ts");
|
|
346
352
|
if (existsSync(tsConfigPath)) {
|
|
347
353
|
try {
|
|
348
|
-
const
|
|
349
|
-
const mod = _require(tsConfigPath);
|
|
350
|
-
const config = mod.default ?? mod.config ?? mod;
|
|
354
|
+
const config = evaluateProjectConfigSync(tsConfigPath, dir);
|
|
351
355
|
if (typeof config === "object" && config !== null) {
|
|
352
356
|
// ChantConfig format: extract lint property
|
|
353
357
|
if ("lint" in config && typeof config.lint === "object") {
|