@intentius/chant 0.21.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.
Files changed (57) hide show
  1. package/dist/build.d.ts +7 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/build-params-cli.d.ts +55 -0
  4. package/dist/cli/build-params-cli.d.ts.map +1 -0
  5. package/dist/cli/commands/build.d.ts.map +1 -1
  6. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  7. package/dist/cli/commands/lint.d.ts.map +1 -1
  8. package/dist/cli/handlers/build.d.ts.map +1 -1
  9. package/dist/cli/handlers/run.d.ts +14 -1
  10. package/dist/cli/handlers/run.d.ts.map +1 -1
  11. package/dist/cli/lsp/server.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/discovery/fold-import.d.ts +71 -9
  17. package/dist/discovery/fold-import.d.ts.map +1 -1
  18. package/dist/discovery/index.d.ts +27 -4
  19. package/dist/discovery/index.d.ts.map +1 -1
  20. package/dist/fold/fold.d.ts.map +1 -1
  21. package/dist/fold/subset.d.ts +39 -2
  22. package/dist/fold/subset.d.ts.map +1 -1
  23. package/dist/lint/engine.d.ts +11 -1
  24. package/dist/lint/engine.d.ts.map +1 -1
  25. package/dist/lint/rule.d.ts +14 -0
  26. package/dist/lint/rule.d.ts.map +1 -1
  27. package/dist/serializer-walker.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/build.ts +9 -0
  30. package/src/cli/build-params-cli.test.ts +139 -0
  31. package/src/cli/build-params-cli.ts +107 -0
  32. package/src/cli/commands/build.ts +25 -36
  33. package/src/cli/commands/check-lexicon-examples.ts +16 -1
  34. package/src/cli/commands/lint.test.ts +74 -0
  35. package/src/cli/commands/lint.ts +33 -9
  36. package/src/cli/handlers/build.test.ts +147 -0
  37. package/src/cli/handlers/build.ts +23 -8
  38. package/src/cli/handlers/run.test.ts +160 -5
  39. package/src/cli/handlers/run.ts +46 -8
  40. package/src/cli/lsp/server.ts +7 -2
  41. package/src/components/cli-support.test.ts +221 -3
  42. package/src/components/cli-support.ts +37 -6
  43. package/src/components/discover.test.ts +63 -1
  44. package/src/components/discover.ts +42 -0
  45. package/src/discovery/fold-import.test.ts +328 -1
  46. package/src/discovery/fold-import.ts +414 -31
  47. package/src/discovery/index.test.ts +131 -0
  48. package/src/discovery/index.ts +53 -8
  49. package/src/discovery/sandbox/fold-boundary.test.ts +254 -0
  50. package/src/fold/fold.ts +6 -2
  51. package/src/fold/subset.test.ts +95 -15
  52. package/src/fold/subset.ts +46 -5
  53. package/src/lint/engine.ts +12 -0
  54. package/src/lint/rule.ts +14 -0
  55. package/src/lint/rules/evl001-non-literal-expression.test.ts +39 -0
  56. package/src/lint/rules/evl001-non-literal-expression.ts +1 -1
  57. package/src/serializer-walker.ts +14 -0
@@ -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"),
@@ -56,15 +56,39 @@ export interface DiscoveryOptions {
56
56
  */
57
57
  intrinsics?: IntrinsicDef[];
58
58
 
59
+ /**
60
+ * chant #1063 — the lexicon NAMES loaded for this build (`["aws", "k8s"]`),
61
+ * i.e. what `resolveProjectLexicons()` returned and `loadPlugins()` then
62
+ * imported. Threaded into the fold session as the ALLOWLIST of packages a
63
+ * bare import specifier may be resolved into, so a lexicon's plain data
64
+ * exports (`Azure`/`GCP`'s pseudo-parameter namespaces, AWS's `S3Actions`,
65
+ * gitlab's `CI`) fold as identifier values instead of failing the file.
66
+ * Only meaningful when {@link fold} is set. Default: none — a caller that
67
+ * doesn't say which lexicons are active gets no bare-specifier resolution
68
+ * at all, rather than a looser fallback.
69
+ */
70
+ lexicons?: readonly string[];
71
+
59
72
  /**
60
73
  * chant #1045 Phase 2 — opt-in: whatever would otherwise reach the
61
74
  * in-process `importModule` step (every file, when {@link fold} isn't set;
62
75
  * only the per-file run-fallback remainder, when it is) instead runs
63
76
  * together, isolated, in one sandboxed child process — see
64
- * `./sandbox/run.ts`. Folded files are unaffected: fold already executes
65
- * zero of a file's own top-level code, so it stays in this process exactly
66
- * as it does today. Default `false` behavior, including performance
67
- * (no bundling, no child process, no IPC), is unchanged unless requested.
77
+ * `./sandbox/run.ts`.
78
+ *
79
+ * chant #1093 this ALSO tightens what fold itself may do in this process.
80
+ * Fold executes none of a file's own top-level code, but it does import and
81
+ * invoke the module behind a composite factory / resource constructor /
82
+ * intrinsic tag; when that module is project-owned, a file reported as
83
+ * "folded" still ran project code here. With `sandbox` set, fold refuses
84
+ * any import outside chant's own packages and this build's active lexicons,
85
+ * and the file demotes to the (sandboxed) run path instead — so the
86
+ * security property is uniform: under `sandbox`, project source executes
87
+ * only inside the child, folded or not. Fold COVERAGE is therefore lower
88
+ * under `sandbox` than under plain `fold` — deliberately.
89
+ *
90
+ * Default `false` — behavior, including performance (no bundling, no child
91
+ * process, no IPC) and fold coverage, is unchanged unless requested.
68
92
  */
69
93
  sandbox?: boolean;
70
94
 
@@ -156,7 +180,17 @@ export async function discover(path: string, options?: DiscoveryOptions): Promis
156
180
  // a project file imported by several others is folded exactly once, so
157
181
  // every referrer shares the identical constructed Declarable/
158
182
  // CompositeInstance objects rather than each building its own copy.
159
- const foldSession = options?.fold ? createFoldSession(options.intrinsics, buildParamValuesMap) : undefined;
183
+ // chant #1093 — `sandbox` is threaded into the fold session, not just used
184
+ // for the run-fallback set below: fold itself resolves a composite factory /
185
+ // resource constructor / intrinsic tag by IMPORTING the module that defines
186
+ // it and invoking it, which for a project-owned one means executing project
187
+ // code in this process even though the file is reported as "folded". Under
188
+ // `sandbox` the session refuses those imports and the file demotes to the
189
+ // run path — which, under `sandbox`, is the isolated child. See
190
+ // fold-import.ts's `sandboxedExecutionRefusal`.
191
+ const foldSession = options?.fold
192
+ ? createFoldSession(options.intrinsics, buildParamValuesMap, options.lexicons, options.sandbox === true)
193
+ : undefined;
160
194
  if (options?.fold) {
161
195
  for (const file of files) {
162
196
  foldAttempts.set(file, await tryFoldFile(file, options.intrinsics, foldSession));
@@ -182,9 +216,20 @@ export async function discover(path: string, options?: DiscoveryOptions): Promis
182
216
  if (options?.fold) {
183
217
  const folded = foldAttempts.get(file)!;
184
218
  if (folded.ok && !taintedFiles.has(file)) {
185
- const exportsObj: Record<string, unknown> = {};
186
- for (const [name, value] of folded.entities) exportsObj[name] = value;
187
- modules.push({ file, exports: exportsObj });
219
+ // chant #1112 hand `collectEntities` the file's WHOLE folded export
220
+ // namespace, exactly as the run path hands it the real module
221
+ // namespace object `importModule` returns. It used to get only the
222
+ // `Declarable`/`CompositeInstance` subset fold had already picked
223
+ // out, which quietly made fold-import a SECOND owner of the "which
224
+ // export becomes an entity" decision — and it had one fewer case than
225
+ // the real owner (`./collect.ts`'s `enumerateEntries`): a
226
+ // `LexiconOutput` (`export const oArn = output(bucket.Arn, "oArn")`)
227
+ // was resolved, dropped, and the template lost its whole `Outputs`
228
+ // section with no warning and exit 0. Fold now decides nothing here;
229
+ // `collectEntities` filters both paths' exports the same way, so a
230
+ // shape it learns about (today: outputs and arrays of declarables)
231
+ // cannot reach one path and not the other.
232
+ modules.push({ file, exports: Object.fromEntries(folded.exportedValues) });
188
233
  foldDecisions.push({ file, mode: "fold", resourceCount: folded.entities.length });
189
234
  continue;
190
235
  }
@@ -0,0 +1,254 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdir, writeFile, rm, realpath } from "node:fs/promises";
3
+ import { join, dirname, resolve } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { fileURLToPath } from "node:url";
6
+ import { discover } from "../index";
7
+
8
+ /**
9
+ * chant #1093 — the security property `--sandbox` is supposed to buy, tested
10
+ * where it was actually broken.
11
+ *
12
+ * chant #1045 isolates the run-fallback set, and `../sandbox/run.test.ts`
13
+ * proves THAT boundary holds (no reads outside the project, no writes, no
14
+ * spawning, no ambient env). It says nothing about the FOLD half, which is
15
+ * where the hole was: fold executes none of a file's own top-level code, but
16
+ * it resolves a composite factory / resource constructor / intrinsic tag by
17
+ * importing the module that defines it and invoking it. When that module is a
18
+ * sibling project file, a file reported as `mode: "fold"` had nonetheless run
19
+ * project code — its module top level AND the factory body — inside the CLI's
20
+ * own process, with the CLI's filesystem, network, environment and
21
+ * process-spawning access.
22
+ *
23
+ * These tests observe execution DIRECTLY rather than inferring it: the fixture
24
+ * sets a `globalThis` marker at module top level and another inside the
25
+ * factory body. A marker set inside the sandboxed child cannot reach this
26
+ * process — it is a different process — so "marker present" is precisely
27
+ * "this ran in the CLI process". The plain-`--fold` case is asserted first in
28
+ * every pair, so the probe is proven to be capable of firing before the
29
+ * `--sandbox` case asserts that it doesn't.
30
+ *
31
+ * Fixtures are written to a fresh tmpdir per test (never into the source
32
+ * tree), which also keeps each test's module paths unique — no module-cache
33
+ * bleed between the two halves of a pair.
34
+ */
35
+
36
+ const thisDir = dirname(fileURLToPath(import.meta.url));
37
+ /** Absolute paths to chant-core's real modules, imported by the fixtures the way a lexicon package would be. */
38
+ const runtimePath = resolve(thisDir, "../../runtime");
39
+ const compositePath = resolve(thisDir, "../../composite");
40
+
41
+ const MODULE_MARKER = "__chant1093ModuleEvaluated";
42
+ const FACTORY_MARKER = "__chant1093FactoryInvoked";
43
+
44
+ type MarkerHost = Record<string, boolean | undefined>;
45
+
46
+ function marker(name: string): boolean | undefined {
47
+ return (globalThis as unknown as MarkerHost)[name];
48
+ }
49
+
50
+ function clearMarkers(): void {
51
+ delete (globalThis as unknown as MarkerHost)[MODULE_MARKER];
52
+ delete (globalThis as unknown as MarkerHost)[FACTORY_MARKER];
53
+ }
54
+
55
+ describe("fold under --sandbox never executes project code in the CLI process (chant #1093)", () => {
56
+ let testDir: string;
57
+ let seq = 0;
58
+
59
+ beforeEach(async () => {
60
+ const dir = join(tmpdir(), `chant-1093-fold-boundary-${Date.now()}-${Math.random()}`);
61
+ await mkdir(dir, { recursive: true });
62
+ testDir = await realpath(dir);
63
+ clearMarkers();
64
+ });
65
+
66
+ afterEach(async () => {
67
+ clearMarkers();
68
+ await rm(testDir, { recursive: true, force: true });
69
+ });
70
+
71
+ /**
72
+ * A project-owned composite factory: the shape chant#1022/#1023 folds by
73
+ * importing `./composites` and calling `WebApp(...)` for real.
74
+ */
75
+ async function writeCompositeFixture(): Promise<void> {
76
+ await writeFile(
77
+ join(testDir, "composites.ts"),
78
+ `
79
+ import { Composite } from ${JSON.stringify(compositePath)};
80
+ import { createResource } from ${JSON.stringify(runtimePath)};
81
+
82
+ globalThis[${JSON.stringify(MODULE_MARKER)}] = true;
83
+
84
+ const Bucket = createResource("Test::Bucket", "test", { arn: "Arn" });
85
+ const Role = createResource("Test::Role", "test", {});
86
+
87
+ export const WebApp = Composite((props) => {
88
+ globalThis[${JSON.stringify(FACTORY_MARKER)}] = true;
89
+ const bucket = new Bucket({ bucketName: props.name });
90
+ const role = new Role({ resource: bucket.arn });
91
+ return { bucket, role };
92
+ }, "WebApp");
93
+ `,
94
+ );
95
+ await writeFile(
96
+ join(testDir, "main.ts"),
97
+ `
98
+ import { WebApp } from "./composites";
99
+ export const web = WebApp({ name: "data" });
100
+ `,
101
+ );
102
+ }
103
+
104
+ test("plain --fold DOES invoke a project-owned composite factory in-process (the probe fires)", async () => {
105
+ await writeCompositeFixture();
106
+
107
+ const result = await discover(testDir, { fold: true });
108
+
109
+ // The file folds today — and folding it ran project code right here.
110
+ const main = result.foldDecisions.find((d) => d.file.endsWith("main.ts"));
111
+ expect(main?.mode).toBe("fold");
112
+ expect(marker(MODULE_MARKER), "composites.ts's module top level ran in this process").toBe(true);
113
+ expect(marker(FACTORY_MARKER), "the factory body ran in this process").toBe(true);
114
+ expect([...result.entities.keys()].sort()).toEqual(["webBucket", "webRole"]);
115
+ });
116
+
117
+ test("--sandbox invokes it in the child instead: no marker here, same entities", async () => {
118
+ await writeCompositeFixture();
119
+
120
+ const result = await discover(testDir, { fold: true, sandbox: true });
121
+
122
+ expect(result.errors).toEqual([]);
123
+ // Same entities, produced by the same factory with the same arguments —
124
+ // just on the other side of the boundary.
125
+ expect([...result.entities.keys()].sort()).toEqual(["webBucket", "webRole"]);
126
+ expect(marker(MODULE_MARKER), "project module top level must NOT run in the CLI process").toBeUndefined();
127
+ expect(marker(FACTORY_MARKER), "the factory body must NOT run in the CLI process").toBeUndefined();
128
+
129
+ // The demotion is reported, with a reason that names the cause.
130
+ const main = result.foldDecisions.find((d) => d.file.endsWith("main.ts"));
131
+ expect(main?.mode).toBe("run");
132
+ expect(main?.reason).toContain("--sandbox");
133
+ expect(main?.reason).toContain("./composites");
134
+ });
135
+
136
+ test("the cross-file AttrRef still resolves through the boundary", async () => {
137
+ await writeCompositeFixture();
138
+
139
+ const result = await discover(testDir, { fold: true, sandbox: true });
140
+
141
+ // `role.props.resource` is `bucket.arn`, an AttrRef whose logical name is
142
+ // assigned by naming INSIDE the child (chant#1045's design) — the same
143
+ // value the in-process fold produces, reached without executing anything
144
+ // here.
145
+ const role = result.entities.get("webRole") as unknown as { props: { resource: unknown } };
146
+ const ref = role.props.resource as { getLogicalName?: () => string | undefined; attribute?: string };
147
+ expect(ref.getLogicalName?.()).toBe("webBucket");
148
+ expect(ref.attribute).toBe("Arn");
149
+ });
150
+
151
+ /**
152
+ * The same hole, reached through `new Type(...)` rather than a factory call:
153
+ * a resource class is a lexicon export in every corpus entry today, but
154
+ * nothing in the language or the folder requires that.
155
+ */
156
+ async function writeConstructorFixture(): Promise<void> {
157
+ await writeFile(
158
+ join(testDir, "resources.ts"),
159
+ `
160
+ import { createResource } from ${JSON.stringify(runtimePath)};
161
+ globalThis[${JSON.stringify(MODULE_MARKER)}] = true;
162
+ export const Bucket = createResource("Test::Bucket", "test", { arn: "Arn" });
163
+ `,
164
+ );
165
+ await writeFile(
166
+ join(testDir, "main.ts"),
167
+ `
168
+ import { Bucket } from "./resources";
169
+ export const dataBucket = new Bucket({ bucketName: "data" });
170
+ `,
171
+ );
172
+ }
173
+
174
+ test("plain --fold DOES evaluate a project-owned constructor module in-process", async () => {
175
+ await writeConstructorFixture();
176
+
177
+ const result = await discover(testDir, { fold: true });
178
+
179
+ expect(result.foldDecisions.find((d) => d.file.endsWith("main.ts"))?.mode).toBe("fold");
180
+ expect(marker(MODULE_MARKER)).toBe(true);
181
+ expect([...result.entities.keys()]).toEqual(["dataBucket"]);
182
+ });
183
+
184
+ test("--sandbox demotes a project-owned constructor too", async () => {
185
+ await writeConstructorFixture();
186
+
187
+ const result = await discover(testDir, { fold: true, sandbox: true });
188
+
189
+ expect(result.errors).toEqual([]);
190
+ expect([...result.entities.keys()]).toEqual(["dataBucket"]);
191
+ expect(marker(MODULE_MARKER)).toBeUndefined();
192
+ const main = result.foldDecisions.find((d) => d.file.endsWith("main.ts"));
193
+ expect(main?.mode).toBe("run");
194
+ expect(main?.reason).toContain("--sandbox");
195
+ });
196
+
197
+ /**
198
+ * The allowlist is a boundary, not a blanket ban: a factory owned by one of
199
+ * THIS build's active lexicon packages still folds in-process under
200
+ * `--sandbox`. That is deliberate and is exactly chant#1045's stated scope
201
+ * ("sandboxing chant itself, or the lexicon packages" is a non-goal) — the
202
+ * CLI has already imported and executed every active lexicon package
203
+ * (`loadPlugins`) before discovery starts.
204
+ *
205
+ * Same fixture, same specifier, in both halves below — the ONLY difference
206
+ * is whether the build declared that lexicon as active.
207
+ */
208
+ async function installLexiconPackage(): Promise<{ lexicon: string; specifier: string }> {
209
+ const lexicon = `fold1093x${seq++}${Date.now().toString(36)}`;
210
+ const specifier = `@intentius/chant-lexicon-${lexicon}`;
211
+ const dir = join(testDir, "node_modules", specifier);
212
+ await mkdir(dir, { recursive: true });
213
+ await writeFile(
214
+ join(dir, "package.json"),
215
+ JSON.stringify({ name: specifier, version: "0.0.0", type: "module", exports: { ".": "./index.js" } }),
216
+ );
217
+ await writeFile(
218
+ join(dir, "index.js"),
219
+ `
220
+ const DECLARABLE_MARKER = Symbol.for("chant.declarable");
221
+ export function Widget(props) {
222
+ return { [DECLARABLE_MARKER]: true, lexicon: "test", entityType: "Test::Widget", kind: "resource", props };
223
+ }
224
+ `,
225
+ );
226
+ await writeFile(
227
+ join(testDir, "main.ts"),
228
+ `
229
+ import { Widget } from ${JSON.stringify(specifier)};
230
+ export const widget = Widget({ size: "large" });
231
+ `,
232
+ );
233
+ return { lexicon, specifier };
234
+ }
235
+
236
+ test("a factory from an ACTIVE lexicon package still folds under --sandbox", async () => {
237
+ const { lexicon } = await installLexiconPackage();
238
+
239
+ const result = await discover(testDir, { fold: true, sandbox: true, lexicons: [lexicon] });
240
+
241
+ expect(result.foldDecisions.find((d) => d.file.endsWith("main.ts"))?.mode).toBe("fold");
242
+ expect([...result.entities.keys()]).toEqual(["widget"]);
243
+ });
244
+
245
+ test("the same factory from a lexicon this build did NOT load is refused", async () => {
246
+ await installLexiconPackage();
247
+
248
+ const result = await discover(testDir, { fold: true, sandbox: true, lexicons: ["aws"] });
249
+
250
+ const main = result.foldDecisions.find((d) => d.file.endsWith("main.ts"));
251
+ expect(main?.mode).toBe("run");
252
+ expect(main?.reason).toContain("--sandbox");
253
+ });
254
+ });
package/src/fold/fold.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  SUPPORTED_UNARY_OPERATORS,
6
6
  UNSUPPORTED_OBJECT_MEMBER_MESSAGE,
7
7
  UNSUPPORTED_UNARY_MESSAGE,
8
+ briefNodeText,
8
9
  callExpressionMessage,
9
10
  computedPropertyNameMessage,
10
11
  dynamicElementAccessMessage,
@@ -396,7 +397,7 @@ function foldTaggedTemplate(
396
397
  const tagName = node.tag.getText();
397
398
  const isRegistered = intrinsics.some((i) => i.name === tagName && intrinsicTagFolds(i));
398
399
  if (!isRegistered) {
399
- throw foldError(node, `unregistered tagged template intrinsic: ${tagName}\`...\``);
400
+ throw foldError(node, `unregistered tagged template intrinsic: ${briefNodeText(node.tag)}\`...\``);
400
401
  }
401
402
 
402
403
  const template = node.template;
@@ -635,7 +636,10 @@ export function fold(
635
636
  // Reject so the file falls back to run, which constructs and serializes it
636
637
  // correctly. EVL permits this statically — it's a documented fold/EVL
637
638
  // divergence, like identifier resolution and spread runtime type.
638
- throw foldError(node, `nested \`new ${node.expression.getText()}(...)\` as a value is not foldable — falls back to run`);
639
+ throw foldError(
640
+ node,
641
+ `nested \`new ${briefNodeText(node.expression)}(...)\` as a value is not foldable — falls back to run`,
642
+ );
639
643
  }
640
644
 
641
645
  if (ts.isCallExpression(node)) {
@@ -1,7 +1,7 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import * as ts from "typescript";
3
3
  import { fold, foldResource, collectConsts, FoldError } from "./fold";
4
- import { findSubsetViolation } from "./subset";
4
+ import { briefNodeText, callExpressionMessage, findSubsetViolation } from "./subset";
5
5
  import { evl001NonLiteralExpressionRule } from "../lint/rules/evl001-non-literal-expression";
6
6
  import { evl003DynamicPropertyAccessRule } from "../lint/rules/evl003-dynamic-property-access";
7
7
  import { evl004SpreadNonConstRule } from "../lint/rules/evl004-spread-non-const";
@@ -280,12 +280,15 @@ describe("documented divergences — NOT unified by design (see subset.ts module
280
280
  /**
281
281
  * chant #1044 — the shared predicate's optional intrinsic registry.
282
282
  *
283
- * `findSubsetViolation` answers "is this shape foldable?" for two kinds of
284
- * caller: `fold()`'s own EVL twin, which has no registry, and a tool that
285
- * does (a control plane deciding whether a repository needs a sandboxed
286
- * child process). The parameter is what lets the second kind get fold()'s
287
- * real answer without running fold, while the first keeps the answer it
288
- * always had.
283
+ * `findSubsetViolation` answers "is this shape foldable?" for a caller that
284
+ * has a registry and one that doesn't: with it, the answer for a call is
285
+ * exact (fold()'s own); without it, every call is a violation, the
286
+ * pre-#1044 answer. EVL001 (`chant lint`) is the first kind as of #1106 —
287
+ * `runLint` threads the active lexicons' intrinsics onto
288
+ * `LintContext.intrinsics`, which EVL001 forwards here — and the second
289
+ * kind whenever a caller hasn't resolved a project's lexicons (a bare unit
290
+ * test, a tool asking "would this fold?" with no lexicon context of its
291
+ * own).
289
292
  */
290
293
  describe("findSubsetViolation — optional intrinsic registry (#1044)", () => {
291
294
  const REF: IntrinsicDef[] = [{ name: "Ref", isTag: false, foldsAsCall: true }];
@@ -331,14 +334,15 @@ describe("findSubsetViolation — optional intrinsic registry (#1044)", () => {
331
334
  expect(v?.message).toContain("getName(...)");
332
335
  });
333
336
 
334
- test("registry-less EVL is now STRICTER than fold on an opted-in call a known divergence, not a hole", () => {
335
- // chant #1044 EVL001 has no registry (see subset.ts module doc, point
336
- // 2c), so it still flags `Ref(...)` in a resource's props exactly as it
337
- // did before this change, while fold() — which is always given one —
338
- // folds it. Recorded here so the divergence is a tracked property with a
339
- // test rather than a surprise; closing it means handing the lint engine
340
- // the active lexicons' intrinsics, which is a change to lint's own
341
- // surface and deliberately not part of #1044.
337
+ test("EVL converges with fold on an opted-in call once it carries the registry (chant #1106)", () => {
338
+ // chant #1044 left EVL001 with no registry (see subset.ts module doc,
339
+ // point 2c), so it flagged `Ref(...)` in a resource's props even though
340
+ // fold() — which is always given one — folded it cleanly. #1106 closes
341
+ // that by threading `runLint`'s intrinsics parameter onto
342
+ // `LintContext.intrinsics`, which EVL001 passes straight through to this
343
+ // same `findSubsetViolation`/`checkObjectMember` predicate. A
344
+ // `LintContext` built WITH the registry (what `chant lint` now
345
+ // constructs for a real project) no longer flags what fold() accepts.
342
346
  const source = `const bad = new Thing({ x: Ref(env) });`;
343
347
  const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
344
348
  const consts = collectConsts(sourceFile);
@@ -346,7 +350,83 @@ describe("findSubsetViolation — optional intrinsic registry (#1044)", () => {
346
350
 
347
351
  expect(() => foldResource(badInit, consts, REF)).not.toThrow();
348
352
 
353
+ const context: LintContext = { sourceFile, entities: [], filePath: "t.ts", lexicon: undefined, intrinsics: REF };
354
+ expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
355
+ });
356
+
357
+ test("without the registry, EVL001 keeps the pre-#1044 conservative answer", () => {
358
+ // A `LintContext` built without `intrinsics` (a caller that hasn't
359
+ // resolved a project's lexicons) still flags the call — the safe
360
+ // default subset.ts's module doc describes, unchanged by #1106.
361
+ const source = `const bad = new Thing({ x: Ref(env) });`;
362
+ const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
349
363
  const context: LintContext = { sourceFile, entities: [], filePath: "t.ts", lexicon: undefined };
350
364
  expect(evl001NonLiteralExpressionRule.check(context).length).toBeGreaterThan(0);
351
365
  });
352
366
  });
367
+
368
+ /**
369
+ * chant #1054 — `briefNodeText` is what keeps every fold fallback reason
370
+ * that used to embed a node's raw `getText()` down to one bounded line. A
371
+ * real composite call's source is many lines; a fold reason that reproduces
372
+ * it verbatim buries the actual error after all of it (the bug this issue
373
+ * reports) and breaks any line-oriented consumer of `[fold:run]` output.
374
+ */
375
+ describe("briefNodeText — single-line, bounded diagnostic text (chant #1054)", () => {
376
+ function initializerOf(source: string): ts.Expression {
377
+ const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
378
+ const consts = collectConsts(sourceFile);
379
+ const init = consts.get("x");
380
+ if (!init) throw new Error(`fixture error: "x" did not parse in ${JSON.stringify(source)}`);
381
+ return init;
382
+ }
383
+
384
+ test("a short, single-line node passes through unchanged", () => {
385
+ expect(briefNodeText(initializerOf(`const x = GkeCluster;`))).toBe("GkeCluster");
386
+ });
387
+
388
+ test("a multi-line node's newlines collapse to spaces — the result is always one line", () => {
389
+ const text = briefNodeText(
390
+ initializerOf(`
391
+ const x = GkeCluster({
392
+ name: config.clusterName,
393
+ location: config.region,
394
+ });
395
+ `),
396
+ );
397
+ expect(text).not.toContain("\n");
398
+ expect(text.split("\n")).toHaveLength(1);
399
+ });
400
+
401
+ test("text over the length cap is truncated with a trailing marker rather than left unbounded", () => {
402
+ const text = briefNodeText(
403
+ initializerOf(`const x = { aVeryLongPropertyNameNumberOne: 1, aVeryLongPropertyNameNumberTwo: 2 };`),
404
+ 20,
405
+ );
406
+ expect(text.length).toBe(20);
407
+ expect(text.endsWith("...")).toBe(true);
408
+ });
409
+ });
410
+
411
+ describe("callExpressionMessage — one line regardless of the call's own argument list (chant #1054)", () => {
412
+ test("only the callee is embedded — a multi-line argument list never leaks into the message", () => {
413
+ const sourceFile = ts.createSourceFile(
414
+ "t.ts",
415
+ `
416
+ const x = GkeCluster({
417
+ name: config.clusterName,
418
+ location: config.region,
419
+ machineType: "n2-standard-2",
420
+ });
421
+ `,
422
+ ts.ScriptTarget.Latest,
423
+ true,
424
+ );
425
+ const consts = collectConsts(sourceFile);
426
+ const call = consts.get("x") as ts.CallExpression;
427
+
428
+ const message = callExpressionMessage(call);
429
+
430
+ expect(message).toBe("function call as a value is not foldable: GkeCluster(...)");
431
+ });
432
+ });