@intentius/chant 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/build-params-cli.d.ts +55 -0
- package/dist/cli/build-params-cli.d.ts.map +1 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +14 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/lsp/server.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +33 -2
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts +28 -0
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +38 -8
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/index.d.ts +15 -4
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +16 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lint/engine.d.ts +11 -1
- package/dist/lint/engine.d.ts.map +1 -1
- package/dist/lint/rule.d.ts +14 -0
- package/dist/lint/rule.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/build-params-cli.test.ts +139 -0
- package/src/cli/build-params-cli.ts +107 -0
- package/src/cli/commands/build.ts +16 -36
- package/src/cli/commands/lint.test.ts +74 -0
- package/src/cli/commands/lint.ts +33 -9
- package/src/cli/handlers/build.test.ts +147 -0
- package/src/cli/handlers/build.ts +23 -8
- package/src/cli/handlers/run.test.ts +160 -5
- package/src/cli/handlers/run.ts +46 -8
- package/src/cli/lsp/server.ts +7 -2
- package/src/components/cli-support.test.ts +221 -3
- package/src/components/cli-support.ts +37 -6
- package/src/components/discover.test.ts +63 -1
- package/src/components/discover.ts +42 -0
- package/src/discovery/fold-import.ts +202 -20
- package/src/discovery/index.test.ts +131 -0
- package/src/discovery/index.ts +38 -8
- package/src/discovery/sandbox/fold-boundary.test.ts +254 -0
- package/src/fold/subset.test.ts +28 -14
- package/src/fold/subset.ts +16 -2
- package/src/lint/engine.ts +12 -0
- package/src/lint/rule.ts +14 -0
- package/src/lint/rules/evl001-non-literal-expression.test.ts +39 -0
- package/src/lint/rules/evl001-non-literal-expression.ts +1 -1
|
@@ -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
|
@@ -74,10 +74,21 @@ export interface DiscoveryOptions {
|
|
|
74
74
|
* in-process `importModule` step (every file, when {@link fold} isn't set;
|
|
75
75
|
* only the per-file run-fallback remainder, when it is) instead runs
|
|
76
76
|
* together, isolated, in one sandboxed child process — see
|
|
77
|
-
* `./sandbox/run.ts`.
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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.
|
|
81
92
|
*/
|
|
82
93
|
sandbox?: boolean;
|
|
83
94
|
|
|
@@ -169,8 +180,16 @@ export async function discover(path: string, options?: DiscoveryOptions): Promis
|
|
|
169
180
|
// a project file imported by several others is folded exactly once, so
|
|
170
181
|
// every referrer shares the identical constructed Declarable/
|
|
171
182
|
// CompositeInstance objects rather than each building its own copy.
|
|
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`.
|
|
172
191
|
const foldSession = options?.fold
|
|
173
|
-
? createFoldSession(options.intrinsics, buildParamValuesMap, options.lexicons)
|
|
192
|
+
? createFoldSession(options.intrinsics, buildParamValuesMap, options.lexicons, options.sandbox === true)
|
|
174
193
|
: undefined;
|
|
175
194
|
if (options?.fold) {
|
|
176
195
|
for (const file of files) {
|
|
@@ -197,9 +216,20 @@ export async function discover(path: string, options?: DiscoveryOptions): Promis
|
|
|
197
216
|
if (options?.fold) {
|
|
198
217
|
const folded = foldAttempts.get(file)!;
|
|
199
218
|
if (folded.ok && !taintedFiles.has(file)) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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) });
|
|
203
233
|
foldDecisions.push({ file, mode: "fold", resourceCount: folded.entities.length });
|
|
204
234
|
continue;
|
|
205
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/subset.test.ts
CHANGED
|
@@ -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,6 +350,16 @@ 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
|
});
|
package/src/fold/subset.ts
CHANGED
|
@@ -69,8 +69,22 @@ import { intrinsicCallFolds, type IntrinsicDef } from "../lexicon";
|
|
|
69
69
|
* the flow-sensitivity note below — the single divergence in the other
|
|
70
70
|
* direction, and the one this module treats as a wart). A caller with
|
|
71
71
|
* no registry degrades to "assume it runs", which is safe and cheap to
|
|
72
|
-
* reason about
|
|
73
|
-
*
|
|
72
|
+
* reason about.
|
|
73
|
+
*
|
|
74
|
+
* chant #1106 — EVL is no longer such a caller by default. `runLint`
|
|
75
|
+
* (../lint/engine.ts) takes the active lexicons' `IntrinsicDef[]` as a
|
|
76
|
+
* parameter and puts it on `LintContext.intrinsics`
|
|
77
|
+
* (../lint/rule.ts), and EVL001 (evl001-non-literal-expression.ts)
|
|
78
|
+
* passes it straight through to `checkObjectMember`. `chant lint`'s
|
|
79
|
+
* three CLI entry points (the `lint` command's initial pass, its
|
|
80
|
+
* `--fix` re-lint, and the LSP's per-file diagnostics) all resolve the
|
|
81
|
+
* project's lexicons and thread their intrinsics through, mirroring
|
|
82
|
+
* how `discover()` has done it for the fold path since #1039/#1105 —
|
|
83
|
+
* so `chant lint` on a real project no longer flags `Ref(...)` that
|
|
84
|
+
* `fold()` accepts. A `LintContext` built without that plumbing (a
|
|
85
|
+
* unit test constructing one directly, a consumer that hasn't
|
|
86
|
+
* resolved lexicons) still gets the pre-#1044 conservative answer —
|
|
87
|
+
* that path was never wrong, only stricter than it had to be.
|
|
74
88
|
* 3. Runtime *type* of a folded value — e.g. spreading `const n = 5`
|
|
75
89
|
* (`{...n}`) is shape-valid (`n` is a plain identifier) but `fold()`
|
|
76
90
|
* rejects it once it discovers `n` folds to a number, not an object.
|
package/src/lint/engine.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LintRule, LintDiagnostic, LintContext } from "./rule";
|
|
2
|
+
import type { IntrinsicDef } from "../lexicon";
|
|
2
3
|
import { parseFile } from "./parser";
|
|
3
4
|
import { readFileSync } from "fs";
|
|
4
5
|
|
|
@@ -193,12 +194,22 @@ function isDiagnosticDisabled(
|
|
|
193
194
|
* @param files - Array of file paths to lint
|
|
194
195
|
* @param rules - Array of lint rules to execute
|
|
195
196
|
* @param ruleOptions - Optional map of rule ID to options object
|
|
197
|
+
* @param intrinsics - chant #1106 — the active lexicons' registered
|
|
198
|
+
* intrinsics (e.g. AWS's `Ref`, `GetAtt`), put on every file's
|
|
199
|
+
* `LintContext.intrinsics` so a rule built on `../fold/subset.ts`'s
|
|
200
|
+
* shared predicate (EVL001) answers exactly like `fold()` does for a
|
|
201
|
+
* registered, opted-in call, instead of degrading to "every call is a
|
|
202
|
+
* violation". Mirrors how `discover()` has threaded the same
|
|
203
|
+
* `IntrinsicDef[]` into the fold path since #1039/#1105. Optional and
|
|
204
|
+
* defaulting to none, so a caller that hasn't resolved a project's
|
|
205
|
+
* lexicons (a unit test, `bench.test.ts`) is unaffected.
|
|
196
206
|
* @returns LintRunResult with diagnostics and suppressed items
|
|
197
207
|
*/
|
|
198
208
|
export async function runLint(
|
|
199
209
|
files: string[],
|
|
200
210
|
rules: LintRule[],
|
|
201
211
|
ruleOptions?: Map<string, Record<string, unknown>>,
|
|
212
|
+
intrinsics?: readonly IntrinsicDef[],
|
|
202
213
|
): Promise<LintRunResult> {
|
|
203
214
|
const allDiagnostics: LintDiagnostic[] = [];
|
|
204
215
|
const allSuppressed: Array<LintDiagnostic & { reason?: string }> = [];
|
|
@@ -219,6 +230,7 @@ export async function runLint(
|
|
|
219
230
|
entities: [],
|
|
220
231
|
filePath,
|
|
221
232
|
lexicon: undefined,
|
|
233
|
+
intrinsics,
|
|
222
234
|
};
|
|
223
235
|
|
|
224
236
|
// Execute each rule
|
package/src/lint/rule.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type * as ts from "typescript";
|
|
2
|
+
import type { IntrinsicDef } from "../lexicon";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Severity level for lint diagnostics
|
|
@@ -60,6 +61,19 @@ export interface LintContext {
|
|
|
60
61
|
filePath: string;
|
|
61
62
|
/** Optional lexicon context (undefined for core rules) */
|
|
62
63
|
lexicon?: string;
|
|
64
|
+
/**
|
|
65
|
+
* chant #1106 — the active lexicons' registered intrinsics (`Ref`,
|
|
66
|
+
* `GetAtt`, ...), threaded down from `runLint` (../lint/engine.ts) so a
|
|
67
|
+
* rule built on the shared `../fold/subset.ts` predicate
|
|
68
|
+
* (`findSubsetViolation`/`checkObjectMember`, used by EVL001) gets the
|
|
69
|
+
* SAME answer `fold()` does for a registered, opted-in call. Mirrors how
|
|
70
|
+
* `discover()` has threaded `IntrinsicDef[]` into the fold path since
|
|
71
|
+
* #1039/#1105. Undefined when the caller hasn't resolved a project's
|
|
72
|
+
* lexicons (a bare unit test constructing a `LintContext` directly, for
|
|
73
|
+
* instance) — subset.ts then falls back to its pre-#1044 answer: every
|
|
74
|
+
* call is a violation.
|
|
75
|
+
*/
|
|
76
|
+
intrinsics?: readonly IntrinsicDef[];
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
/**
|