@intentius/chant 0.19.1 → 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 (164) hide show
  1. package/dist/build-params.d.ts +108 -0
  2. package/dist/build-params.d.ts.map +1 -0
  3. package/dist/build.d.ts +79 -1
  4. package/dist/build.d.ts.map +1 -1
  5. package/dist/cli/commands/build.d.ts +31 -0
  6. package/dist/cli/commands/build.d.ts.map +1 -1
  7. package/dist/cli/commands/check-lexicon-examples.d.ts +41 -0
  8. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -0
  9. package/dist/cli/commands/check-lexicon-intrinsics.d.ts +71 -0
  10. package/dist/cli/commands/check-lexicon-intrinsics.d.ts.map +1 -0
  11. package/dist/cli/commands/check-lexicon.d.ts +1 -1
  12. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  13. package/dist/cli/commands/lint.d.ts +27 -0
  14. package/dist/cli/commands/lint.d.ts.map +1 -1
  15. package/dist/cli/handlers/build.d.ts.map +1 -1
  16. package/dist/cli/handlers/components.d.ts.map +1 -1
  17. package/dist/cli/handlers/lint.d.ts.map +1 -1
  18. package/dist/cli/handlers/run-client.d.ts +1 -1
  19. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  20. package/dist/cli/handlers/run.d.ts.map +1 -1
  21. package/dist/cli/main.d.ts.map +1 -1
  22. package/dist/cli/plugins.d.ts +16 -0
  23. package/dist/cli/plugins.d.ts.map +1 -1
  24. package/dist/cli/registry.d.ts +10 -0
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/codegen/docs-sections.d.ts.map +1 -1
  27. package/dist/codegen/docs-types.d.ts +2 -0
  28. package/dist/codegen/docs-types.d.ts.map +1 -1
  29. package/dist/components/cli-support.d.ts +12 -5
  30. package/dist/components/cli-support.d.ts.map +1 -1
  31. package/dist/components/discover.d.ts +62 -7
  32. package/dist/components/discover.d.ts.map +1 -1
  33. package/dist/components/sandbox/driver.d.ts +12 -0
  34. package/dist/components/sandbox/driver.d.ts.map +1 -0
  35. package/dist/components/sandbox/run.d.ts +42 -0
  36. package/dist/components/sandbox/run.d.ts.map +1 -0
  37. package/dist/composite.d.ts +5 -0
  38. package/dist/composite.d.ts.map +1 -1
  39. package/dist/config.d.ts +71 -0
  40. package/dist/config.d.ts.map +1 -1
  41. package/dist/declarable.d.ts +16 -0
  42. package/dist/declarable.d.ts.map +1 -1
  43. package/dist/discovery/collect.d.ts.map +1 -1
  44. package/dist/discovery/entity-wire-codec.d.ts +166 -0
  45. package/dist/discovery/entity-wire-codec.d.ts.map +1 -0
  46. package/dist/discovery/entity-wire.d.ts +50 -0
  47. package/dist/discovery/entity-wire.d.ts.map +1 -0
  48. package/dist/discovery/fold-import.d.ts +239 -0
  49. package/dist/discovery/fold-import.d.ts.map +1 -0
  50. package/dist/discovery/index.d.ts +74 -1
  51. package/dist/discovery/index.d.ts.map +1 -1
  52. package/dist/discovery/sandbox/bundle.d.ts +18 -0
  53. package/dist/discovery/sandbox/bundle.d.ts.map +1 -0
  54. package/dist/discovery/sandbox/child-errors.d.ts +15 -0
  55. package/dist/discovery/sandbox/child-errors.d.ts.map +1 -0
  56. package/dist/discovery/sandbox/driver.d.ts +13 -0
  57. package/dist/discovery/sandbox/driver.d.ts.map +1 -0
  58. package/dist/discovery/sandbox/run.d.ts +69 -0
  59. package/dist/discovery/sandbox/run.d.ts.map +1 -0
  60. package/dist/errors.d.ts +9 -1
  61. package/dist/errors.d.ts.map +1 -1
  62. package/dist/fold/fold.d.ts +299 -0
  63. package/dist/fold/fold.d.ts.map +1 -0
  64. package/dist/fold/foldable-helpers.d.ts +121 -0
  65. package/dist/fold/foldable-helpers.d.ts.map +1 -0
  66. package/dist/fold/subset.d.ts +134 -0
  67. package/dist/fold/subset.d.ts.map +1 -0
  68. package/dist/index.d.ts +2 -0
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/lexicon-output.d.ts +7 -2
  71. package/dist/lexicon-output.d.ts.map +1 -1
  72. package/dist/lexicon-schema.d.ts +4 -2
  73. package/dist/lexicon-schema.d.ts.map +1 -1
  74. package/dist/lexicon.d.ts +131 -1
  75. package/dist/lexicon.d.ts.map +1 -1
  76. package/dist/lifecycle/release-ledger.d.ts +11 -0
  77. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  78. package/dist/lint/component-checks.d.ts +7 -1
  79. package/dist/lint/component-checks.d.ts.map +1 -1
  80. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  81. package/dist/lint/rules/evl003-dynamic-property-access.d.ts.map +1 -1
  82. package/dist/params.d.ts +60 -0
  83. package/dist/params.d.ts.map +1 -0
  84. package/dist/provenance.d.ts +21 -0
  85. package/dist/provenance.d.ts.map +1 -1
  86. package/dist/runtime.d.ts +10 -1
  87. package/dist/runtime.d.ts.map +1 -1
  88. package/package.json +2 -1
  89. package/src/build-params.test.ts +144 -0
  90. package/src/build-params.ts +207 -0
  91. package/src/build.test.ts +38 -0
  92. package/src/build.ts +144 -7
  93. package/src/cli/commands/build.test.ts +220 -2
  94. package/src/cli/commands/build.ts +111 -3
  95. package/src/cli/commands/check-lexicon-examples.test.ts +92 -0
  96. package/src/cli/commands/check-lexicon-examples.ts +103 -0
  97. package/src/cli/commands/check-lexicon-intrinsics.test.ts +179 -0
  98. package/src/cli/commands/check-lexicon-intrinsics.ts +348 -0
  99. package/src/cli/commands/check-lexicon.test.ts +34 -0
  100. package/src/cli/commands/check-lexicon.ts +137 -1
  101. package/src/cli/commands/lint.ts +31 -3
  102. package/src/cli/commands/onboard.ts +1 -1
  103. package/src/cli/component-security-boundary.test.ts +170 -0
  104. package/src/cli/handlers/build.ts +24 -3
  105. package/src/cli/handlers/components.ts +9 -2
  106. package/src/cli/handlers/dev.ts +1 -1
  107. package/src/cli/handlers/graph.ts +7 -5
  108. package/src/cli/handlers/lifecycle.ts +2 -2
  109. package/src/cli/handlers/lint.ts +2 -0
  110. package/src/cli/handlers/misc.ts +2 -2
  111. package/src/cli/handlers/run-client.ts +1 -1
  112. package/src/cli/handlers/run.ts +20 -5
  113. package/src/cli/main.test.ts +22 -0
  114. package/src/cli/main.ts +39 -0
  115. package/src/cli/plugins.ts +20 -4
  116. package/src/cli/registry.ts +10 -0
  117. package/src/cli/security-boundary.test.ts +135 -0
  118. package/src/codegen/docs-sections.test.ts +67 -0
  119. package/src/codegen/docs-sections.ts +7 -3
  120. package/src/codegen/docs-types.ts +2 -0
  121. package/src/components/cli-support.ts +22 -10
  122. package/src/components/discover.ts +127 -25
  123. package/src/components/sandbox/driver.ts +114 -0
  124. package/src/components/sandbox/run.test.ts +185 -0
  125. package/src/components/sandbox/run.ts +177 -0
  126. package/src/composite.test.ts +21 -0
  127. package/src/composite.ts +20 -1
  128. package/src/config.ts +81 -0
  129. package/src/declarable.ts +20 -0
  130. package/src/discovery/collect.ts +17 -3
  131. package/src/discovery/entity-wire-codec.ts +487 -0
  132. package/src/discovery/entity-wire.test.ts +240 -0
  133. package/src/discovery/entity-wire.ts +67 -0
  134. package/src/discovery/fold-import.test.ts +1598 -0
  135. package/src/discovery/fold-import.ts +1998 -0
  136. package/src/discovery/index.test.ts +191 -1
  137. package/src/discovery/index.ts +242 -1
  138. package/src/discovery/sandbox/bundle.ts +218 -0
  139. package/src/discovery/sandbox/child-errors.ts +65 -0
  140. package/src/discovery/sandbox/driver.ts +147 -0
  141. package/src/discovery/sandbox/run.test.ts +179 -0
  142. package/src/discovery/sandbox/run.ts +196 -0
  143. package/src/errors.ts +9 -1
  144. package/src/fold/fold.test.ts +812 -0
  145. package/src/fold/fold.ts +805 -0
  146. package/src/fold/foldable-helpers.ts +171 -0
  147. package/src/fold/subset-doc-parity.test.ts +210 -0
  148. package/src/fold/subset.test.ts +352 -0
  149. package/src/fold/subset.ts +383 -0
  150. package/src/index.ts +2 -0
  151. package/src/lexicon-output.ts +7 -2
  152. package/src/lexicon-schema.test.ts +57 -0
  153. package/src/lexicon-schema.ts +8 -1
  154. package/src/lexicon.ts +132 -1
  155. package/src/lifecycle/git.test.ts +10 -5
  156. package/src/lifecycle/release-ledger.test.ts +28 -0
  157. package/src/lifecycle/release-ledger.ts +11 -0
  158. package/src/lint/component-checks.ts +8 -1
  159. package/src/lint/rules/evl001-non-literal-expression.ts +26 -110
  160. package/src/lint/rules/evl003-dynamic-property-access.ts +11 -2
  161. package/src/params.test.ts +22 -0
  162. package/src/params.ts +66 -0
  163. package/src/provenance.ts +22 -0
  164. package/src/runtime.ts +11 -2
@@ -0,0 +1,383 @@
1
+ import * as ts from "typescript";
2
+ import { isFoldableHelperName } from "./foldable-helpers";
3
+ import { intrinsicCallFolds, type IntrinsicDef } from "../lexicon";
4
+
5
+ /**
6
+ * subset — the single canonical definition of chant's statically-foldable
7
+ * expression subset (chant #1024, epic #1019).
8
+ *
9
+ * `fold()` ({@link "./fold"}, the enforcement layer — a construct outside
10
+ * this subset simply has no case there) and EVL001/EVL003
11
+ * ({@link "../lint/rules/evl001-non-literal-expression"},
12
+ * {@link "../lint/rules/evl003-dynamic-property-access"} — the pre-flight
13
+ * diagnostic layer) both import this module so the two can never drift
14
+ * apart on *which node kinds, operators, and key shapes* are foldable.
15
+ * Before this module existed, `fold()` and EVL001 each hand-rolled their
16
+ * own recursive classifier; they agreed almost everywhere but had several
17
+ * real, silent gaps (see git history of #1024 for the enumerated list —
18
+ * dynamic object-literal keys, template/tagged-template interiors, and
19
+ * unrestricted binary/unary operators were all accepted by EVL001 but
20
+ * rejected by `fold()`). This module is the fix: one classifier, two
21
+ * importers.
22
+ *
23
+ * Scope — this module classifies *shape* only: the syntactic kind of an
24
+ * expression, its operator, and (for keys) its literal-ness. It
25
+ * deliberately does NOT resolve bindings or perform any evaluation, because
26
+ * three things a full fold needs are inherently environment-dependent and
27
+ * cannot be recovered from shape alone:
28
+ *
29
+ * 1. Identifier *resolution* — is a bare name actually a local `const`,
30
+ * vs. an unbound name? That needs the file's `consts` map. Both
31
+ * `fold()` and this module treat a bare identifier as shape-valid;
32
+ * `fold()` alone resolves it (and rejects if unresolved) once it has
33
+ * that map. A lint rule has no equivalent binding-resolution pass
34
+ * today, so EVL (via this module) stays permissive here — a known,
35
+ * intentional asymmetry, not a bug: it can only ever be a *false
36
+ * negative* on EVL's part (EVL passes something `fold()` might later
37
+ * reject for being unresolved), never the reverse.
38
+ * 2. Tagged-template *tag registration* — needs a lexicon's intrinsics
39
+ * manifest, which isn't available to a syntax-only lint rule. `fold()`
40
+ * alone checks it; this module treats any tag name as shape-valid and
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.
74
+ * 3. Runtime *type* of a folded value — e.g. spreading `const n = 5`
75
+ * (`{...n}`) is shape-valid (`n` is a plain identifier) but `fold()`
76
+ * rejects it once it discovers `n` folds to a number, not an object.
77
+ * Only real evaluation catches this; EVL004 independently narrows
78
+ * spread sources to a stricter "traceable to a const" shape, which
79
+ * catches most real-world misuse without evaluating, but is not a
80
+ * full substitute.
81
+ *
82
+ * One more inherent gap, on the *value-flow* side rather than shape:
83
+ * `fold()` evaluates `&&`/`||`/`??` and `? :` lazily — it only folds the
84
+ * side/branch actually taken, exactly like the JS runtime — so an
85
+ * otherwise-unfoldable *untaken* branch does not reject
86
+ * (`false && sideEffect()` folds cleanly to `false`). This module (and so
87
+ * EVL) is flow-insensitive: it has no notion of "taken", so it requires
88
+ * every operand/branch to be shape-valid. This can only make EVL *stricter*
89
+ * than `fold()` (a false positive relative to fold, flagging code the
90
+ * folder would actually accept) — never the reverse. Making EVL
91
+ * flow-sensitive would mean re-implementing an evaluator inside a lint
92
+ * rule; out of scope here. See #1024.
93
+ */
94
+
95
+ /** The two EVL rule ids a shape violation can be attributed to. */
96
+ export type SubsetRuleId = "EVL001" | "EVL003";
97
+
98
+ /** A located, shape-level rejection: the offending node, the EVL rule id it maps to, and a message. */
99
+ export interface SubsetViolation {
100
+ node: ts.Node;
101
+ ruleId: SubsetRuleId;
102
+ message: string;
103
+ }
104
+
105
+ function violation(node: ts.Node, message: string, ruleId: SubsetRuleId = "EVL001"): SubsetViolation {
106
+ return { node, ruleId, message };
107
+ }
108
+
109
+ /** Binary operators `fold()` implements — see `fold()`'s operator switch in ./fold.ts. */
110
+ export const SUPPORTED_BINARY_OPERATORS: ReadonlySet<ts.SyntaxKind> = new Set([
111
+ ts.SyntaxKind.AmpersandAmpersandToken,
112
+ ts.SyntaxKind.BarBarToken,
113
+ ts.SyntaxKind.QuestionQuestionToken,
114
+ ts.SyntaxKind.PlusToken,
115
+ ts.SyntaxKind.MinusToken,
116
+ ts.SyntaxKind.AsteriskToken,
117
+ ts.SyntaxKind.SlashToken,
118
+ ts.SyntaxKind.EqualsEqualsEqualsToken,
119
+ ts.SyntaxKind.ExclamationEqualsEqualsToken,
120
+ ts.SyntaxKind.GreaterThanToken,
121
+ ts.SyntaxKind.LessThanToken,
122
+ ts.SyntaxKind.GreaterThanEqualsToken,
123
+ ts.SyntaxKind.LessThanEqualsToken,
124
+ ]);
125
+
126
+ /** Prefix unary operators `fold()` implements: logical-not and numeric negation. */
127
+ export const SUPPORTED_UNARY_OPERATORS: ReadonlySet<ts.SyntaxKind> = new Set([
128
+ ts.SyntaxKind.ExclamationToken,
129
+ ts.SyntaxKind.MinusToken,
130
+ ]);
131
+
132
+ /** A property name foldable without execution: identifier, string, or numeric literal (not a computed name). */
133
+ export function isLiteralPropertyName(
134
+ node: ts.PropertyName,
135
+ ): node is ts.Identifier | ts.StringLiteral | ts.NumericLiteral {
136
+ return ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node);
137
+ }
138
+
139
+ /** An element-access key foldable without execution: a string or numeric LITERAL only (EVL003 semantics). */
140
+ export function isLiteralElementKey(node: ts.Expression): node is ts.StringLiteral | ts.NumericLiteral {
141
+ return ts.isStringLiteral(node) || ts.isNumericLiteral(node);
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Shared message builders — `fold()` and `findSubsetViolation` both call
146
+ // these so the diagnostic text for the same violation kind is the same
147
+ // string, not two hand-written copies that can drift.
148
+ // ---------------------------------------------------------------------------
149
+
150
+ export function computedPropertyNameMessage(node: ts.PropertyName): string {
151
+ return `computed/dynamic property name not foldable: ${node.getText()}`;
152
+ }
153
+
154
+ export function dynamicElementAccessMessage(keyNode: ts.Expression): string {
155
+ return `dynamic property access — computed key must be a string or numeric literal: ${keyNode.getText()}`;
156
+ }
157
+
158
+ export const UNSUPPORTED_OBJECT_MEMBER_MESSAGE = "unsupported object member";
159
+
160
+ export const UNSUPPORTED_UNARY_MESSAGE = "unsupported unary";
161
+
162
+ export function unsupportedBinaryMessage(opKind: ts.SyntaxKind): string {
163
+ return `unsupported binary operator: ${ts.SyntaxKind[opKind]}`;
164
+ }
165
+
166
+ export function callExpressionMessage(node: ts.CallExpression): string {
167
+ return `function call as a value is not foldable: ${node.expression.getText()}(...)`;
168
+ }
169
+
170
+ export function unsupportedExpressionMessage(node: ts.Node): string {
171
+ return `unsupported expression: ${ts.SyntaxKind[node.kind]}`;
172
+ }
173
+
174
+ /**
175
+ * Classify one object-literal member (`{ a: 1 }`'s `a: 1`, `{ ...x }`'s
176
+ * `...x`, or a shorthand `{ a }`). Checks the key's shape before the
177
+ * value's, mirroring `propName()` in fold.ts, which runs before folding
178
+ * the value. Returns the first violation within this member, or
179
+ * `undefined` when it's fully in the subset.
180
+ */
181
+ export function checkObjectMember(
182
+ prop: ts.ObjectLiteralElementLike,
183
+ intrinsics?: readonly IntrinsicDef[],
184
+ ): SubsetViolation | undefined {
185
+ if (ts.isPropertyAssignment(prop)) {
186
+ if (!isLiteralPropertyName(prop.name)) {
187
+ return violation(prop.name, computedPropertyNameMessage(prop.name));
188
+ }
189
+ return findSubsetViolation(prop.initializer, intrinsics);
190
+ }
191
+ if (ts.isShorthandPropertyAssignment(prop)) {
192
+ return undefined;
193
+ }
194
+ if (ts.isSpreadAssignment(prop)) {
195
+ return findSubsetViolation(prop.expression, intrinsics);
196
+ }
197
+ return violation(prop, UNSUPPORTED_OBJECT_MEMBER_MESSAGE);
198
+ }
199
+
200
+ /** Classify one array-literal element: a value, or a `...spread`. */
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);
207
+ }
208
+
209
+ /**
210
+ * The canonical recursive shape classifier: is `node`'s expression *shape*
211
+ * within the subset `fold()` can reduce? Mirrors `fold()`'s own dispatch
212
+ * node-kind for node-kind (see ./fold.ts) — every branch here has a
213
+ * matching branch there, and vice versa — but performs no
214
+ * resolution/evaluation (see the module doc comment for the three
215
+ * environment-dependent exceptions). Returns the first (deepest,
216
+ * `fold()`-evaluation-order) unsupported node, or `undefined` when `node`'s
217
+ * whole shape is foldable.
218
+ */
219
+ export function findSubsetViolation(
220
+ node: ts.Node,
221
+ intrinsics?: readonly IntrinsicDef[],
222
+ ): SubsetViolation | undefined {
223
+ if (
224
+ ts.isParenthesizedExpression(node) ||
225
+ ts.isAsExpression(node) ||
226
+ ts.isSatisfiesExpression(node) ||
227
+ ts.isNonNullExpression(node)
228
+ ) {
229
+ return findSubsetViolation(node.expression, intrinsics);
230
+ }
231
+
232
+ if (
233
+ ts.isStringLiteral(node) ||
234
+ ts.isNoSubstitutionTemplateLiteral(node) ||
235
+ ts.isNumericLiteral(node) ||
236
+ node.kind === ts.SyntaxKind.TrueKeyword ||
237
+ node.kind === ts.SyntaxKind.FalseKeyword ||
238
+ node.kind === ts.SyntaxKind.NullKeyword
239
+ ) {
240
+ return undefined;
241
+ }
242
+
243
+ // Identifiers (including a bare `undefined` reference, which the parser
244
+ // represents as an Identifier, not a keyword token) — resolution is
245
+ // environment-dependent, see module doc.
246
+ if (ts.isIdentifier(node)) return undefined;
247
+
248
+ if (ts.isTaggedTemplateExpression(node)) {
249
+ // Tagged-template interiors are OPAQUE to the shape classifier. A tagged
250
+ // template may be a registered lexicon intrinsic (e.g. Sub`...`) whose
251
+ // interpolations legitimately contain deploy-time intrinsic references
252
+ // (Ref(env), AWS.StackName, ...) — valid, but not statically foldable. EVL
253
+ // has no intrinsic registry at lint time (see module doc), so it cannot tell
254
+ // an intrinsic call from a plain one; recursing here would false-flag Ref()
255
+ // inside Sub`...` and break every intrinsic-using example. fold() DOES have
256
+ // the registry: it recurses into a *registered* tag's interior itself
257
+ // (foldIntrinsicValue) and rejects an unfoldable one there. So an unfoldable
258
+ // tagged-template interior is a documented fold/EVL divergence, not a hole.
259
+ return undefined;
260
+ }
261
+
262
+ if (ts.isTemplateExpression(node)) {
263
+ for (const span of node.templateSpans) {
264
+ const v = findSubsetViolation(span.expression, intrinsics);
265
+ if (v) return v;
266
+ }
267
+ return undefined;
268
+ }
269
+
270
+ if (ts.isObjectLiteralExpression(node)) {
271
+ for (const prop of node.properties) {
272
+ const v = checkObjectMember(prop, intrinsics);
273
+ if (v) return v;
274
+ }
275
+ return undefined;
276
+ }
277
+
278
+ if (ts.isArrayLiteralExpression(node)) {
279
+ for (const el of node.elements) {
280
+ const v = checkArrayElement(el, intrinsics);
281
+ if (v) return v;
282
+ }
283
+ return undefined;
284
+ }
285
+
286
+ if (ts.isPropertyAccessExpression(node)) {
287
+ return findSubsetViolation(node.expression, intrinsics);
288
+ }
289
+
290
+ if (ts.isElementAccessExpression(node)) {
291
+ if (!isLiteralElementKey(node.argumentExpression)) {
292
+ return violation(node.argumentExpression, dynamicElementAccessMessage(node.argumentExpression), "EVL003");
293
+ }
294
+ return findSubsetViolation(node.expression, intrinsics);
295
+ }
296
+
297
+ if (ts.isPrefixUnaryExpression(node)) {
298
+ if (!SUPPORTED_UNARY_OPERATORS.has(node.operator)) {
299
+ return violation(node, UNSUPPORTED_UNARY_MESSAGE);
300
+ }
301
+ return findSubsetViolation(node.operand, intrinsics);
302
+ }
303
+
304
+ if (ts.isBinaryExpression(node)) {
305
+ const opKind = node.operatorToken.kind;
306
+ if (!SUPPORTED_BINARY_OPERATORS.has(opKind)) {
307
+ return violation(node, unsupportedBinaryMessage(opKind));
308
+ }
309
+ // Flow-insensitive — see module doc: fold() short-circuits &&/||/?? and
310
+ // only evaluates the taken side; EVL requires both sides shape-valid.
311
+ return findSubsetViolation(node.left, intrinsics) ?? findSubsetViolation(node.right, intrinsics);
312
+ }
313
+
314
+ if (ts.isConditionalExpression(node)) {
315
+ // Flow-insensitive — see module doc: fold() only folds the taken branch.
316
+ return (
317
+ findSubsetViolation(node.condition, intrinsics) ??
318
+ findSubsetViolation(node.whenTrue, intrinsics) ??
319
+ findSubsetViolation(node.whenFalse, intrinsics)
320
+ );
321
+ }
322
+
323
+ if (ts.isNewExpression(node)) {
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;
332
+ }
333
+ return undefined;
334
+ }
335
+
336
+ if (ts.isSpreadElement(node)) {
337
+ return findSubsetViolation(node.expression, intrinsics);
338
+ }
339
+
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
+
379
+ return violation(node, callExpressionMessage(node));
380
+ }
381
+
382
+ return violation(node, unsupportedExpressionMessage(node));
383
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  export * from "./declarable";
8
8
  export * from "./composite";
9
9
  export * from "./provenance";
10
+ export * from "./build-params";
10
11
  export * from "./intrinsic";
11
12
  export * from "./types";
12
13
  export * from "./errors";
@@ -33,6 +34,7 @@ export * from "./graph-dot";
33
34
  export * from "./graph-layout";
34
35
  export * from "./graph-lens";
35
36
  export * from "./detectLexicon";
37
+ export * from "./fold/fold";
36
38
  export * from "./lint/parser";
37
39
  export * from "./lint/rule";
38
40
  export * from "./lint/rules";
@@ -45,8 +45,13 @@ export class LexiconOutput implements Intrinsic {
45
45
  readonly outputName: string;
46
46
  /** @internal WeakRef to the source entity object for identity-based matching */
47
47
  readonly _sourceParent: WeakRef<object> | null;
48
- /** @internal Intrinsic value when constructed from an Intrinsic rather than AttrRef */
49
- private readonly _intrinsic: Intrinsic | null;
48
+ /**
49
+ * @internal Intrinsic value when constructed from an Intrinsic rather than AttrRef.
50
+ * Readable outside the class (like `_sourceParent` above) so the entity-wire
51
+ * encoder can reach it without an `as unknown as` cast that would erase type
52
+ * checking on every field it reads (#1047).
53
+ */
54
+ readonly _intrinsic: Intrinsic | null;
50
55
 
51
56
  constructor(ref: AttrRef | Intrinsic | string, name: string) {
52
57
  if (ref instanceof AttrRef) {
@@ -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
@@ -131,6 +132,62 @@ describe("IntrinsicDefSchema", () => {
131
132
  const result = IntrinsicDefSchema.safeParse({ description: "no name" });
132
133
  expect(result.success).toBe(false);
133
134
  });
135
+
136
+ // chant #1067 — isTag went from optional to required: a registration
137
+ // silently defaulting to "not a tag" is exactly how #1039 shipped
138
+ // (aws's Sub had no isTag at all and never folded in production).
139
+ test("rejects a registration with isTag omitted", () => {
140
+ const result = IntrinsicDefSchema.safeParse({ name: "Sub", description: "no isTag" });
141
+ expect(result.success).toBe(false);
142
+ });
143
+
144
+ test("accepts isTag: true and isTag: false, rejects non-boolean isTag", () => {
145
+ expect(IntrinsicDefSchema.safeParse({ name: "Sub", isTag: true }).success).toBe(true);
146
+ expect(IntrinsicDefSchema.safeParse({ name: "Ref", isTag: false }).success).toBe(true);
147
+ expect(IntrinsicDefSchema.safeParse({ name: "Ref", isTag: "false" }).success).toBe(false);
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
+ });
134
191
  });
135
192
 
136
193
  // ---------------------------------------------------------------------------
@@ -13,7 +13,14 @@ export const IntrinsicDefSchema = z.object({
13
13
  name: z.string().min(1, "intrinsic name must not be empty"),
14
14
  description: z.string().optional(),
15
15
  outputKey: z.string().optional(),
16
- isTag: z.boolean().optional(),
16
+ // Required (chant #1067) — no silent default for whether an intrinsic
17
+ // folds. See IntrinsicDef.isTag in ../lexicon.ts for the history.
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(),
17
24
  });
18
25
 
19
26
  // ---------------------------------------------------------------------------
package/src/lexicon.ts CHANGED
@@ -18,6 +18,41 @@ export type { ReferenceCatalog, IdentityRule, RefRule } from "./graph-refs";
18
18
 
19
19
  /**
20
20
  * Manifest for a packaged lexicon — metadata embedded in the tarball.
21
+ *
22
+ * chant #1067 decided each optional field below explicitly rather than
23
+ * leaving them all equally unchecked:
24
+ *
25
+ * - `chantVersion` — now validated for presence/shape by `chant dev
26
+ * check-lexicon` (a lexicon's `dist/manifest.json` must declare one).
27
+ * NOT validated for compatibility against the core version actually
28
+ * running — that needs a live check at plugin-load time (a different
29
+ * surface: `loadPlugin`/`loadPlugins` in `./cli/plugins.ts`), which stays
30
+ * a deliberate non-goal here.
31
+ * - `namespace` — deliberately NOT validated for cross-lexicon uniqueness in
32
+ * #1067. `chant dev check-lexicon <dir>` only ever inspects one lexicon at
33
+ * a time, so it structurally can't catch a collision between two
34
+ * *different* lexicons. The natural home is `checkConflicts` (./cli/
35
+ * conflict-check.ts), which already detects cross-lexicon rule-id/skill/
36
+ * MCP-tool/MCP-resource collisions when multiple plugins load together —
37
+ * but `namespace` isn't on the runtime `LexiconPlugin` surface at all
38
+ * (manifest-only, baked in at package time), so extending that check would
39
+ * mean adding a new field to `LexiconPlugin` itself. Deferred rather than
40
+ * done under time pressure alongside a concurrent, unrelated change to
41
+ * this same interface (chant #1064).
42
+ * - `pseudoParameters` — deliberately NOT validated against what the
43
+ * lexicon actually exports in #1067, unlike `intrinsics` (see
44
+ * `IntrinsicDef.isTag` below and `./cli/commands/check-lexicon-
45
+ * intrinsics.ts`). The same static-analysis approach would apply — check
46
+ * each declared pseudo-parameter's short name against the real exported
47
+ * namespace object's properties (`AWS.StackName`, `Azure.ResourceGroupName`,
48
+ * ...) — but unlike `intrinsics()`, `pseudoParameters(): string[]` is a
49
+ * flat list of dotted strings with no structural link back to a specific
50
+ * export/property declaration, making the same trick more involved to
51
+ * generalize correctly. Relevant to #1063 (folding cross-file references
52
+ * into lexicon pseudo-parameter namespaces): once that lands, a wrong or
53
+ * missing pseudo-parameter property would silently break folding exactly
54
+ * the way #1039 did for intrinsics, which would be the moment this
55
+ * deferral needs revisiting.
21
56
  */
22
57
  export interface LexiconManifest {
23
58
  name: string;
@@ -102,7 +137,103 @@ export interface IntrinsicDef {
102
137
  readonly name: string;
103
138
  readonly description?: string;
104
139
  readonly outputKey?: string;
105
- readonly isTag?: boolean;
140
+ /**
141
+ * Whether this intrinsic is authored as a JS tagged template (`` Sub`...` ``)
142
+ * rather than a plain function call (`Ref(...)`). Required — chant #1067 —
143
+ * because an omitted value silently defaulted to "not a tag" with no
144
+ * signal that the registration had never been decided. That produced #1039
145
+ * in both directions at once: aws's `Sub` (a genuine tagged template, the
146
+ * most-used intrinsic in the ecosystem) shipped with no `isTag` at all and
147
+ * silently never folded, while gitlab's `reference()` (a plain call)
148
+ * shipped with `isTag: true`. Both were wrong, and both shipped, because
149
+ * nothing forced the declaration or checked it against how the intrinsic is
150
+ * actually authored. `chant dev check-lexicon` now validates every
151
+ * registration here against its real declaration (tagged-template
152
+ * signature vs plain call) and against the package's own exports — see
153
+ * `../cli/commands/check-lexicon-intrinsics.ts`.
154
+ */
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;
185
+ }
186
+
187
+ /**
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.
200
+ *
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.
207
+ *
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.
215
+ */
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 {
222
+ return def.isTag === true;
223
+ }
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;
106
237
  }
107
238
 
108
239
  /**