@intentius/chant 0.20.0 → 0.21.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 (45) hide show
  1. package/dist/cli/commands/check-lexicon-intrinsics.d.ts +17 -0
  2. package/dist/cli/commands/check-lexicon-intrinsics.d.ts.map +1 -1
  3. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  4. package/dist/codegen/docs-types.d.ts +2 -0
  5. package/dist/codegen/docs-types.d.ts.map +1 -1
  6. package/dist/declarable.d.ts +16 -0
  7. package/dist/declarable.d.ts.map +1 -1
  8. package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
  9. package/dist/discovery/fold-import.d.ts +30 -1
  10. package/dist/discovery/fold-import.d.ts.map +1 -1
  11. package/dist/discovery/index.d.ts.map +1 -1
  12. package/dist/fold/fold.d.ts +89 -16
  13. package/dist/fold/fold.d.ts.map +1 -1
  14. package/dist/fold/foldable-helpers.d.ts +121 -0
  15. package/dist/fold/foldable-helpers.d.ts.map +1 -0
  16. package/dist/fold/subset.d.ts +35 -3
  17. package/dist/fold/subset.d.ts.map +1 -1
  18. package/dist/lexicon-schema.d.ts +2 -0
  19. package/dist/lexicon-schema.d.ts.map +1 -1
  20. package/dist/lexicon.d.ts +73 -23
  21. package/dist/lexicon.d.ts.map +1 -1
  22. package/dist/runtime.d.ts +10 -1
  23. package/dist/runtime.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/cli/commands/check-lexicon-intrinsics.test.ts +35 -1
  26. package/src/cli/commands/check-lexicon-intrinsics.ts +38 -2
  27. package/src/cli/commands/check-lexicon.ts +18 -0
  28. package/src/codegen/docs-sections.test.ts +7 -1
  29. package/src/codegen/docs-sections.ts +1 -1
  30. package/src/codegen/docs-types.ts +2 -0
  31. package/src/declarable.ts +20 -0
  32. package/src/discovery/entity-wire-codec.ts +9 -7
  33. package/src/discovery/fold-import.test.ts +572 -0
  34. package/src/discovery/fold-import.ts +229 -36
  35. package/src/discovery/index.ts +9 -0
  36. package/src/fold/fold.test.ts +277 -0
  37. package/src/fold/fold.ts +213 -56
  38. package/src/fold/foldable-helpers.ts +171 -0
  39. package/src/fold/subset-doc-parity.test.ts +27 -0
  40. package/src/fold/subset.test.ts +111 -0
  41. package/src/fold/subset.ts +109 -28
  42. package/src/lexicon-schema.test.ts +43 -0
  43. package/src/lexicon-schema.ts +5 -0
  44. package/src/lexicon.ts +74 -24
  45. package/src/runtime.ts +11 -2
@@ -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 { 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,77 @@ 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
+ });
@@ -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.
@@ -137,10 +171,6 @@ export function unsupportedExpressionMessage(node: ts.Node): string {
137
171
  return `unsupported expression: ${ts.SyntaxKind[node.kind]}`;
138
172
  }
139
173
 
140
- export function resourceCtorArgMessage(typeName: string): string {
141
- return `resource constructor argument must be an object literal: ${typeName}(...)`;
142
- }
143
-
144
174
  /**
145
175
  * Classify one object-literal member (`{ a: 1 }`'s `a: 1`, `{ ...x }`'s
146
176
  * `...x`, or a shorthand `{ a }`). Checks the key's shape before the
@@ -148,26 +178,32 @@ export function resourceCtorArgMessage(typeName: string): string {
148
178
  * the value. Returns the first violation within this member, or
149
179
  * `undefined` when it's fully in the subset.
150
180
  */
151
- export function checkObjectMember(prop: ts.ObjectLiteralElementLike): SubsetViolation | undefined {
181
+ export function checkObjectMember(
182
+ prop: ts.ObjectLiteralElementLike,
183
+ intrinsics?: readonly IntrinsicDef[],
184
+ ): SubsetViolation | undefined {
152
185
  if (ts.isPropertyAssignment(prop)) {
153
186
  if (!isLiteralPropertyName(prop.name)) {
154
187
  return violation(prop.name, computedPropertyNameMessage(prop.name));
155
188
  }
156
- return findSubsetViolation(prop.initializer);
189
+ return findSubsetViolation(prop.initializer, intrinsics);
157
190
  }
158
191
  if (ts.isShorthandPropertyAssignment(prop)) {
159
192
  return undefined;
160
193
  }
161
194
  if (ts.isSpreadAssignment(prop)) {
162
- return findSubsetViolation(prop.expression);
195
+ return findSubsetViolation(prop.expression, intrinsics);
163
196
  }
164
197
  return violation(prop, UNSUPPORTED_OBJECT_MEMBER_MESSAGE);
165
198
  }
166
199
 
167
200
  /** 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);
201
+ function checkArrayElement(
202
+ el: ts.Expression,
203
+ intrinsics?: readonly IntrinsicDef[],
204
+ ): SubsetViolation | undefined {
205
+ if (ts.isSpreadElement(el)) return findSubsetViolation(el.expression, intrinsics);
206
+ return findSubsetViolation(el, intrinsics);
171
207
  }
172
208
 
173
209
  /**
@@ -180,14 +216,17 @@ function checkArrayElement(el: ts.Expression): SubsetViolation | undefined {
180
216
  * `fold()`-evaluation-order) unsupported node, or `undefined` when `node`'s
181
217
  * whole shape is foldable.
182
218
  */
183
- export function findSubsetViolation(node: ts.Node): SubsetViolation | undefined {
219
+ export function findSubsetViolation(
220
+ node: ts.Node,
221
+ intrinsics?: readonly IntrinsicDef[],
222
+ ): SubsetViolation | undefined {
184
223
  if (
185
224
  ts.isParenthesizedExpression(node) ||
186
225
  ts.isAsExpression(node) ||
187
226
  ts.isSatisfiesExpression(node) ||
188
227
  ts.isNonNullExpression(node)
189
228
  ) {
190
- return findSubsetViolation(node.expression);
229
+ return findSubsetViolation(node.expression, intrinsics);
191
230
  }
192
231
 
193
232
  if (
@@ -222,7 +261,7 @@ export function findSubsetViolation(node: ts.Node): SubsetViolation | undefined
222
261
 
223
262
  if (ts.isTemplateExpression(node)) {
224
263
  for (const span of node.templateSpans) {
225
- const v = findSubsetViolation(span.expression);
264
+ const v = findSubsetViolation(span.expression, intrinsics);
226
265
  if (v) return v;
227
266
  }
228
267
  return undefined;
@@ -230,7 +269,7 @@ export function findSubsetViolation(node: ts.Node): SubsetViolation | undefined
230
269
 
231
270
  if (ts.isObjectLiteralExpression(node)) {
232
271
  for (const prop of node.properties) {
233
- const v = checkObjectMember(prop);
272
+ const v = checkObjectMember(prop, intrinsics);
234
273
  if (v) return v;
235
274
  }
236
275
  return undefined;
@@ -238,28 +277,28 @@ export function findSubsetViolation(node: ts.Node): SubsetViolation | undefined
238
277
 
239
278
  if (ts.isArrayLiteralExpression(node)) {
240
279
  for (const el of node.elements) {
241
- const v = checkArrayElement(el);
280
+ const v = checkArrayElement(el, intrinsics);
242
281
  if (v) return v;
243
282
  }
244
283
  return undefined;
245
284
  }
246
285
 
247
286
  if (ts.isPropertyAccessExpression(node)) {
248
- return findSubsetViolation(node.expression);
287
+ return findSubsetViolation(node.expression, intrinsics);
249
288
  }
250
289
 
251
290
  if (ts.isElementAccessExpression(node)) {
252
291
  if (!isLiteralElementKey(node.argumentExpression)) {
253
292
  return violation(node.argumentExpression, dynamicElementAccessMessage(node.argumentExpression), "EVL003");
254
293
  }
255
- return findSubsetViolation(node.expression);
294
+ return findSubsetViolation(node.expression, intrinsics);
256
295
  }
257
296
 
258
297
  if (ts.isPrefixUnaryExpression(node)) {
259
298
  if (!SUPPORTED_UNARY_OPERATORS.has(node.operator)) {
260
299
  return violation(node, UNSUPPORTED_UNARY_MESSAGE);
261
300
  }
262
- return findSubsetViolation(node.operand);
301
+ return findSubsetViolation(node.operand, intrinsics);
263
302
  }
264
303
 
265
304
  if (ts.isBinaryExpression(node)) {
@@ -269,32 +308,74 @@ export function findSubsetViolation(node: ts.Node): SubsetViolation | undefined
269
308
  }
270
309
  // Flow-insensitive — see module doc: fold() short-circuits &&/||/?? and
271
310
  // only evaluates the taken side; EVL requires both sides shape-valid.
272
- return findSubsetViolation(node.left) ?? findSubsetViolation(node.right);
311
+ return findSubsetViolation(node.left, intrinsics) ?? findSubsetViolation(node.right, intrinsics);
273
312
  }
274
313
 
275
314
  if (ts.isConditionalExpression(node)) {
276
315
  // Flow-insensitive — see module doc: fold() only folds the taken branch.
277
316
  return (
278
- findSubsetViolation(node.condition) ??
279
- findSubsetViolation(node.whenTrue) ??
280
- findSubsetViolation(node.whenFalse)
317
+ findSubsetViolation(node.condition, intrinsics) ??
318
+ findSubsetViolation(node.whenTrue, intrinsics) ??
319
+ findSubsetViolation(node.whenFalse, intrinsics)
281
320
  );
282
321
  }
283
322
 
284
323
  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()));
324
+ // chant #1082 no positional assumption about which argument is the
325
+ // props object. `foldResource` folds every argument in source order (the
326
+ // props object is second in `new Parameter("String", {...})`), so every
327
+ // argument is classified on its own terms and nothing is rejected merely
328
+ // for being in the "wrong" position.
329
+ for (const arg of node.arguments ?? []) {
330
+ const v = findSubsetViolation(arg, intrinsics);
331
+ if (v) return v;
289
332
  }
290
- return findSubsetViolation(firstArg);
333
+ return undefined;
291
334
  }
292
335
 
293
336
  if (ts.isSpreadElement(node)) {
294
- return findSubsetViolation(node.expression);
337
+ return findSubsetViolation(node.expression, intrinsics);
295
338
  }
296
339
 
297
340
  if (ts.isCallExpression(node)) {
341
+ // chant #1082 — a call to a REGISTERED chant authoring helper folds
342
+ // (`phase(...)`, `output(...)`, …; see ./foldable-helpers.ts), so this
343
+ // classifier must accept it too or EVL001 would flag source `fold()`
344
+ // reduces cleanly. Name-only here, deliberately: this module classifies
345
+ // shape and never resolves bindings (module doc, point 1), and the
346
+ // provenance half of the check — is this name actually bound to an import
347
+ // of chant's own? — needs the module graph, which only
348
+ // ../discovery/fold-import.ts has. Same asymmetry as intrinsic tag
349
+ // registration (point 2) and in the same direction: this module can only
350
+ // ever be MORE permissive than `fold()`, never stricter.
351
+ if (ts.isIdentifier(node.expression) && isFoldableHelperName(node.expression.text)) {
352
+ for (const arg of node.arguments) {
353
+ const v = findSubsetViolation(arg, intrinsics);
354
+ if (v) return v;
355
+ }
356
+ return undefined;
357
+ }
358
+
359
+ // chant #1044 — a plain call to a lexicon intrinsic whose lexicon opted
360
+ // its call form in folds too (`Ref(bucket)`, `Concat("a", b)`). Unlike
361
+ // the helper case above, this one is only answerable with the registry
362
+ // in hand, which is exactly why it is a parameter: a caller that passes
363
+ // `intrinsics` gets fold()'s own answer, and a caller that can't supply
364
+ // one (EVL, any syntax-only tool) keeps the pre-#1044 answer — every
365
+ // call is a violation. See the module doc, point 2c, for why the
366
+ // registry-less answer is the safe one to leave in place.
367
+ if (
368
+ intrinsics &&
369
+ ts.isIdentifier(node.expression) &&
370
+ intrinsics.some((i) => i.name === (node.expression as ts.Identifier).text && intrinsicCallFolds(i))
371
+ ) {
372
+ for (const arg of node.arguments) {
373
+ const v = findSubsetViolation(arg, intrinsics);
374
+ if (v) return v;
375
+ }
376
+ return undefined;
377
+ }
378
+
298
379
  return violation(node, callExpressionMessage(node));
299
380
  }
300
381
 
@@ -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
  // ---------------------------------------------------------------------------
package/src/lexicon.ts CHANGED
@@ -153,39 +153,89 @@ export interface IntrinsicDef {
153
153
  * `../cli/commands/check-lexicon-intrinsics.ts`.
154
154
  */
155
155
  readonly isTag: boolean;
156
+ /**
157
+ * chant #1044 — opt this intrinsic's PLAIN-CALL form into folding
158
+ * (`Ref(bucket)`, `Concat(a, b)` reduce to their intrinsic node instead of
159
+ * falling the whole file back to the run path).
160
+ *
161
+ * Optional, and OFF unless a lexicon writes `true`. That default is the
162
+ * point: `fold()` has no general `CallExpression` case by construction
163
+ * (epic #1019), and this field is the only thing that admits one. It is a
164
+ * closed, lexicon-declared allowlist, decided one intrinsic at a time —
165
+ * never inferred from `isTag`, from the name, or from the call's shape. An
166
+ * intrinsic with no `foldsAsCall` behaves exactly as it did before #1044.
167
+ *
168
+ * Only set it when calling the intrinsic is a pure function of its
169
+ * arguments that builds a deterministic data envelope — the whole
170
+ * correctness argument is that invoking it while folding is
171
+ * indistinguishable from invoking it during a real run of the file. An
172
+ * intrinsic that reads the environment, mutates state, or depends on
173
+ * anything but its arguments does not qualify, and neither does a tagged
174
+ * template (see {@link isTag}: the two forms are mutually exclusive, and
175
+ * `chant dev check-lexicon` rejects `isTag: true` + `foldsAsCall: true`).
176
+ *
177
+ * Registration is by name. It is not permission to invoke whatever that
178
+ * name happens to be bound to: `fold()` reduces the call to a symbolic
179
+ * envelope executing nothing, and `../discovery/fold-import.ts` resolves
180
+ * the name through the folding FILE'S OWN imports before invoking the real
181
+ * function — so the function that runs while folding is the same one the
182
+ * run path would have called, from the module the source itself named.
183
+ */
184
+ readonly foldsAsCall?: boolean;
156
185
  }
157
186
 
158
187
  /**
159
- * Whether `chant build --fold` can ever fold a call to this intrinsic
160
- * (chant #1062, epic #1019). Today the answer is a direct function of
161
- * `isTag`: `fold()` (../fold/fold.ts) has no `CallExpression` case at all —
162
- * a plain-call intrinsic (`Ref(...)`, `Concat(...)`, `reference(...)`, …) is
163
- * always a run-fallback, no matter what it's named or registered as — while
164
- * a *registered* tagged-template intrinsic (`Sub\`...\``) folds because
165
- * `foldTaggedTemplate` recognizes its tag and recurses into the interior.
188
+ * Whether `chant build --fold` can ever fold a use of this intrinsic
189
+ * (chant #1062, epic #1019) in EITHER authored form.
190
+ *
191
+ * Two disjoint ways to qualify, one per form:
192
+ *
193
+ * - a registered tagged-template intrinsic (`Sub\`...\``) folds because
194
+ * `foldTaggedTemplate` recognizes its tag and recurses into the interior
195
+ * ({@link intrinsicTagFolds});
196
+ * - a registered plain-call intrinsic (`Ref(...)`, `Concat(...)`) folds
197
+ * only when its lexicon opted it in with `foldsAsCall`
198
+ * ({@link intrinsicCallFolds}, chant #1044). Before #1044 no plain call
199
+ * folded at all, whatever it was named or registered as.
166
200
  *
167
- * This function is the single predicate both `fold()` (deciding whether a
168
- * tag is registered for real) and the generated per-lexicon intrinsics page
169
- * (`../codegen/docs-sections.ts`'s "Folds?" column) call never two copies
170
- * of the same `isTag === true` check that could silently drift. #1044 (per-
171
- * intrinsic, per-lexicon foldability) changes this function's body to
172
- * consult something more than `isTag` for a plain call that becomes
173
- * foldable; every caller keeps working unchanged, and the generated matrix
174
- * updates the moment a lexicon's registration says a given intrinsic now
175
- * folds — no doc rewrite, no second code path to remember.
201
+ * This function is the single predicate the generated per-lexicon intrinsics
202
+ * page (`../codegen/docs-sections.ts`'s "Folds?" column) calls never a
203
+ * restated copy that could silently drift from the code. `fold()` itself
204
+ * calls the two form-specific predicates below rather than this one, because
205
+ * it always knows which form it is looking at, and a tag must not fold as a
206
+ * call (or vice versa) merely because the other form was opted in.
176
207
  *
177
- * Takes `{ isTag?: boolean }` rather than `Pick<IntrinsicDef, "isTag">`
178
- * deliberately: `IntrinsicDef.isTag` is required for new registrations
179
- * (chant #1067), but this predicate also reads `isTag` off untrusted,
180
- * possibly-older parsed JSON (`ManifestJSON` in `./codegen/docs-types.ts`,
181
- * on disk as a published lexicon's `dist/manifest.json`) that may predate
182
- * the required field. `undefined` there means the same thing it always
183
- * did — not a tag so the check below is intentionally unchanged.
208
+ * Takes a structural `{ isTag?, foldsAsCall? }` rather than
209
+ * `Pick<IntrinsicDef, ...>` deliberately: `IntrinsicDef.isTag` is required
210
+ * for new registrations (chant #1067), but these predicates also read off
211
+ * untrusted, possibly-older parsed JSON (`ManifestJSON` in
212
+ * `./codegen/docs-types.ts`, on disk as a published lexicon's
213
+ * `dist/manifest.json`) that may predate either field. `undefined` there
214
+ * means what it always did — not a tag, not opted in.
184
215
  */
185
- export function intrinsicFolds(def: { isTag?: boolean }): boolean {
216
+ export function intrinsicFolds(def: { isTag?: boolean; foldsAsCall?: boolean }): boolean {
217
+ return intrinsicTagFolds(def) || intrinsicCallFolds(def);
218
+ }
219
+
220
+ /** True when this intrinsic's TAGGED-TEMPLATE form folds (`Sub\`...\``) — see {@link intrinsicFolds}. */
221
+ export function intrinsicTagFolds(def: { isTag?: boolean }): boolean {
186
222
  return def.isTag === true;
187
223
  }
188
224
 
225
+ /**
226
+ * True when this intrinsic's PLAIN-CALL form folds (`Ref(...)`) — i.e. the
227
+ * lexicon opted it in via {@link IntrinsicDef.foldsAsCall} (chant #1044).
228
+ *
229
+ * `isTag: true` disqualifies regardless: a tagged template is invoked as
230
+ * `` Name`...` ``, so a call to it isn't the registered authoring form at
231
+ * all. Keeping that here rather than trusting registrations means a lexicon
232
+ * that declares both flags cannot quietly widen `fold()`'s call case — and
233
+ * `chant dev check-lexicon` fails the registration outright.
234
+ */
235
+ export function intrinsicCallFolds(def: { isTag?: boolean; foldsAsCall?: boolean }): boolean {
236
+ return def.isTag !== true && def.foldsAsCall === true;
237
+ }
238
+
189
239
  /**
190
240
  * Options passed to a MigrationSource by `chant migrate`.
191
241
  */
package/src/runtime.ts CHANGED
@@ -54,18 +54,27 @@ export function createResource(
54
54
 
55
55
  /**
56
56
  * Create a property-kind class for a given property type.
57
+ *
58
+ * Instances carry DECLARABLE_MARKER/lexicon/entityType/kind at runtime (set
59
+ * via defineProperty below), so they already satisfy `Declarable` — the
60
+ * return type just needs to say so. Before this, the signature was
61
+ * `Record<string, unknown>` with no `Declarable`, so any composite that
62
+ * returned a property-kind instance as a top-level member only type-checked
63
+ * by accident: either the caller went through an untyped `require()` (losing
64
+ * the type entirely) or never assigned the instance where its `Declarable`-ness
65
+ * was checked statically. See chant #1068.
57
66
  */
58
67
  export function createProperty(
59
68
  type: string,
60
69
  lexicon: string,
61
- ): new (props: Record<string, unknown>) => Record<string, unknown> {
70
+ ): new (props: Record<string, unknown>) => Declarable & Record<string, unknown> {
62
71
  const PropertyClass = function (this: Record<string, unknown>, props: Record<string, unknown>) {
63
72
  Object.defineProperty(this, DECLARABLE_MARKER, { value: true, enumerable: false });
64
73
  Object.defineProperty(this, "lexicon", { value: lexicon, enumerable: false });
65
74
  Object.defineProperty(this, "entityType", { value: type, enumerable: false });
66
75
  Object.defineProperty(this, "kind", { value: "property", enumerable: false });
67
76
  Object.defineProperty(this, "props", { value: props ?? {}, enumerable: false, configurable: true });
68
- } as unknown as new (props: Record<string, unknown>) => Record<string, unknown>;
77
+ } as unknown as new (props: Record<string, unknown>) => Declarable & Record<string, unknown>;
69
78
 
70
79
  Object.defineProperty(PropertyClass, "name", { value: type.split(".").pop() ?? type });
71
80