@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
package/src/fold/fold.ts CHANGED
@@ -1,20 +1,21 @@
1
1
  import * as ts from "typescript";
2
- import { intrinsicFolds, type IntrinsicDef } from "../lexicon";
2
+ import { intrinsicCallFolds, intrinsicTagFolds, type IntrinsicDef } from "../lexicon";
3
3
  import {
4
4
  SUPPORTED_BINARY_OPERATORS,
5
5
  SUPPORTED_UNARY_OPERATORS,
6
6
  UNSUPPORTED_OBJECT_MEMBER_MESSAGE,
7
7
  UNSUPPORTED_UNARY_MESSAGE,
8
+ briefNodeText,
8
9
  callExpressionMessage,
9
10
  computedPropertyNameMessage,
10
11
  dynamicElementAccessMessage,
11
12
  isLiteralElementKey,
12
13
  isLiteralPropertyName,
13
- resourceCtorArgMessage,
14
14
  unsupportedBinaryMessage,
15
15
  unsupportedExpressionMessage,
16
16
  type SubsetRuleId,
17
17
  } from "./subset";
18
+ import { isFoldableHelperName } from "./foldable-helpers";
18
19
 
19
20
  /**
20
21
  * fold — static AST value reducer (chant #1026/#1021/#1024, part of epic #1019)
@@ -32,9 +33,22 @@ import {
32
33
  * ({@link "../lint/rules/evl001-non-literal-expression"}), so the linted
33
34
  * subset and the folded subset can never drift apart (#1024).
34
35
  *
35
- * A `CallExpression` has no case — a function call as a value is
36
+ * A `CallExpression` has almost no case — a function call as a value is
36
37
  * structurally unrepresentable, not merely linted against. Composite
37
- * factory calls are out of scope here (epic Phase 5, #1023).
38
+ * factory calls are out of scope here (epic Phase 5, #1023). There are
39
+ * exactly two exceptions, both closed allowlists of names declared
40
+ * somewhere a human had to write them down, and both reducing to a symbolic
41
+ * envelope that executes nothing here:
42
+ *
43
+ * - a call to a REGISTERED chant authoring helper
44
+ * ({@link "./foldable-helpers"}, chant #1082) → {@link FoldedHelperCall};
45
+ * - a call to a lexicon intrinsic whose lexicon registered it AND opted
46
+ * its call form in ({@link intrinsicCallFolds}, ../lexicon.ts, chant
47
+ * #1044) → {@link FoldedIntrinsicCall}, the same `{__intrinsic}` family
48
+ * the tagged-template form already reduces to.
49
+ *
50
+ * Everything else — a user's function, a method call, an array `.map`, a
51
+ * registered name shadowed by a local binding — still throws.
38
52
  *
39
53
  * Cross-file identifier resolution (chant #1020): `consts` alone is always
40
54
  * this file's own top-level bindings — that part stays single-file, and
@@ -69,6 +83,7 @@ export type FoldedValue =
69
83
  | { [key: string]: FoldedValue }
70
84
  | AttrRefValue
71
85
  | FoldedIntrinsic
86
+ | FoldedHelperCall
72
87
  | SymbolicValue
73
88
  | FoldedResource;
74
89
 
@@ -96,12 +111,50 @@ export interface AttrRefValue {
96
111
  * Mirrors the runtime call shape `Tag(strings, ...values)` so a later
97
112
  * build path can replay it into the real intrinsic object (#1022).
98
113
  */
99
- export interface FoldedIntrinsic {
114
+ export type FoldedIntrinsic = FoldedIntrinsicTag | FoldedIntrinsicCall;
115
+
116
+ /** The tagged-template form: `Sub\`${x}-y\`` — see {@link FoldedIntrinsic}. */
117
+ export interface FoldedIntrinsicTag {
100
118
  __intrinsic: string;
101
119
  strings: string[];
102
120
  values: FoldedValue[];
103
121
  }
104
122
 
123
+ /**
124
+ * The plain-call form of a registered, opted-in lexicon intrinsic
125
+ * (chant #1044) — `Ref(bucket)`, `Concat("a", b)`, `GetAtt("Fn", "Arn")`.
126
+ * Same `__intrinsic` key as the tagged-template form, so the same revival
127
+ * branch handles both and nothing downstream learns a new envelope; the
128
+ * payload differs because the call shape does — positional `args` mirroring
129
+ * `Name(...args)`, where the tag form mirrors `Name(strings, ...values)`.
130
+ *
131
+ * Symbolic, exactly like the tag form: `fold()` executes nothing, it only
132
+ * records that a registered intrinsic was called and with what. The real
133
+ * function is resolved through the folding file's own imports and invoked by
134
+ * `../discovery/fold-import.ts`'s `reviveFoldedValue`.
135
+ */
136
+ export interface FoldedIntrinsicCall {
137
+ __intrinsic: string;
138
+ args: FoldedValue[];
139
+ }
140
+
141
+ /**
142
+ * The result of folding a call to a registered chant authoring helper
143
+ * (chant #1082) — e.g. `phase("Apply", [...])`, `output(ref, "oX")`. Holds
144
+ * the helper's name and its folded arguments, in source order.
145
+ *
146
+ * Symbolic, exactly like {@link FoldedIntrinsic}: `fold()` executes nothing,
147
+ * it only records that a registered helper was called and with what. The real
148
+ * function is resolved through the folding file's own imports and invoked by
149
+ * `../discovery/fold-import.ts`'s `reviveFoldedValue`, which is also where
150
+ * the "is this name actually bound to chant's own helper" check lives. See
151
+ * {@link "./foldable-helpers"} for the allowlist and why it is closed.
152
+ */
153
+ export interface FoldedHelperCall {
154
+ __helper: string;
155
+ args: FoldedValue[];
156
+ }
157
+
105
158
  /**
106
159
  * A sub-expression inside a folded intrinsic that fold could not reduce to
107
160
  * a value without resolving an identifier from outside this file — e.g. an
@@ -129,6 +182,18 @@ export interface FoldedResource {
129
182
  * ../runtime.ts). Present only when the source actually passed one.
130
183
  */
131
184
  attributes?: { [key: string]: FoldedValue };
185
+ /**
186
+ * chant #1082 — every constructor argument, folded, in source order. Present
187
+ * only when the argument list is NOT the classic `(props)` / `(props,
188
+ * attributes)` shape — most often because the props object isn't first
189
+ * (`new Parameter("String", {...})`, whose signature is `(type, props)`).
190
+ *
191
+ * When present this is authoritative: the entity is constructed by spreading
192
+ * it, so the constructor receives exactly what the source wrote. `props`
193
+ * alongside it is the first object-literal argument, reported for readers,
194
+ * never re-passed (which would double-count it).
195
+ */
196
+ args?: FoldedValue[];
132
197
  }
133
198
 
134
199
  /**
@@ -257,26 +322,49 @@ function resolvesToResource(consts: Map<string, ts.Expression>, ident: ts.Identi
257
322
 
258
323
  /**
259
324
  * True when `node` is an identifier, or a dotted/bracketed access chain
260
- * rooted at an identifier, that isn't bound in `consts` e.g.
261
- * `AWS.StackName` from an imported pseudo-parameter namespace, or a bare
262
- * imported identifier. Resolving what it actually refers to requires
263
- * following an import (#1020), which is out of scope here.
325
+ * rooted at an identifier, that neither `consts` nor `externals` can resolve
326
+ * — e.g. `AWS.StackName` from an imported pseudo-parameter namespace inside
327
+ * a lexicon package (still #1063). Nothing here can say what it refers to,
328
+ * so an intrinsic's interior keeps it symbolically rather than rejecting it.
329
+ *
330
+ * `externals` (chant #1020) is consulted so a root that fold CAN resolve is
331
+ * not treated as unresolved: `Ref(environment)`, where `environment` is a
332
+ * `Parameter` imported from a sibling project file, must fold to the REAL,
333
+ * already-constructed Declarable the fold session made for that file, not to
334
+ * a `{__symbol}` the bridge later re-imports — re-importing the defining
335
+ * module builds a second, differently-identified instance of the same
336
+ * resource, which is exactly the shared-identity property #1020 exists to
337
+ * preserve (see fold-import.ts's module doc).
264
338
  */
265
- function isUnresolvedSymbolChain(node: ts.Expression, consts: Map<string, ts.Expression>): boolean {
266
- if (ts.isIdentifier(node)) return node.text !== "undefined" && !consts.has(node.text);
267
- if (ts.isPropertyAccessExpression(node)) return isUnresolvedSymbolChain(node.expression, consts);
268
- if (ts.isElementAccessExpression(node)) return isUnresolvedSymbolChain(node.expression, consts);
269
- if (ts.isNonNullExpression(node)) return isUnresolvedSymbolChain(node.expression, consts);
339
+ function isUnresolvedSymbolChain(
340
+ node: ts.Expression,
341
+ consts: Map<string, ts.Expression>,
342
+ externals?: ReadonlyMap<string, unknown>,
343
+ ): boolean {
344
+ if (ts.isIdentifier(node)) {
345
+ return node.text !== "undefined" && !consts.has(node.text) && !externals?.has(node.text);
346
+ }
347
+ if (ts.isPropertyAccessExpression(node)) return isUnresolvedSymbolChain(node.expression, consts, externals);
348
+ if (ts.isElementAccessExpression(node)) return isUnresolvedSymbolChain(node.expression, consts, externals);
349
+ if (ts.isNonNullExpression(node)) return isUnresolvedSymbolChain(node.expression, consts, externals);
270
350
  return false;
271
351
  }
272
352
 
273
353
  /**
274
- * Fold one interpolated sub-expression of a registered intrinsic tagged
275
- * template. Identical to {@link fold}, except an unresolved external
276
- * symbol chain (a pseudo-parameter-style access this file can't see the
277
- * import for) folds to a {@link SymbolicValue} instead of throwing — the
278
- * run path resolves it once the module actually imports and runs; fold
279
- * preserves it symbolically rather than stringifying or rejecting it.
354
+ * Fold one sub-expression of a registered intrinsic's interior — an
355
+ * interpolation of its tagged-template form, or (chant #1044) an argument of
356
+ * its plain-call form. Identical to {@link fold}, except a symbol chain
357
+ * nothing can resolve (a pseudo-parameter-style access into a lexicon
358
+ * package, `AWS.StackName`) folds to a {@link SymbolicValue} instead of
359
+ * throwing — the run path resolves it once the module actually imports and
360
+ * runs; fold preserves it symbolically rather than stringifying or rejecting
361
+ * it, and fold-import.ts's `resolveSymbolicValue` resolves it for real
362
+ * before the intrinsic is constructed.
363
+ *
364
+ * `externals` (chant #1020) takes precedence over the symbolic path: see
365
+ * {@link isUnresolvedSymbolChain} for why an already-resolved cross-file
366
+ * binding must reach the intrinsic as the real, shared object rather than as
367
+ * a symbol the bridge re-imports.
280
368
  */
281
369
  function foldIntrinsicValue(
282
370
  node: ts.Expression,
@@ -284,16 +372,7 @@ function foldIntrinsicValue(
284
372
  intrinsics: readonly IntrinsicDef[],
285
373
  externals?: ReadonlyMap<string, unknown>,
286
374
  ): FoldedValue {
287
- // Deliberately checks `consts` only, not `externals` — an imported
288
- // identifier here still folds to a symbolic `{__symbol}` node exactly as
289
- // before #1020, revived later by fold-import.ts's own
290
- // `resolveSymbolicValue` (a real, trusted import of the defining module —
291
- // fine for a plain pseudo-parameter-style namespace, the only shape this
292
- // path exists for). Wiring `externals` in here too would mean two
293
- // different mechanisms resolving the same cross-file name inside an
294
- // intrinsic interpolation; out of scope for #1020, which only needs
295
- // identifiers/property access OUTSIDE tagged templates to resolve.
296
- if (isUnresolvedSymbolChain(node, consts)) {
375
+ if (isUnresolvedSymbolChain(node, consts, externals)) {
297
376
  return { __symbol: node.getText() };
298
377
  }
299
378
  return fold(node, consts, intrinsics, externals);
@@ -301,9 +380,13 @@ function foldIntrinsicValue(
301
380
 
302
381
  /**
303
382
  * Fold a `TaggedTemplateExpression` whose tag is a registered, foldable
304
- * lexicon intrinsic ({@link intrinsicFolds}, `../lexicon.ts`) to its node
383
+ * lexicon intrinsic ({@link intrinsicTagFolds}, `../lexicon.ts`) to its node
305
384
  * form. An unregistered — or registered-but-not-foldable — tag throws a
306
385
  * located {@link FoldError}.
386
+ *
387
+ * Checks the TAG-form predicate specifically (chant #1044): an intrinsic
388
+ * whose lexicon opted its plain-call form in is not thereby usable as a
389
+ * tagged template, and `` Ref`...` `` stays a rejection.
307
390
  */
308
391
  function foldTaggedTemplate(
309
392
  node: ts.TaggedTemplateExpression,
@@ -312,9 +395,9 @@ function foldTaggedTemplate(
312
395
  externals?: ReadonlyMap<string, unknown>,
313
396
  ): FoldedIntrinsic {
314
397
  const tagName = node.tag.getText();
315
- const isRegistered = intrinsics.some((i) => i.name === tagName && intrinsicFolds(i));
398
+ const isRegistered = intrinsics.some((i) => i.name === tagName && intrinsicTagFolds(i));
316
399
  if (!isRegistered) {
317
- throw foldError(node, `unregistered tagged template intrinsic: ${tagName}\`...\``);
400
+ throw foldError(node, `unregistered tagged template intrinsic: ${briefNodeText(node.tag)}\`...\``);
318
401
  }
319
402
 
320
403
  const template = node.template;
@@ -332,13 +415,15 @@ function foldTaggedTemplate(
332
415
  /**
333
416
  * Fold a single expression node to a value. Throws {@link FoldError} for
334
417
  * anything outside the supported subset — including any `CallExpression`
335
- * that isn't a registered intrinsic tagged template.
418
+ * that is neither a registered chant authoring helper nor a registered,
419
+ * call-form-opted-in lexicon intrinsic (see the module doc).
336
420
  *
337
- * @param intrinsics - Lexicon-registered intrinsics that fold
338
- * ({@link intrinsicFolds}, e.g. `Sub`). A tagged template whose tag isn't
339
- * in this list, or is registered but not foldable, is rejected. Defaults
340
- * to none pass the target lexicon's manifest `intrinsics` to recognize
341
- * its tags.
421
+ * @param intrinsics - The active lexicons' registered intrinsics. A tagged
422
+ * template whose tag isn't in this list, or is in it without
423
+ * {@link intrinsicTagFolds}, is rejected; a plain call is rejected unless
424
+ * its callee is in this list with {@link intrinsicCallFolds} (chant
425
+ * #1044). Defaults to none — pass the target lexicon's manifest
426
+ * `intrinsics` to recognize either form.
342
427
  * @param externals - chant #1020: pre-resolved imported bindings, consulted
343
428
  * only when an identifier isn't in `consts`. See the module doc above.
344
429
  * `undefined` (the default) preserves the exact pre-#1020 single-file
@@ -551,10 +636,66 @@ export function fold(
551
636
  // Reject so the file falls back to run, which constructs and serializes it
552
637
  // correctly. EVL permits this statically — it's a documented fold/EVL
553
638
  // divergence, like identifier resolution and spread runtime type.
554
- throw foldError(node, `nested \`new ${node.expression.getText()}(...)\` as a value is not foldable — falls back to run`);
639
+ throw foldError(
640
+ node,
641
+ `nested \`new ${briefNodeText(node.expression)}(...)\` as a value is not foldable — falls back to run`,
642
+ );
555
643
  }
556
644
 
557
645
  if (ts.isCallExpression(node)) {
646
+ // chant #1082 — the ONE call shape that folds: a registered chant
647
+ // authoring helper ({@link FOLDABLE_AUTHORING_HELPERS}), called through a
648
+ // bare identifier that this file hasn't shadowed with its own `const`.
649
+ // Nothing is executed here — the call reduces to a symbolic
650
+ // {@link FoldedHelperCall} envelope, exactly as a registered intrinsic
651
+ // tagged template reduces to a {@link FoldedIntrinsic} one, and for the
652
+ // same reason: the real function lives in another module, and resolving
653
+ // + invoking it is the async bridge's job (../discovery/fold-import.ts's
654
+ // `reviveFoldedValue`), which also verifies the name is actually bound to
655
+ // an import of chant's own before invoking anything. Every other call —
656
+ // a user's function, a method call, a call to something declared in this
657
+ // file — still has no case and throws, unchanged.
658
+ if (
659
+ ts.isIdentifier(node.expression) &&
660
+ isFoldableHelperName(node.expression.text) &&
661
+ !consts.has(node.expression.text)
662
+ ) {
663
+ return {
664
+ __helper: node.expression.text,
665
+ args: node.arguments.map((arg) => fold(arg, consts, intrinsics, externals)),
666
+ };
667
+ }
668
+
669
+ // chant #1044 — the other call shape that folds: a lexicon intrinsic in
670
+ // PLAIN-CALL form (`Ref(bucket)`, `Concat("a", b)`), where that lexicon
671
+ // registered it AND opted its call form in ({@link intrinsicCallFolds},
672
+ // ../lexicon.ts — default off, never inferred). Reduces to the same
673
+ // `{__intrinsic}` envelope family the tagged-template form produces, with
674
+ // positional `args`; nothing is executed here, for the same reason as the
675
+ // tag form — the real function lives in the lexicon module, and resolving
676
+ // it through this file's own imports and invoking it is the async
677
+ // bridge's job (../discovery/fold-import.ts's `reviveFoldedValue`).
678
+ //
679
+ // Arguments fold through {@link foldIntrinsicValue}, exactly like a tag's
680
+ // interpolations: an intrinsic's interior is where a pseudo-parameter
681
+ // chain (`GetAZs(AWS.Region)`) legitimately appears, and it stays
682
+ // symbolic rather than rejecting.
683
+ //
684
+ // The door does not open any wider than this. A bare-identifier callee
685
+ // only, so `ns.Ref(...)` and `arr.map(...)` are untouched; the file's own
686
+ // `const` shadowing wins, so a local `Ref` is not the lexicon's; and a
687
+ // name absent from the active lexicons' registered set — or registered
688
+ // without the opt-in — falls straight through to the throw below.
689
+ if (ts.isIdentifier(node.expression) && !consts.has(node.expression.text)) {
690
+ const calleeName = node.expression.text;
691
+ if (intrinsics.some((i) => i.name === calleeName && intrinsicCallFolds(i))) {
692
+ return {
693
+ __intrinsic: calleeName,
694
+ args: node.arguments.map((arg) => foldIntrinsicValue(arg, consts, intrinsics, externals)),
695
+ };
696
+ }
697
+ }
698
+
558
699
  throw foldError(node, callExpressionMessage(node));
559
700
  }
560
701
 
@@ -562,12 +703,23 @@ export function fold(
562
703
  }
563
704
 
564
705
  /**
565
- * Fold a resource constructor call `new Type({ ...props }, { ...attributes
566
- * })` — to its spec. Each argument present must be an object literal
567
- * (anything else is not statically evaluable and throws a located
568
- * {@link FoldError}). The second argument (CFN-style resource attributes —
569
- * `DependsOn`, `Condition`, `DeletionPolicy`, …) is optional, matching
570
- * `createResource`'s runtime constructor signature (../runtime.ts).
706
+ * Fold a resource constructor call to its spec.
707
+ *
708
+ * The common `createResource` shape (../runtime.ts) is `new Type({ ...props
709
+ * })` or `new Type({ ...props }, { ...attributes })` CFN-style resource
710
+ * attributes (`DependsOn`, `Condition`, `DeletionPolicy`, …) second and
711
+ * that shape reduces to `props` (+ `attributes`) exactly as before.
712
+ *
713
+ * chant #1082 — but that is a convention, not a rule every lexicon class
714
+ * follows. AWS's deploy-time `Parameter` is `(type, props)`
715
+ * (lexicons/aws/src/parameter.ts): the props object is the SECOND argument
716
+ * and the first is a plain string. `foldResource` used to require argument 0
717
+ * to be an object literal, so no `new Parameter(...)` anywhere could ever
718
+ * fold, whatever surrounded it. The general case now folds every argument in
719
+ * source order into {@link FoldedResource.args}, which the caller constructs
720
+ * the entity from verbatim — no positional assumption at all. `props` is
721
+ * still reported (the first object-literal argument, for callers that read
722
+ * it) but is a VIEW onto `args`, not the thing constructed from.
571
723
  */
572
724
  export function foldResource(
573
725
  node: ts.NewExpression,
@@ -576,24 +728,33 @@ export function foldResource(
576
728
  externals?: ReadonlyMap<string, unknown>,
577
729
  ): FoldedResource {
578
730
  const typeName = node.expression.getText();
579
- const [firstArg, secondArg] = node.arguments ?? [];
731
+ const args = node.arguments ?? ([] as unknown as ts.NodeArray<ts.Expression>);
732
+ const [firstArg, secondArg] = args;
733
+ const foldArg = (arg: ts.Expression) => fold(arg, consts, intrinsics, externals);
580
734
 
581
735
  if (!firstArg) {
582
736
  return { __resource: typeName, props: {} };
583
737
  }
584
- if (!ts.isObjectLiteralExpression(firstArg)) {
585
- throw foldError(firstArg, resourceCtorArgMessage(typeName));
586
- }
587
- const props = fold(firstArg, consts, intrinsics, externals) as { [key: string]: FoldedValue };
588
738
 
589
- if (!secondArg) {
590
- return { __resource: typeName, props };
591
- }
592
- if (!ts.isObjectLiteralExpression(secondArg)) {
593
- throw foldError(secondArg, `resource attributes argument must be an object literal: ${typeName}(...)`);
739
+ // The classic (props) / (props, attributes) shape — reported without an
740
+ // `args` list so the spec of an ordinary resource is unchanged.
741
+ if (ts.isObjectLiteralExpression(firstArg)) {
742
+ const props = foldArg(firstArg) as { [key: string]: FoldedValue };
743
+ if (args.length === 1) {
744
+ return { __resource: typeName, props };
745
+ }
746
+ if (args.length === 2 && ts.isObjectLiteralExpression(secondArg)) {
747
+ return { __resource: typeName, props, attributes: foldArg(secondArg) as { [key: string]: FoldedValue } };
748
+ }
594
749
  }
595
- const attributes = fold(secondArg, consts, intrinsics, externals) as { [key: string]: FoldedValue };
596
- return { __resource: typeName, props, attributes };
750
+
751
+ // Anything else: fold every argument positionally. Each one still has to be
752
+ // in the fold subset on its own terms — a non-foldable argument throws from
753
+ // `fold()` exactly as a non-foldable prop value does.
754
+ const folded = args.map(foldArg);
755
+ const propsIndex = args.findIndex((arg) => ts.isObjectLiteralExpression(arg));
756
+ const props = (propsIndex === -1 ? {} : folded[propsIndex]) as { [key: string]: FoldedValue };
757
+ return { __resource: typeName, props, args: folded };
597
758
  }
598
759
 
599
760
  function hasExportModifier(statement: ts.VariableStatement): boolean {
@@ -0,0 +1,171 @@
1
+ /**
2
+ * foldable-helpers — the closed, declared allowlist of chant's OWN authoring
3
+ * helpers that a call expression may fold through (chant #1082, epic #1019).
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * `fold()` has no general case for a `CallExpression`: a function call as a
8
+ * value is structurally unrepresentable there, because folding it would mean
9
+ * executing code, which is the one thing the fold path exists to avoid. That
10
+ * rule is right for user code and stays right — a user's own function, an
11
+ * arrow function, a method call (`naming.name(...)`) all keep failing exactly
12
+ * as before.
13
+ *
14
+ * But it also blocks chant's own documented authoring API. `phase("Apply",
15
+ * [...])` is how the component contract says to write a component; `output(ref,
16
+ * "oX")` is how a lexicon output is authored. An application cannot avoid them
17
+ * and still use components or outputs, so every file that uses one falls back
18
+ * to run no matter how statically evaluable the rest of it is.
19
+ *
20
+ * #1044 already settled the shape of the answer one level down, for lexicon
21
+ * intrinsics: a call-shaped thing may fold ONLY when it is registered, opt-in,
22
+ * per-helper — never because it merely looks like a call. This module is the
23
+ * same decision applied one level up, to chant's own helpers, with the
24
+ * registration written here by hand instead of coming from a lexicon manifest.
25
+ *
26
+ * ## What "registered" buys, and what still has to be true
27
+ *
28
+ * A name in this list is NOT permission to invoke whatever it happens to be
29
+ * bound to. Folding a helper call is a two-key operation:
30
+ *
31
+ * 1. **Shape + name** (here, and in `fold()`/`findSubsetViolation`): the
32
+ * callee is a bare identifier whose text is in {@link
33
+ * FOLDABLE_AUTHORING_HELPERS} and which is not shadowed by a local
34
+ * `const`. `fold()` reduces the call to a symbolic
35
+ * `{ __helper, args }` envelope — it still executes nothing.
36
+ * 2. **Provenance + invocation** (`../discovery/fold-import.ts`): the
37
+ * envelope is revived by resolving that name through the folding FILE'S
38
+ * OWN `import` bindings and checking the import actually comes from chant
39
+ * ({@link isChantOwnedSpecifier}, or a path inside chant-core's own tree
40
+ * for in-repo/absolute-specifier callers). Only then is the real function
41
+ * invoked, with the real folded arguments. A same-named helper imported
42
+ * from somewhere else, or declared in the file itself, resolves to
43
+ * nothing chant owns and the whole file falls back to run.
44
+ *
45
+ * So the function that runs is always the same function the run path would
46
+ * have called, from the same module the source itself imported — fold does not
47
+ * substitute its own reimplementation, which is why this list cannot drift
48
+ * from the helpers' real behavior.
49
+ *
50
+ * ## Admission criteria
51
+ *
52
+ * A helper belongs here only if all of these hold:
53
+ *
54
+ * - chant owns and documents it as authoring surface;
55
+ * - it is a pure function of its arguments — no I/O, no `process.env`, no
56
+ * module-level mutable state, no observable side effect;
57
+ * - calling it early (at fold time) is indistinguishable from calling it
58
+ * during a real run of the file.
59
+ *
60
+ * Deliberately NOT admitted — see this module's tests and the #1082 PR body:
61
+ *
62
+ * - `env()` (`../env.ts`) reads `process.env`. Folding it would bake one
63
+ * run's environment into a statically-derived value. It is exactly the
64
+ * kind of call fold must keep rejecting.
65
+ * - `Op()` (`../op/builders.ts`) constructs an `OpResource` — a `Declarable`.
66
+ * `fold()` already rejects a nested `new Type(...)` used as a value for a
67
+ * real, differential-caught reason (the envelope leaks into
68
+ * serialization); a factory that returns one is the same hazard wearing a
69
+ * call.
70
+ * - `propagate()`, `withDefaults()`, `resource()`, `mergeDefaults()`
71
+ * (`../composite.ts`) are composite *definition* helpers, not value-position
72
+ * helpers. `propagate()` in particular mutates its argument in place, and
73
+ * the composite spine in fold-import.ts already resolves it live.
74
+ * - `createResource()`/`createProperty()` (`../runtime.ts`) are used at a
75
+ * lexicon module's top level to build classes, never as a value inside a
76
+ * project file.
77
+ * - Lexicon intrinsics in CALL form (`Ref(...)`, `Join(...)`) are lexicon
78
+ * surface, not chant's own, and are #1044's registry (`IntrinsicDef`,
79
+ * `../lexicon.ts`) to admit — not this one. Only their tagged-template
80
+ * form folds today.
81
+ */
82
+
83
+ /**
84
+ * One registered helper. `module` and `note` carry no runtime behavior — they
85
+ * are the audit trail for why this entry passed the admission criteria above,
86
+ * kept next to the name it justifies rather than in a comment that can drift
87
+ * away from the list.
88
+ */
89
+ export interface FoldableHelperDef {
90
+ /** The exported name, matched against the callee identifier's text. */
91
+ readonly name: string;
92
+ /** Where chant defines it (a path under `packages/core/src`, for the audit trail). */
93
+ readonly module: string;
94
+ /** Why it qualifies — what it returns and why calling it at fold time is safe. */
95
+ readonly note: string;
96
+ }
97
+
98
+ /**
99
+ * The allowlist. Adding an entry is a deliberate act: it must satisfy every
100
+ * admission criterion in this module's doc, and it widens what `fold()` and
101
+ * `findSubsetViolation` accept for EVERY project, so it belongs in a PR that
102
+ * says so.
103
+ *
104
+ * Two names below are defined TWICE in chant, by different modules, with
105
+ * different return types (`phase`/`gate` by both the component contract and
106
+ * the Op builders; `stackOutput` by both the component contract and the
107
+ * cross-stack output primitive). That is fine and needs no disambiguation
108
+ * here: registration is by name, but the function actually invoked is the one
109
+ * the folding file itself imported (see step 2 in the module doc), so each
110
+ * file gets its own. Both definitions of each name independently satisfy the
111
+ * criteria, which is what makes registering the shared name safe.
112
+ */
113
+ export const FOLDABLE_AUTHORING_HELPERS: readonly FoldableHelperDef[] = [
114
+ {
115
+ name: "phase",
116
+ module: "components/component.ts, op/builders.ts",
117
+ note: "Returns a plain `{ phase, steps, parallel? }` / `{ name, steps, parallel? }` object literal built from its arguments. No state, no I/O.",
118
+ },
119
+ {
120
+ name: "gate",
121
+ module: "components/component.ts, op/builders.ts",
122
+ note: "Returns a plain `{ kind: 'gate', signalName, ... }` object literal built from its arguments.",
123
+ },
124
+ {
125
+ name: "activity",
126
+ module: "op/builders.ts",
127
+ note: "Returns a plain `{ kind: 'activity', fn, args?, profile? }` object literal built from its arguments.",
128
+ },
129
+ {
130
+ name: "stackOutput",
131
+ module: "components/component.ts, stack-output.ts",
132
+ note: "Component form returns a plain `{ stackOutput: { stack, name } }` literal. Cross-stack form derives a `StackOutput` from a real `AttrRef`/`Intrinsic` and throws on anything else — so a fold that only has a symbolic reference fails loudly into run-fallback rather than producing a wrong output.",
133
+ },
134
+ {
135
+ name: "output",
136
+ module: "lexicon-output.ts",
137
+ note: "Constructs a `LexiconOutput` from a real `AttrRef`/`Intrinsic` and a name. Pure, but identity-sensitive: it reads through the ref's `WeakRef` to its parent entity. Only folds when the ref argument revives to a REAL live reference (see fold-import.ts's `requireLiveRefs`); a symbolic `{ __attrRef }` envelope is rejected, not silently wrapped.",
138
+ },
139
+ ];
140
+
141
+ const HELPER_NAMES: ReadonlySet<string> = new Set(FOLDABLE_AUTHORING_HELPERS.map((h) => h.name));
142
+
143
+ /**
144
+ * True when `name` is a registered foldable authoring helper. Name-only — this
145
+ * is the shape-level half of the check (step 1 in the module doc). It says
146
+ * nothing about where the name is bound; `../discovery/fold-import.ts` decides
147
+ * that before anything is invoked.
148
+ */
149
+ export function isFoldableHelperName(name: string): boolean {
150
+ return HELPER_NAMES.has(name);
151
+ }
152
+
153
+ /**
154
+ * Package specifiers chant itself publishes. A registered helper name only
155
+ * folds when the folding file imported it from one of these — or, for in-repo
156
+ * and test callers that import chant-core by relative/absolute path, from
157
+ * inside chant-core's own tree (checked separately, in fold-import.ts, since
158
+ * only that module knows how to resolve a specifier to a path).
159
+ *
160
+ * Lexicon packages are included because several core helpers are re-exported
161
+ * through them and that is the documented import in real projects — e.g.
162
+ * `import { output } from "@intentius/chant-lexicon-aws"`.
163
+ */
164
+ const CHANT_PACKAGE_SPECIFIERS: readonly string[] = ["@intentius/chant", "@intentius/chant-lexicon-"];
165
+
166
+ /** True for a bare specifier that names a chant-published package (or one of its subpaths). */
167
+ export function isChantOwnedSpecifier(specifier: string): boolean {
168
+ return CHANT_PACKAGE_SPECIFIERS.some(
169
+ (prefix) => specifier === prefix || specifier.startsWith(prefix.endsWith("-") ? prefix : `${prefix}/`),
170
+ );
171
+ }
@@ -135,12 +135,39 @@ describe("subset-doc-parity — supported patterns in typescript-as-data.mdx cla
135
135
  expect(findSubsetViolation(resourceArg(consts, "store"))).toBeUndefined();
136
136
  });
137
137
 
138
+ test("Registered intrinsic calls", () => {
139
+ // chant #1044 — the one doc snippet that needs the registry to classify:
140
+ // `Ref(...)` is only in the subset because a lexicon opted its call form
141
+ // in, and `findSubsetViolation` answers exactly that question when it is
142
+ // given the registry (subset.ts module doc, point 2c). Without one it
143
+ // would report a violation, which is the pre-#1044 answer EVL still gets.
144
+ const consts = parseConsts(extractFencedBlock("Registered intrinsic calls"));
145
+ const intrinsics = [
146
+ { name: "Sub", isTag: true },
147
+ { name: "Ref", isTag: false, foldsAsCall: true },
148
+ ];
149
+ expect(findSubsetViolation(resourceArg(consts, "store"), intrinsics)).toBeUndefined();
150
+ expect(findSubsetViolation(resourceArg(consts, "store"))).toBeDefined();
151
+ });
152
+
138
153
  test("Typed property-kind constructors", () => {
139
154
  const consts = parseConsts(extractFencedBlock("Typed property-kind constructors"));
140
155
  expect(findSubsetViolation(resourceArg(consts, "config"))).toBeUndefined();
141
156
  expect(findSubsetViolation(resourceArg(consts, "access"))).toBeUndefined();
142
157
  });
143
158
 
159
+ test("Registered authoring helpers", () => {
160
+ // chant #1082 — not a `new Type({...})` declaration, so classify the
161
+ // exported component object literal directly. `findSubsetViolation`
162
+ // checks the helper NAME only (subset.ts module doc, point 2b); the
163
+ // doc's own snippet imports them from chant, which is what the bridge
164
+ // additionally verifies at fold time.
165
+ const consts = parseConsts(extractFencedBlock("Registered authoring helpers"));
166
+ const web = consts.get("web");
167
+ if (!web) throw new Error(`subset-doc-parity: "web" did not parse as a const declaration`);
168
+ expect(findSubsetViolation(web)).toBeUndefined();
169
+ });
170
+
144
171
  test("Nullish coalescing for defaults", () => {
145
172
  // Not a standalone statement in the doc (a single object-literal
146
173
  // property, deliberately shown as a fragment) — wrapped in an object