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