@intentius/chant 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/cli/build-params-cli.d.ts +55 -0
  2. package/dist/cli/build-params-cli.d.ts.map +1 -0
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/lint.d.ts.map +1 -1
  5. package/dist/cli/handlers/build.d.ts.map +1 -1
  6. package/dist/cli/handlers/run.d.ts +22 -1
  7. package/dist/cli/handlers/run.d.ts.map +1 -1
  8. package/dist/cli/lsp/server.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/plugins.d.ts +8 -0
  11. package/dist/cli/plugins.d.ts.map +1 -1
  12. package/dist/components/cli-support.d.ts +33 -2
  13. package/dist/components/cli-support.d.ts.map +1 -1
  14. package/dist/components/discover.d.ts +28 -0
  15. package/dist/components/discover.d.ts.map +1 -1
  16. package/dist/config.d.ts +18 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/discovery/fold-import.d.ts +38 -8
  19. package/dist/discovery/fold-import.d.ts.map +1 -1
  20. package/dist/discovery/index.d.ts +15 -4
  21. package/dist/discovery/index.d.ts.map +1 -1
  22. package/dist/fold/subset.d.ts +16 -2
  23. package/dist/fold/subset.d.ts.map +1 -1
  24. package/dist/lint/config.d.ts +1 -12
  25. package/dist/lint/config.d.ts.map +1 -1
  26. package/dist/lint/engine.d.ts +11 -1
  27. package/dist/lint/engine.d.ts.map +1 -1
  28. package/dist/lint/rule.d.ts +14 -0
  29. package/dist/lint/rule.d.ts.map +1 -1
  30. package/dist/project-root.d.ts +51 -0
  31. package/dist/project-root.d.ts.map +1 -0
  32. package/package.json +1 -1
  33. package/src/cli/build-params-cli.test.ts +139 -0
  34. package/src/cli/build-params-cli.ts +107 -0
  35. package/src/cli/commands/build.ts +44 -41
  36. package/src/cli/commands/lint.test.ts +74 -0
  37. package/src/cli/commands/lint.ts +33 -9
  38. package/src/cli/handlers/build.test.ts +149 -0
  39. package/src/cli/handlers/build.ts +28 -8
  40. package/src/cli/handlers/run.test.ts +191 -5
  41. package/src/cli/handlers/run.ts +61 -8
  42. package/src/cli/lsp/server.ts +7 -2
  43. package/src/cli/main.test.ts +23 -0
  44. package/src/cli/main.ts +17 -3
  45. package/src/cli/plugins.ts +10 -2
  46. package/src/components/cli-support.test.ts +221 -3
  47. package/src/components/cli-support.ts +37 -6
  48. package/src/components/discover.test.ts +63 -1
  49. package/src/components/discover.ts +42 -0
  50. package/src/config.ts +23 -0
  51. package/src/discovery/fold-import.ts +202 -20
  52. package/src/discovery/index.test.ts +131 -0
  53. package/src/discovery/index.ts +38 -8
  54. package/src/discovery/sandbox/fold-boundary.test.ts +254 -0
  55. package/src/fold/subset.test.ts +28 -14
  56. package/src/fold/subset.ts +16 -2
  57. package/src/lint/config.test.ts +9 -4
  58. package/src/lint/config.ts +7 -23
  59. package/src/lint/engine.ts +12 -0
  60. package/src/lint/policy.ts +5 -5
  61. package/src/lint/rule.ts +14 -0
  62. package/src/lint/rules/evl001-non-literal-expression.test.ts +39 -0
  63. package/src/lint/rules/evl001-non-literal-expression.ts +1 -1
  64. package/src/project-root.test.ts +105 -0
  65. package/src/project-root.ts +78 -0
@@ -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`. Folded files are unaffected: fold already executes
78
- * zero of a file's own top-level code, so it stays in this process exactly
79
- * as it does today. Default `false` behavior, including performance
80
- * (no bundling, no child process, no IPC), is unchanged unless requested.
77
+ * `./sandbox/run.ts`.
78
+ *
79
+ * chant #1093 this ALSO tightens what fold itself may do in this process.
80
+ * Fold executes none of a file's own top-level code, but it does import and
81
+ * invoke the module behind a composite factory / resource constructor /
82
+ * intrinsic tag; when that module is project-owned, a file reported as
83
+ * "folded" still ran project code here. With `sandbox` set, fold refuses
84
+ * any import outside chant's own packages and this build's active lexicons,
85
+ * and the file demotes to the (sandboxed) run path instead — so the
86
+ * security property is uniform: under `sandbox`, project source executes
87
+ * only inside the child, folded or not. Fold COVERAGE is therefore lower
88
+ * under `sandbox` than under plain `fold` — deliberately.
89
+ *
90
+ * Default `false` — behavior, including performance (no bundling, no child
91
+ * process, no IPC) and fold coverage, is unchanged unless requested.
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
- const exportsObj: Record<string, unknown> = {};
201
- for (const [name, value] of folded.entities) exportsObj[name] = value;
202
- modules.push({ file, exports: exportsObj });
219
+ // chant #1112 hand `collectEntities` the file's WHOLE folded export
220
+ // namespace, exactly as the run path hands it the real module
221
+ // namespace object `importModule` returns. It used to get only the
222
+ // `Declarable`/`CompositeInstance` subset fold had already picked
223
+ // out, which quietly made fold-import a SECOND owner of the "which
224
+ // export becomes an entity" decision — and it had one fewer case than
225
+ // the real owner (`./collect.ts`'s `enumerateEntries`): a
226
+ // `LexiconOutput` (`export const oArn = output(bucket.Arn, "oArn")`)
227
+ // was resolved, dropped, and the template lost its whole `Outputs`
228
+ // section with no warning and exit 0. Fold now decides nothing here;
229
+ // `collectEntities` filters both paths' exports the same way, so a
230
+ // shape it learns about (today: outputs and arrays of declarables)
231
+ // cannot reach one path and not the other.
232
+ modules.push({ file, exports: Object.fromEntries(folded.exportedValues) });
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
+ });
@@ -280,12 +280,15 @@ describe("documented divergences — NOT unified by design (see subset.ts module
280
280
  /**
281
281
  * chant #1044 — the shared predicate's optional intrinsic registry.
282
282
  *
283
- * `findSubsetViolation` answers "is this shape foldable?" for two kinds of
284
- * caller: `fold()`'s own EVL twin, which has no registry, and a tool that
285
- * does (a control plane deciding whether a repository needs a sandboxed
286
- * child process). The parameter is what lets the second kind get fold()'s
287
- * real answer without running fold, while the first keeps the answer it
288
- * always had.
283
+ * `findSubsetViolation` answers "is this shape foldable?" for a caller that
284
+ * has a registry and one that doesn't: with it, the answer for a call is
285
+ * exact (fold()'s own); without it, every call is a violation, the
286
+ * pre-#1044 answer. EVL001 (`chant lint`) is the first kind as of #1106 —
287
+ * `runLint` threads the active lexicons' intrinsics onto
288
+ * `LintContext.intrinsics`, which EVL001 forwards here — and the second
289
+ * kind whenever a caller hasn't resolved a project's lexicons (a bare unit
290
+ * test, a tool asking "would this fold?" with no lexicon context of its
291
+ * own).
289
292
  */
290
293
  describe("findSubsetViolation — optional intrinsic registry (#1044)", () => {
291
294
  const REF: IntrinsicDef[] = [{ name: "Ref", isTag: false, foldsAsCall: true }];
@@ -331,14 +334,15 @@ describe("findSubsetViolation — optional intrinsic registry (#1044)", () => {
331
334
  expect(v?.message).toContain("getName(...)");
332
335
  });
333
336
 
334
- test("registry-less EVL is now STRICTER than fold on an opted-in call a known divergence, not a hole", () => {
335
- // chant #1044 EVL001 has no registry (see subset.ts module doc, point
336
- // 2c), so it still flags `Ref(...)` in a resource's props exactly as it
337
- // did before this change, while fold() — which is always given one —
338
- // folds it. Recorded here so the divergence is a tracked property with a
339
- // test rather than a surprise; closing it means handing the lint engine
340
- // the active lexicons' intrinsics, which is a change to lint's own
341
- // surface and deliberately not part of #1044.
337
+ test("EVL converges with fold on an opted-in call once it carries the registry (chant #1106)", () => {
338
+ // chant #1044 left EVL001 with no registry (see subset.ts module doc,
339
+ // point 2c), so it flagged `Ref(...)` in a resource's props even though
340
+ // fold() — which is always given one — folded it cleanly. #1106 closes
341
+ // that by threading `runLint`'s intrinsics parameter onto
342
+ // `LintContext.intrinsics`, which EVL001 passes straight through to this
343
+ // same `findSubsetViolation`/`checkObjectMember` predicate. A
344
+ // `LintContext` built WITH the registry (what `chant lint` now
345
+ // constructs for a real project) no longer flags what fold() accepts.
342
346
  const source = `const bad = new Thing({ x: Ref(env) });`;
343
347
  const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
344
348
  const consts = collectConsts(sourceFile);
@@ -346,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
  });
@@ -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; EVL is exactly such a caller and its behavior on calls
73
- * is unchanged by #1044.
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.
@@ -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("falls back to the start dir when no config is found", () => {
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 — returns the (resolved) start dir.
712
- expect(findProjectRoot(sub)).toBe(sub);
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
  });
@@ -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
  *
@@ -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
@@ -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 { loadChantConfig } from "../config";
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
- // Config can live in the build dir or its parent (the project root).
59
- const loaded = await loadChantConfig(buildPath).then((r) =>
60
- r.configPath ? r : loadChantConfig(dirname(buildPath)),
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),