@intentius/chant 0.71.0 → 0.72.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/discovery/fold-import.d.ts +28 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +29 -0
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +34 -1
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/cli/commands/lint.test.ts +38 -4
- package/src/discovery/fold-host-packages.test.ts +89 -0
- package/src/discovery/fold-import.ts +35 -53
- package/src/fold/fold.test.ts +99 -6
- package/src/fold/fold.ts +139 -0
- package/src/fold/subset.test.ts +161 -20
- package/src/fold/subset.ts +136 -1
- package/src/index.ts +1 -1
- package/src/meta/declared-imports.test.ts +141 -0
package/src/fold/fold.test.ts
CHANGED
|
@@ -688,11 +688,17 @@ describe("fold — registered call-form intrinsics (#1044)", () => {
|
|
|
688
688
|
expect(() => fold(expr, consts, [REF])).toThrow(FoldError);
|
|
689
689
|
});
|
|
690
690
|
|
|
691
|
-
test("a registered name SHADOWED by a local const
|
|
691
|
+
test("a registered name SHADOWED by a local const folds as the LOCAL function, never the lexicon's", () => {
|
|
692
|
+
// chant#2436 — the local binding wins, which is what this has always
|
|
693
|
+
// asserted. It used to win by rejecting, because `fold()` had no way to
|
|
694
|
+
// call a local function; now it wins by being called, which is what a
|
|
695
|
+
// build does with this file. The registered meaning is still not applied:
|
|
696
|
+
// the lexicon's `Ref` produces `{ __intrinsic: "Ref" }`, this produces the
|
|
697
|
+
// arrow's own object.
|
|
692
698
|
const consts = parseConsts(`const Ref = (n) => ({ mine: n }); const x = Ref("db");`);
|
|
693
699
|
const expr = consts.get("x");
|
|
694
700
|
if (!expr) throw new Error("fixture error");
|
|
695
|
-
expect(
|
|
701
|
+
expect(fold(expr, consts, [REF])).toEqual({ mine: "db" });
|
|
696
702
|
});
|
|
697
703
|
|
|
698
704
|
test("an unfoldable argument still rejects the whole call", () => {
|
|
@@ -765,11 +771,14 @@ describe("fold — registered authoring helpers (#1082)", () => {
|
|
|
765
771
|
expect((error as FoldError).message).toContain("function call as a value is not foldable: compose(...)");
|
|
766
772
|
});
|
|
767
773
|
|
|
768
|
-
test("a registered name SHADOWED by a local const
|
|
774
|
+
test("a registered name SHADOWED by a local const folds as the LOCAL function, never chant's", () => {
|
|
775
|
+
// chant#2436 — see the matching case under the intrinsic registry above.
|
|
776
|
+
// chant's own `phase(name, steps)` yields `{ phase, steps }`; the absence
|
|
777
|
+
// of `steps` here is what proves the shadowing arrow was the one called.
|
|
769
778
|
const consts = parseConsts(`const phase = (n) => ({ phase: n }); const x = phase("Apply");`);
|
|
770
779
|
const expr = consts.get("x");
|
|
771
780
|
if (!expr) throw new Error("fixture error");
|
|
772
|
-
expect(
|
|
781
|
+
expect(fold(expr, consts)).toEqual({ phase: "Apply" });
|
|
773
782
|
});
|
|
774
783
|
|
|
775
784
|
test("a registered name used as a METHOD (`ns.phase(...)`) is rejected — only a bare identifier callee folds", () => {
|
|
@@ -835,11 +844,13 @@ describe("fold: the ConvergeOp rule builders are registered helpers (#2171)", ()
|
|
|
835
844
|
}
|
|
836
845
|
});
|
|
837
846
|
|
|
838
|
-
test("a rule builder SHADOWED by a local const
|
|
847
|
+
test("a rule builder SHADOWED by a local const folds as the LOCAL function", () => {
|
|
848
|
+
// chant#2436 — the builder's own shape is `{ field, op, value }`; this
|
|
849
|
+
// yields the arrow's `{ f, v }`, so the shadowing binding is the one used.
|
|
839
850
|
const consts = parseConsts(`const gt = (f, v) => ({ f, v }); const x = gt("updateCount", 0);`);
|
|
840
851
|
const expr = consts.get("x");
|
|
841
852
|
if (!expr) throw new Error("fixture error");
|
|
842
|
-
expect(
|
|
853
|
+
expect(fold(expr, consts)).toEqual({ f: "updateCount", v: 0 });
|
|
843
854
|
});
|
|
844
855
|
});
|
|
845
856
|
|
|
@@ -1244,3 +1255,85 @@ describe("constructions as values (chant #1169)", () => {
|
|
|
1244
1255
|
});
|
|
1245
1256
|
});
|
|
1246
1257
|
});
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* chant#2436 — a unit-level fold binds the file's own functions.
|
|
1261
|
+
*
|
|
1262
|
+
* The build has bound them since #1373: `fold-import.ts` puts a
|
|
1263
|
+
* {@link FoldableFunction} in `externals` for every top-level declaration
|
|
1264
|
+
* before folding anything. A caller with no module graph, and so no
|
|
1265
|
+
* `externals`, had no way to call one, so `fold()` alone answered `run` for a
|
|
1266
|
+
* shape the build folds. The spec's conformance adapter is exactly such a
|
|
1267
|
+
* caller.
|
|
1268
|
+
*
|
|
1269
|
+
* The binding is derived from the node's own source file, and only when the
|
|
1270
|
+
* caller passed no `externals` at all: a caller that passed one is managing
|
|
1271
|
+
* its own bindings and may have left a name out on purpose.
|
|
1272
|
+
*/
|
|
1273
|
+
describe("fold — same-file functions without externals (chant#2436)", () => {
|
|
1274
|
+
const foldSource = (src: string): unknown => {
|
|
1275
|
+
const sourceFile = ts.createSourceFile("m.ts", src, ts.ScriptTarget.Latest, true);
|
|
1276
|
+
const consts = collectConsts(sourceFile);
|
|
1277
|
+
const expr = consts.get("x");
|
|
1278
|
+
if (!expr) throw new Error("fixture has no `x`");
|
|
1279
|
+
return fold(expr, consts);
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1282
|
+
test("a function declaration, exported or not", () => {
|
|
1283
|
+
expect(foldSource("function twice(n) { return n * 2; } const x = twice(21);")).toBe(42);
|
|
1284
|
+
expect(foldSource("export function twice(n) { return n * 2; } const x = twice(21);")).toBe(42);
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
test("an arrow const", () => {
|
|
1288
|
+
expect(foldSource('const label = (s) => ({ label: s }); const x = label("k");')).toEqual({ label: "k" });
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
test("a sibling calling a sibling", () => {
|
|
1292
|
+
expect(foldSource("function a(n) { return b(n) + 1; } function b(n) { return n * 2; } const x = a(10);")).toBe(21);
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
test("recursion terminates on its own base case", () => {
|
|
1296
|
+
expect(foldSource("function down(n) { return n <= 0 ? 0 : down(n - 1); } const x = down(3);")).toBe(0);
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
describe("falls back to run rather than producing a value", () => {
|
|
1300
|
+
const rejects = (src: string) => expect(() => foldSource(src)).toThrow(FoldError);
|
|
1301
|
+
|
|
1302
|
+
test("a body that reads a `new`-bound const, so no duplicate resource is built", () => {
|
|
1303
|
+
// The stripping this relies on is why `collectSameFileFunctions` has one
|
|
1304
|
+
// implementation: the body must read the live instance out of
|
|
1305
|
+
// `externals`, never re-fold the initializer. With no externals there is
|
|
1306
|
+
// no instance to read, so the only correct answer is to fall back.
|
|
1307
|
+
rejects("class B { constructor(_p) {} } const b = new B({}); function f() { return b; } const x = f();");
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
test("through an alias of one, so aliasing cannot smuggle it in", () => {
|
|
1311
|
+
rejects(
|
|
1312
|
+
"class B { constructor(_p) {} } const b = new B({}); const alias = b; function f() { return alias; } const x = f();",
|
|
1313
|
+
);
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
test("a body reading an import, which needs a module graph this entry does not have", () => {
|
|
1317
|
+
rejects('import { thing } from "./other"; function f() { return thing; } const x = f();');
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
test("a body with a statement outside the subset", () => {
|
|
1321
|
+
rejects("function f() { for (;;) {} } const x = f();");
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
test("a name the file never binds", () => {
|
|
1325
|
+
rejects("const x = missing(1);");
|
|
1326
|
+
});
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
test("an imported project-local function is still not called here — that needs the module graph", () => {
|
|
1330
|
+
expect(() => foldSource('import { twice } from "./pure"; const x = twice(21);')).toThrow(FoldError);
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
test("an explicit `externals` is left alone, even when it omits the name", () => {
|
|
1334
|
+
const sourceFile = ts.createSourceFile("m.ts", "function twice(n) { return n * 2; } const x = twice(21);", ts.ScriptTarget.Latest, true);
|
|
1335
|
+
const consts = collectConsts(sourceFile);
|
|
1336
|
+
// The caller said "these are the bindings" and did not include `twice`.
|
|
1337
|
+
expect(() => fold(consts.get("x")!, consts, [], new Map())).toThrow(FoldError);
|
|
1338
|
+
});
|
|
1339
|
+
});
|
package/src/fold/fold.ts
CHANGED
|
@@ -471,6 +471,131 @@ function carriesLiveObject(value: unknown, seen = new Set<unknown>()): boolean {
|
|
|
471
471
|
return false;
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
+
/**
|
|
475
|
+
* True when a module-level `const` is (transitively) bound to a `new Type(...)`.
|
|
476
|
+
* Follows an identifier chain (`const a = new T(); const b = a;`) so aliasing
|
|
477
|
+
* cannot smuggle a module-level resource into a function body.
|
|
478
|
+
*
|
|
479
|
+
* chant#2436 moved this here from ../discovery/fold-import.ts so the two places
|
|
480
|
+
* that build {@link FoldableFunction}s share one implementation rather than two
|
|
481
|
+
* that can drift.
|
|
482
|
+
*/
|
|
483
|
+
export function constResolvesToResource(
|
|
484
|
+
consts: ReadonlyMap<string, ts.Expression>,
|
|
485
|
+
name: string,
|
|
486
|
+
seen: Set<string> = new Set(),
|
|
487
|
+
): boolean {
|
|
488
|
+
if (seen.has(name)) return false;
|
|
489
|
+
seen.add(name);
|
|
490
|
+
const init = consts.get(name);
|
|
491
|
+
if (init === undefined) return false;
|
|
492
|
+
if (ts.isNewExpression(init)) return true;
|
|
493
|
+
if (ts.isIdentifier(init)) return constResolvesToResource(consts, init.text, seen);
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** `const f = (…) => …`, through parentheses / `as` / `satisfies`. */
|
|
498
|
+
function unwrapFunctionInitializer(node: ts.Expression): ts.ArrowFunction | ts.FunctionExpression | undefined {
|
|
499
|
+
let current = node;
|
|
500
|
+
while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current)) {
|
|
501
|
+
current = current.expression;
|
|
502
|
+
}
|
|
503
|
+
return ts.isArrowFunction(current) || ts.isFunctionExpression(current) ? current : undefined;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Every top-level function a file declares, exported or not, as a
|
|
508
|
+
* {@link FoldableFunction}: `function f(...) {...}` and `const f = (...) => …`.
|
|
509
|
+
*
|
|
510
|
+
* The markers carry `externals` BY REFERENCE, so the caller may keep filling it
|
|
511
|
+
* after this returns (which is how a sibling function, or a resource built
|
|
512
|
+
* later, becomes reachable from a body).
|
|
513
|
+
*
|
|
514
|
+
* `consts` drops every `new`-bound const, transitively through aliases: a body
|
|
515
|
+
* that mentions one must read the live instance out of `externals`, the same
|
|
516
|
+
* object the module's own fold registered, never re-fold the initializer and
|
|
517
|
+
* construct a duplicate of a resource discovery already has.
|
|
518
|
+
*
|
|
519
|
+
* chant#2436 — one implementation, called both by ../discovery/fold-import.ts
|
|
520
|
+
* during a build and by {@link fold} when a unit-level fold has no externals of
|
|
521
|
+
* its own. Getting the `new`-bound stripping wrong yields a wrong value rather
|
|
522
|
+
* than a fallback to run, which is why there is only one copy of it.
|
|
523
|
+
*/
|
|
524
|
+
export function collectSameFileFunctions(
|
|
525
|
+
sourceFile: ts.SourceFile,
|
|
526
|
+
consts: ReadonlyMap<string, ts.Expression>,
|
|
527
|
+
file: string,
|
|
528
|
+
externals: ReadonlyMap<string, unknown>,
|
|
529
|
+
failures?: ReadonlyMap<string, string>,
|
|
530
|
+
): Map<string, FoldableFunction> {
|
|
531
|
+
const bodyConsts = new Map(consts);
|
|
532
|
+
for (const [name] of [...bodyConsts]) {
|
|
533
|
+
if (constResolvesToResource(consts, name, new Set())) bodyConsts.delete(name);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const functions = new Map<string, FoldableFunction>();
|
|
537
|
+
const add = (name: string, fn: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression): void => {
|
|
538
|
+
functions.set(name, new FoldableFunction(name, fn, file, bodyConsts, externals, failures));
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
for (const statement of sourceFile.statements) {
|
|
542
|
+
if (ts.isFunctionDeclaration(statement)) {
|
|
543
|
+
if (statement.name && statement.body) add(statement.name.text, statement);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
547
|
+
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
|
|
548
|
+
for (const decl of statement.declarationList.declarations) {
|
|
549
|
+
if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
|
|
550
|
+
const fn = unwrapFunctionInitializer(decl.initializer);
|
|
551
|
+
if (fn) add(decl.name.text, fn);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return functions;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The same-file functions in scope at `node`, for a fold with no `externals`.
|
|
559
|
+
*
|
|
560
|
+
* Memoized per (source file, consts map), both of which are objects the caller
|
|
561
|
+
* holds for the duration of a fold, so a file with many calls walks its
|
|
562
|
+
* statements once. `undefined` when the node has no file to read, which is the
|
|
563
|
+
* pre-#2436 answer: the call is not foldable.
|
|
564
|
+
*
|
|
565
|
+
* The map is its own `externals`, so a function can call a sibling.
|
|
566
|
+
*/
|
|
567
|
+
function sameFileFunctions(
|
|
568
|
+
node: ts.Node,
|
|
569
|
+
consts: Map<string, ts.Expression>,
|
|
570
|
+
): Map<string, FoldableFunction> | undefined {
|
|
571
|
+
let sourceFile: ts.SourceFile | undefined;
|
|
572
|
+
try {
|
|
573
|
+
sourceFile = node.getSourceFile();
|
|
574
|
+
} catch {
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
if (!sourceFile) return undefined;
|
|
578
|
+
|
|
579
|
+
let byConsts = sameFileFunctionCache.get(sourceFile);
|
|
580
|
+
if (!byConsts) {
|
|
581
|
+
byConsts = new WeakMap();
|
|
582
|
+
sameFileFunctionCache.set(sourceFile, byConsts);
|
|
583
|
+
}
|
|
584
|
+
const cached = byConsts.get(consts);
|
|
585
|
+
if (cached) return cached;
|
|
586
|
+
|
|
587
|
+
const scope = new Map<string, unknown>();
|
|
588
|
+
const functions = collectSameFileFunctions(sourceFile, consts, sourceFile.fileName, scope);
|
|
589
|
+
for (const [name, fn] of functions) scope.set(name, fn);
|
|
590
|
+
byConsts.set(consts, functions);
|
|
591
|
+
return functions;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const sameFileFunctionCache = new WeakMap<
|
|
595
|
+
ts.SourceFile,
|
|
596
|
+
WeakMap<Map<string, ts.Expression>, Map<string, FoldableFunction>>
|
|
597
|
+
>();
|
|
598
|
+
|
|
474
599
|
export function isFoldableFunction(value: unknown): value is FoldableFunction {
|
|
475
600
|
return value instanceof FoldableFunction;
|
|
476
601
|
}
|
|
@@ -1430,6 +1555,20 @@ export function fold(
|
|
|
1430
1555
|
if (isFoldableFunction(callee)) {
|
|
1431
1556
|
return callFoldableFunction(callee, node, consts, intrinsics, externals);
|
|
1432
1557
|
}
|
|
1558
|
+
// chant#2436 — the same shape, reached without a build. A caller that
|
|
1559
|
+
// supplied no `externals` has no module graph and so no markers, and a
|
|
1560
|
+
// same-file call fell through to the throw below even though a build
|
|
1561
|
+
// folds it. Derive the file's own functions and call one.
|
|
1562
|
+
//
|
|
1563
|
+
// Only when `externals` is absent. A caller that passed one is managing
|
|
1564
|
+
// its own bindings (a build, which already put the markers there before
|
|
1565
|
+
// folding anything), and second-guessing it here could bind a name it
|
|
1566
|
+
// deliberately left out. A same-file function only: an imported one needs
|
|
1567
|
+
// module resolution, which this module does not do and will not start.
|
|
1568
|
+
if (externals === undefined) {
|
|
1569
|
+
const local = sameFileFunctions(node, consts)?.get(node.expression.text);
|
|
1570
|
+
if (local) return callFoldableFunction(local, node, consts, intrinsics, local.externals);
|
|
1571
|
+
}
|
|
1433
1572
|
}
|
|
1434
1573
|
|
|
1435
1574
|
// chant #1966 — the fourth call shape: a lexicon-package function its
|
package/src/fold/subset.test.ts
CHANGED
|
@@ -1,7 +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
|
+
import { briefNodeText, callExpressionMessage, findSubsetViolation, collectLocalCallables } from "./subset";
|
|
5
5
|
import { evl001NonLiteralExpressionRule } from "../lint/rules/evl001-non-literal-expression";
|
|
6
6
|
import { evl003DynamicPropertyAccessRule } from "../lint/rules/evl003-dynamic-property-access";
|
|
7
7
|
import { evl004SpreadNonConstRule } from "../lint/rules/evl004-spread-non-const";
|
|
@@ -137,6 +137,38 @@ describe("subset equivalence — supported cases: fold succeeds AND EVL is clean
|
|
|
137
137
|
}
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* chant#2436 closed a divergence that used to live in the block below.
|
|
142
|
+
*
|
|
143
|
+
* `phase` is a registered authoring helper, but shadowed by the file's own
|
|
144
|
+
* arrow the local binding wins — which is what #1082 asserted, and still
|
|
145
|
+
* true. It used to win by making `fold()` reject, because `fold()` had no way
|
|
146
|
+
* to call a local function; a build folded the same file. Now both fold it, so
|
|
147
|
+
* the two agree rather than diverging by design.
|
|
148
|
+
*/
|
|
149
|
+
describe("authoring-helper shadowing — now unified (chant#2436)", () => {
|
|
150
|
+
const source = `
|
|
151
|
+
const phase = (n) => ({ phase: n });
|
|
152
|
+
const good = new Thing({ x: phase("Apply") });
|
|
153
|
+
`;
|
|
154
|
+
|
|
155
|
+
test("fold calls the local arrow, and does not apply the registered meaning", () => {
|
|
156
|
+
const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
|
|
157
|
+
const consts = collectConsts(sourceFile);
|
|
158
|
+
const folded = foldResource(consts.get("good") as ts.NewExpression, consts, []);
|
|
159
|
+
|
|
160
|
+
// chant's own `phase(name, steps)` yields `{ phase, steps }`; no `steps`
|
|
161
|
+
// here is what proves the shadowing binding was the one called.
|
|
162
|
+
expect((folded.props as { x: unknown }).x).toEqual({ phase: "Apply" });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("and EVL001 is clean, as it always was", () => {
|
|
166
|
+
const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
|
|
167
|
+
const context: LintContext = { sourceFile, entities: [], filePath: "t.ts", lexicon: undefined };
|
|
168
|
+
expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
140
172
|
describe("subset equivalence — unsupported cases: fold rejects AND EVL flags the same rule id + position", () => {
|
|
141
173
|
for (const c of UNSUPPORTED_CASES) {
|
|
142
174
|
test(c.name, () => {
|
|
@@ -241,25 +273,6 @@ describe("documented divergences — NOT unified by design (see subset.ts module
|
|
|
241
273
|
expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
|
|
242
274
|
});
|
|
243
275
|
|
|
244
|
-
test("authoring-helper shadowing: fold rejects a registered name bound to a local const; EVL001 does not (shape-only, no binding resolution)", () => {
|
|
245
|
-
// chant #1082 — `phase` is registered, but here it's the file's own local
|
|
246
|
-
// arrow function, so the local binding wins and fold() rejects. EVL has no
|
|
247
|
-
// binding resolver (subset.ts module doc, point 1) and stays permissive —
|
|
248
|
-
// the same direction as every other divergence here.
|
|
249
|
-
const source = `
|
|
250
|
-
const phase = (n) => ({ phase: n });
|
|
251
|
-
const bad = new Thing({ x: phase("Apply") });
|
|
252
|
-
`;
|
|
253
|
-
const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
|
|
254
|
-
const consts = collectConsts(sourceFile);
|
|
255
|
-
const badInit = consts.get("bad") as ts.NewExpression;
|
|
256
|
-
|
|
257
|
-
expect(() => foldResource(badInit, consts, [])).toThrow(FoldError);
|
|
258
|
-
|
|
259
|
-
const context: LintContext = { sourceFile, entities: [], filePath: "t.ts", lexicon: undefined };
|
|
260
|
-
expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
276
|
test("method call on an unresolved receiver: fold rejects; EVL001 accepts the shape (chant #1966, resolution-dependent)", () => {
|
|
264
277
|
// `config.getName()` — chant #1966 widened `findSubsetViolation` to
|
|
265
278
|
// accept a method call unconditionally (mirroring `.step` and a nested
|
|
@@ -550,3 +563,131 @@ describe("callExpressionMessage — one line regardless of the call's own argume
|
|
|
550
563
|
expect(message).toBe("function call as a value is not foldable: GkeCluster(...)");
|
|
551
564
|
});
|
|
552
565
|
});
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* chant#2435 — `S-CallLocal`.
|
|
569
|
+
*
|
|
570
|
+
* The classifier rejected every call to a project-local function while the
|
|
571
|
+
* build folds all of them, which is the one direction `F-Direction` forbids:
|
|
572
|
+
* this module may accept what the fold rejects, never the reverse. In practice
|
|
573
|
+
* it meant `chant lint` reported EVL001 on a file `chant build --fold` folds
|
|
574
|
+
* cleanly.
|
|
575
|
+
*
|
|
576
|
+
* The admission is by NAME and the names come from the node's own source file,
|
|
577
|
+
* so nothing a caller does changes the answer.
|
|
578
|
+
*/
|
|
579
|
+
describe("findSubsetViolation — project-local calls (chant#2435)", () => {
|
|
580
|
+
/** The initializer of `export const x = …` in `src`, with its file attached. */
|
|
581
|
+
const initializerOf = (src: string): ts.Expression => {
|
|
582
|
+
const sourceFile = ts.createSourceFile("m.ts", src, ts.ScriptTarget.Latest, true);
|
|
583
|
+
for (const statement of sourceFile.statements) {
|
|
584
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
585
|
+
for (const decl of statement.declarationList.declarations) {
|
|
586
|
+
if (ts.isIdentifier(decl.name) && decl.name.text === "x" && decl.initializer) return decl.initializer;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
throw new Error("fixture has no `x`");
|
|
590
|
+
};
|
|
591
|
+
const accepts = (src: string) => findSubsetViolation(initializerOf(src)) === undefined;
|
|
592
|
+
|
|
593
|
+
describe("admitted, because a build folds them", () => {
|
|
594
|
+
test("a same-file function declaration, exported or not", () => {
|
|
595
|
+
expect(accepts("function twice(n: number) { return n * 2; }\nexport const x = twice(21);")).toBe(true);
|
|
596
|
+
expect(accepts("export function twice(n: number) { return n * 2; }\nexport const x = twice(21);")).toBe(true);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test("a same-file arrow const, through parentheses and `as`", () => {
|
|
600
|
+
expect(accepts('const label = (s: string) => ({ label: s });\nexport const x = label("k");')).toBe(true);
|
|
601
|
+
expect(accepts('const label = ((s: string) => s) as (s: string) => string;\nexport const x = label("k");')).toBe(true);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("a name imported from a project specifier", () => {
|
|
605
|
+
expect(accepts('import { twice } from "./pure";\nexport const x = twice(21);')).toBe(true);
|
|
606
|
+
expect(accepts('import { twice } from "../lib/pure";\nexport const x = twice(21);')).toBe(true);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("a default import from a project specifier", () => {
|
|
610
|
+
expect(accepts('import make from "./make";\nexport const x = make(1);')).toBe(true);
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
describe("still rejected, so the diagnostic keeps its value", () => {
|
|
615
|
+
test("a call to a name imported from a package", () => {
|
|
616
|
+
expect(accepts('import { v4 } from "uuid";\nexport const x = v4();')).toBe(false);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("a call to a name the file never binds", () => {
|
|
620
|
+
expect(accepts("export const x = getId();")).toBe(false);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("a local name that shadows nothing local — the specifier decides, not the name", () => {
|
|
624
|
+
expect(accepts('import { twice } from "some-package";\nexport const x = twice(1);')).toBe(false);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("a type-only import, which is erased before anything can call it", () => {
|
|
628
|
+
expect(accepts('import type { T } from "./t";\nexport const x = T();')).toBe(false);
|
|
629
|
+
expect(accepts('import { type T } from "./t";\nexport const x = T();')).toBe(false);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("a `let`-bound arrow, which `S-LocalFunction` does not admit", () => {
|
|
633
|
+
expect(accepts("let f = (n: number) => n;\nexport const x = f(1);")).toBe(false);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("a function nested inside another, so not a top-level binding", () => {
|
|
637
|
+
expect(accepts("function outer() { function inner() { return 1; } return inner; }\nexport const x = inner();")).toBe(false);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test("an argument outside the subset, even when the callee is local", () => {
|
|
641
|
+
const violation = findSubsetViolation(
|
|
642
|
+
initializerOf("function twice(n: number) { return n; }\nexport const x = twice(getId());"),
|
|
643
|
+
);
|
|
644
|
+
expect(violation?.ruleId).toBe("EVL001");
|
|
645
|
+
// The rejection points at the argument, not at the admitted call.
|
|
646
|
+
expect(violation?.node.getText()).toBe("getId()");
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
describe("collectLocalCallables", () => {
|
|
651
|
+
test("collects each binding kind, and nothing else", () => {
|
|
652
|
+
const sourceFile = ts.createSourceFile(
|
|
653
|
+
"m.ts",
|
|
654
|
+
[
|
|
655
|
+
'import { fromProject } from "./p";',
|
|
656
|
+
'import fromProjectDefault from "./d";',
|
|
657
|
+
'import { fromPackage } from "pkg";',
|
|
658
|
+
'import type { TypeOnly } from "./t";',
|
|
659
|
+
"export function declared() { return 1; }",
|
|
660
|
+
"const arrowConst = () => 1;",
|
|
661
|
+
"let notConst = () => 1;",
|
|
662
|
+
"const notAFunction = 1;",
|
|
663
|
+
].join("\n"),
|
|
664
|
+
ts.ScriptTarget.Latest,
|
|
665
|
+
true,
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
expect([...collectLocalCallables(sourceFile)].sort()).toEqual([
|
|
669
|
+
"arrowConst",
|
|
670
|
+
"declared",
|
|
671
|
+
"fromProject",
|
|
672
|
+
"fromProjectDefault",
|
|
673
|
+
]);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test("is memoized per source file, so a large file is walked once", () => {
|
|
677
|
+
const sourceFile = ts.createSourceFile("m.ts", "function f() { return 1; }", ts.ScriptTarget.Latest, true);
|
|
678
|
+
expect(collectLocalCallables(sourceFile)).toBe(collectLocalCallables(sourceFile));
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test("the local-callable lookup is not a new failure mode for a node with no file", () => {
|
|
683
|
+
// A synthetic node has no parent chain, so `getSourceFile()` throws. That
|
|
684
|
+
// is pre-existing: `violation()` renders the offending node with
|
|
685
|
+
// `getText()`, which throws for the same reason, so this shape was never
|
|
686
|
+
// supported. What is asserted here is narrower and is what chant#2435
|
|
687
|
+
// could have broken: the lookup added in front of that rejection swallows
|
|
688
|
+
// the throw instead of turning it into a different error.
|
|
689
|
+
const synthetic = ts.factory.createCallExpression(ts.factory.createIdentifier("twice"), undefined, []);
|
|
690
|
+
expect(() => collectLocalCallables(synthetic.getSourceFile())).toThrow();
|
|
691
|
+
expect(() => findSubsetViolation(synthetic)).toThrow(/Cannot read|undefined|getSourceFile|getText/i);
|
|
692
|
+
});
|
|
693
|
+
});
|
package/src/fold/subset.ts
CHANGED
|
@@ -48,7 +48,16 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
|
|
|
48
48
|
* expression, its operator, and (for keys) its literal-ness. It
|
|
49
49
|
* deliberately does NOT resolve bindings or perform any evaluation, because
|
|
50
50
|
* three things a full fold needs are inherently environment-dependent and
|
|
51
|
-
* cannot be recovered from shape alone
|
|
51
|
+
* cannot be recovered from shape alone.
|
|
52
|
+
*
|
|
53
|
+
* One thing it does read beyond the expression itself, since chant#2435: the
|
|
54
|
+
* enclosing file's top-level bindings, for `S-CallLocal`. A call to a
|
|
55
|
+
* project-local function is admitted by NAME (see
|
|
56
|
+
* {@link collectLocalCallables}), which is decidable from that file's syntax
|
|
57
|
+
* and needs no resolution. Before that, this module rejected all four shapes
|
|
58
|
+
* the build folds, which is the one direction `F-Direction` forbids, and it
|
|
59
|
+
* made `chant lint` report EVL001 on a file `chant build --fold` folds
|
|
60
|
+
* cleanly. The three genuinely environment-dependent cases are:
|
|
52
61
|
*
|
|
53
62
|
* 1. Identifier *resolution* — is a bare name actually a local `const`,
|
|
54
63
|
* vs. an unbound name? That needs the file's `consts` map. Both
|
|
@@ -237,6 +246,111 @@ export function briefNodeText(node: ts.Node, maxLength = 60): string {
|
|
|
237
246
|
return collapsed.length > maxLength ? `${collapsed.slice(0, maxLength - 3)}...` : collapsed;
|
|
238
247
|
}
|
|
239
248
|
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Project-local callables (chant#2435, spec `S-CallLocal`)
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Every name a file binds to something a project-local call can reach.
|
|
255
|
+
*
|
|
256
|
+
* Two bindings, both of which the specification's `S-LocalFunction` names and
|
|
257
|
+
* both decidable from this file's own syntax, which is all a shape classifier
|
|
258
|
+
* has:
|
|
259
|
+
*
|
|
260
|
+
* - a top-level `function f(...) {...}`, or a `const f = (...) => ...`
|
|
261
|
+
* (a function expression counts, and parentheses / `as` / `satisfies`
|
|
262
|
+
* wrappers are unwrapped), exported or not;
|
|
263
|
+
* - a name imported from a *project* specifier, one starting `./` or `../`.
|
|
264
|
+
* A bare specifier is a package, and a package's function is not
|
|
265
|
+
* project-local.
|
|
266
|
+
*
|
|
267
|
+
* Deliberately syntax-only, and deliberately not a judgment about the callee's
|
|
268
|
+
* body. Whether the body is admissible is `S-FnBody`'s question and the fold's
|
|
269
|
+
* to answer, exactly as it already is for a call this classifier admits by
|
|
270
|
+
* name (a registered authoring helper, an opted-in intrinsic).
|
|
271
|
+
*
|
|
272
|
+
* Mirrors `collectLocalFunctions` in ../discovery/fold-import.ts, which builds
|
|
273
|
+
* the real `FoldableFunction` for each of these during a build. This one only
|
|
274
|
+
* needs the names.
|
|
275
|
+
*/
|
|
276
|
+
export function collectLocalCallables(sourceFile: ts.SourceFile): ReadonlySet<string> {
|
|
277
|
+
const cached = localCallableCache.get(sourceFile);
|
|
278
|
+
if (cached) return cached;
|
|
279
|
+
|
|
280
|
+
const names = new Set<string>();
|
|
281
|
+
const unwrap = (node: ts.Expression): ts.Expression => {
|
|
282
|
+
let current = node;
|
|
283
|
+
while (
|
|
284
|
+
ts.isParenthesizedExpression(current) ||
|
|
285
|
+
ts.isAsExpression(current) ||
|
|
286
|
+
ts.isSatisfiesExpression(current)
|
|
287
|
+
) {
|
|
288
|
+
current = current.expression;
|
|
289
|
+
}
|
|
290
|
+
return current;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
for (const statement of sourceFile.statements) {
|
|
294
|
+
if (ts.isFunctionDeclaration(statement)) {
|
|
295
|
+
if (statement.name && statement.body) names.add(statement.name.text);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (ts.isImportDeclaration(statement)) {
|
|
299
|
+
const specifier = statement.moduleSpecifier;
|
|
300
|
+
if (!ts.isStringLiteral(specifier) || !/^\.\.?\//.test(specifier.text)) continue;
|
|
301
|
+
const clause = statement.importClause;
|
|
302
|
+
// `import type { T } from "./t"` is erased before anything runs, so `T`
|
|
303
|
+
// is not something a call can reach. Both spellings are skipped: the
|
|
304
|
+
// whole-clause one and the per-specifier `import { type T }`.
|
|
305
|
+
if (!clause || clause.isTypeOnly) continue;
|
|
306
|
+
if (clause.name) names.add(clause.name.text);
|
|
307
|
+
const bindings = clause.namedBindings;
|
|
308
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
309
|
+
for (const element of bindings.elements) {
|
|
310
|
+
if (!element.isTypeOnly) names.add(element.name.text);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
316
|
+
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
|
|
317
|
+
for (const decl of statement.declarationList.declarations) {
|
|
318
|
+
if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
|
|
319
|
+
const initializer = unwrap(decl.initializer);
|
|
320
|
+
if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) {
|
|
321
|
+
names.add(decl.name.text);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
localCallableCache.set(sourceFile, names);
|
|
327
|
+
return names;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Per-file memo: the classifier asks this once per call expression, and a big file has many. */
|
|
331
|
+
const localCallableCache = new WeakMap<ts.SourceFile, ReadonlySet<string>>();
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The project-local callables in scope at `node`, or an empty set when the node
|
|
335
|
+
* has no file to read.
|
|
336
|
+
*
|
|
337
|
+
* The guard is not a claim that a file-less node is supported: the rejection
|
|
338
|
+
* this sits in front of renders the node with `getText()`, which needs the same
|
|
339
|
+
* parent chain. It is here so that looking the names up never becomes a *new*
|
|
340
|
+
* way for this function to fail, and so the answer for a node whose file cannot
|
|
341
|
+
* be read is the pre-#2435 one, that every call is a violation.
|
|
342
|
+
*/
|
|
343
|
+
function localCallablesAt(node: ts.Node): ReadonlySet<string> {
|
|
344
|
+
try {
|
|
345
|
+
const sourceFile = node.getSourceFile();
|
|
346
|
+
return sourceFile ? collectLocalCallables(sourceFile) : EMPTY_NAMES;
|
|
347
|
+
} catch {
|
|
348
|
+
return EMPTY_NAMES;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const EMPTY_NAMES: ReadonlySet<string> = new Set<string>();
|
|
353
|
+
|
|
240
354
|
// ---------------------------------------------------------------------------
|
|
241
355
|
// Shared message builders — `fold()` and `findSubsetViolation` both call
|
|
242
356
|
// these so the diagnostic text for the same violation kind is the same
|
|
@@ -547,6 +661,27 @@ export function findSubsetViolation(
|
|
|
547
661
|
return undefined;
|
|
548
662
|
}
|
|
549
663
|
|
|
664
|
+
// chant#2435 — a call to a project-local function. `fold-import.ts` binds
|
|
665
|
+
// every top-level function a file declares (and every one it imports from
|
|
666
|
+
// a project file) before folding it, so a build folds all four shapes the
|
|
667
|
+
// issue tabulates; this classifier rejected all four. That is the one
|
|
668
|
+
// direction `F-Direction` forbids, and it made `chant lint` report EVL001
|
|
669
|
+
// on a file `chant build --fold` folds cleanly.
|
|
670
|
+
//
|
|
671
|
+
// Name-only, like the authoring-helper case above and for the same reason:
|
|
672
|
+
// this module classifies shape and never resolves bindings. Whether the
|
|
673
|
+
// callee's body is admissible is `S-FnBody`'s question, which the fold
|
|
674
|
+
// answers. The set comes from the node's own source file, so every caller
|
|
675
|
+
// gets the corrected answer without passing anything — a caller holding a
|
|
676
|
+
// synthetic node with no file keeps the old, stricter one.
|
|
677
|
+
if (ts.isIdentifier(node.expression) && localCallablesAt(node).has(node.expression.text)) {
|
|
678
|
+
for (const arg of node.arguments) {
|
|
679
|
+
const v = findSubsetViolation(arg, intrinsics);
|
|
680
|
+
if (v) return v;
|
|
681
|
+
}
|
|
682
|
+
return undefined;
|
|
683
|
+
}
|
|
684
|
+
|
|
550
685
|
return violation(node, callExpressionMessage(node));
|
|
551
686
|
}
|
|
552
687
|
|
package/src/index.ts
CHANGED
|
@@ -43,7 +43,7 @@ export * from "./fold/fold";
|
|
|
43
43
|
// fold?" of without running a fold (subset.ts module doc, point 2c), and the
|
|
44
44
|
// half of the fold subset a conformance adapter needs that `fold()` alone
|
|
45
45
|
// does not expose. INTENTIUS/typescript-as-data#11.
|
|
46
|
-
export { findSubsetViolation, checkObjectMember, type SubsetViolation, type SubsetRuleId } from "./fold/subset";
|
|
46
|
+
export { findSubsetViolation, checkObjectMember, collectLocalCallables, type SubsetViolation, type SubsetRuleId } from "./fold/subset";
|
|
47
47
|
// The version of the TypeScript-as-Data specification chant implements
|
|
48
48
|
// (INTENTIUS/typescript-as-data#18, chant#2424). The specification's
|
|
49
49
|
// conformance adapter reads this off the public entry; without it a suite
|