@intentius/chant 0.22.0 → 0.23.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/cli/build-params-cli.d.ts +55 -0
- package/dist/cli/build-params-cli.d.ts.map +1 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +14 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/lsp/server.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +33 -2
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts +28 -0
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +38 -8
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/index.d.ts +15 -4
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +16 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lint/engine.d.ts +11 -1
- package/dist/lint/engine.d.ts.map +1 -1
- package/dist/lint/rule.d.ts +14 -0
- package/dist/lint/rule.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/build-params-cli.test.ts +139 -0
- package/src/cli/build-params-cli.ts +107 -0
- package/src/cli/commands/build.ts +16 -36
- package/src/cli/commands/lint.test.ts +74 -0
- package/src/cli/commands/lint.ts +33 -9
- package/src/cli/handlers/build.test.ts +147 -0
- package/src/cli/handlers/build.ts +23 -8
- package/src/cli/handlers/run.test.ts +160 -5
- package/src/cli/handlers/run.ts +46 -8
- package/src/cli/lsp/server.ts +7 -2
- package/src/components/cli-support.test.ts +221 -3
- package/src/components/cli-support.ts +37 -6
- package/src/components/discover.test.ts +63 -1
- package/src/components/discover.ts +42 -0
- package/src/discovery/fold-import.ts +202 -20
- package/src/discovery/index.test.ts +131 -0
- package/src/discovery/index.ts +38 -8
- package/src/discovery/sandbox/fold-boundary.test.ts +254 -0
- package/src/fold/subset.test.ts +28 -14
- package/src/fold/subset.ts +16 -2
- package/src/lint/engine.ts +12 -0
- package/src/lint/rule.ts +14 -0
- package/src/lint/rules/evl001-non-literal-expression.test.ts +39 -0
- package/src/lint/rules/evl001-non-literal-expression.ts +1 -1
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { discoverComponents } from "./discover";
|
|
26
|
+
import type { BuildParamProvenance } from "../provenance";
|
|
26
27
|
import { projectToJson, type Archetype } from "./component";
|
|
27
28
|
import {
|
|
28
29
|
resolveComponentGraph,
|
|
@@ -185,6 +186,8 @@ export interface GenerateComponentsResult {
|
|
|
185
186
|
/** Every generated job, for a machine-readable view (`--format json`). */
|
|
186
187
|
jobs?: Array<{ jobName: string; component: string; stage: string; needs: string[] }>;
|
|
187
188
|
error?: string;
|
|
189
|
+
/** This invocation's resolved build-time parameters (chant #1108) — the generate-mode counterpart of `../cli/commands/build.ts`'s `BuildResult.buildParams`. Empty when the project declares/supplies none. */
|
|
190
|
+
buildParams?: BuildParamProvenance[];
|
|
188
191
|
}
|
|
189
192
|
|
|
190
193
|
/**
|
|
@@ -221,6 +224,15 @@ export async function generateComponentsPipeline(
|
|
|
221
224
|
lexicon: GenerateLexicon,
|
|
222
225
|
options?: ComponentPipelineOptions,
|
|
223
226
|
sandbox?: boolean,
|
|
227
|
+
/**
|
|
228
|
+
* chant #1108 — this invocation's resolved build-time parameter values
|
|
229
|
+
* (../cli/handlers/build.ts resolves them, the same sequence `chant build`
|
|
230
|
+
* runs, BEFORE calling this function), forwarded into discovery so a
|
|
231
|
+
* `params.<name>` reference inside a discovered `*.component.ts` file
|
|
232
|
+
* resolves instead of reading `{}`. See `discoverComponents`'s
|
|
233
|
+
* `buildParams` option (./discover.ts) for the full doc.
|
|
234
|
+
*/
|
|
235
|
+
buildParams?: BuildParamProvenance[],
|
|
224
236
|
): Promise<GenerateComponentsResult> {
|
|
225
237
|
const plugin = await loadLexiconPlugin(lexicon);
|
|
226
238
|
if (!plugin?.generateComponentPipeline) {
|
|
@@ -230,7 +242,7 @@ export async function generateComponentsPipeline(
|
|
|
230
242
|
};
|
|
231
243
|
}
|
|
232
244
|
|
|
233
|
-
const result = await discoverComponents(path, { sandbox });
|
|
245
|
+
const result = await discoverComponents(path, { sandbox, buildParams });
|
|
234
246
|
if (result.errors.length > 0) {
|
|
235
247
|
return { success: false, error: result.errors.map((e) => e.message).join("\n") };
|
|
236
248
|
}
|
|
@@ -243,7 +255,7 @@ export async function generateComponentsPipeline(
|
|
|
243
255
|
|
|
244
256
|
try {
|
|
245
257
|
const { yaml, stages, jobs } = plugin.generateComponentPipeline(driverComponents, options);
|
|
246
|
-
return { success: true, yaml, stages, jobs };
|
|
258
|
+
return { success: true, yaml, stages, jobs, buildParams };
|
|
247
259
|
} catch (err) {
|
|
248
260
|
if (err instanceof UnknownDependencyError || err instanceof DependencyCycleError) {
|
|
249
261
|
return { success: false, error: err.message };
|
|
@@ -344,6 +356,21 @@ export interface RunComponentsOptions {
|
|
|
344
356
|
* `--progress-json`, in which case nothing changes.
|
|
345
357
|
*/
|
|
346
358
|
onProgress?: (event: RunProgressEvent) => void;
|
|
359
|
+
/**
|
|
360
|
+
* chant #1108 — this run's resolved build-time parameter values, resolved
|
|
361
|
+
* the exact same way `chant build` resolves them
|
|
362
|
+
* (../cli/build-params-cli.ts's `resolveCliBuildParams`, driven by
|
|
363
|
+
* `--param`/`--params-file`/a declared `env` mapping/`chant.config.ts`'s
|
|
364
|
+
* `buildParams` defaults). The CLI handler (../cli/handlers/run.ts)
|
|
365
|
+
* resolves + logs these BEFORE calling `runComponents`, the same
|
|
366
|
+
* sequencing `chant build` uses — this function only forwards the
|
|
367
|
+
* already-resolved values into discovery (`resolveComponentTargets` below,
|
|
368
|
+
* then `discoverComponents`); it does not resolve them itself, so
|
|
369
|
+
* `runComponents` stays free of CLI-flag-parsing/formatting concerns.
|
|
370
|
+
* Default: none — `params.*` stays `{}`, matching every caller (including
|
|
371
|
+
* every test in `cli-support.test.ts`) that doesn't supply this.
|
|
372
|
+
*/
|
|
373
|
+
buildParams?: BuildParamProvenance[];
|
|
347
374
|
}
|
|
348
375
|
|
|
349
376
|
/** Result of `chant run --components <name|all>`. */
|
|
@@ -356,6 +383,8 @@ export interface RunComponentsResult {
|
|
|
356
383
|
error?: string;
|
|
357
384
|
/** Set when a selected component (or one of its `deploy`/`rollback` phases) contains a `gate` the local executor cannot run. */
|
|
358
385
|
gateUnsupported?: { component: string; signalName: string };
|
|
386
|
+
/** This run's resolved build-time parameters (chant #1108) — the component-driver counterpart of `../cli/commands/build.ts`'s `BuildResult.buildParams`. Present only once the run actually reached dispatch (mirrors `BuildResult.buildParams`, which is likewise absent on an early-error return). */
|
|
387
|
+
buildParams?: BuildParamProvenance[];
|
|
359
388
|
}
|
|
360
389
|
|
|
361
390
|
/**
|
|
@@ -404,8 +433,10 @@ export async function resolveComponentTargets(
|
|
|
404
433
|
path: string,
|
|
405
434
|
selector: string,
|
|
406
435
|
sandbox?: boolean,
|
|
436
|
+
/** chant #1108 — this invocation's resolved build-time parameter values, forwarded into `discoverComponents` so a `params.<name>` reference inside a discovered `*.component.ts` file resolves instead of reading `{}`. See `discoverComponents`'s `buildParams` option (./discover.ts). */
|
|
437
|
+
buildParams?: BuildParamProvenance[],
|
|
407
438
|
): Promise<ResolvedComponentTargets> {
|
|
408
|
-
const result = await discoverComponents(path, { sandbox });
|
|
439
|
+
const result = await discoverComponents(path, { sandbox, buildParams });
|
|
409
440
|
if (result.errors.length > 0) {
|
|
410
441
|
return { success: false, targets: [], error: result.errors.map((e) => e.message).join("\n") };
|
|
411
442
|
}
|
|
@@ -433,7 +464,7 @@ export async function runComponents(
|
|
|
433
464
|
selector: string,
|
|
434
465
|
options: RunComponentsOptions = {},
|
|
435
466
|
): Promise<RunComponentsResult> {
|
|
436
|
-
const resolved = await resolveComponentTargets(path, selector, options.sandbox);
|
|
467
|
+
const resolved = await resolveComponentTargets(path, selector, options.sandbox, options.buildParams);
|
|
437
468
|
if (!resolved.success) {
|
|
438
469
|
return { success: false, selected: [], error: resolved.error };
|
|
439
470
|
}
|
|
@@ -479,7 +510,7 @@ export async function runComponents(
|
|
|
479
510
|
try {
|
|
480
511
|
if (selector === "all") {
|
|
481
512
|
const run = await runInterpretDriver(resolvedTargets, registry, { env, componentOutputs: seedOutputs, onProgress });
|
|
482
|
-
return { success: true, run, selected };
|
|
513
|
+
return { success: true, run, selected, buildParams: options.buildParams };
|
|
483
514
|
}
|
|
484
515
|
|
|
485
516
|
// Single-component invocation: run just this component, bypassing
|
|
@@ -518,7 +549,7 @@ export async function runComponents(
|
|
|
518
549
|
failedComponent: componentResult.ok ? undefined : componentResult.component,
|
|
519
550
|
componentOutputs,
|
|
520
551
|
};
|
|
521
|
-
return { success: componentResult.ok, run, selected };
|
|
552
|
+
return { success: componentResult.ok, run, selected, buildParams: options.buildParams };
|
|
522
553
|
} catch (err) {
|
|
523
554
|
if (err instanceof DriverRunFailure) {
|
|
524
555
|
return { success: false, run: err.result, selected, error: err.message };
|
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
|
|
12
12
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
13
13
|
import { mkdir, writeFile, rm } from "node:fs/promises";
|
|
14
|
-
import { join } from "node:path";
|
|
14
|
+
import { join, dirname, resolve as resolvePath } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
15
16
|
import { tmpdir } from "node:os";
|
|
16
17
|
import { discoverComponents } from "./discover";
|
|
18
|
+
import { params } from "../params";
|
|
17
19
|
|
|
18
20
|
describe("discoverComponents", () => {
|
|
19
21
|
let testDir: string;
|
|
@@ -349,4 +351,64 @@ describe("discoverComponents", () => {
|
|
|
349
351
|
expect(result.components.has("first-svc")).toBe(true);
|
|
350
352
|
expect(result.components.has("nested-svc")).toBe(false);
|
|
351
353
|
});
|
|
354
|
+
|
|
355
|
+
// ── chant #1108 — build-time parameters populated before import ────────────
|
|
356
|
+
|
|
357
|
+
describe("buildParams (chant #1108)", () => {
|
|
358
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
359
|
+
const paramsPath = resolvePath(thisDir, "../params");
|
|
360
|
+
|
|
361
|
+
test("with no buildParams option, params.* stays empty (matches every non-run/generate caller today)", async () => {
|
|
362
|
+
await writeFile(
|
|
363
|
+
join(testDir, "svc.component.ts"),
|
|
364
|
+
`
|
|
365
|
+
export const svc = {
|
|
366
|
+
name: "svc",
|
|
367
|
+
dependsOn: [],
|
|
368
|
+
deploy: [{ phase: "Apply", steps: [{ kind: "shell" }] }],
|
|
369
|
+
};
|
|
370
|
+
`,
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const result = await discoverComponents(testDir);
|
|
374
|
+
|
|
375
|
+
expect(result.components.has("svc")).toBe(true);
|
|
376
|
+
expect(params).toEqual({});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("populates params.* BEFORE importing *.component.ts, so a live import observes the resolved value", async () => {
|
|
380
|
+
await writeFile(
|
|
381
|
+
join(testDir, "svc.component.ts"),
|
|
382
|
+
`
|
|
383
|
+
import { params } from ${JSON.stringify(paramsPath)};
|
|
384
|
+
export const svc = {
|
|
385
|
+
name: "svc",
|
|
386
|
+
dependsOn: [],
|
|
387
|
+
deploy: [{ phase: "Apply", steps: [{ kind: "shell", command: String(params.tier) }] }],
|
|
388
|
+
};
|
|
389
|
+
`,
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const result = await discoverComponents(testDir, {
|
|
393
|
+
buildParams: [{ name: "tier", value: "production", source: "cli" }],
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(result.errors).toEqual([]);
|
|
397
|
+
const svc = result.components.get("svc");
|
|
398
|
+
expect((svc?.component.deploy[0].steps[0] as { command?: unknown }).command).toBe("production");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("a second call with no buildParams resets params.* — no stale leak from a prior call in the same process", async () => {
|
|
402
|
+
await writeFile(
|
|
403
|
+
join(testDir, "a.component.ts"),
|
|
404
|
+
`export const a = { name: "a", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell" }] }] };`,
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
await discoverComponents(testDir, { buildParams: [{ name: "tier", value: "production", source: "cli" }] });
|
|
408
|
+
expect(params).toEqual({ tier: "production" });
|
|
409
|
+
|
|
410
|
+
await discoverComponents(testDir);
|
|
411
|
+
expect(params).toEqual({});
|
|
412
|
+
});
|
|
413
|
+
});
|
|
352
414
|
});
|
|
@@ -46,6 +46,9 @@ import { pathToFileURL } from "node:url";
|
|
|
46
46
|
import { existsSync } from "node:fs";
|
|
47
47
|
import { DiscoveryError } from "../errors";
|
|
48
48
|
import { isComponent, type Component } from "./component";
|
|
49
|
+
import type { BuildParamProvenance } from "../provenance";
|
|
50
|
+
import { buildParamValues } from "../build-params";
|
|
51
|
+
import { setBuildParams } from "../params";
|
|
49
52
|
|
|
50
53
|
/** One discovered component, paired with the file it was exported from. */
|
|
51
54
|
export interface DiscoveredComponent {
|
|
@@ -75,6 +78,34 @@ export interface ComponentDiscoveryOptions {
|
|
|
75
78
|
* requested.
|
|
76
79
|
*/
|
|
77
80
|
sandbox?: boolean;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* chant #1108 — this invocation's resolved build-time parameter values
|
|
84
|
+
* (../build-params.ts's `resolveBuildParams`, driven by the CLI's
|
|
85
|
+
* `--param`/`--params-file`/a declared `env` mapping/`chant.config.ts`'s
|
|
86
|
+
* `buildParams` defaults — see ../cli/build-params-cli.ts's
|
|
87
|
+
* `resolveCliBuildParams`, the exact resolution `chant build` runs).
|
|
88
|
+
* Populated into ../params.ts's shared `params` object (`setBuildParams`,
|
|
89
|
+
* below) before any `*.component.ts` file is scanned/imported, mirroring
|
|
90
|
+
* ../discovery/index.ts's `discover()` — the lexicon-resource counterpart
|
|
91
|
+
* of this function, and the thing chant #1108 exists to bring this one to
|
|
92
|
+
* parity with. Before #1108, no caller populated this, so a live `import {
|
|
93
|
+
* params } from "@intentius/chant/params"` inside a component file (e.g. a
|
|
94
|
+
* naming helper deriving a stack name) always saw `{}`.
|
|
95
|
+
*
|
|
96
|
+
* Default: none — `params` stays `{}`, matching every caller that doesn't
|
|
97
|
+
* resolve build-time parameters today (`chant list/describe/graph/lint
|
|
98
|
+
* --components`, `chant components status`); only the run and generate
|
|
99
|
+
* call sites (../cli/handlers/run.ts, ../cli/handlers/build.ts) pass a
|
|
100
|
+
* resolved value.
|
|
101
|
+
*
|
|
102
|
+
* Only takes effect for the in-process (non-sandboxed) import path below —
|
|
103
|
+
* `{ sandbox: true }`'s child process gets its own, unpopulated `params`
|
|
104
|
+
* module instance, the same pre-existing gap `discover({ sandbox: true })`
|
|
105
|
+
* has for lexicon resources (chant #1045 Phase 2 never threaded
|
|
106
|
+
* `buildParams` into its sandboxed child either; out of scope here too).
|
|
107
|
+
*/
|
|
108
|
+
buildParams?: BuildParamProvenance[];
|
|
78
109
|
}
|
|
79
110
|
|
|
80
111
|
/** One already-imported `*.component.ts` module — the input to {@link collectComponents}. */
|
|
@@ -240,6 +271,17 @@ export async function discoverComponents(
|
|
|
240
271
|
path: string,
|
|
241
272
|
options?: ComponentDiscoveryOptions,
|
|
242
273
|
): Promise<ComponentDiscoveryResult> {
|
|
274
|
+
// chant #1108 — populate the shared build-time-parameters object BEFORE
|
|
275
|
+
// scanning/importing any *.component.ts file below, so a live `import {
|
|
276
|
+
// params } from "@intentius/chant/params"` inside a component file
|
|
277
|
+
// observes this invocation's resolved values instead of an empty object.
|
|
278
|
+
// Unconditional (not just when `buildParams` is set) so a stale value from
|
|
279
|
+
// a PRIOR discoverComponents() call in the same process (tests, several
|
|
280
|
+
// `--components` subcommands run back-to-back) never leaks into a call
|
|
281
|
+
// that supplied none — mirrors ../discovery/index.ts's `discover()`,
|
|
282
|
+
// identical rationale.
|
|
283
|
+
setBuildParams(buildParamValues(options?.buildParams ?? []));
|
|
284
|
+
|
|
243
285
|
const sourceFiles = await findComponentFiles(path);
|
|
244
286
|
|
|
245
287
|
if (options?.sandbox && sourceFiles.length > 0) {
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type FoldedHelperCall,
|
|
21
21
|
type SymbolicValue,
|
|
22
22
|
} from "../fold/fold";
|
|
23
|
-
import { isChantOwnedSpecifier } from "../fold/foldable-helpers";
|
|
23
|
+
import { isChantOwnedSpecifier, isFoldableHelperName } from "../fold/foldable-helpers";
|
|
24
24
|
import { briefNodeText, callExpressionMessage } from "../fold/subset";
|
|
25
25
|
import { importModule } from "./import";
|
|
26
26
|
import type { IntrinsicDef } from "../lexicon";
|
|
@@ -74,16 +74,29 @@ export type FoldedEntity = [name: string, entity: Declarable | CompositeInstance
|
|
|
74
74
|
export type FoldFileResult =
|
|
75
75
|
| {
|
|
76
76
|
ok: true;
|
|
77
|
+
/**
|
|
78
|
+
* The `Declarable`/`CompositeInstance` subset of {@link
|
|
79
|
+
* FoldFileResult.exportedValues} — how many resources this file
|
|
80
|
+
* contributed, for the `[fold:fold] x.ts — N resource(s)` decision
|
|
81
|
+
* line. chant #1112: NOT what discovery collects from. Collection
|
|
82
|
+
* reads `exportedValues`, so that `./collect.ts` stays the single
|
|
83
|
+
* owner of which exports become entities — see {@link
|
|
84
|
+
* applyResolvedValue}.
|
|
85
|
+
*/
|
|
77
86
|
entities: FoldedEntity[];
|
|
78
87
|
/**
|
|
79
88
|
* chant #1020 — EVERY exported name's fully-resolved value, not just
|
|
80
|
-
* the `Declarable`/`CompositeInstance` ones
|
|
81
|
-
* plain value folds too (a string, a number, a plain object)
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
89
|
+
* the `Declarable`/`CompositeInstance` ones also listed in `entities`:
|
|
90
|
+
* a plain value folds too (a string, a number, a plain object). A
|
|
91
|
+
* successful fold means `scanExports` recognized every export the file
|
|
92
|
+
* has (anything else disqualifies the whole file), so this IS the
|
|
93
|
+
* file's complete export namespace — the same table the run path gets
|
|
94
|
+
* from actually importing it. Two consumers: `discover()` passes it
|
|
95
|
+
* straight to `collectEntities` (chant #1112), and another file's
|
|
96
|
+
* cross-file reference resolves against it — see `buildExternals`
|
|
97
|
+
* below and the module doc on `planFoldTaint` for why a
|
|
98
|
+
* resource/composite value here MUST be the exact same object every
|
|
99
|
+
* referencing file sees.
|
|
87
100
|
*/
|
|
88
101
|
exportedValues: Map<string, unknown>;
|
|
89
102
|
/**
|
|
@@ -190,6 +203,20 @@ export interface FoldSession {
|
|
|
190
203
|
* established one.
|
|
191
204
|
*/
|
|
192
205
|
readonly lexiconPackages: ReadonlySet<string>;
|
|
206
|
+
/**
|
|
207
|
+
* chant #1093 — this build asked for the #1045 sandbox
|
|
208
|
+
* (`DiscoveryOptions.sandbox`, `chant build --sandbox`), so fold must not
|
|
209
|
+
* import or invoke a module the CLI process isn't already trusted to
|
|
210
|
+
* execute. See {@link isTrustedExecutableBinding} for what that allowlist
|
|
211
|
+
* is and {@link sandboxedExecutionRefusal} for what happens at each site
|
|
212
|
+
* that would otherwise execute one.
|
|
213
|
+
*
|
|
214
|
+
* `false` (the default, plain `--fold`) leaves every resolution path
|
|
215
|
+
* exactly as it was: fold already trusts the code enough to fall back to
|
|
216
|
+
* an in-process `importModule` when it can't fold something, so gating
|
|
217
|
+
* only the fold half would buy nothing there.
|
|
218
|
+
*/
|
|
219
|
+
readonly sandbox: boolean;
|
|
193
220
|
}
|
|
194
221
|
|
|
195
222
|
/**
|
|
@@ -210,11 +237,15 @@ export function lexiconPackageName(lexiconName: string): string {
|
|
|
210
237
|
* @param lexicons - chant #1063: the lexicon NAMES active for this build
|
|
211
238
|
* (`["aws", "k8s"]`). Converted to package specifiers via
|
|
212
239
|
* {@link lexiconPackageName}; see {@link FoldSession.lexiconPackages}.
|
|
240
|
+
* @param sandbox - chant #1093: this build asked for the #1045 sandbox, so
|
|
241
|
+
* fold may not import or invoke anything outside the trusted allowlist —
|
|
242
|
+
* see {@link FoldSession.sandbox}.
|
|
213
243
|
*/
|
|
214
244
|
export function createFoldSession(
|
|
215
245
|
intrinsics: readonly IntrinsicDef[] = [],
|
|
216
246
|
buildParams?: Readonly<Record<string, BuildParamValue>>,
|
|
217
247
|
lexicons: readonly string[] = [],
|
|
248
|
+
sandbox = false,
|
|
218
249
|
): FoldSession {
|
|
219
250
|
return {
|
|
220
251
|
intrinsics,
|
|
@@ -224,6 +255,7 @@ export function createFoldSession(
|
|
|
224
255
|
resolvePathCache: new Map(),
|
|
225
256
|
buildParams,
|
|
226
257
|
lexiconPackages: new Set(lexicons.map(lexiconPackageName)),
|
|
258
|
+
sandbox,
|
|
227
259
|
};
|
|
228
260
|
}
|
|
229
261
|
|
|
@@ -1007,6 +1039,16 @@ interface ResolveCtx {
|
|
|
1007
1039
|
* {@link resolveModulePathMemoized} with this map.
|
|
1008
1040
|
*/
|
|
1009
1041
|
resolvePathCache: Map<string, string>;
|
|
1042
|
+
/**
|
|
1043
|
+
* chant #1063 — this build's active lexicon PACKAGE specifiers (see
|
|
1044
|
+
* {@link FoldSession.lexiconPackages}). Threaded down here, not just used
|
|
1045
|
+
* in `buildExternals`, because chant #1093's trust check
|
|
1046
|
+
* ({@link isTrustedExecutableBinding}) needs the same allowlist at every
|
|
1047
|
+
* site that imports and executes a module.
|
|
1048
|
+
*/
|
|
1049
|
+
lexiconPackages: ReadonlySet<string>;
|
|
1050
|
+
/** chant #1093 — see {@link FoldSession.sandbox}. */
|
|
1051
|
+
sandbox: boolean;
|
|
1010
1052
|
}
|
|
1011
1053
|
|
|
1012
1054
|
/** `{ value }` when `node`'s shape was recognized and resolved (value may itself be `undefined`/`null` — e.g. an optional composite member that wasn't created); `undefined` when the shape isn't one the live resolver understands (a plain literal, etc.) — callers fall back to the original, unchanged handling for that shape. */
|
|
@@ -1116,6 +1158,14 @@ async function resolveCallExpression(node: ts.CallExpression, ctx: ResolveCtx):
|
|
|
1116
1158
|
throw cheapError(callExpressionMessage(node));
|
|
1117
1159
|
}
|
|
1118
1160
|
|
|
1161
|
+
// chant #1093 — THE gap this check exists for. Invoking the callee runs
|
|
1162
|
+
// project code (the factory body, and its whole module's top level) in the
|
|
1163
|
+
// CLI's own process; under --sandbox that has to happen in the child
|
|
1164
|
+
// instead, so refuse here and let the file fall back to the sandboxed run
|
|
1165
|
+
// path. See {@link sandboxedExecutionRefusal}.
|
|
1166
|
+
const refusal = sandboxedExecutionRefusal(binding, ctx, calleeName, "composite factory");
|
|
1167
|
+
if (refusal) throw cheapError(refusal);
|
|
1168
|
+
|
|
1119
1169
|
let modulePath: string;
|
|
1120
1170
|
try {
|
|
1121
1171
|
modulePath = resolveModulePathMemoized(binding.specifier, ctx.file, ctx.resolvePathCache);
|
|
@@ -1152,6 +1202,22 @@ async function resolveCallExpression(node: ts.CallExpression, ctx: ResolveCtx):
|
|
|
1152
1202
|
throw cheapError(`"${binding.imported}" from "${binding.specifier}" is not a function`);
|
|
1153
1203
|
}
|
|
1154
1204
|
|
|
1205
|
+
// chant #1112 — ONE rule for a registered authoring helper's arguments,
|
|
1206
|
+
// applied at BOTH sites that can invoke one. `reviveHelperCall` (the
|
|
1207
|
+
// nested-value site, #1082) already revives a helper's arguments with
|
|
1208
|
+
// `requireLiveRefs`, because a helper reads THROUGH its ref (`output()`
|
|
1209
|
+
// derefs the `WeakRef` parent) and a look-alike `{__attrRef}` envelope
|
|
1210
|
+
// makes it produce a wrong result rather than none. This site — a
|
|
1211
|
+
// top-level `export const oArn = output(bucket.Arn, "oArn")` — took the
|
|
1212
|
+
// composite-factory rule (`false`) instead, which is right for a factory
|
|
1213
|
+
// (its props keep the envelope, and the serializer's own walker resolves
|
|
1214
|
+
// it) and wrong for a helper. It went unnoticed while the resulting
|
|
1215
|
+
// `LexiconOutput` was being discarded anyway; with the export namespace
|
|
1216
|
+
// now collected in full, a same-file `output(...)` would reach the
|
|
1217
|
+
// serializer holding an inert envelope where the run path has a real
|
|
1218
|
+
// reference. Rejected here instead, which falls the file back to run —
|
|
1219
|
+
// absent output, never a wrong one.
|
|
1220
|
+
const helperArgs = isFoldableHelperName(calleeName);
|
|
1155
1221
|
const args: unknown[] = [];
|
|
1156
1222
|
for (const argNode of node.arguments) {
|
|
1157
1223
|
const live = await resolveLiveValue(argNode, ctx);
|
|
@@ -1162,7 +1228,7 @@ async function resolveCallExpression(node: ts.CallExpression, ctx: ResolveCtx):
|
|
|
1162
1228
|
args.push(
|
|
1163
1229
|
live !== undefined
|
|
1164
1230
|
? live.value
|
|
1165
|
-
: await reviveFoldedValue(fold(argNode, ctx.consts, ctx.intrinsics, ctx.externals), ctx,
|
|
1231
|
+
: await reviveFoldedValue(fold(argNode, ctx.consts, ctx.intrinsics, ctx.externals), ctx, helperArgs),
|
|
1166
1232
|
);
|
|
1167
1233
|
}
|
|
1168
1234
|
|
|
@@ -1170,17 +1236,23 @@ async function resolveCallExpression(node: ts.CallExpression, ctx: ResolveCtx):
|
|
|
1170
1236
|
}
|
|
1171
1237
|
|
|
1172
1238
|
/**
|
|
1173
|
-
* Record one exported name's fully-resolved value: into
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1176
|
-
*
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1182
|
-
* `
|
|
1183
|
-
*
|
|
1239
|
+
* Record one exported name's fully-resolved value: unconditionally into
|
|
1240
|
+
* `exportedValues` (chant #1020) — the file's export namespace, which is
|
|
1241
|
+
* what discovery hands to `collectEntities` and what another file's
|
|
1242
|
+
* cross-file reference resolves against — and, additionally, into `entities`
|
|
1243
|
+
* when the value is a real `Declarable`/`CompositeInstance`.
|
|
1244
|
+
*
|
|
1245
|
+
* chant #1112 — `entities` is a REPORTING subset, not a filter. It used to
|
|
1246
|
+
* be the only thing discovery passed on, which made this function a second
|
|
1247
|
+
* owner of the "which export becomes an entity" decision; it had one fewer
|
|
1248
|
+
* case than the real owner (`enumerateEntries`, ../collect.ts), so a
|
|
1249
|
+
* `LexiconOutput` export folded fine and was then thrown away, and the
|
|
1250
|
+
* template silently lost its `Outputs` section. Discovery now passes
|
|
1251
|
+
* `exportedValues` — the whole namespace, exactly like the run path's real
|
|
1252
|
+
* `exports` object — and `collectEntities` filters it, so nothing here can
|
|
1253
|
+
* fall behind what collection understands. What `entities` still answers is
|
|
1254
|
+
* "how many resources did this file contribute", for the `[fold:fold] x.ts —
|
|
1255
|
+
* N resource(s)` decision line.
|
|
1184
1256
|
*/
|
|
1185
1257
|
function applyResolvedValue(
|
|
1186
1258
|
name: string,
|
|
@@ -1234,6 +1306,14 @@ async function resolveImportedExport(name: string, ctx: ResolveCtx): Promise<unk
|
|
|
1234
1306
|
throw cheapError(`"${name}" is not a resolvable import`);
|
|
1235
1307
|
}
|
|
1236
1308
|
|
|
1309
|
+
// chant #1093 — reached for an intrinsic tag and for a symbolic chain's root
|
|
1310
|
+
// (`AWS.StackName`), both of which are resolved BY NAME out of the file's own
|
|
1311
|
+
// imports: nothing guarantees the module behind that name is a lexicon's.
|
|
1312
|
+
// `reviveHelperCall` checks chant-ownership before it gets here; this covers
|
|
1313
|
+
// the paths that don't.
|
|
1314
|
+
const refusal = sandboxedExecutionRefusal(binding, ctx, name, "import");
|
|
1315
|
+
if (refusal) throw cheapError(refusal);
|
|
1316
|
+
|
|
1237
1317
|
let modulePath: string;
|
|
1238
1318
|
try {
|
|
1239
1319
|
modulePath = resolveModulePathMemoized(binding.specifier, ctx.file, ctx.resolvePathCache);
|
|
@@ -1434,6 +1514,15 @@ async function reviveHelperCall(call: FoldedHelperCall, ctx: ResolveCtx): Promis
|
|
|
1434
1514
|
* pathological cold-resolution cost chant#1020 measured (see
|
|
1435
1515
|
* {@link fastResolveBareSpecifier}), and a bare specifier chant publishes is
|
|
1436
1516
|
* already covered by the text arm.
|
|
1517
|
+
*
|
|
1518
|
+
* Not the same question as chant#1093's {@link isTrustedExecutableBinding}
|
|
1519
|
+
* below, and deliberately not shared with it: this one asks "may this NAME be
|
|
1520
|
+
* invoked as one of chant's registered authoring helpers", and a text match
|
|
1521
|
+
* is the right answer for it — a project that shadows `@intentius/chant` in
|
|
1522
|
+
* its own `node_modules` gets its own copy of `output()` invoked either way,
|
|
1523
|
+
* fold or run, so fold cannot diverge from run by trusting the text here.
|
|
1524
|
+
* #1093's question is "may this module execute in the CLI's process at all",
|
|
1525
|
+
* where a specifier the project controls the text of proves nothing.
|
|
1437
1526
|
*/
|
|
1438
1527
|
function isChantOwnedHelperBinding(binding: ImportBinding, ctx: ResolveCtx): boolean {
|
|
1439
1528
|
if (isChantOwnedSpecifier(binding.specifier)) return true;
|
|
@@ -1448,6 +1537,89 @@ function isChantOwnedHelperBinding(binding: ImportBinding, ctx: ResolveCtx): boo
|
|
|
1448
1537
|
return targetPath === root || targetPath.startsWith(root + sep);
|
|
1449
1538
|
}
|
|
1450
1539
|
|
|
1540
|
+
/**
|
|
1541
|
+
* chant #1093 — the closed allowlist of modules fold may import AND EXECUTE
|
|
1542
|
+
* in the CLI's own process when the #1045 sandbox is active. Exactly two
|
|
1543
|
+
* arms, and neither of them trusts text the project controls:
|
|
1544
|
+
*
|
|
1545
|
+
* 1. **An ACTIVE lexicon package of this build** — matched against
|
|
1546
|
+
* {@link FoldSession.lexiconPackages}, a closed set built from the
|
|
1547
|
+
* lexicon names the BUILD resolved and `loadPlugins` already imported
|
|
1548
|
+
* (../cli/plugins.ts), not from anything the file under fold says.
|
|
1549
|
+
* 2. **chant-core's own executing tree** — the specifier is RESOLVED and the
|
|
1550
|
+
* resulting path checked against {@link chantCoreRoot}. A text match is
|
|
1551
|
+
* not enough here: `@intentius/chant` and `@intentius/chant-lexicon-evil`
|
|
1552
|
+
* are both strings an untrusted repo can write into its own source and
|
|
1553
|
+
* back with its own `node_modules` directory, and
|
|
1554
|
+
* {@link isChantOwnedSpecifier} would accept either. Resolution happens
|
|
1555
|
+
* before the decision rather than after, so an allowed binding pays
|
|
1556
|
+
* exactly the resolution it was about to pay anyway (the memoized one —
|
|
1557
|
+
* see {@link resolveModulePathMemoized}); a bare specifier that isn't
|
|
1558
|
+
* even chant-shaped is rejected on text alone, so no arbitrary bare
|
|
1559
|
+
* specifier is ever resolved here (chant#1020's cold-resolution cost).
|
|
1560
|
+
*
|
|
1561
|
+
* This is deliberately the boundary chant #1045 drew: "the boundary is around
|
|
1562
|
+
* executing PROJECT SOURCE, which is the untrusted input" — not around chant
|
|
1563
|
+
* itself or the lexicon packages, which the CLI has already imported and
|
|
1564
|
+
* executed in its own process before discovery starts, to get the serializers
|
|
1565
|
+
* and lint rules it cannot run without. Fold reaching the same already-loaded
|
|
1566
|
+
* module (the identical un-cache-busted `import()` of the identical resolved
|
|
1567
|
+
* path) adds no execution the process wasn't already performing.
|
|
1568
|
+
*
|
|
1569
|
+
* Everything else is out: a sibling project file, a project-local helper
|
|
1570
|
+
* module, an arbitrary npm dependency, a lexicon this build didn't load. A
|
|
1571
|
+
* build that supplied no lexicon list keeps only arm 2, rather than falling
|
|
1572
|
+
* back to something more permissive — the same stance
|
|
1573
|
+
* {@link FoldSession.lexiconPackages} takes for #1063.
|
|
1574
|
+
*/
|
|
1575
|
+
function isTrustedExecutableBinding(binding: ImportBinding, ctx: ResolveCtx): boolean {
|
|
1576
|
+
if (activeLexiconPackage(binding.specifier, ctx.lexiconPackages) !== undefined) return true;
|
|
1577
|
+
// Only a chant-shaped or project-relative specifier is worth resolving; any
|
|
1578
|
+
// other bare specifier is untrusted by definition, and resolving it to find
|
|
1579
|
+
// that out would cost the pathological cold `require.resolve` (chant#1020).
|
|
1580
|
+
if (!isProjectFileSpecifier(binding.specifier) && !isChantOwnedSpecifier(binding.specifier)) return false;
|
|
1581
|
+
let targetPath: string;
|
|
1582
|
+
try {
|
|
1583
|
+
targetPath = resolveModulePathMemoized(binding.specifier, ctx.file, ctx.resolvePathCache);
|
|
1584
|
+
} catch {
|
|
1585
|
+
return false;
|
|
1586
|
+
}
|
|
1587
|
+
const root = chantCoreRoot();
|
|
1588
|
+
return targetPath === root || targetPath.startsWith(root + sep);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/**
|
|
1592
|
+
* chant #1093 — the one-line fold-fallback reason for a resolution that would
|
|
1593
|
+
* import and execute an untrusted module in the CLI's own process, or
|
|
1594
|
+
* `undefined` when the import may proceed (the sandbox wasn't asked for, or
|
|
1595
|
+
* the module is on {@link isTrustedExecutableBinding}'s allowlist).
|
|
1596
|
+
*
|
|
1597
|
+
* A refusal is not a failure to fold something folder-shaped — the shape is
|
|
1598
|
+
* perfectly foldable and folds fine under plain `--fold`. It is a deliberate
|
|
1599
|
+
* demotion: the file falls back to the run path, and under `--sandbox` the
|
|
1600
|
+
* run path is the sandboxed child (`./index.ts` queues every run-fallback
|
|
1601
|
+
* file for `./sandbox/run.ts`). So the factory/constructor/intrinsic still
|
|
1602
|
+
* executes, with the same arguments, in the same module graph as the rest of
|
|
1603
|
+
* that file — just behind Node's Permission Model and a scrubbed environment
|
|
1604
|
+
* instead of inside the CLI. Coverage drops; the boundary holds.
|
|
1605
|
+
*
|
|
1606
|
+
* @param what - What the binding is being resolved AS, for the message
|
|
1607
|
+
* ("composite factory", "constructor", …).
|
|
1608
|
+
*/
|
|
1609
|
+
function sandboxedExecutionRefusal(
|
|
1610
|
+
binding: ImportBinding,
|
|
1611
|
+
ctx: ResolveCtx,
|
|
1612
|
+
name: string,
|
|
1613
|
+
what: string,
|
|
1614
|
+
): string | undefined {
|
|
1615
|
+
if (!ctx.sandbox) return undefined;
|
|
1616
|
+
if (isTrustedExecutableBinding(binding, ctx)) return undefined;
|
|
1617
|
+
return (
|
|
1618
|
+
`${what} "${name}" is imported from "${binding.specifier}", which is neither chant's own nor an active lexicon — ` +
|
|
1619
|
+
`under --sandbox it is executed in the sandboxed child, not in this process`
|
|
1620
|
+
);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1451
1623
|
/**
|
|
1452
1624
|
* chant-core's own module root — `packages/core/src` in this repo,
|
|
1453
1625
|
* `<pkg>/dist` in a published install — derived from THIS module's location.
|
|
@@ -1533,6 +1705,14 @@ async function resolveResourceEntity(
|
|
|
1533
1705
|
return { ok: false, reason: `constructor "${typeName}" for "${name}" is not a resolvable import` };
|
|
1534
1706
|
}
|
|
1535
1707
|
|
|
1708
|
+
// chant #1093 — a resource class is a lexicon export in every corpus entry
|
|
1709
|
+
// today, but nothing forces that: `new Thing(...)` where `Thing` comes from
|
|
1710
|
+
// a project file (or an arbitrary dependency) would import and run that
|
|
1711
|
+
// module here, in the CLI's process. Same refusal as the composite-factory
|
|
1712
|
+
// path above.
|
|
1713
|
+
const refusal = sandboxedExecutionRefusal(binding, ctx, typeName, "constructor");
|
|
1714
|
+
if (refusal) return { ok: false, reason: refusal };
|
|
1715
|
+
|
|
1536
1716
|
let modulePath: string;
|
|
1537
1717
|
try {
|
|
1538
1718
|
modulePath = resolveModulePathMemoized(binding.specifier, ctx.file, ctx.resolvePathCache);
|
|
@@ -1903,6 +2083,8 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
|
|
|
1903
2083
|
crossFileFailures: failures,
|
|
1904
2084
|
importCache: session.importCache,
|
|
1905
2085
|
resolvePathCache: session.resolvePathCache,
|
|
2086
|
+
lexiconPackages: session.lexiconPackages,
|
|
2087
|
+
sandbox: session.sandbox,
|
|
1906
2088
|
};
|
|
1907
2089
|
|
|
1908
2090
|
const entities: FoldedEntity[] = [];
|