@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.
- package/dist/cli/commands/check-lexicon-intrinsics.d.ts +17 -0
- package/dist/cli/commands/check-lexicon-intrinsics.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/codegen/docs-types.d.ts +2 -0
- package/dist/codegen/docs-types.d.ts.map +1 -1
- package/dist/declarable.d.ts +16 -0
- package/dist/declarable.d.ts.map +1 -1
- package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +30 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +89 -16
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/foldable-helpers.d.ts +121 -0
- package/dist/fold/foldable-helpers.d.ts.map +1 -0
- package/dist/fold/subset.d.ts +35 -3
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon-schema.d.ts +2 -0
- package/dist/lexicon-schema.d.ts.map +1 -1
- package/dist/lexicon.d.ts +73 -23
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/runtime.d.ts +10 -1
- package/dist/runtime.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/check-lexicon-intrinsics.test.ts +35 -1
- package/src/cli/commands/check-lexicon-intrinsics.ts +38 -2
- package/src/cli/commands/check-lexicon.ts +18 -0
- package/src/codegen/docs-sections.test.ts +7 -1
- package/src/codegen/docs-sections.ts +1 -1
- package/src/codegen/docs-types.ts +2 -0
- package/src/declarable.ts +20 -0
- package/src/discovery/entity-wire-codec.ts +9 -7
- package/src/discovery/fold-import.test.ts +572 -0
- package/src/discovery/fold-import.ts +229 -36
- package/src/discovery/index.ts +9 -0
- package/src/fold/fold.test.ts +277 -0
- package/src/fold/fold.ts +213 -56
- package/src/fold/foldable-helpers.ts +171 -0
- package/src/fold/subset-doc-parity.test.ts +27 -0
- package/src/fold/subset.test.ts +111 -0
- package/src/fold/subset.ts +109 -28
- package/src/lexicon-schema.test.ts +43 -0
- package/src/lexicon-schema.ts +5 -0
- package/src/lexicon.ts +74 -24
- package/src/runtime.ts +11 -2
package/src/fold/subset.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/fold/subset.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
169
|
-
|
|
170
|
-
|
|
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(
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/lexicon-schema.ts
CHANGED
|
@@ -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
|
|
160
|
-
* (chant #1062, epic #1019)
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* a
|
|
165
|
-
*
|
|
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
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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
|
|
178
|
-
* deliberately: `IntrinsicDef.isTag` is required
|
|
179
|
-
* (chant #1067), but
|
|
180
|
-
* possibly-older parsed JSON (`ManifestJSON` in
|
|
181
|
-
* on disk as a published lexicon's
|
|
182
|
-
*
|
|
183
|
-
* did — not a tag
|
|
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
|
|