@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.
- 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 +1 -1
- package/dist/discovery/entity-wire-codec.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 +68 -0
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -0
- package/dist/discovery/sandbox/driver.d.ts +20 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +52 -0
- package/dist/discovery/sandbox/fork.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/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/package.json +1 -1
- package/src/build.test.ts +19 -0
- package/src/build.ts +21 -10
- package/src/cli/commands/build.ts +13 -0
- package/src/cli/main.ts +10 -0
- 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 +21 -12
- package/src/discovery/entity-wire.test.ts +26 -0
- 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 +174 -0
- package/src/discovery/sandbox/driver.ts +68 -0
- package/src/discovery/sandbox/fork.ts +110 -0
- package/src/discovery/sandbox/run.ts +28 -85
- package/src/lexicon-output.test.ts +137 -1
- package/src/lexicon-output.ts +112 -13
- package/src/lint/config.ts +8 -4
package/src/build.ts
CHANGED
|
@@ -296,18 +296,28 @@ export function collectLexiconOutputs(
|
|
|
296
296
|
for (const [name, entity] of entities) {
|
|
297
297
|
if (isLexiconOutput(entity as unknown)) {
|
|
298
298
|
const lexiconOutput = entity as unknown as LexiconOutput;
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
299
|
+
// A literal-valued output (chant #1121) has no source entity at all —
|
|
300
|
+
// it isn't a reference to anything, so it must not fall back to the
|
|
301
|
+
// output's OWN map key the way an AttrRef whose parent didn't resolve
|
|
302
|
+
// would. Leaving `sourceEntity` empty keeps `getOutputValue()`'s
|
|
303
|
+
// (never-reached, for a literal) `Fn::GetAtt` fallback from ever being
|
|
304
|
+
// handed a bogus source, and keeps consumers that read `sourceEntity`
|
|
305
|
+
// directly (e.g. `graph-ir.ts`'s cross-stack export, the build
|
|
306
|
+
// manifest) from reporting the output as its own source.
|
|
307
|
+
if (lexiconOutput._literalValue === null) {
|
|
308
|
+
// Resolve source entity name from the WeakRef parent identity
|
|
309
|
+
const parent = lexiconOutput._sourceParent?.deref();
|
|
310
|
+
let sourceName = name;
|
|
311
|
+
if (parent) {
|
|
312
|
+
for (const [entityName, e] of entities) {
|
|
313
|
+
if (e === parent) {
|
|
314
|
+
sourceName = entityName;
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
307
317
|
}
|
|
308
318
|
}
|
|
319
|
+
lexiconOutput._setSourceEntity(sourceName);
|
|
309
320
|
}
|
|
310
|
-
lexiconOutput._setSourceEntity(sourceName);
|
|
311
321
|
outputs.push(lexiconOutput);
|
|
312
322
|
continue;
|
|
313
323
|
}
|
|
@@ -317,7 +327,8 @@ export function collectLexiconOutputs(
|
|
|
317
327
|
const prevLength = outputs.length;
|
|
318
328
|
walk(entity.props);
|
|
319
329
|
for (let i = prevLength; i < outputs.length; i++) {
|
|
320
|
-
|
|
330
|
+
// Same #1121 guard as above — a literal has no source entity to name.
|
|
331
|
+
if (!outputs[i].sourceEntity && outputs[i]._literalValue === null) {
|
|
321
332
|
outputs[i]._setSourceEntity(name);
|
|
322
333
|
}
|
|
323
334
|
}
|
|
@@ -158,6 +158,19 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
158
158
|
// as fold, resolved independently.
|
|
159
159
|
const sandbox = resolveSandboxEnabled(config, options.sandbox);
|
|
160
160
|
|
|
161
|
+
// #1113 — the bootstrap limit, surfaced rather than left implicit. Reading
|
|
162
|
+
// `build.sandbox` out of `chant.config.ts` requires evaluating that file, so
|
|
163
|
+
// a config-only opt-in cannot have covered its own evaluation; only the CLI
|
|
164
|
+
// flag, known before any config is touched, arms `../config-sandbox.ts`.
|
|
165
|
+
// Say so instead of letting two different boundaries share one word.
|
|
166
|
+
if (sandbox && !options.sandbox && loaded.configPath?.endsWith(".ts")) {
|
|
167
|
+
warnings.push(
|
|
168
|
+
formatWarning({
|
|
169
|
+
message: `build.sandbox is enabled by ${loaded.configPath}, but that file was itself evaluated in this process — reading the setting requires running the config. Pass --sandbox on the command line to evaluate chant.config.ts inside the boundary too.`,
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
161
174
|
// #1064 (factored into ../build-params-cli.ts's resolveCliBuildParams by
|
|
162
175
|
// #1108, so the component deploy driver runs the identical sequence) —
|
|
163
176
|
// resolve declared build-time parameters (chant.config.ts's buildParams)
|
package/src/cli/main.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { formatSuccess, formatError } from "./format";
|
|
|
6
6
|
import { loadPlugins, resolveProjectLexicons } from "./plugins";
|
|
7
7
|
import { resolveCommand, type CommandDef, type ParsedArgs } from "./registry";
|
|
8
8
|
import { loadChantConfigUpward } from "../config";
|
|
9
|
+
import { armSandboxConfigEvaluation } from "../config-sandbox";
|
|
9
10
|
import { ENV_VAR, unknownEnvError } from "../env";
|
|
10
11
|
import { initRuntime } from "../runtime-adapter";
|
|
11
12
|
import { runBuild } from "./handlers/build";
|
|
@@ -598,6 +599,15 @@ async function main(): Promise<void> {
|
|
|
598
599
|
// chant.config itself may branch on the env. (#505)
|
|
599
600
|
if (args.env) process.env[ENV_VAR] = args.env;
|
|
600
601
|
|
|
602
|
+
// chant #1113 — `--sandbox` is a property of the whole invocation, and it
|
|
603
|
+
// has to be known BEFORE the first config load, because `chant.config.ts` is
|
|
604
|
+
// itself project-authored code. Arming here, straight off the parsed flag,
|
|
605
|
+
// is the only ordering that works: the project's own `build.sandbox: true`
|
|
606
|
+
// cannot cover its own evaluation (reading it means running it), so the
|
|
607
|
+
// command-line flag is what puts the config inside the boundary. See
|
|
608
|
+
// `../config-sandbox.ts`.
|
|
609
|
+
if (args.sandbox) armSandboxConfigEvaluation();
|
|
610
|
+
|
|
601
611
|
// Initialize runtime adapter early — before plugins or commands run.
|
|
602
612
|
// chant #1117 — walks up from `args.path` to the project root: for a
|
|
603
613
|
// subdirectory build/command (`chant build src/<stack> --env prod`) the
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The single place chant evaluates a project's `chant.config.ts` **in the CLI's
|
|
6
|
+
* own process**.
|
|
7
|
+
*
|
|
8
|
+
* This is the config-side analogue of `./discovery/import.ts`'s `importModule`
|
|
9
|
+
* — one narrow module whose only job is "execute project-authored code here",
|
|
10
|
+
* so that the question "did any project code run in this process?" has one
|
|
11
|
+
* place to look and one place to instrument (see
|
|
12
|
+
* `examples/sandbox-execution-boundary.test.ts`, which spies on both).
|
|
13
|
+
*
|
|
14
|
+
* Callers must not `import()` a config file directly. `./config-sandbox.ts`
|
|
15
|
+
* decides whether a given load is allowed to come through here at all: under
|
|
16
|
+
* `chant build --sandbox` it routes the evaluation into the sandboxed child
|
|
17
|
+
* instead (chant #1113), and these functions are never reached.
|
|
18
|
+
*
|
|
19
|
+
* `chant.config.json` is pure data and is parsed, not executed — it never goes
|
|
20
|
+
* through this module.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The shape a config module evaluates to, before `default`/`config`/namespace selection. */
|
|
24
|
+
export type ConfigModuleNamespace = Record<string, unknown>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Import a `chant.config.ts` into THIS process and return its module namespace.
|
|
28
|
+
* Node's ESM registry caches it, so repeated loads within one CLI invocation
|
|
29
|
+
* evaluate the file once — the behavior `loadChantConfig` has always had.
|
|
30
|
+
*/
|
|
31
|
+
export async function importConfigModule(configPath: string): Promise<ConfigModuleNamespace> {
|
|
32
|
+
return (await import(configPath)) as ConfigModuleNamespace;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `require()` a `chant.config.ts` into THIS process from `dir`'s resolution
|
|
37
|
+
* context — the synchronous path `./lint/config.ts`'s `loadConfig` has used
|
|
38
|
+
* since before the async loader existed (`chant lint` is a sync pipeline).
|
|
39
|
+
*/
|
|
40
|
+
export function requireConfigModule(configPath: string, dir: string): ConfigModuleNamespace {
|
|
41
|
+
const req = createRequire(join(dir, "package.json"));
|
|
42
|
+
return req(configPath) as ConfigModuleNamespace;
|
|
43
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { importConfigModule, requireConfigModule, type ConfigModuleNamespace } from "./config-import";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* chant #1113 — decides WHERE a project's `chant.config.ts` is evaluated.
|
|
6
|
+
*
|
|
7
|
+
* `chant.config.ts` is project-authored code. Every other piece of project
|
|
8
|
+
* source moved behind the `--sandbox` boundary in chant #1045 (run-fallback
|
|
9
|
+
* files) and #1093 (composite factories, constructors, intrinsic tags), and
|
|
10
|
+
* both PRs had to document the config file as a remaining hole: the CLI reads
|
|
11
|
+
* its configuration by importing it, in-process, before it knows anything else
|
|
12
|
+
* about the project. This module closes it.
|
|
13
|
+
*
|
|
14
|
+
* ## Why an armed process mode rather than a threaded option
|
|
15
|
+
*
|
|
16
|
+
* Config is loaded from a dozen call sites (`../cli/main.ts` before command
|
|
17
|
+
* dispatch, `./cli/plugins.ts`'s `resolveProjectLexicons`,
|
|
18
|
+
* `./cli/commands/build.ts`, `./lint/policy.ts`, every lifecycle handler …).
|
|
19
|
+
* Threading a `sandbox` option to each one is fail-OPEN: miss a site and
|
|
20
|
+
* project code executes in the CLI process with nothing to notice it. Arming
|
|
21
|
+
* the process once, from the `--sandbox` flag, before any config load happens,
|
|
22
|
+
* is fail-CLOSED — a call site that nobody remembered still routes through the
|
|
23
|
+
* child.
|
|
24
|
+
*
|
|
25
|
+
* ## The bootstrap limit, stated plainly
|
|
26
|
+
*
|
|
27
|
+
* `chant.config.ts`'s own `build.sandbox: true` **cannot** sandbox its own
|
|
28
|
+
* evaluation. Reading that field requires evaluating the file, so by the time
|
|
29
|
+
* chant knows the project asked for sandboxing, the project's code has already
|
|
30
|
+
* run. Only `--sandbox` on the command line — known from `parseArgs`, before
|
|
31
|
+
* any config is touched — arms this. `chant build` warns when sandboxing was
|
|
32
|
+
* enabled by config alone, rather than letting the difference stay invisible.
|
|
33
|
+
* (A `chant.config.json` project has no such limit: JSON is data, parsed and
|
|
34
|
+
* never executed, so `build.sandbox: true` there is fully honest.)
|
|
35
|
+
*
|
|
36
|
+
* ## Memoization
|
|
37
|
+
*
|
|
38
|
+
* Armed loads are memoized per config path for the life of the process. That
|
|
39
|
+
* is not an optimization detail with a semantic cost: the unarmed path is
|
|
40
|
+
* already memoized by Node's own ESM registry (`await import()` evaluates a
|
|
41
|
+
* given config file once per process, so `chant build --watch` has never
|
|
42
|
+
* re-read a config mid-session either). Matching that keeps the two paths
|
|
43
|
+
* behaviorally identical while keeping a build to one bundle+fork instead of
|
|
44
|
+
* one per call site.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/** Whether this process must evaluate project configs inside the sandbox boundary. */
|
|
48
|
+
let armed = false;
|
|
49
|
+
|
|
50
|
+
/** Armed-mode results, keyed by absolute config path. See the module doc on why this matches unarmed behavior rather than diverging from it. */
|
|
51
|
+
const memo = new Map<string, unknown>();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Arm sandboxed config evaluation for the rest of this process. Called from
|
|
55
|
+
* `../cli/main.ts` immediately after `parseArgs`, when `--sandbox` was passed
|
|
56
|
+
* — before the first config load. Idempotent; there is deliberately no
|
|
57
|
+
* disarm, because a security mode that can be turned off partway through a
|
|
58
|
+
* process is not one.
|
|
59
|
+
*/
|
|
60
|
+
export function armSandboxConfigEvaluation(): void {
|
|
61
|
+
armed = true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whether {@link armSandboxConfigEvaluation} has been called. */
|
|
65
|
+
export function isSandboxConfigEvaluationArmed(): boolean {
|
|
66
|
+
return armed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Test-only reset. Vitest gives each test file its own module registry, so
|
|
71
|
+
* this exists for suites that arm and disarm within one file.
|
|
72
|
+
*/
|
|
73
|
+
export function resetSandboxConfigEvaluationForTests(): void {
|
|
74
|
+
armed = false;
|
|
75
|
+
memo.clear();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The export a config module's configuration lives on: an explicit `default`,
|
|
80
|
+
* a named `config`, else the module namespace itself (a config authored as a
|
|
81
|
+
* set of top-level named exports). Applied identically on both sides of the
|
|
82
|
+
* boundary — in the child by `./discovery/sandbox/driver.ts`'s config driver,
|
|
83
|
+
* here for the in-process path — so `--sandbox` changes only where the file is
|
|
84
|
+
* evaluated, never how its result is read.
|
|
85
|
+
*/
|
|
86
|
+
export function selectConfigExport(namespace: ConfigModuleNamespace): unknown {
|
|
87
|
+
return namespace.default ?? namespace.config ?? namespace;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Evaluate a project's `chant.config.ts` and return its configuration object,
|
|
92
|
+
* pre-`normalizeConfig`. Sandboxed when armed, in-process otherwise.
|
|
93
|
+
*
|
|
94
|
+
* @param configPath - Absolute path to the config file.
|
|
95
|
+
* @param projectRoot - Directory to grant the sandboxed child read access to;
|
|
96
|
+
* defaults to the config file's own directory.
|
|
97
|
+
*/
|
|
98
|
+
export async function evaluateProjectConfig(
|
|
99
|
+
configPath: string,
|
|
100
|
+
projectRoot: string = dirname(configPath),
|
|
101
|
+
): Promise<unknown> {
|
|
102
|
+
if (!armed) {
|
|
103
|
+
return selectConfigExport(await importConfigModule(configPath));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (memo.has(configPath)) return memo.get(configPath);
|
|
107
|
+
|
|
108
|
+
// Dynamic, not static, for the same reason `./discovery/index.ts` imports
|
|
109
|
+
// `./sandbox/run` dynamically: this pulls in `esbuild`, a large CJS package
|
|
110
|
+
// no unsandboxed build should pay to load.
|
|
111
|
+
const { evaluateConfigSandboxed } = await import("./discovery/sandbox/config-run");
|
|
112
|
+
const { config } = await evaluateConfigSandboxed(configPath, projectRoot);
|
|
113
|
+
memo.set(configPath, config);
|
|
114
|
+
return config;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Synchronous counterpart for `./lint/config.ts`'s `loadConfig` (`chant lint`
|
|
119
|
+
* is a sync pipeline and predates the async loader).
|
|
120
|
+
*
|
|
121
|
+
* When armed, this cannot spawn a child — so it uses the result of an earlier
|
|
122
|
+
* armed load of the same file, and refuses if there isn't one. In practice
|
|
123
|
+
* there always is: `../cli/main.ts` loads the project config before dispatching
|
|
124
|
+
* to any command. Refusing rather than falling back to an in-process `require`
|
|
125
|
+
* is the point — a sync call site is exactly where a boundary would otherwise
|
|
126
|
+
* be lost by accident.
|
|
127
|
+
*/
|
|
128
|
+
export function evaluateProjectConfigSync(configPath: string, dir: string): unknown {
|
|
129
|
+
if (!armed) {
|
|
130
|
+
return selectConfigExport(requireConfigModule(configPath, dir));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (memo.has(configPath)) return memo.get(configPath);
|
|
134
|
+
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Cannot read ${configPath} synchronously under --sandbox: it has not yet been evaluated inside the boundary, and evaluating it here would run project code in the chant process. This is a chant bug — the project config should have been loaded before this point.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { Severity } from "./components/verbs/vuln-scan";
|
|
|
8
8
|
import type { VulnPolicy } from "./components/verbs/vuln-gate";
|
|
9
9
|
import type { BuildParamsConfig } from "./build-params";
|
|
10
10
|
import { findProjectConfig } from "./project-root";
|
|
11
|
+
import { evaluateProjectConfig } from "./config-sandbox";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Zod schema for ChantConfig validation.
|
|
@@ -274,16 +275,24 @@ export const DEFAULT_CHANT_CONFIG: ChantConfig = {};
|
|
|
274
275
|
/**
|
|
275
276
|
* Load project configuration from a directory.
|
|
276
277
|
*
|
|
277
|
-
* Tries `chant.config.ts` first
|
|
278
|
-
*
|
|
278
|
+
* Tries `chant.config.ts` first, then `chant.config.json`. Returns default
|
|
279
|
+
* config if neither exists.
|
|
280
|
+
*
|
|
281
|
+
* chant #1113 — `chant.config.ts` is project-authored code, so *where* it is
|
|
282
|
+
* evaluated is a security question, and the answer is
|
|
283
|
+
* `./config-sandbox.ts`'s: in a sandboxed child when this process was armed by
|
|
284
|
+
* `chant build --sandbox`, in-process (exactly as before) otherwise. Either
|
|
285
|
+
* way the result is the same plain configuration object, and validation
|
|
286
|
+
* ({@link normalizeConfig}) happens here, in the trusted process.
|
|
287
|
+
* `chant.config.json` is data, not code — it is parsed in-process under
|
|
288
|
+
* `--sandbox` too, because there is nothing to execute.
|
|
279
289
|
*/
|
|
280
290
|
export async function loadChantConfig(dir: string): Promise<ResolvedConfig> {
|
|
281
291
|
// Try chant.config.ts first
|
|
282
292
|
const tsPath = join(dir, "chant.config.ts");
|
|
283
293
|
if (existsSync(tsPath)) {
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
return { config: normalizeConfig(config, tsPath), configPath: tsPath };
|
|
294
|
+
const config = await evaluateProjectConfig(tsPath, dir);
|
|
295
|
+
return { config: normalizeConfig(config as Record<string, unknown>, tsPath), configPath: tsPath };
|
|
287
296
|
}
|
|
288
297
|
|
|
289
298
|
// Fall back to chant.config.json
|
|
@@ -58,7 +58,7 @@ import { DECLARABLE_MARKER, isResourceDeclarable, type Declarable } from "../dec
|
|
|
58
58
|
import { AttrRef } from "../attrref";
|
|
59
59
|
import { INTRINSIC_MARKER, type Intrinsic } from "../intrinsic";
|
|
60
60
|
import { isAttrRefLike } from "../utils";
|
|
61
|
-
import { isLexiconOutput, LexiconOutput } from "../lexicon-output";
|
|
61
|
+
import { isLexiconOutput, LexiconOutput, type LexiconOutputLiteral } from "../lexicon-output";
|
|
62
62
|
import { isChildProject } from "../child-project";
|
|
63
63
|
|
|
64
64
|
/**
|
|
@@ -137,7 +137,7 @@ export interface WireLexiconOutputEntity {
|
|
|
137
137
|
form: "lexiconOutput";
|
|
138
138
|
name: string;
|
|
139
139
|
outputName: string;
|
|
140
|
-
/** The wrapped `AttrRef` or
|
|
140
|
+
/** The wrapped `AttrRef`, `Intrinsic`, or literal — encodes to `{__attrRef}`, `{__intrinsic}`, or a plain JSON primitive respectively (chant #1121; see {@link WireValue}). */
|
|
141
141
|
ref: WireValue;
|
|
142
142
|
}
|
|
143
143
|
|
|
@@ -156,15 +156,22 @@ const CORE_FIELD_NAMES = new Set(["lexicon", "entityType", "kind", "props", "att
|
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
158
|
* Read a `LexiconOutput`'s internal ref without re-deriving it — see the
|
|
159
|
-
* `_intrinsic`/`_sourceParent` fields in `../lexicon-output.ts`.
|
|
160
|
-
* built from an `AttrRef`, `LexiconOutput` keeps only the parent
|
|
161
|
-
* attribute name (not the original `AttrRef` instance), so a
|
|
162
|
-
* synthesized here — its logical name must be set explicitly
|
|
163
|
-
* `entityNames`, the same map every other reference in this module
|
|
164
|
-
* names through) since it never went through `resolveAttrRefs`.
|
|
159
|
+
* `_intrinsic`/`_sourceParent`/`_literalValue` fields in `../lexicon-output.ts`.
|
|
160
|
+
* When it was built from an `AttrRef`, `LexiconOutput` keeps only the parent
|
|
161
|
+
* `WeakRef` + attribute name (not the original `AttrRef` instance), so a
|
|
162
|
+
* fresh one is synthesized here — its logical name must be set explicitly
|
|
163
|
+
* (from `entityNames`, the same map every other reference in this module
|
|
164
|
+
* resolves names through) since it never went through `resolveAttrRefs`.
|
|
165
|
+
*
|
|
166
|
+
* chant #1121 — a literal-valued output (a real string/number/boolean the
|
|
167
|
+
* author's code already computed, not a reference) has neither a parent nor
|
|
168
|
+
* an intrinsic to hand back; its `_literalValue` round-trips as a plain wire
|
|
169
|
+
* primitive instead, and `LexiconOutput`'s own constructor reconstructs it
|
|
170
|
+
* identically on decode (see `decodeEntitySet` below).
|
|
165
171
|
*/
|
|
166
|
-
function lexiconOutputRef(output: LexiconOutput, entityNames: Map<unknown, string>): AttrRef | Intrinsic {
|
|
172
|
+
function lexiconOutputRef(output: LexiconOutput, entityNames: Map<unknown, string>): AttrRef | Intrinsic | LexiconOutputLiteral {
|
|
167
173
|
if (output._intrinsic) return output._intrinsic;
|
|
174
|
+
if (output._literalValue !== null) return output._literalValue;
|
|
168
175
|
const parent = output._sourceParent?.deref();
|
|
169
176
|
const parentName = parent ? entityNames.get(parent) : undefined;
|
|
170
177
|
if (!parent || output.sourceAttribute === null || !parentName) {
|
|
@@ -475,9 +482,11 @@ export function decodeEntitySet(wire: EntitySetWire): Map<string, Declarable> {
|
|
|
475
482
|
}
|
|
476
483
|
|
|
477
484
|
// LexiconOutput — constructed via its real constructor from the decoded
|
|
478
|
-
// ref, so it derives sourceLexicon/_sourceParent/sourceAttribute
|
|
479
|
-
// the way constructing it from a live
|
|
480
|
-
|
|
485
|
+
// ref, so it derives sourceLexicon/_sourceParent/sourceAttribute/
|
|
486
|
+
// _literalValue exactly the way constructing it from a live
|
|
487
|
+
// AttrRef/Intrinsic/literal would (chant #1121 for the literal case: a
|
|
488
|
+
// plain wire primitive decodes back to itself, see `decodeValue` above).
|
|
489
|
+
const ref = decodeValue(entry.ref, registry) as AttrRef | Intrinsic | LexiconOutputLiteral;
|
|
481
490
|
const output = new LexiconOutput(ref, entry.outputName);
|
|
482
491
|
registry.set(entry.name, output);
|
|
483
492
|
result.set(entry.name, output as unknown as Declarable);
|
|
@@ -197,6 +197,32 @@ describe("entity-wire round trip (chant #1045 Phase 1)", () => {
|
|
|
197
197
|
expect(isAttrRefLike(embedded[0])).toBe(true);
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
+
// chant #1121 — before this fix, encoding a literal-valued LexiconOutput
|
|
201
|
+
// (neither an AttrRef nor an Intrinsic) threw
|
|
202
|
+
// `encodeEntitySet: LexiconOutput "..." has neither a resolvable AttrRef
|
|
203
|
+
// parent nor an intrinsic` — this is the sandboxed-child boundary a
|
|
204
|
+
// `chant build --sandbox` run crosses for every entity, so a project using
|
|
205
|
+
// `output("literal", "name")` could not build under `--sandbox` at all.
|
|
206
|
+
test.each([
|
|
207
|
+
["string", "fold-output-repro"],
|
|
208
|
+
["number", 42],
|
|
209
|
+
["boolean", true],
|
|
210
|
+
] as const)("LexiconOutput built from a %s literal round-trips via its real constructor", (_kind, value) => {
|
|
211
|
+
const lexOutput = new LexiconOutput(value, "LiteralOut");
|
|
212
|
+
const entities = new Map<string, Declarable>([["LiteralOut", lexOutput as unknown as Declarable]]);
|
|
213
|
+
|
|
214
|
+
const wire = encodeEntitySet(entities);
|
|
215
|
+
assertPureJson(wire);
|
|
216
|
+
|
|
217
|
+
const decoded = decodeEntitySet(JSON.parse(JSON.stringify(wire)) as EntitySetWire);
|
|
218
|
+
const decodedOutput = decoded.get("LiteralOut") as unknown as LexiconOutput;
|
|
219
|
+
expect(isLexiconOutput(decodedOutput)).toBe(true);
|
|
220
|
+
expect(decodedOutput.outputName).toBe("LiteralOut");
|
|
221
|
+
expect(decodedOutput.sourceEntity).toBe("");
|
|
222
|
+
expect(decodedOutput.sourceAttribute).toBeNull();
|
|
223
|
+
expect(decodedOutput.getOutputValue()).toBe(value);
|
|
224
|
+
});
|
|
225
|
+
|
|
200
226
|
test("a lexicon-specific marker symbol (not core-owned) round-trips generically", () => {
|
|
201
227
|
const MARKER = Symbol.for("chant.test.customMarker");
|
|
202
228
|
const custom: Declarable = {
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdir, writeFile, rm, realpath } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { loadChantConfig } from "../../config";
|
|
6
|
+
import {
|
|
7
|
+
armSandboxConfigEvaluation,
|
|
8
|
+
evaluateProjectConfigSync,
|
|
9
|
+
isSandboxConfigEvaluationArmed,
|
|
10
|
+
resetSandboxConfigEvaluationForTests,
|
|
11
|
+
} from "../../config-sandbox";
|
|
12
|
+
import { ENV_VAR } from "../../env";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* chant #1113 — `chant.config.ts` is project-authored code, and under
|
|
16
|
+
* `--sandbox` it must not execute in the CLI's process.
|
|
17
|
+
*
|
|
18
|
+
* Same shape of proof as `./fold-boundary.test.ts` (chant #1093): the fixture
|
|
19
|
+
* config sets a `globalThis` marker at module top level, and a marker set
|
|
20
|
+
* inside the sandboxed child cannot reach this process. Every pair asserts the
|
|
21
|
+
* UNARMED half fires the marker first, so the probe is proven capable of
|
|
22
|
+
* failing before the armed half asserts it stays clean.
|
|
23
|
+
*
|
|
24
|
+
* Fixtures go to a fresh tmpdir per test — never the source tree — so no two
|
|
25
|
+
* tests share a module path and nothing bleeds through Node's module cache.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const MARKER = "__chant1113ConfigEvaluated";
|
|
29
|
+
|
|
30
|
+
type MarkerHost = Record<string, boolean | undefined>;
|
|
31
|
+
|
|
32
|
+
function marker(): boolean | undefined {
|
|
33
|
+
return (globalThis as unknown as MarkerHost)[MARKER];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("chant.config.ts evaluation under --sandbox (chant #1113)", () => {
|
|
37
|
+
let testDir: string;
|
|
38
|
+
let savedEnv: string | undefined;
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
const dir = join(tmpdir(), `chant-1113-config-${Date.now()}-${Math.random()}`);
|
|
42
|
+
await mkdir(dir, { recursive: true });
|
|
43
|
+
testDir = await realpath(dir);
|
|
44
|
+
delete (globalThis as unknown as MarkerHost)[MARKER];
|
|
45
|
+
savedEnv = process.env[ENV_VAR];
|
|
46
|
+
resetSandboxConfigEvaluationForTests();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
delete (globalThis as unknown as MarkerHost)[MARKER];
|
|
51
|
+
if (savedEnv === undefined) delete process.env[ENV_VAR];
|
|
52
|
+
else process.env[ENV_VAR] = savedEnv;
|
|
53
|
+
resetSandboxConfigEvaluationForTests();
|
|
54
|
+
await rm(testDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** A config that announces its own evaluation, the way any project file's top level can. */
|
|
58
|
+
async function writeMarkerConfig(body = `{ lexicons: ["aws"], ownership: { stack: "s" } }`): Promise<void> {
|
|
59
|
+
await writeFile(
|
|
60
|
+
join(testDir, "chant.config.ts"),
|
|
61
|
+
`globalThis[${JSON.stringify(MARKER)}] = true;\nexport default ${body};\n`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test("without --sandbox the config DOES evaluate in this process (the probe fires)", async () => {
|
|
66
|
+
await writeMarkerConfig();
|
|
67
|
+
|
|
68
|
+
const { config } = await loadChantConfig(testDir);
|
|
69
|
+
|
|
70
|
+
expect(isSandboxConfigEvaluationArmed()).toBe(false);
|
|
71
|
+
expect(marker(), "the config's top level ran in this process").toBe(true);
|
|
72
|
+
expect(config.lexicons).toEqual(["aws"]);
|
|
73
|
+
expect(config.ownership).toEqual({ stack: "s" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("armed, the same config evaluates in the child: no marker here, same config", async () => {
|
|
77
|
+
await writeMarkerConfig();
|
|
78
|
+
armSandboxConfigEvaluation();
|
|
79
|
+
|
|
80
|
+
const { config, configPath } = await loadChantConfig(testDir);
|
|
81
|
+
|
|
82
|
+
expect(marker(), "the config's top level must NOT run in the CLI process").toBeUndefined();
|
|
83
|
+
expect(configPath).toBe(join(testDir, "chant.config.ts"));
|
|
84
|
+
expect(config.lexicons).toEqual(["aws"]);
|
|
85
|
+
expect(config.ownership).toEqual({ stack: "s" });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a config authored as a named `config` export crosses the same way", async () => {
|
|
89
|
+
await writeFile(
|
|
90
|
+
join(testDir, "chant.config.ts"),
|
|
91
|
+
`globalThis[${JSON.stringify(MARKER)}] = true;\nexport const config = { lexicons: ["k8s"] };\n`,
|
|
92
|
+
);
|
|
93
|
+
armSandboxConfigEvaluation();
|
|
94
|
+
|
|
95
|
+
const { config } = await loadChantConfig(testDir);
|
|
96
|
+
|
|
97
|
+
expect(marker()).toBeUndefined();
|
|
98
|
+
expect(config.lexicons).toEqual(["k8s"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("nested data — buildParams, stacks, lint rules — survives the round trip", async () => {
|
|
102
|
+
await writeMarkerConfig(
|
|
103
|
+
`{
|
|
104
|
+
lexicons: ["aws"],
|
|
105
|
+
stacks: [{ name: "net", src: "src/net" }, { name: "app", src: "src/app" }],
|
|
106
|
+
buildParams: { tier: { type: "string", default: "light", enum: ["light", "prod"] } },
|
|
107
|
+
lint: { rules: { COR001: "error", COR002: ["warning", { max: 3 }] }, policies: ["policies/org.ts"] },
|
|
108
|
+
}`,
|
|
109
|
+
);
|
|
110
|
+
armSandboxConfigEvaluation();
|
|
111
|
+
|
|
112
|
+
const { config } = await loadChantConfig(testDir);
|
|
113
|
+
|
|
114
|
+
expect(marker()).toBeUndefined();
|
|
115
|
+
expect(config.stacks).toEqual([
|
|
116
|
+
{ name: "net", src: "src/net" },
|
|
117
|
+
{ name: "app", src: "src/app" },
|
|
118
|
+
]);
|
|
119
|
+
expect(config.buildParams).toEqual({
|
|
120
|
+
tier: { type: "string", default: "light", enum: ["light", "prod"] },
|
|
121
|
+
});
|
|
122
|
+
expect(config.lint?.rules).toEqual({ COR001: "error", COR002: ["warning", { max: 3 }] });
|
|
123
|
+
expect(config.lint?.policies).toEqual(["policies/org.ts"]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("chant.config.json needs no child — it is data, parsed in-process either way", async () => {
|
|
127
|
+
await writeFile(
|
|
128
|
+
join(testDir, "chant.config.json"),
|
|
129
|
+
JSON.stringify({ lexicons: ["gcp"], build: { sandbox: true } }),
|
|
130
|
+
);
|
|
131
|
+
armSandboxConfigEvaluation();
|
|
132
|
+
|
|
133
|
+
const { config, configPath } = await loadChantConfig(testDir);
|
|
134
|
+
|
|
135
|
+
expect(configPath).toBe(join(testDir, "chant.config.json"));
|
|
136
|
+
expect(config.lexicons).toEqual(["gcp"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a value that cannot cross as JSON is refused, naming the key", async () => {
|
|
140
|
+
await writeFile(
|
|
141
|
+
join(testDir, "chant.config.ts"),
|
|
142
|
+
`export default { lexicons: ["aws"], hooks: { beforeBuild: () => 1 } };\n`,
|
|
143
|
+
);
|
|
144
|
+
armSandboxConfigEvaluation();
|
|
145
|
+
|
|
146
|
+
await expect(loadChantConfig(testDir)).rejects.toThrow(/hooks\.beforeBuild: a function/);
|
|
147
|
+
expect(marker()).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a Date is refused too — JSON.stringify would silently turn it into a string", async () => {
|
|
151
|
+
await writeFile(
|
|
152
|
+
join(testDir, "chant.config.ts"),
|
|
153
|
+
`export default { lexicons: ["aws"], meta: { generatedAt: new Date(0) } };\n`,
|
|
154
|
+
);
|
|
155
|
+
armSandboxConfigEvaluation();
|
|
156
|
+
|
|
157
|
+
await expect(loadChantConfig(testDir)).rejects.toThrow(/meta\.generatedAt: a Date/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("a config that reads outside the project is denied, naming the config file", async () => {
|
|
161
|
+
await writeFile(
|
|
162
|
+
join(testDir, "chant.config.ts"),
|
|
163
|
+
`import { readFileSync } from "node:fs";\n` +
|
|
164
|
+
`const stolen = readFileSync("/etc/hosts", "utf-8");\n` +
|
|
165
|
+
`export default { lexicons: [stolen.slice(0, 3)] };\n`,
|
|
166
|
+
);
|
|
167
|
+
armSandboxConfigEvaluation();
|
|
168
|
+
|
|
169
|
+
await expect(loadChantConfig(testDir)).rejects.toThrow(/sandbox denied FileSystemRead/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a config that tries to spawn a process is denied", async () => {
|
|
173
|
+
await writeFile(
|
|
174
|
+
join(testDir, "chant.config.ts"),
|
|
175
|
+
`import { execSync } from "node:child_process";\n` +
|
|
176
|
+
`execSync("echo pwned");\n` +
|
|
177
|
+
`export default { lexicons: ["aws"] };\n`,
|
|
178
|
+
);
|
|
179
|
+
armSandboxConfigEvaluation();
|
|
180
|
+
|
|
181
|
+
await expect(loadChantConfig(testDir)).rejects.toThrow(/sandbox denied/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("the child's environment is scrubbed — but --env still reaches the config", async () => {
|
|
185
|
+
process.env[ENV_VAR] = "prod";
|
|
186
|
+
process.env.CHANT_1113_SECRET = "hunter2";
|
|
187
|
+
try {
|
|
188
|
+
await writeFile(
|
|
189
|
+
join(testDir, "chant.config.ts"),
|
|
190
|
+
`export default {\n` +
|
|
191
|
+
` environments: [process.env[${JSON.stringify(ENV_VAR)}] ?? "none"],\n` +
|
|
192
|
+
` sourceDir: process.env.CHANT_1113_SECRET ?? "scrubbed",\n` +
|
|
193
|
+
`};\n`,
|
|
194
|
+
);
|
|
195
|
+
armSandboxConfigEvaluation();
|
|
196
|
+
|
|
197
|
+
const { config } = await loadChantConfig(testDir);
|
|
198
|
+
|
|
199
|
+
expect(config.environments, "--env is forwarded so the config resolves the same either way").toEqual(["prod"]);
|
|
200
|
+
expect(config.sourceDir, "nothing else from the CLI's environment is visible").toBe("scrubbed");
|
|
201
|
+
} finally {
|
|
202
|
+
delete process.env.CHANT_1113_SECRET;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("an armed load is memoized, so repeated loads cost one child, not one each", async () => {
|
|
207
|
+
await writeMarkerConfig();
|
|
208
|
+
armSandboxConfigEvaluation();
|
|
209
|
+
|
|
210
|
+
const first = await loadChantConfig(testDir);
|
|
211
|
+
const second = await loadChantConfig(testDir);
|
|
212
|
+
|
|
213
|
+
expect(second.config).toBe(first.config);
|
|
214
|
+
expect(marker()).toBeUndefined();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("the sync lint loader reuses that result rather than requiring the file", async () => {
|
|
218
|
+
await writeMarkerConfig(`{ lexicons: ["aws"], lint: { rules: { COR001: "warning" } } }`);
|
|
219
|
+
armSandboxConfigEvaluation();
|
|
220
|
+
|
|
221
|
+
await loadChantConfig(testDir);
|
|
222
|
+
const config = evaluateProjectConfigSync(join(testDir, "chant.config.ts"), testDir) as {
|
|
223
|
+
lint?: { rules?: Record<string, unknown> };
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
expect(config.lint?.rules?.COR001).toBe("warning");
|
|
227
|
+
expect(marker()).toBeUndefined();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("the sync lint loader refuses rather than importing when nothing was evaluated yet", async () => {
|
|
231
|
+
await writeMarkerConfig();
|
|
232
|
+
armSandboxConfigEvaluation();
|
|
233
|
+
|
|
234
|
+
expect(() => evaluateProjectConfigSync(join(testDir, "chant.config.ts"), testDir)).toThrow(
|
|
235
|
+
/Cannot read .* synchronously under --sandbox/,
|
|
236
|
+
);
|
|
237
|
+
expect(marker()).toBeUndefined();
|
|
238
|
+
});
|
|
239
|
+
});
|