@intentius/chant 0.20.0 → 0.22.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 (55) hide show
  1. package/dist/build.d.ts +7 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  5. package/dist/cli/commands/check-lexicon-intrinsics.d.ts +17 -0
  6. package/dist/cli/commands/check-lexicon-intrinsics.d.ts.map +1 -1
  7. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  8. package/dist/codegen/docs-types.d.ts +2 -0
  9. package/dist/codegen/docs-types.d.ts.map +1 -1
  10. package/dist/declarable.d.ts +16 -0
  11. package/dist/declarable.d.ts.map +1 -1
  12. package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
  13. package/dist/discovery/fold-import.d.ts +64 -3
  14. package/dist/discovery/fold-import.d.ts.map +1 -1
  15. package/dist/discovery/index.d.ts +12 -0
  16. package/dist/discovery/index.d.ts.map +1 -1
  17. package/dist/fold/fold.d.ts +89 -16
  18. package/dist/fold/fold.d.ts.map +1 -1
  19. package/dist/fold/foldable-helpers.d.ts +121 -0
  20. package/dist/fold/foldable-helpers.d.ts.map +1 -0
  21. package/dist/fold/subset.d.ts +58 -3
  22. package/dist/fold/subset.d.ts.map +1 -1
  23. package/dist/lexicon-schema.d.ts +2 -0
  24. package/dist/lexicon-schema.d.ts.map +1 -1
  25. package/dist/lexicon.d.ts +73 -23
  26. package/dist/lexicon.d.ts.map +1 -1
  27. package/dist/runtime.d.ts +10 -1
  28. package/dist/runtime.d.ts.map +1 -1
  29. package/dist/serializer-walker.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/build.ts +9 -0
  32. package/src/cli/commands/build.ts +9 -0
  33. package/src/cli/commands/check-lexicon-examples.ts +16 -1
  34. package/src/cli/commands/check-lexicon-intrinsics.test.ts +35 -1
  35. package/src/cli/commands/check-lexicon-intrinsics.ts +38 -2
  36. package/src/cli/commands/check-lexicon.ts +18 -0
  37. package/src/codegen/docs-sections.test.ts +7 -1
  38. package/src/codegen/docs-sections.ts +1 -1
  39. package/src/codegen/docs-types.ts +2 -0
  40. package/src/declarable.ts +20 -0
  41. package/src/discovery/entity-wire-codec.ts +9 -7
  42. package/src/discovery/fold-import.test.ts +900 -1
  43. package/src/discovery/fold-import.ts +441 -47
  44. package/src/discovery/index.ts +25 -1
  45. package/src/fold/fold.test.ts +277 -0
  46. package/src/fold/fold.ts +219 -58
  47. package/src/fold/foldable-helpers.ts +171 -0
  48. package/src/fold/subset-doc-parity.test.ts +27 -0
  49. package/src/fold/subset.test.ts +177 -0
  50. package/src/fold/subset.ts +139 -31
  51. package/src/lexicon-schema.test.ts +43 -0
  52. package/src/lexicon-schema.ts +5 -0
  53. package/src/lexicon.ts +74 -24
  54. package/src/runtime.ts +11 -2
  55. package/src/serializer-walker.ts +14 -0
@@ -1,6 +1,7 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import * as ts from "typescript";
3
3
  import { fold, foldResource, collectConsts, FoldError } from "./fold";
4
+ import { briefNodeText, callExpressionMessage, findSubsetViolation } from "./subset";
4
5
  import { evl001NonLiteralExpressionRule } from "../lint/rules/evl001-non-literal-expression";
5
6
  import { evl003DynamicPropertyAccessRule } from "../lint/rules/evl003-dynamic-property-access";
6
7
  import { evl004SpreadNonConstRule } from "../lint/rules/evl004-spread-non-const";
@@ -58,11 +59,28 @@ const SUPPORTED_CASES: SubsetCase[] = [
58
59
  expr: "Sub`${name}-data`",
59
60
  intrinsics: [{ name: "Sub", isTag: true }],
60
61
  },
62
+ {
63
+ // chant #1082 — a registered chant authoring helper is the one call shape
64
+ // that folds, so EVL001 must not flag it either.
65
+ name: "registered authoring helper call with foldable arguments",
66
+ preamble: `const stack = "web";`,
67
+ expr: `phase("Apply", [{ kind: "cfn-deploy", stack }])`,
68
+ },
69
+ {
70
+ name: "registered authoring helper nested inside another",
71
+ expr: `phase("Outer", [phase("Inner", []), gate("approve")])`,
72
+ },
61
73
  ];
62
74
 
63
75
  const UNSUPPORTED_CASES: SubsetCase[] = [
64
76
  { name: "function call as a value", expr: `getName()` },
65
77
  { name: "method call as a value", expr: `config.getName()` },
78
+ // chant #1082 — a registered helper NAME reached through a namespace is
79
+ // still a method call, and still rejected by both sides.
80
+ { name: "registered helper name reached as a method", expr: `helpers.phase("Apply", [])` },
81
+ // ...and a registered helper with an unfoldable argument is rejected on the
82
+ // argument, by both sides, at the argument's own position.
83
+ { name: "registered helper with an unfoldable argument", expr: `phase("Apply", [getName()])` },
66
84
  { name: "computed/dynamic object-literal key", expr: `{ [dynKey]: 1 }` },
67
85
  { name: "dynamic element-access key", expr: `config[dynKey]` },
68
86
  { name: "non-whitelisted binary operator (%)", expr: `n % 2` },
@@ -221,6 +239,25 @@ describe("documented divergences — NOT unified by design (see subset.ts module
221
239
  expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
222
240
  });
223
241
 
242
+ test("authoring-helper shadowing: fold rejects a registered name bound to a local const; EVL001 does not (shape-only, no binding resolution)", () => {
243
+ // chant #1082 — `phase` is registered, but here it's the file's own local
244
+ // arrow function, so the local binding wins and fold() rejects. EVL has no
245
+ // binding resolver (subset.ts module doc, point 1) and stays permissive —
246
+ // the same direction as every other divergence here.
247
+ const source = `
248
+ const phase = (n) => ({ phase: n });
249
+ const bad = new Thing({ x: phase("Apply") });
250
+ `;
251
+ const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
252
+ const consts = collectConsts(sourceFile);
253
+ const badInit = consts.get("bad") as ts.NewExpression;
254
+
255
+ expect(() => foldResource(badInit, consts, [])).toThrow(FoldError);
256
+
257
+ const context: LintContext = { sourceFile, entities: [], filePath: "t.ts", lexicon: undefined };
258
+ expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
259
+ });
260
+
224
261
  test("nested resource construction: fold rejects a nested `new Type()` used as a value (falls back to run); EVL001 allows it statically", () => {
225
262
  // A top-level `new Type()` folds (fold-import constructs a real Declarable),
226
263
  // but a NESTED one as a property value can only fold to a {__resource,props}
@@ -239,3 +276,143 @@ describe("documented divergences — NOT unified by design (see subset.ts module
239
276
  expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
240
277
  });
241
278
  });
279
+
280
+ /**
281
+ * chant #1044 — the shared predicate's optional intrinsic registry.
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.
289
+ */
290
+ describe("findSubsetViolation — optional intrinsic registry (#1044)", () => {
291
+ const REF: IntrinsicDef[] = [{ name: "Ref", isTag: false, foldsAsCall: true }];
292
+
293
+ /** The `x` initializer of `const bad = new Thing({ x: <expr> });`. */
294
+ function propValue(expr: string, preamble = ""): ts.Expression {
295
+ const sourceFile = ts.createSourceFile(
296
+ "t.ts",
297
+ `${preamble}\nconst bad = new Thing({ x: ${expr} });`,
298
+ ts.ScriptTarget.Latest,
299
+ true,
300
+ );
301
+ const consts = collectConsts(sourceFile);
302
+ const init = consts.get("bad") as ts.NewExpression;
303
+ const props = init.arguments![0] as ts.ObjectLiteralExpression;
304
+ return (props.properties[0] as ts.PropertyAssignment).initializer;
305
+ }
306
+
307
+ test("with the registry supplied, an opted-in intrinsic call is not a violation — the same answer fold() gives", () => {
308
+ expect(findSubsetViolation(propValue(`Ref(env)`), REF)).toBeUndefined();
309
+ const consts = collectConsts(
310
+ ts.createSourceFile("t.ts", `const env = "p"; const x = Ref(env);`, ts.ScriptTarget.Latest, true),
311
+ );
312
+ expect(fold(consts.get("x") as ts.Expression, consts, REF)).toEqual({ __intrinsic: "Ref", args: ["p"] });
313
+ });
314
+
315
+ test("with NO registry, every call stays a violation — the pre-#1044 answer, and the safe one", () => {
316
+ const v = findSubsetViolation(propValue(`Ref(env)`));
317
+ expect(v?.ruleId).toBe("EVL001");
318
+ expect(v?.message).toContain("function call as a value is not foldable: Ref(...)");
319
+ });
320
+
321
+ test("the registry doesn't widen anything else: a name in it without the opt-in, a method call, and .map all stay violations", () => {
322
+ const notOptedIn: IntrinsicDef[] = [{ name: "Reference", isTag: false }];
323
+ expect(findSubsetViolation(propValue(`Reference("db")`), notOptedIn)).toBeDefined();
324
+ expect(findSubsetViolation(propValue(`aws.Ref("db")`), REF)).toBeDefined();
325
+ expect(findSubsetViolation(propValue(`cidrs.map((c) => c)`, `const cidrs = [];`), REF)).toBeDefined();
326
+ expect(findSubsetViolation(propValue(`makeName("a")`), REF)).toBeDefined();
327
+ });
328
+
329
+ test("arguments are still classified on their own terms, at their own position", () => {
330
+ const v = findSubsetViolation(propValue(`Ref(getName())`), REF);
331
+ expect(v?.message).toContain("getName(...)");
332
+ });
333
+
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.
342
+ const source = `const bad = new Thing({ x: Ref(env) });`;
343
+ const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
344
+ const consts = collectConsts(sourceFile);
345
+ const badInit = consts.get("bad") as ts.NewExpression;
346
+
347
+ expect(() => foldResource(badInit, consts, REF)).not.toThrow();
348
+
349
+ const context: LintContext = { sourceFile, entities: [], filePath: "t.ts", lexicon: undefined };
350
+ expect(evl001NonLiteralExpressionRule.check(context).length).toBeGreaterThan(0);
351
+ });
352
+ });
353
+
354
+ /**
355
+ * chant #1054 — `briefNodeText` is what keeps every fold fallback reason
356
+ * that used to embed a node's raw `getText()` down to one bounded line. A
357
+ * real composite call's source is many lines; a fold reason that reproduces
358
+ * it verbatim buries the actual error after all of it (the bug this issue
359
+ * reports) and breaks any line-oriented consumer of `[fold:run]` output.
360
+ */
361
+ describe("briefNodeText — single-line, bounded diagnostic text (chant #1054)", () => {
362
+ function initializerOf(source: string): ts.Expression {
363
+ const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
364
+ const consts = collectConsts(sourceFile);
365
+ const init = consts.get("x");
366
+ if (!init) throw new Error(`fixture error: "x" did not parse in ${JSON.stringify(source)}`);
367
+ return init;
368
+ }
369
+
370
+ test("a short, single-line node passes through unchanged", () => {
371
+ expect(briefNodeText(initializerOf(`const x = GkeCluster;`))).toBe("GkeCluster");
372
+ });
373
+
374
+ test("a multi-line node's newlines collapse to spaces — the result is always one line", () => {
375
+ const text = briefNodeText(
376
+ initializerOf(`
377
+ const x = GkeCluster({
378
+ name: config.clusterName,
379
+ location: config.region,
380
+ });
381
+ `),
382
+ );
383
+ expect(text).not.toContain("\n");
384
+ expect(text.split("\n")).toHaveLength(1);
385
+ });
386
+
387
+ test("text over the length cap is truncated with a trailing marker rather than left unbounded", () => {
388
+ const text = briefNodeText(
389
+ initializerOf(`const x = { aVeryLongPropertyNameNumberOne: 1, aVeryLongPropertyNameNumberTwo: 2 };`),
390
+ 20,
391
+ );
392
+ expect(text.length).toBe(20);
393
+ expect(text.endsWith("...")).toBe(true);
394
+ });
395
+ });
396
+
397
+ describe("callExpressionMessage — one line regardless of the call's own argument list (chant #1054)", () => {
398
+ test("only the callee is embedded — a multi-line argument list never leaks into the message", () => {
399
+ const sourceFile = ts.createSourceFile(
400
+ "t.ts",
401
+ `
402
+ const x = GkeCluster({
403
+ name: config.clusterName,
404
+ location: config.region,
405
+ machineType: "n2-standard-2",
406
+ });
407
+ `,
408
+ ts.ScriptTarget.Latest,
409
+ true,
410
+ );
411
+ const consts = collectConsts(sourceFile);
412
+ const call = consts.get("x") as ts.CallExpression;
413
+
414
+ const message = callExpressionMessage(call);
415
+
416
+ expect(message).toBe("function call as a value is not foldable: GkeCluster(...)");
417
+ });
418
+ });
@@ -1,4 +1,6 @@
1
1
  import * as ts from "typescript";
2
+ import { isFoldableHelperName } from "./foldable-helpers";
3
+ import { intrinsicCallFolds, type IntrinsicDef } from "../lexicon";
2
4
 
3
5
  /**
4
6
  * subset — the single canonical definition of chant's statically-foldable
@@ -37,6 +39,38 @@ import * as ts from "typescript";
37
39
  * manifest, which isn't available to a syntax-only lint rule. `fold()`
38
40
  * alone checks it; this module treats any tag name as shape-valid and
39
41
  * only classifies the interpolated values.
42
+ * 2b. Authoring-helper *provenance* (chant #1082) — a call to a registered
43
+ * chant helper (`phase(...)`, `output(...)`; ./foldable-helpers.ts)
44
+ * folds, but only when the name is genuinely bound to an import of
45
+ * chant's own. That needs the module graph, which a syntax-only lint
46
+ * rule doesn't have. This module checks the NAME only and stays
47
+ * permissive; `fold()`'s bridge does the provenance check and falls the
48
+ * file back to run when it fails. Same direction as every other item
49
+ * here — a false negative for EVL, never a false positive.
50
+ * 2c. Intrinsic *call-form* registration (chant #1044) — a plain call to a
51
+ * lexicon intrinsic the lexicon opted in (`Ref(bucket)`,
52
+ * `Concat(a, b)`; `IntrinsicDef.foldsAsCall`, ../lexicon.ts) folds.
53
+ * Whether a given name is such an intrinsic is not knowable from shape,
54
+ * so {@link findSubsetViolation} takes the registry as an OPTIONAL
55
+ * parameter instead of guessing: supply it and the answer for a call is
56
+ * exact (fold()'s own), omit it and every call is a violation, the
57
+ * pre-#1044 answer.
58
+ *
59
+ * That parameter is the whole reason the call case lives here rather
60
+ * than only in `fold()`. This module is a shared predicate, and the
61
+ * point of a shared predicate is that a consumer can ask "will this
62
+ * fold?" without running fold — a control plane deciding whether a
63
+ * repository needs a sandboxed child process at all, for instance.
64
+ * Keeping the case out of here would make the predicate answer "no" for
65
+ * idiomatic `Ref(...)` source that `fold()` reduces cleanly: not a
66
+ * permissive gap but a systematically WRONG answer in the expensive
67
+ * direction, and the one direction this module is not allowed to be
68
+ * wrong in (see the false-negative/false-positive rule in point 1, and
69
+ * the flow-sensitivity note below — the single divergence in the other
70
+ * direction, and the one this module treats as a wart). A caller with
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.
40
74
  * 3. Runtime *type* of a folded value — e.g. spreading `const n = 5`
41
75
  * (`{...n}`) is shape-valid (`n` is a plain identifier) but `fold()`
42
76
  * rejects it once it discovers `n` folds to a number, not an object.
@@ -107,6 +141,22 @@ export function isLiteralElementKey(node: ts.Expression): node is ts.StringLiter
107
141
  return ts.isStringLiteral(node) || ts.isNumericLiteral(node);
108
142
  }
109
143
 
144
+ /**
145
+ * A short, single-line rendering of `node`'s source text for embedding in a
146
+ * diagnostic message — never the raw `getText()`, which reproduces the
147
+ * node's ENTIRE source verbatim and can span dozens of lines for a real
148
+ * composite call or object literal (chant #1054: a fold fallback reason that
149
+ * embeds one of these buries the actual error after it, and breaks any
150
+ * line-oriented consumer of `[fold:run]` output). Internal whitespace
151
+ * (including newlines) collapses to a single space, and the result is capped
152
+ * to a bounded length so one pathological node can't blow out an otherwise
153
+ * one-line reason either.
154
+ */
155
+ export function briefNodeText(node: ts.Node, maxLength = 60): string {
156
+ const collapsed = node.getText().replace(/\s+/g, " ").trim();
157
+ return collapsed.length > maxLength ? `${collapsed.slice(0, maxLength - 3)}...` : collapsed;
158
+ }
159
+
110
160
  // ---------------------------------------------------------------------------
111
161
  // Shared message builders — `fold()` and `findSubsetViolation` both call
112
162
  // these so the diagnostic text for the same violation kind is the same
@@ -114,11 +164,11 @@ export function isLiteralElementKey(node: ts.Expression): node is ts.StringLiter
114
164
  // ---------------------------------------------------------------------------
115
165
 
116
166
  export function computedPropertyNameMessage(node: ts.PropertyName): string {
117
- return `computed/dynamic property name not foldable: ${node.getText()}`;
167
+ return `computed/dynamic property name not foldable: ${briefNodeText(node)}`;
118
168
  }
119
169
 
120
170
  export function dynamicElementAccessMessage(keyNode: ts.Expression): string {
121
- return `dynamic property access — computed key must be a string or numeric literal: ${keyNode.getText()}`;
171
+ return `dynamic property access — computed key must be a string or numeric literal: ${briefNodeText(keyNode)}`;
122
172
  }
123
173
 
124
174
  export const UNSUPPORTED_OBJECT_MEMBER_MESSAGE = "unsupported object member";
@@ -129,18 +179,25 @@ export function unsupportedBinaryMessage(opKind: ts.SyntaxKind): string {
129
179
  return `unsupported binary operator: ${ts.SyntaxKind[opKind]}`;
130
180
  }
131
181
 
182
+ /**
183
+ * chant #1054 — the ONE wording for "a bare function/method call used where
184
+ * chant needs a value it can fold." Before this, `fold()` (this message) and
185
+ * `../discovery/fold-import.ts`'s `resolveCallExpression` (a top-level
186
+ * export's own call-as-a-value check) had each grown their own hand-written
187
+ * copy — "function call as a value" here, "call expression as a value"
188
+ * there — for the identical rejection, which meant a tool grouping fallback
189
+ * reasons by text had to match both to avoid silently under-counting one of
190
+ * them. `resolveCallExpression` now calls this function directly instead of
191
+ * building its own string.
192
+ */
132
193
  export function callExpressionMessage(node: ts.CallExpression): string {
133
- return `function call as a value is not foldable: ${node.expression.getText()}(...)`;
194
+ return `function call as a value is not foldable: ${briefNodeText(node.expression)}(...)`;
134
195
  }
135
196
 
136
197
  export function unsupportedExpressionMessage(node: ts.Node): string {
137
198
  return `unsupported expression: ${ts.SyntaxKind[node.kind]}`;
138
199
  }
139
200
 
140
- export function resourceCtorArgMessage(typeName: string): string {
141
- return `resource constructor argument must be an object literal: ${typeName}(...)`;
142
- }
143
-
144
201
  /**
145
202
  * Classify one object-literal member (`{ a: 1 }`'s `a: 1`, `{ ...x }`'s
146
203
  * `...x`, or a shorthand `{ a }`). Checks the key's shape before the
@@ -148,26 +205,32 @@ export function resourceCtorArgMessage(typeName: string): string {
148
205
  * the value. Returns the first violation within this member, or
149
206
  * `undefined` when it's fully in the subset.
150
207
  */
151
- export function checkObjectMember(prop: ts.ObjectLiteralElementLike): SubsetViolation | undefined {
208
+ export function checkObjectMember(
209
+ prop: ts.ObjectLiteralElementLike,
210
+ intrinsics?: readonly IntrinsicDef[],
211
+ ): SubsetViolation | undefined {
152
212
  if (ts.isPropertyAssignment(prop)) {
153
213
  if (!isLiteralPropertyName(prop.name)) {
154
214
  return violation(prop.name, computedPropertyNameMessage(prop.name));
155
215
  }
156
- return findSubsetViolation(prop.initializer);
216
+ return findSubsetViolation(prop.initializer, intrinsics);
157
217
  }
158
218
  if (ts.isShorthandPropertyAssignment(prop)) {
159
219
  return undefined;
160
220
  }
161
221
  if (ts.isSpreadAssignment(prop)) {
162
- return findSubsetViolation(prop.expression);
222
+ return findSubsetViolation(prop.expression, intrinsics);
163
223
  }
164
224
  return violation(prop, UNSUPPORTED_OBJECT_MEMBER_MESSAGE);
165
225
  }
166
226
 
167
227
  /** Classify one array-literal element: a value, or a `...spread`. */
168
- function checkArrayElement(el: ts.Expression): SubsetViolation | undefined {
169
- if (ts.isSpreadElement(el)) return findSubsetViolation(el.expression);
170
- return findSubsetViolation(el);
228
+ function checkArrayElement(
229
+ el: ts.Expression,
230
+ intrinsics?: readonly IntrinsicDef[],
231
+ ): SubsetViolation | undefined {
232
+ if (ts.isSpreadElement(el)) return findSubsetViolation(el.expression, intrinsics);
233
+ return findSubsetViolation(el, intrinsics);
171
234
  }
172
235
 
173
236
  /**
@@ -180,14 +243,17 @@ function checkArrayElement(el: ts.Expression): SubsetViolation | undefined {
180
243
  * `fold()`-evaluation-order) unsupported node, or `undefined` when `node`'s
181
244
  * whole shape is foldable.
182
245
  */
183
- export function findSubsetViolation(node: ts.Node): SubsetViolation | undefined {
246
+ export function findSubsetViolation(
247
+ node: ts.Node,
248
+ intrinsics?: readonly IntrinsicDef[],
249
+ ): SubsetViolation | undefined {
184
250
  if (
185
251
  ts.isParenthesizedExpression(node) ||
186
252
  ts.isAsExpression(node) ||
187
253
  ts.isSatisfiesExpression(node) ||
188
254
  ts.isNonNullExpression(node)
189
255
  ) {
190
- return findSubsetViolation(node.expression);
256
+ return findSubsetViolation(node.expression, intrinsics);
191
257
  }
192
258
 
193
259
  if (
@@ -222,7 +288,7 @@ export function findSubsetViolation(node: ts.Node): SubsetViolation | undefined
222
288
 
223
289
  if (ts.isTemplateExpression(node)) {
224
290
  for (const span of node.templateSpans) {
225
- const v = findSubsetViolation(span.expression);
291
+ const v = findSubsetViolation(span.expression, intrinsics);
226
292
  if (v) return v;
227
293
  }
228
294
  return undefined;
@@ -230,7 +296,7 @@ export function findSubsetViolation(node: ts.Node): SubsetViolation | undefined
230
296
 
231
297
  if (ts.isObjectLiteralExpression(node)) {
232
298
  for (const prop of node.properties) {
233
- const v = checkObjectMember(prop);
299
+ const v = checkObjectMember(prop, intrinsics);
234
300
  if (v) return v;
235
301
  }
236
302
  return undefined;
@@ -238,28 +304,28 @@ export function findSubsetViolation(node: ts.Node): SubsetViolation | undefined
238
304
 
239
305
  if (ts.isArrayLiteralExpression(node)) {
240
306
  for (const el of node.elements) {
241
- const v = checkArrayElement(el);
307
+ const v = checkArrayElement(el, intrinsics);
242
308
  if (v) return v;
243
309
  }
244
310
  return undefined;
245
311
  }
246
312
 
247
313
  if (ts.isPropertyAccessExpression(node)) {
248
- return findSubsetViolation(node.expression);
314
+ return findSubsetViolation(node.expression, intrinsics);
249
315
  }
250
316
 
251
317
  if (ts.isElementAccessExpression(node)) {
252
318
  if (!isLiteralElementKey(node.argumentExpression)) {
253
319
  return violation(node.argumentExpression, dynamicElementAccessMessage(node.argumentExpression), "EVL003");
254
320
  }
255
- return findSubsetViolation(node.expression);
321
+ return findSubsetViolation(node.expression, intrinsics);
256
322
  }
257
323
 
258
324
  if (ts.isPrefixUnaryExpression(node)) {
259
325
  if (!SUPPORTED_UNARY_OPERATORS.has(node.operator)) {
260
326
  return violation(node, UNSUPPORTED_UNARY_MESSAGE);
261
327
  }
262
- return findSubsetViolation(node.operand);
328
+ return findSubsetViolation(node.operand, intrinsics);
263
329
  }
264
330
 
265
331
  if (ts.isBinaryExpression(node)) {
@@ -269,32 +335,74 @@ export function findSubsetViolation(node: ts.Node): SubsetViolation | undefined
269
335
  }
270
336
  // Flow-insensitive — see module doc: fold() short-circuits &&/||/?? and
271
337
  // only evaluates the taken side; EVL requires both sides shape-valid.
272
- return findSubsetViolation(node.left) ?? findSubsetViolation(node.right);
338
+ return findSubsetViolation(node.left, intrinsics) ?? findSubsetViolation(node.right, intrinsics);
273
339
  }
274
340
 
275
341
  if (ts.isConditionalExpression(node)) {
276
342
  // Flow-insensitive — see module doc: fold() only folds the taken branch.
277
343
  return (
278
- findSubsetViolation(node.condition) ??
279
- findSubsetViolation(node.whenTrue) ??
280
- findSubsetViolation(node.whenFalse)
344
+ findSubsetViolation(node.condition, intrinsics) ??
345
+ findSubsetViolation(node.whenTrue, intrinsics) ??
346
+ findSubsetViolation(node.whenFalse, intrinsics)
281
347
  );
282
348
  }
283
349
 
284
350
  if (ts.isNewExpression(node)) {
285
- const [firstArg] = node.arguments ?? [];
286
- if (!firstArg) return undefined;
287
- if (!ts.isObjectLiteralExpression(firstArg)) {
288
- return violation(firstArg, resourceCtorArgMessage(node.expression.getText()));
351
+ // chant #1082 no positional assumption about which argument is the
352
+ // props object. `foldResource` folds every argument in source order (the
353
+ // props object is second in `new Parameter("String", {...})`), so every
354
+ // argument is classified on its own terms and nothing is rejected merely
355
+ // for being in the "wrong" position.
356
+ for (const arg of node.arguments ?? []) {
357
+ const v = findSubsetViolation(arg, intrinsics);
358
+ if (v) return v;
289
359
  }
290
- return findSubsetViolation(firstArg);
360
+ return undefined;
291
361
  }
292
362
 
293
363
  if (ts.isSpreadElement(node)) {
294
- return findSubsetViolation(node.expression);
364
+ return findSubsetViolation(node.expression, intrinsics);
295
365
  }
296
366
 
297
367
  if (ts.isCallExpression(node)) {
368
+ // chant #1082 — a call to a REGISTERED chant authoring helper folds
369
+ // (`phase(...)`, `output(...)`, …; see ./foldable-helpers.ts), so this
370
+ // classifier must accept it too or EVL001 would flag source `fold()`
371
+ // reduces cleanly. Name-only here, deliberately: this module classifies
372
+ // shape and never resolves bindings (module doc, point 1), and the
373
+ // provenance half of the check — is this name actually bound to an import
374
+ // of chant's own? — needs the module graph, which only
375
+ // ../discovery/fold-import.ts has. Same asymmetry as intrinsic tag
376
+ // registration (point 2) and in the same direction: this module can only
377
+ // ever be MORE permissive than `fold()`, never stricter.
378
+ if (ts.isIdentifier(node.expression) && isFoldableHelperName(node.expression.text)) {
379
+ for (const arg of node.arguments) {
380
+ const v = findSubsetViolation(arg, intrinsics);
381
+ if (v) return v;
382
+ }
383
+ return undefined;
384
+ }
385
+
386
+ // chant #1044 — a plain call to a lexicon intrinsic whose lexicon opted
387
+ // its call form in folds too (`Ref(bucket)`, `Concat("a", b)`). Unlike
388
+ // the helper case above, this one is only answerable with the registry
389
+ // in hand, which is exactly why it is a parameter: a caller that passes
390
+ // `intrinsics` gets fold()'s own answer, and a caller that can't supply
391
+ // one (EVL, any syntax-only tool) keeps the pre-#1044 answer — every
392
+ // call is a violation. See the module doc, point 2c, for why the
393
+ // registry-less answer is the safe one to leave in place.
394
+ if (
395
+ intrinsics &&
396
+ ts.isIdentifier(node.expression) &&
397
+ intrinsics.some((i) => i.name === (node.expression as ts.Identifier).text && intrinsicCallFolds(i))
398
+ ) {
399
+ for (const arg of node.arguments) {
400
+ const v = findSubsetViolation(arg, intrinsics);
401
+ if (v) return v;
402
+ }
403
+ return undefined;
404
+ }
405
+
298
406
  return violation(node, callExpressionMessage(node));
299
407
  }
300
408
 
@@ -6,6 +6,7 @@ import {
6
6
  IntrinsicDefSchema,
7
7
  LexiconEntrySchema,
8
8
  } from "./lexicon-schema";
9
+ import { intrinsicFolds, intrinsicTagFolds, intrinsicCallFolds } from "./lexicon";
9
10
 
10
11
  // ---------------------------------------------------------------------------
11
12
  // validateManifest
@@ -145,6 +146,48 @@ describe("IntrinsicDefSchema", () => {
145
146
  expect(IntrinsicDefSchema.safeParse({ name: "Ref", isTag: false }).success).toBe(true);
146
147
  expect(IntrinsicDefSchema.safeParse({ name: "Ref", isTag: "false" }).success).toBe(false);
147
148
  });
149
+
150
+ test("foldsAsCall is optional and boolean — omitted means not opted in (chant #1044)", () => {
151
+ const omitted = IntrinsicDefSchema.safeParse({ name: "Ref", isTag: false });
152
+ expect(omitted.success).toBe(true);
153
+ if (omitted.success) expect(omitted.data.foldsAsCall).toBeUndefined();
154
+ expect(IntrinsicDefSchema.safeParse({ name: "Ref", isTag: false, foldsAsCall: true }).success).toBe(true);
155
+ expect(IntrinsicDefSchema.safeParse({ name: "Ref", isTag: false, foldsAsCall: "yes" }).success).toBe(false);
156
+ });
157
+ });
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Foldability predicates (chant #1044)
161
+ // ---------------------------------------------------------------------------
162
+
163
+ describe("intrinsic foldability predicates", () => {
164
+ test("a tagged template folds as a tag, never as a call", () => {
165
+ const sub = { isTag: true };
166
+ expect(intrinsicTagFolds(sub)).toBe(true);
167
+ expect(intrinsicCallFolds(sub)).toBe(false);
168
+ expect(intrinsicFolds(sub)).toBe(true);
169
+ });
170
+
171
+ test("a plain call folds only once its lexicon opts it in — never by inference", () => {
172
+ const optedIn = { isTag: false, foldsAsCall: true };
173
+ expect(intrinsicCallFolds({ isTag: false })).toBe(false);
174
+ expect(intrinsicFolds({ isTag: false })).toBe(false);
175
+ expect(intrinsicCallFolds(optedIn)).toBe(true);
176
+ expect(intrinsicFolds(optedIn)).toBe(true);
177
+ expect(intrinsicTagFolds(optedIn)).toBe(false);
178
+ });
179
+
180
+ test("a registration claiming BOTH forms does not fold as a call — isTag wins, and check-lexicon fails it", () => {
181
+ const both = { isTag: true, foldsAsCall: true };
182
+ expect(intrinsicCallFolds(both)).toBe(false);
183
+ expect(intrinsicTagFolds(both)).toBe(true);
184
+ });
185
+
186
+ test("an older manifest with neither field folds in neither form", () => {
187
+ expect(intrinsicFolds({})).toBe(false);
188
+ expect(intrinsicTagFolds({})).toBe(false);
189
+ expect(intrinsicCallFolds({})).toBe(false);
190
+ });
148
191
  });
149
192
 
150
193
  // ---------------------------------------------------------------------------
@@ -16,6 +16,11 @@ export const IntrinsicDefSchema = z.object({
16
16
  // Required (chant #1067) — no silent default for whether an intrinsic
17
17
  // folds. See IntrinsicDef.isTag in ../lexicon.ts for the history.
18
18
  isTag: z.boolean(),
19
+ // chant #1044 — optional and default-off on purpose: absent means "this
20
+ // intrinsic's plain-call form does not fold", which is what every
21
+ // registration written before #1044 means. Only an explicit `true` opts a
22
+ // call into folding. See IntrinsicDef.foldsAsCall in ../lexicon.ts.
23
+ foldsAsCall: z.boolean().optional(),
19
24
  });
20
25
 
21
26
  // ---------------------------------------------------------------------------