@intentius/chant 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/config-import.d.ts +33 -0
  5. package/dist/config-import.d.ts.map +1 -0
  6. package/dist/config-sandbox.d.ts +47 -0
  7. package/dist/config-sandbox.d.ts.map +1 -0
  8. package/dist/config.d.ts +11 -2
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/discovery/entity-wire-codec.d.ts +1 -1
  11. package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
  12. package/dist/discovery/sandbox/config-run.d.ts +24 -0
  13. package/dist/discovery/sandbox/config-run.d.ts.map +1 -0
  14. package/dist/discovery/sandbox/config-wire.d.ts +68 -0
  15. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -0
  16. package/dist/discovery/sandbox/driver.d.ts +20 -0
  17. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  18. package/dist/discovery/sandbox/fork.d.ts +52 -0
  19. package/dist/discovery/sandbox/fork.d.ts.map +1 -0
  20. package/dist/discovery/sandbox/run.d.ts +10 -20
  21. package/dist/discovery/sandbox/run.d.ts.map +1 -1
  22. package/dist/lexicon-output.d.ts +62 -6
  23. package/dist/lexicon-output.d.ts.map +1 -1
  24. package/dist/lint/config.d.ts +6 -0
  25. package/dist/lint/config.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/build.test.ts +19 -0
  28. package/src/build.ts +21 -10
  29. package/src/cli/commands/build.ts +13 -0
  30. package/src/cli/main.ts +10 -0
  31. package/src/config-import.ts +43 -0
  32. package/src/config-sandbox.ts +138 -0
  33. package/src/config.ts +14 -5
  34. package/src/discovery/entity-wire-codec.ts +21 -12
  35. package/src/discovery/entity-wire.test.ts +26 -0
  36. package/src/discovery/sandbox/config-boundary.test.ts +239 -0
  37. package/src/discovery/sandbox/config-run.ts +130 -0
  38. package/src/discovery/sandbox/config-wire.test.ts +110 -0
  39. package/src/discovery/sandbox/config-wire.ts +174 -0
  40. package/src/discovery/sandbox/driver.ts +68 -0
  41. package/src/discovery/sandbox/fork.ts +110 -0
  42. package/src/discovery/sandbox/run.ts +28 -85
  43. package/src/lexicon-output.test.ts +137 -1
  44. package/src/lexicon-output.ts +112 -13
  45. package/src/lint/config.ts +8 -4
@@ -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 (verified on Node v24.13.1see the chant#1045 PR
18
- * description for the full write-up):
19
- * - `--permission --allow-fs-read=<bundle dir>,<project dir>[,<trusted
20
- * external package dirs>]` no filesystem write, no child-process, no
21
- * worker-thread access. Bundling with esbuild first (not a packaging
22
- * change see `./bundle.ts`) means the child needs NO TypeScript loader
23
- * (no `tsx`, so no `--allow-worker` and no writable temp dir either),
24
- * unlike the plain `tsx`-based run path. The "trusted external package
25
- * dirs" allowance is narrow and specific: `./bundle.ts` deliberately
26
- * leaves a couple of chant/lexicon-internal dependencies (`typescript`)
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 runChildProcess(bundlePath, bundleDir, projectRealpath, externalReadPaths);
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, 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";
@@ -92,6 +92,60 @@ describe("LexiconOutput", () => {
92
92
  const lo = new LexiconOutput(mockIntrinsic, "MyUrl");
93
93
  expect(lo.getOutputValue()).toEqual({ "Fn::Sub": "http://${Param}/path" });
94
94
  });
95
+
96
+ // chant #1121 — an already-resolved plain value (not a reference to
97
+ // anything) must be emitted verbatim as the Output's Value, never coerced
98
+ // into a bogus Fn::GetAtt pointing at the output's own logical id.
99
+ describe("literal-valued output (chant #1121)", () => {
100
+ test("accepts a string literal and sets no source entity/attribute", () => {
101
+ const lo = new LexiconOutput("us-east-1", "Region");
102
+ expect(lo.sourceLexicon).toBe("");
103
+ expect(lo.sourceEntity).toBe("");
104
+ expect(lo.sourceAttribute).toBeNull();
105
+ expect(lo.outputName).toBe("Region");
106
+ });
107
+
108
+ test("getOutputValue() returns a string literal verbatim", () => {
109
+ const lo = new LexiconOutput("fold-output-repro", "oParamName");
110
+ expect(lo.getOutputValue()).toBe("fold-output-repro");
111
+ });
112
+
113
+ test("getOutputValue() returns a number literal verbatim", () => {
114
+ const lo = new LexiconOutput(42, "oCount");
115
+ expect(lo.getOutputValue()).toBe(42);
116
+ });
117
+
118
+ test("getOutputValue() returns a boolean literal verbatim (including false)", () => {
119
+ expect(new LexiconOutput(true, "oEnabled").getOutputValue()).toBe(true);
120
+ expect(new LexiconOutput(false, "oDisabled").getOutputValue()).toBe(false);
121
+ });
122
+
123
+ test("getOutputValue() returns 0 and empty string verbatim (falsy but valid)", () => {
124
+ expect(new LexiconOutput(0, "oZero").getOutputValue()).toBe(0);
125
+ expect(new LexiconOutput("", "oEmpty").getOutputValue()).toBe("");
126
+ });
127
+
128
+ test("_setSourceEntity has no effect on getOutputValue() for a literal", () => {
129
+ const lo = new LexiconOutput("v1", "oVersion");
130
+ lo._setSourceEntity("someUnrelatedEntity");
131
+ expect(lo.getOutputValue()).toBe("v1");
132
+ });
133
+
134
+ test("throws for a ref that is neither an AttrRef, an Intrinsic, nor a string/number/boolean literal", () => {
135
+ // Mirrors what a resource member access that resolves to `undefined`
136
+ // looks like at runtime (e.g. a typo, or a non-attribute field a
137
+ // generated resource class never echoes onto the instance).
138
+ expect(() => new LexiconOutput(undefined as unknown as string, "oBroken")).toThrow(
139
+ /must be an AttrRef, an Intrinsic, or an already-resolved string\/number\/boolean/,
140
+ );
141
+ });
142
+
143
+ test("throws for null", () => {
144
+ expect(() => new LexiconOutput(null as unknown as string, "oBroken")).toThrow(
145
+ /must be an AttrRef, an Intrinsic, or an already-resolved string\/number\/boolean/,
146
+ );
147
+ });
148
+ });
95
149
  });
96
150
 
97
151
  describe("LexiconOutput.auto", () => {
@@ -164,6 +218,20 @@ describe("output() helper", () => {
164
218
  expect(result.sourceAttribute).toBe("Arn");
165
219
  expect(result.outputName).toBe("DataBucketArn");
166
220
  });
221
+
222
+ // chant #1121
223
+ test.each([
224
+ ["string", "v1"],
225
+ ["number", 42],
226
+ ["boolean", true],
227
+ ] as const)("creates a literal-valued LexiconOutput from a %s and emits it verbatim", (_kind, value) => {
228
+ const result = output(value, "oLiteral");
229
+
230
+ expect(result).toBeInstanceOf(LexiconOutput);
231
+ expect(result.sourceEntity).toBe("");
232
+ expect(result.sourceAttribute).toBeNull();
233
+ expect(result.getOutputValue()).toBe(value);
234
+ });
167
235
  });
168
236
 
169
237
  describe("isLexiconOutput", () => {
@@ -188,6 +256,41 @@ describe("isLexiconOutput", () => {
188
256
 
189
257
  expect(isLexiconOutput(ref)).toBe(false);
190
258
  });
259
+
260
+ // chant #1122 — the guard used to be `value instanceof LexiconOutput`,
261
+ // which returns false when the value was built by a SEPARATELY-LOADED
262
+ // copy of this module (a plain npm-dedupe outcome: a lexicon pinned to a
263
+ // chant range that doesn't overlap the project's own gets its own nested
264
+ // `node_modules/@intentius/chant`). `vi.resetModules()` + a fresh dynamic
265
+ // import reproduces that split module graph exactly, without needing an
266
+ // actual second install on disk — the resulting instance is structurally
267
+ // and behaviorally identical to a real LexiconOutput, just built from a
268
+ // distinct `LexiconOutput` class object.
269
+ test("recognizes a LexiconOutput built by a second, separately-loaded copy of this module", async () => {
270
+ vi.resetModules();
271
+ const secondCopy = await import("./lexicon-output");
272
+
273
+ // Sanity check that this really is a distinct module instance — the
274
+ // premise the rest of the test depends on.
275
+ expect(secondCopy.LexiconOutput).not.toBe(LexiconOutput);
276
+
277
+ const second = new secondCopy.LexiconOutput("v2", "oFromSecondCopy");
278
+
279
+ // The historic bug: instanceof fails across separately-loaded copies of
280
+ // chant-core, even though the two classes are structurally identical.
281
+ expect(second instanceof LexiconOutput).toBe(false);
282
+
283
+ // The fix: a Symbol.for global marker holds across copies the way
284
+ // DECLARABLE_MARKER/INTRINSIC_MARKER/STACK_OUTPUT_MARKER already do —
285
+ // isLexiconOutput (from EITHER copy) recognizes the other copy's output.
286
+ expect(isLexiconOutput(second)).toBe(true);
287
+ expect(secondCopy.isLexiconOutput(second)).toBe(true);
288
+
289
+ // And the callers that gate on this guard only ever read own-prototype
290
+ // members that survive the cross-copy split.
291
+ expect(second.getOutputValue()).toBe("v2");
292
+ vi.resetModules();
293
+ });
191
294
  });
192
295
 
193
296
  describe("collectLexiconOutputs", () => {
@@ -231,4 +334,37 @@ describe("collectLexiconOutputs", () => {
231
334
  expect(collected).toHaveLength(1);
232
335
  expect(collected[0].outputName).toBe("BucketArn");
233
336
  });
337
+
338
+ // chant #1121 — a literal-valued output has no source entity. Before this
339
+ // fix, a top-level `export const x = output("literal", "x")` fell back to
340
+ // naming the output's OWN map key as its "source entity" (there being no
341
+ // `_sourceParent` to resolve), which `getOutputValue()`'s `Fn::GetAtt`
342
+ // fallback then read back out as a self-referencing, invalid reference.
343
+ test("does NOT fall back to the output's own key as sourceEntity for a literal-valued output", () => {
344
+ const literalOutput = output("fold-output-repro", "oParamName");
345
+
346
+ const entities = new Map<string, Declarable>();
347
+ entities.set("oParamName", literalOutput as unknown as Declarable);
348
+
349
+ const collected = collectLexiconOutputs(entities);
350
+
351
+ expect(collected).toHaveLength(1);
352
+ expect(collected[0].sourceEntity).toBe("");
353
+ expect(collected[0].getOutputValue()).toBe("fold-output-repro");
354
+ });
355
+
356
+ test("does NOT fall back to the containing entity's name for a literal-valued output nested in props", () => {
357
+ const bucket = new MockResource();
358
+ const literalOutput = output(123, "oNested");
359
+ bucket.props.nested = literalOutput;
360
+
361
+ const entities = new Map<string, Declarable>();
362
+ entities.set("dataBucket", bucket as unknown as Declarable);
363
+
364
+ const collected = collectLexiconOutputs(entities);
365
+
366
+ expect(collected).toHaveLength(1);
367
+ expect(collected[0].sourceEntity).toBe("");
368
+ expect(collected[0].getOutputValue()).toBe(123);
369
+ });
234
370
  });
@@ -1,6 +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
3
 
4
+ /** A value `output()` accepts that is already fully resolved — not a
5
+ * reference to anything, just data the author computed (a literal, a prop,
6
+ * a template string). See chant #1121. */
7
+ export type LexiconOutputLiteral = string | number | boolean;
8
+
9
+ /**
10
+ * Marker symbol for LexiconOutput identification (chant #1122).
11
+ *
12
+ * A GLOBAL symbol (via `Symbol.for`), like every other chant-core brand
13
+ * check — `DECLARABLE_MARKER`, `STACK_OUTPUT_MARKER`, `INTRINSIC_MARKER` —
14
+ * holds across separately-loaded copies of chant-core the way `instanceof`
15
+ * does not. Two copies in one process is a plain npm-dedupe outcome: a
16
+ * lexicon pinned to a chant range that does not overlap the project's own
17
+ * gets a nested `node_modules/@intentius/chant`, and a project file
18
+ * importing `output` from that lexicon then holds a different `LexiconOutput`
19
+ * class than the CLI does. `instanceof LexiconOutput` returns false for a
20
+ * real output built by the other copy, and every `Outputs` entry vanishes
21
+ * silently.
22
+ *
23
+ * Installed non-enumerably in the constructor (not as a public class field)
24
+ * so a shallow spread/clone of a real instance — which would already lack
25
+ * its prototype methods (`getOutputValue()`, `_setSourceEntity()`, …) — does
26
+ * not silently pick up the marker and pass this guard too.
27
+ */
28
+ export const LEXICON_OUTPUT_MARKER = Symbol.for("chant.lexiconOutput");
29
+
4
30
  /**
5
31
  * Sanitize auto-generated Output name parts into a valid CloudFormation
6
32
  * logical id. Real CloudFormation logical ids (including `Outputs` keys)
@@ -34,11 +60,17 @@ export function sanitizeLogicalId(...parts: string[]): string {
34
60
  *
35
61
  * Implements Intrinsic so it can be used as Value<string> anywhere.
36
62
  *
37
- * Accepts either an AttrRef (resource attribute reference) or any Intrinsic
38
- * (e.g. Sub, Join) for computed output values like constructed URLs.
63
+ * Accepts an AttrRef (resource attribute reference), any Intrinsic (e.g. Sub,
64
+ * Join) for computed output values like constructed URLs, or an already-
65
+ * resolved literal (string/number/boolean) — a constant the author's code
66
+ * computed rather than a reference to anything (chant #1121).
39
67
  */
40
68
  export class LexiconOutput implements Intrinsic {
41
69
  readonly [INTRINSIC_MARKER] = true as const;
70
+ /** @internal Brand marker — see {@link LEXICON_OUTPUT_MARKER}. Declared
71
+ * here only for type purposes; the real, non-enumerable property is
72
+ * installed by the constructor. */
73
+ readonly [LEXICON_OUTPUT_MARKER]!: true;
42
74
  readonly sourceLexicon: string;
43
75
  readonly sourceEntity: string;
44
76
  readonly sourceAttribute: string | null;
@@ -52,8 +84,22 @@ export class LexiconOutput implements Intrinsic {
52
84
  * checking on every field it reads (#1047).
53
85
  */
54
86
  readonly _intrinsic: Intrinsic | null;
87
+ /**
88
+ * @internal The already-resolved literal (string/number/boolean) when
89
+ * constructed from neither an AttrRef nor an Intrinsic — non-null exactly
90
+ * when `_intrinsic` is null AND `sourceAttribute` is null. There is no
91
+ * source entity or attribute to reference, so `getOutputValue()` returns
92
+ * this value verbatim rather than fabricating a `Fn::GetAtt` out of an
93
+ * unset attribute (chant #1121). Readable outside the class for the same
94
+ * reason as `_intrinsic`/`_sourceParent` above.
95
+ */
96
+ readonly _literalValue: LexiconOutputLiteral | null;
55
97
 
56
- constructor(ref: AttrRef | Intrinsic | string, name: string) {
98
+ constructor(ref: AttrRef | Intrinsic | LexiconOutputLiteral, name: string) {
99
+ Object.defineProperty(this, LEXICON_OUTPUT_MARKER, {
100
+ value: true,
101
+ enumerable: false,
102
+ });
57
103
  if (ref instanceof AttrRef) {
58
104
  const parent = ref.parent.deref();
59
105
  if (!parent) {
@@ -70,16 +116,45 @@ export class LexiconOutput implements Intrinsic {
70
116
  this.outputName = name;
71
117
  this._sourceParent = ref.parent;
72
118
  this._intrinsic = null;
73
- } else {
74
- // Intrinsic (Sub, Join, Ref, etc.) — no parent entity tracking needed
75
- // Note: `string` in the union is for attribute accessors typed as string at the
76
- // TypeScript level (they are AttrRef at runtime, caught by instanceof above).
119
+ this._literalValue = null;
120
+ } else if (isIntrinsic(ref)) {
121
+ // Intrinsic (Sub, Join, Ref, etc.) no parent entity tracking needed.
77
122
  this.sourceLexicon = "";
78
123
  this.sourceEntity = "";
79
124
  this.sourceAttribute = null;
80
125
  this.outputName = name;
81
126
  this._sourceParent = null;
82
- this._intrinsic = typeof ref !== "string" ? ref : null;
127
+ this._intrinsic = ref;
128
+ this._literalValue = null;
129
+ } else if (typeof ref === "string" || typeof ref === "number" || typeof ref === "boolean") {
130
+ // An already-resolved literal — a real string/number/boolean the
131
+ // caller computed (a prop, a template string, a plain constant), not
132
+ // a reference to anything. The `string` arm of the exported type
133
+ // exists for a documented reason: a generated resource's attribute
134
+ // accessor is typed `string` at the TypeScript level but is a real
135
+ // `AttrRef` at runtime, caught by `instanceof AttrRef` above — so
136
+ // anything that reaches this branch genuinely has no source entity
137
+ // or attribute, and is recorded to be emitted as a plain `Value`
138
+ // rather than a fabricated `Fn::GetAtt` (chant #1121).
139
+ this.sourceLexicon = "";
140
+ this.sourceEntity = "";
141
+ this.sourceAttribute = null;
142
+ this.outputName = name;
143
+ this._sourceParent = null;
144
+ this._intrinsic = null;
145
+ this._literalValue = ref;
146
+ } else {
147
+ // Neither a reference NOR a resolved value — most commonly `undefined`
148
+ // from accessing a resource member that looks like an attribute but
149
+ // isn't one (a typo, or a genuine prop that was never wired onto the
150
+ // instance as either an AttrRef or an echoed literal). Silently
151
+ // treating this as a literal would trade one invalid Output (a
152
+ // fabricated `Fn::GetAtt`) for another (a `Value` that is missing or
153
+ // `null`) — fail loudly instead, the same call `stackOutput()` already
154
+ // makes for a ref it cannot anchor (chant #1121).
155
+ throw new Error(
156
+ `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.`,
157
+ );
83
158
  }
84
159
  }
85
160
 
@@ -94,10 +169,14 @@ export class LexiconOutput implements Intrinsic {
94
169
 
95
170
  /**
96
171
  * Returns the CloudFormation Output Value for this output.
172
+ * For a literal output: the resolved value itself, verbatim.
97
173
  * For AttrRef-based outputs: emits Fn::GetAtt.
98
174
  * For Intrinsic-based outputs: delegates to the intrinsic's toJSON().
99
175
  */
100
176
  getOutputValue(): unknown {
177
+ if (this._literalValue !== null) {
178
+ return this._literalValue;
179
+ }
101
180
  if (this._intrinsic) {
102
181
  return this._intrinsic.toJSON();
103
182
  }
@@ -130,7 +209,8 @@ export class LexiconOutput implements Intrinsic {
130
209
  }
131
210
 
132
211
  /**
133
- * Create a LexiconOutput from an AttrRef or Intrinsic and a user-provided output name.
212
+ * Create a LexiconOutput from an AttrRef, an Intrinsic, or an already-
213
+ * resolved literal, and a user-provided output name.
134
214
  *
135
215
  * Usage with AttrRef:
136
216
  * ```ts
@@ -141,14 +221,33 @@ export class LexiconOutput implements Intrinsic {
141
221
  * ```ts
142
222
  * const solrUrl = output(Sub`http://${Ref(albDnsName)}/solr`, "solrUrl");
143
223
  * ```
224
+ *
225
+ * Usage with a literal (chant #1121) — a real value the caller already
226
+ * computed, not a reference:
227
+ * ```ts
228
+ * const apiVersion = output("v1", "ApiVersion");
229
+ * ```
144
230
  */
145
- export function output(ref: AttrRef | Intrinsic | string, name: string): LexiconOutput {
231
+ export function output(ref: AttrRef | Intrinsic | LexiconOutputLiteral, name: string): LexiconOutput {
146
232
  return new LexiconOutput(ref, name);
147
233
  }
148
234
 
149
235
  /**
150
- * Type guard to check if a value is a LexiconOutput
236
+ * Type guard to check if a value is a LexiconOutput.
237
+ *
238
+ * Structural, not `instanceof` (chant #1122) — keys off {@link
239
+ * LEXICON_OUTPUT_MARKER}, a global symbol, so it holds across separately-
240
+ * loaded copies of chant-core the way `instanceof` does not. Every caller
241
+ * (`collect.ts`, `build.ts`, `graph-ir.ts`, `entity-wire-codec.ts`) reads
242
+ * only own-prototype members off the result (`outputName`, `_sourceParent`,
243
+ * `_setSourceEntity()`, `getOutputValue()`), all of which work identically
244
+ * on a cross-copy instance.
151
245
  */
152
246
  export function isLexiconOutput(value: unknown): value is LexiconOutput {
153
- return value instanceof LexiconOutput;
247
+ return (
248
+ typeof value === "object" &&
249
+ value !== null &&
250
+ LEXICON_OUTPUT_MARKER in value &&
251
+ (value as Record<symbol, unknown>)[LEXICON_OUTPUT_MARKER] === true
252
+ );
154
253
  }
@@ -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 _require = createRequire(join(dir, "package.json"));
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") {