@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.
- package/dist/build.d.ts +7 -0
- package/dist/build.d.ts.map +1 -1
- 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/check-lexicon-examples.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 +71 -9
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/index.d.ts +27 -4
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +39 -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/dist/serializer-walker.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +9 -0
- 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 +25 -36
- package/src/cli/commands/check-lexicon-examples.ts +16 -1
- 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.test.ts +328 -1
- package/src/discovery/fold-import.ts +414 -31
- package/src/discovery/index.test.ts +131 -0
- package/src/discovery/index.ts +53 -8
- package/src/discovery/sandbox/fold-boundary.test.ts +254 -0
- package/src/fold/fold.ts +6 -2
- package/src/fold/subset.test.ts +95 -15
- package/src/fold/subset.ts +46 -5
- 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
- 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"),
|
package/src/discovery/index.ts
CHANGED
|
@@ -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`.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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: ${
|
|
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(
|
|
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)) {
|
package/src/fold/subset.test.ts
CHANGED
|
@@ -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
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
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("
|
|
335
|
-
// chant #1044
|
|
336
|
-
// 2c), so it
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
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
|
+
});
|