@intentius/chant 0.22.0 → 0.24.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 (65) hide show
  1. package/dist/cli/build-params-cli.d.ts +55 -0
  2. package/dist/cli/build-params-cli.d.ts.map +1 -0
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/lint.d.ts.map +1 -1
  5. package/dist/cli/handlers/build.d.ts.map +1 -1
  6. package/dist/cli/handlers/run.d.ts +22 -1
  7. package/dist/cli/handlers/run.d.ts.map +1 -1
  8. package/dist/cli/lsp/server.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/plugins.d.ts +8 -0
  11. package/dist/cli/plugins.d.ts.map +1 -1
  12. package/dist/components/cli-support.d.ts +33 -2
  13. package/dist/components/cli-support.d.ts.map +1 -1
  14. package/dist/components/discover.d.ts +28 -0
  15. package/dist/components/discover.d.ts.map +1 -1
  16. package/dist/config.d.ts +18 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/discovery/fold-import.d.ts +38 -8
  19. package/dist/discovery/fold-import.d.ts.map +1 -1
  20. package/dist/discovery/index.d.ts +15 -4
  21. package/dist/discovery/index.d.ts.map +1 -1
  22. package/dist/fold/subset.d.ts +16 -2
  23. package/dist/fold/subset.d.ts.map +1 -1
  24. package/dist/lint/config.d.ts +1 -12
  25. package/dist/lint/config.d.ts.map +1 -1
  26. package/dist/lint/engine.d.ts +11 -1
  27. package/dist/lint/engine.d.ts.map +1 -1
  28. package/dist/lint/rule.d.ts +14 -0
  29. package/dist/lint/rule.d.ts.map +1 -1
  30. package/dist/project-root.d.ts +51 -0
  31. package/dist/project-root.d.ts.map +1 -0
  32. package/package.json +1 -1
  33. package/src/cli/build-params-cli.test.ts +139 -0
  34. package/src/cli/build-params-cli.ts +107 -0
  35. package/src/cli/commands/build.ts +44 -41
  36. package/src/cli/commands/lint.test.ts +74 -0
  37. package/src/cli/commands/lint.ts +33 -9
  38. package/src/cli/handlers/build.test.ts +149 -0
  39. package/src/cli/handlers/build.ts +28 -8
  40. package/src/cli/handlers/run.test.ts +191 -5
  41. package/src/cli/handlers/run.ts +61 -8
  42. package/src/cli/lsp/server.ts +7 -2
  43. package/src/cli/main.test.ts +23 -0
  44. package/src/cli/main.ts +17 -3
  45. package/src/cli/plugins.ts +10 -2
  46. package/src/components/cli-support.test.ts +221 -3
  47. package/src/components/cli-support.ts +37 -6
  48. package/src/components/discover.test.ts +63 -1
  49. package/src/components/discover.ts +42 -0
  50. package/src/config.ts +23 -0
  51. package/src/discovery/fold-import.ts +202 -20
  52. package/src/discovery/index.test.ts +131 -0
  53. package/src/discovery/index.ts +38 -8
  54. package/src/discovery/sandbox/fold-boundary.test.ts +254 -0
  55. package/src/fold/subset.test.ts +28 -14
  56. package/src/fold/subset.ts +16 -2
  57. package/src/lint/config.test.ts +9 -4
  58. package/src/lint/config.ts +7 -23
  59. package/src/lint/engine.ts +12 -0
  60. package/src/lint/policy.ts +5 -5
  61. package/src/lint/rule.ts +14 -0
  62. package/src/lint/rules/evl001-non-literal-expression.test.ts +39 -0
  63. package/src/lint/rules/evl001-non-literal-expression.ts +1 -1
  64. package/src/project-root.test.ts +105 -0
  65. package/src/project-root.ts +78 -0
@@ -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) {
package/src/config.ts CHANGED
@@ -7,6 +7,7 @@ import { DEFAULT_SBOM_FORMAT, type SbomFormat } from "./components/verbs/sbom-ge
7
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
+ import { findProjectConfig } from "./project-root";
10
11
 
11
12
  /**
12
13
  * Zod schema for ChantConfig validation.
@@ -297,6 +298,28 @@ export async function loadChantConfig(dir: string): Promise<ResolvedConfig> {
297
298
  return { config: DEFAULT_CHANT_CONFIG };
298
299
  }
299
300
 
301
+ /**
302
+ * Load project configuration by walking up from `startDir` to the project
303
+ * root (chant #1117), instead of trying only `startDir` itself.
304
+ *
305
+ * `chant build src/<stack>` (and anything else invoked with a subdirectory —
306
+ * `lint.policies`' `evaluateProjectPolicies`, `--components --generate`)
307
+ * builds scoped to that subdirectory, but `chant.config.ts` almost always
308
+ * lives at the project root, one or more levels up. Before this, callers
309
+ * either read `startDir` alone or bolted on a single `dirname()` fallback —
310
+ * fine for a one-level-deep stack, silently blind to anything deeper
311
+ * (loomster's `src/<stack>` layout is exactly one level too deep: `buildParams`'
312
+ * declared `env:` mappings never resolved, so `LOOM_TIER`/`LOOM_ENV` were inert
313
+ * under every `npm run synth:*` for two releases — loomster#162). Uses the
314
+ * same walk `chant lint`/`chant graph` already used ({@link findProjectConfig},
315
+ * shared with `./lint/config.ts`'s `findProjectRoot`) — one config-discovery
316
+ * contract for the whole CLI.
317
+ */
318
+ export async function loadChantConfigUpward(startDir: string): Promise<ResolvedConfig> {
319
+ const { dir } = findProjectConfig(startDir);
320
+ return loadChantConfig(dir);
321
+ }
322
+
300
323
  /**
301
324
  * Resolve the ownership marker to stamp from project config, or undefined when
302
325
  * ownership marking is off (no `stack`, or `enabled: false`).
@@ -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 already in `entities`: a
81
- * plain value folds too (a string, a number, a plain object), it just
82
- * contributes nothing to `entities` (see {@link applyResolvedValue}).
83
- * This is the table another file's cross-file reference resolves
84
- * againstsee `buildExternals` below and the module doc on
85
- * `planFoldTaint` for why a resource/composite value here MUST be the
86
- * exact same object every referencing file sees.
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, false),
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 `entities` when
1174
- * it's a real `Declarable`/`CompositeInstance` (`collectEntities`, and
1175
- * therefore serialization, only cares about these), and chant #1020 —
1176
- * unconditionally into `exportedValues` too, so a plain value (a string, a
1177
- * number, a plain object, `undefined`/`null`) is still available for
1178
- * ANOTHER file's cross-file reference to this export, exactly as it would
1179
- * be if this file were actually imported and its real `exports` object read
1180
- * directly. A plain value contributing nothing to `entities` isn't a
1181
- * failure: the run path's real `exports` object would contain it too, and
1182
- * `collectEntities` already silently ignores a non-Declarable/array/
1183
- * CompositeInstance export the same way (see enumerateEntries, ../collect.ts).
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[] = [];
@@ -277,6 +277,8 @@ describe("discover — fold mode (#1022, epic #1019)", () => {
277
277
  const thisDir = dirname(fileURLToPath(import.meta.url));
278
278
  const runtimePath = resolve(thisDir, "../runtime");
279
279
  const compositePath = resolve(thisDir, "../composite");
280
+ const lexiconOutputPath = resolve(thisDir, "../lexicon-output");
281
+ const stackOutputPath = resolve(thisDir, "../stack-output");
280
282
 
281
283
  beforeEach(async () => {
282
284
  testDir = join(tmpdir(), `chant-discover-fold-test-${Date.now()}-${Math.random()}`);
@@ -385,6 +387,135 @@ describe("discover — fold mode (#1022, epic #1019)", () => {
385
387
  expect(stackDecision?.resourceCount).toBe(1);
386
388
  });
387
389
 
390
+ // chant #1112 — a folded file's `output(...)` used to be resolved and then
391
+ // thrown away: the fold path handed discovery only the Declarable/
392
+ // CompositeInstance exports it had picked out itself, and a `LexiconOutput`
393
+ // is neither. `build()` never saw it, and the template lost its whole
394
+ // Outputs section with no warning and exit 0. Discovery now hands
395
+ // `collectEntities` the file's WHOLE folded export namespace, so both paths
396
+ // filter exports through the same code.
397
+ test("a folded file's output(...) export reaches the entities map, exactly as running it does", async () => {
398
+ await writeFile(
399
+ join(testDir, "resources.ts"),
400
+ `
401
+ import { createResource } from ${JSON.stringify(runtimePath)};
402
+ export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
403
+ `,
404
+ );
405
+ await writeFile(
406
+ join(testDir, "main.ts"),
407
+ `
408
+ import { Bucket } from "./resources";
409
+ export const bucket = new Bucket({ name: "my-bucket" });
410
+ `,
411
+ );
412
+ // Cross-file, which is what real projects do (a dedicated outputs.ts) and
413
+ // what makes the ref resolve to a genuine live AttrRef, so the file folds.
414
+ await writeFile(
415
+ join(testDir, "outputs.ts"),
416
+ `
417
+ import { output } from ${JSON.stringify(lexiconOutputPath)};
418
+ import { bucket } from "./main";
419
+ export const bucketArn = output(bucket.arn, "BucketArn");
420
+ `,
421
+ );
422
+
423
+ const withoutFold = await discover(testDir);
424
+ const withFold = await discover(testDir, { fold: true });
425
+
426
+ expect(withFold.errors).toEqual([]);
427
+ expect(withFold.foldDecisions.every((d) => d.mode === "fold")).toBe(true);
428
+ expect([...withFold.entities.keys()].sort()).toEqual([...withoutFold.entities.keys()].sort());
429
+ expect(withFold.entities.has("bucketArn")).toBe(true);
430
+
431
+ const folded = withFold.entities.get("bucketArn")! as unknown as { outputName: string };
432
+ const run = withoutFold.entities.get("bucketArn")! as unknown as { outputName: string };
433
+ expect(folded.outputName).toBe("BucketArn");
434
+ expect(folded.outputName).toBe(run.outputName);
435
+
436
+ // An output is not a resource — the fold decision line still counts only
437
+ // what this file contributed to Resources.
438
+ const outputsDecision = withFold.foldDecisions.find((d) => d.file.endsWith("outputs.ts"));
439
+ expect(outputsDecision?.resourceCount).toBe(0);
440
+ });
441
+
442
+ // The sibling primitive, checked in the same shape so a future change can't
443
+ // fix one and regress the other. `stackOutput()` returns a real Declarable,
444
+ // so it was never dropped — this pins that.
445
+ test("a folded file's stackOutput(...) export reaches the entities map too", async () => {
446
+ await writeFile(
447
+ join(testDir, "resources.ts"),
448
+ `
449
+ import { createResource } from ${JSON.stringify(runtimePath)};
450
+ export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
451
+ `,
452
+ );
453
+ await writeFile(
454
+ join(testDir, "main.ts"),
455
+ `
456
+ import { Bucket } from "./resources";
457
+ export const bucket = new Bucket({ name: "my-bucket" });
458
+ `,
459
+ );
460
+ await writeFile(
461
+ join(testDir, "outputs.ts"),
462
+ `
463
+ import { stackOutput } from ${JSON.stringify(stackOutputPath)};
464
+ import { bucket } from "./main";
465
+ export const bucketArn = stackOutput(bucket.arn);
466
+ `,
467
+ );
468
+
469
+ const withoutFold = await discover(testDir);
470
+ const withFold = await discover(testDir, { fold: true });
471
+
472
+ expect(withFold.errors).toEqual([]);
473
+ expect(withFold.foldDecisions.every((d) => d.mode === "fold")).toBe(true);
474
+ expect([...withFold.entities.keys()].sort()).toEqual([...withoutFold.entities.keys()].sort());
475
+ expect(withFold.entities.has("bucketArn")).toBe(true);
476
+ expect((withFold.entities.get("bucketArn")! as unknown as { kind: string }).kind).toBe("output");
477
+ });
478
+
479
+ // chant #1112 — the other half. An authoring helper reads THROUGH its ref
480
+ // (`output()` derefs the WeakRef parent), so handing it fold's symbolic
481
+ // `{__attrRef}` envelope for a SAME-FILE resource would build a
482
+ // `LexiconOutput` wrapping an inert object — output that is wrong rather
483
+ // than absent, which is worse. `reviveHelperCall` already refused this for a
484
+ // helper nested in a value; a top-level `export const x = output(...)` goes
485
+ // through the composite-factory spine instead and did not. Both now apply
486
+ // the same rule, and the file falls back to run.
487
+ test("a same-file resource reference passed to output(...) falls back to run rather than folding a wrong output", async () => {
488
+ await writeFile(
489
+ join(testDir, "resources.ts"),
490
+ `
491
+ import { createResource } from ${JSON.stringify(runtimePath)};
492
+ export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
493
+ `,
494
+ );
495
+ await writeFile(
496
+ join(testDir, "stack.ts"),
497
+ `
498
+ import { Bucket } from "./resources";
499
+ import { output } from ${JSON.stringify(lexiconOutputPath)};
500
+ export const bucket = new Bucket({ name: "my-bucket" });
501
+ export const bucketArn = output(bucket.arn, "BucketArn");
502
+ `,
503
+ );
504
+
505
+ const withoutFold = await discover(testDir);
506
+ const withFold = await discover(testDir, { fold: true });
507
+
508
+ const decision = withFold.foldDecisions.find((d) => d.file.endsWith("stack.ts"));
509
+ expect(decision?.mode).toBe("run");
510
+ expect(decision?.reason).toContain("same-file resource reference");
511
+
512
+ // Falling back is not a loss of output — the run path produces exactly
513
+ // what it always did.
514
+ expect(withFold.errors).toEqual([]);
515
+ expect([...withFold.entities.keys()].sort()).toEqual([...withoutFold.entities.keys()].sort());
516
+ expect(withFold.entities.has("bucketArn")).toBe(true);
517
+ });
518
+
388
519
  test("a composite factory defined locally (not resolvable via import) still falls back to run; output is unchanged", async () => {
389
520
  await writeFile(
390
521
  join(testDir, "stack.ts"),