@intentius/chant 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +22 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/lsp/server.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +8 -0
- package/dist/cli/plugins.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/config.d.ts +18 -0
- package/dist/config.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/config.d.ts +1 -12
- package/dist/lint/config.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/project-root.d.ts +51 -0
- package/dist/project-root.d.ts.map +1 -0
- 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 +44 -41
- package/src/cli/commands/lint.test.ts +74 -0
- package/src/cli/commands/lint.ts +33 -9
- package/src/cli/handlers/build.test.ts +149 -0
- package/src/cli/handlers/build.ts +28 -8
- package/src/cli/handlers/run.test.ts +191 -5
- package/src/cli/handlers/run.ts +61 -8
- package/src/cli/lsp/server.ts +7 -2
- package/src/cli/main.test.ts +23 -0
- package/src/cli/main.ts +17 -3
- package/src/cli/plugins.ts +10 -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/config.ts +23 -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/config.test.ts +9 -4
- package/src/lint/config.ts +7 -23
- package/src/lint/engine.ts +12 -0
- package/src/lint/policy.ts +5 -5
- 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/project-root.test.ts +105 -0
- package/src/project-root.ts +78 -0
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/config.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { loadConfig, DEFAULT_CONFIG, findProjectRoot } from "./config";
|
|
3
3
|
import { writeFileSync, mkdirSync, rmSync } from "fs";
|
|
4
|
-
import { join } from "path";
|
|
4
|
+
import { join, resolve } from "path";
|
|
5
5
|
|
|
6
6
|
const TEST_DIR = join(import.meta.dirname, "__test_config__");
|
|
7
7
|
|
|
@@ -705,10 +705,15 @@ describe("findProjectRoot", () => {
|
|
|
705
705
|
expect(findProjectRoot(sub)).toBe(TEST_DIR);
|
|
706
706
|
});
|
|
707
707
|
|
|
708
|
-
test("
|
|
708
|
+
test("stops at the nearest .git/package.json boundary when no config is found (#1117)", () => {
|
|
709
709
|
const sub = join(TEST_DIR, "nowhere");
|
|
710
710
|
mkdirSync(sub, { recursive: true });
|
|
711
|
-
// No chant.config anywhere under TEST_DIR —
|
|
712
|
-
|
|
711
|
+
// No chant.config anywhere under TEST_DIR — walking up from this real
|
|
712
|
+
// repo location reaches `packages/core`'s own package.json before the
|
|
713
|
+
// filesystem root, so that's the returned boundary, not `sub` itself
|
|
714
|
+
// (chant #1117 — discovery must never wander past the project just
|
|
715
|
+
// because it declares no config).
|
|
716
|
+
const packageRoot = resolve(import.meta.dirname, "..", "..");
|
|
717
|
+
expect(findProjectRoot(sub)).toBe(packageRoot);
|
|
713
718
|
});
|
|
714
719
|
});
|
package/src/lint/config.ts
CHANGED
|
@@ -6,6 +6,13 @@ import type { Severity, RuleConfig } from "./rule";
|
|
|
6
6
|
import { moduleDir, getRuntime } from "../runtime-adapter";
|
|
7
7
|
import strictPreset from "./presets/strict.json";
|
|
8
8
|
|
|
9
|
+
// chant #1117 — the upward config-discovery walk moved to a shared module
|
|
10
|
+
// (`../project-root`) so `chant build`/`lint.policies` use the identical walk
|
|
11
|
+
// `chant lint`/`chant graph` already did. Re-exported here since this is
|
|
12
|
+
// still where every existing call site (`./config.test.ts`, `../cli/commands/lint.ts`)
|
|
13
|
+
// imports it from.
|
|
14
|
+
export { findProjectRoot } from "../project-root";
|
|
15
|
+
|
|
9
16
|
/** Mapping of built-in preset names to their file paths */
|
|
10
17
|
const BUILTIN_PRESETS: Record<string, string> = {
|
|
11
18
|
"@intentius/chant/lint/presets/strict": resolve(moduleDir(import.meta.url), "presets/strict.json"),
|
|
@@ -323,29 +330,6 @@ function loadConfigFile(configPath: string, visited: Set<string> = new Set()): L
|
|
|
323
330
|
return mergedConfig;
|
|
324
331
|
}
|
|
325
332
|
|
|
326
|
-
/**
|
|
327
|
-
* Walk up from `startDir` to the nearest ancestor holding a chant project
|
|
328
|
-
* config (`chant.config.ts` or `chant.config.json`). Returns that directory, or
|
|
329
|
-
* `startDir` unchanged when none is found before the filesystem root.
|
|
330
|
-
*
|
|
331
|
-
* Linting a subpath (`chant graph src --format ir`, `chant lint src/lib`) must
|
|
332
|
-
* still see the project-root config: its `lint.overrides` globs are written
|
|
333
|
-
* project-root-relative (`src/lib/**`), and a rule set scoped only to the lint
|
|
334
|
-
* arg would silently drop them. Config discovery therefore anchors on the
|
|
335
|
-
* project root, not the path being linted.
|
|
336
|
-
*/
|
|
337
|
-
export function findProjectRoot(startDir: string): string {
|
|
338
|
-
let dir = resolve(startDir);
|
|
339
|
-
for (;;) {
|
|
340
|
-
if (existsSync(join(dir, "chant.config.ts")) || existsSync(join(dir, "chant.config.json"))) {
|
|
341
|
-
return dir;
|
|
342
|
-
}
|
|
343
|
-
const parent = dirname(dir);
|
|
344
|
-
if (parent === dir) return resolve(startDir);
|
|
345
|
-
dir = parent;
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
333
|
/**
|
|
350
334
|
* Load lint configuration from a directory.
|
|
351
335
|
*
|
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/policy.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `policyGate` Op step runs this to gate an apply on the same checks.
|
|
5
5
|
*/
|
|
6
6
|
import { resolve, dirname } from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import { loadChantConfigUpward } from "../config";
|
|
8
8
|
import { resolveProjectLexicons, loadPlugins } from "../cli/plugins";
|
|
9
9
|
import { build } from "../build";
|
|
10
10
|
import { runPostSynthChecks, isPostSynthCheck } from "./post-synth";
|
|
@@ -55,10 +55,10 @@ export async function evaluateProjectPolicies(opts: {
|
|
|
55
55
|
const plugins = await loadPlugins(lexiconNames);
|
|
56
56
|
const serializers = plugins.map((p) => p.serializer);
|
|
57
57
|
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
);
|
|
58
|
+
// chant #1117 — walks up from the build dir to the project root, same as
|
|
59
|
+
// `chant build` (`../cli/commands/build.ts`'s `loadChantConfigUpward`), not
|
|
60
|
+
// just the build dir's immediate parent.
|
|
61
|
+
const loaded = await loadChantConfigUpward(buildPath);
|
|
62
62
|
const config = loaded.config;
|
|
63
63
|
const configDir = loaded.configPath ? dirname(loaded.configPath) : buildPath;
|
|
64
64
|
const env = opts.env ?? config.ownership?.env;
|
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
|
/**
|
|
@@ -2,6 +2,7 @@ import { describe, test, expect } from "vitest";
|
|
|
2
2
|
import * as ts from "typescript";
|
|
3
3
|
import { evl001NonLiteralExpressionRule } from "./evl001-non-literal-expression";
|
|
4
4
|
import type { LintContext } from "../rule";
|
|
5
|
+
import type { IntrinsicDef } from "../../lexicon";
|
|
5
6
|
|
|
6
7
|
function createContext(code: string, filePath = "test.ts"): LintContext {
|
|
7
8
|
const sourceFile = ts.createSourceFile(filePath, code, ts.ScriptTarget.Latest, true);
|
|
@@ -155,4 +156,42 @@ describe("EVL001: non-literal-expression", () => {
|
|
|
155
156
|
expect(diags).toHaveLength(1);
|
|
156
157
|
expect(diags[0].ruleId).toBe("EVL001");
|
|
157
158
|
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* chant #1106 — `LintContext.intrinsics`, threaded from `runLint`, is what
|
|
162
|
+
* lets this rule answer a registered call-form intrinsic exactly like
|
|
163
|
+
* `fold()` does instead of flagging every call. See ../../fold/subset.ts
|
|
164
|
+
* and ../../fold/subset.test.ts for the shared predicate this rule calls.
|
|
165
|
+
*/
|
|
166
|
+
describe("context.intrinsics (#1106)", () => {
|
|
167
|
+
const REF: IntrinsicDef[] = [{ name: "Ref", isTag: false, foldsAsCall: true }];
|
|
168
|
+
|
|
169
|
+
test("flags a call to a registered, opted-in intrinsic when the context carries no registry", () => {
|
|
170
|
+
const ctx = createContext(`new Bucket({ name: Ref(env) });`);
|
|
171
|
+
const diags = evl001NonLiteralExpressionRule.check(ctx);
|
|
172
|
+
expect(diags).toHaveLength(1);
|
|
173
|
+
expect(diags[0].ruleId).toBe("EVL001");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("does not flag that same call once the context carries the registry", () => {
|
|
177
|
+
const ctx: LintContext = { ...createContext(`new Bucket({ name: Ref(env) });`), intrinsics: REF };
|
|
178
|
+
expect(evl001NonLiteralExpressionRule.check(ctx)).toHaveLength(0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a registered name WITHOUT the call opt-in still flags, even with the registry present", () => {
|
|
182
|
+
const notOptedIn: IntrinsicDef[] = [{ name: "Reference", isTag: false }];
|
|
183
|
+
const ctx: LintContext = {
|
|
184
|
+
...createContext(`new Bucket({ name: Reference(env) });`),
|
|
185
|
+
intrinsics: notOptedIn,
|
|
186
|
+
};
|
|
187
|
+
const diags = evl001NonLiteralExpressionRule.check(ctx);
|
|
188
|
+
expect(diags).toHaveLength(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("an unregistered call still flags with the registry present", () => {
|
|
192
|
+
const ctx: LintContext = { ...createContext(`new Bucket({ name: makeName() });`), intrinsics: REF };
|
|
193
|
+
const diags = evl001NonLiteralExpressionRule.check(ctx);
|
|
194
|
+
expect(diags).toHaveLength(1);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
158
197
|
});
|
|
@@ -31,7 +31,7 @@ function checkNode(node: ts.Node, context: LintContext, diagnostics: LintDiagnos
|
|
|
31
31
|
const firstArg = node.arguments[0];
|
|
32
32
|
if (ts.isObjectLiteralExpression(firstArg)) {
|
|
33
33
|
for (const prop of firstArg.properties) {
|
|
34
|
-
const violation = checkObjectMember(prop);
|
|
34
|
+
const violation = checkObjectMember(prop, context.intrinsics);
|
|
35
35
|
if (violation) {
|
|
36
36
|
const { line, character } = context.sourceFile.getLineAndCharacterOfPosition(
|
|
37
37
|
violation.node.getStart(context.sourceFile),
|