@intentius/chant 0.71.1 → 0.72.1
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 +1 -1
- package/src/cli/commands/lint.test.ts +38 -4
- package/src/discovery/fold-host-composite.test.ts +115 -0
- package/src/discovery/fold-host-packages.test.ts +89 -0
- package/src/discovery/fold-import.ts +68 -60
- 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
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
FoldableFunction,
|
|
18
18
|
isFoldableFunction,
|
|
19
19
|
locate,
|
|
20
|
+
collectSameFileFunctions,
|
|
21
|
+
constResolvesToResource,
|
|
20
22
|
type FoldedResource,
|
|
21
23
|
type FoldedValue,
|
|
22
24
|
type FoldedIntrinsic,
|
|
@@ -307,6 +309,19 @@ export function createFoldSession(
|
|
|
307
309
|
buildParams?: Readonly<Record<string, BuildParamValue>>,
|
|
308
310
|
lexicons: readonly string[] = [],
|
|
309
311
|
sandbox = false,
|
|
312
|
+
/**
|
|
313
|
+
* chant#2438 — package specifiers to allow verbatim, alongside the ones
|
|
314
|
+
* derived from `lexicons`.
|
|
315
|
+
*
|
|
316
|
+
* A chant build's active packages are always `@intentius/chant-lexicon-*`,
|
|
317
|
+
* so naming lexicons is the whole story there. A caller embedding the fold
|
|
318
|
+
* path against a host that is not a chant lexicon has packages the naming
|
|
319
|
+
* convention cannot express: the specification's conformance harness supplies
|
|
320
|
+
* `@tsad/shapes`, which is its host's own specifier. The boundary is
|
|
321
|
+
* unchanged, an explicitly named package of this build, only now it can be
|
|
322
|
+
* named as a specifier rather than only as a lexicon.
|
|
323
|
+
*/
|
|
324
|
+
lexiconPackages: readonly string[] = [],
|
|
310
325
|
): FoldSession {
|
|
311
326
|
return {
|
|
312
327
|
intrinsics,
|
|
@@ -315,7 +330,7 @@ export function createFoldSession(
|
|
|
315
330
|
importCache: new Map(),
|
|
316
331
|
resolvePathCache: new Map(),
|
|
317
332
|
buildParams,
|
|
318
|
-
lexiconPackages: new Set(lexicons.map(lexiconPackageName)),
|
|
333
|
+
lexiconPackages: new Set([...lexicons.map(lexiconPackageName), ...lexiconPackages]),
|
|
319
334
|
sandbox,
|
|
320
335
|
factoryModules: new Map(),
|
|
321
336
|
};
|
|
@@ -764,14 +779,6 @@ function collectLocalBindings(sourceFile: ts.SourceFile): Map<string, LocalBindi
|
|
|
764
779
|
}
|
|
765
780
|
|
|
766
781
|
/** `(x) => …` / `function (x) {…}`, possibly wrapped in parens/`as`/`satisfies` — the initializer shapes that make a `const` a function binding. */
|
|
767
|
-
function unwrapFunctionInitializer(node: ts.Expression): ts.ArrowFunction | ts.FunctionExpression | undefined {
|
|
768
|
-
let current = node;
|
|
769
|
-
while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current)) {
|
|
770
|
-
current = current.expression;
|
|
771
|
-
}
|
|
772
|
-
return ts.isArrowFunction(current) || ts.isFunctionExpression(current) ? current : undefined;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
782
|
/**
|
|
776
783
|
* chant #1373 — every top-level function a file declares, exported or not, as
|
|
777
784
|
* a {@link FoldableFunction}: `function f(...) {...}` and `const f = (...) =>
|
|
@@ -786,29 +793,10 @@ function unwrapFunctionInitializer(node: ts.Expression): ts.ArrowFunction | ts.F
|
|
|
786
793
|
* module-level resource through `externals` only.
|
|
787
794
|
*/
|
|
788
795
|
function collectLocalFunctions(sourceFile: ts.SourceFile, ctx: ResolveCtx): Map<string, FoldableFunction> {
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
const functions = new Map<string, FoldableFunction>();
|
|
794
|
-
const add = (name: string, fn: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression): void => {
|
|
795
|
-
functions.set(name, new FoldableFunction(name, fn, ctx.file, consts, ctx.externals, ctx.crossFileFailures));
|
|
796
|
-
};
|
|
797
|
-
|
|
798
|
-
for (const statement of sourceFile.statements) {
|
|
799
|
-
if (ts.isFunctionDeclaration(statement)) {
|
|
800
|
-
if (statement.name && statement.body) add(statement.name.text, statement);
|
|
801
|
-
continue;
|
|
802
|
-
}
|
|
803
|
-
if (!ts.isVariableStatement(statement)) continue;
|
|
804
|
-
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
|
|
805
|
-
for (const decl of statement.declarationList.declarations) {
|
|
806
|
-
if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
|
|
807
|
-
const fn = unwrapFunctionInitializer(decl.initializer);
|
|
808
|
-
if (fn) add(decl.name.text, fn);
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
return functions;
|
|
796
|
+
// chant#2436 — the walk itself, and the `new`-bound stripping that makes it
|
|
797
|
+
// correct, live in ../fold/fold.ts so a unit-level fold with no externals of
|
|
798
|
+
// its own binds exactly what a build binds.
|
|
799
|
+
return collectSameFileFunctions(sourceFile, ctx.consts, ctx.file, ctx.externals, ctx.crossFileFailures);
|
|
812
800
|
}
|
|
813
801
|
|
|
814
802
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -1869,6 +1857,16 @@ interface InterpretableFactory {
|
|
|
1869
1857
|
fn: ts.ArrowFunction | ts.FunctionExpression;
|
|
1870
1858
|
/** `Composite()`'s second argument, or `"anonymous"` when it has none — matching {@link Composite}'s own default. */
|
|
1871
1859
|
compositeName: string;
|
|
1860
|
+
/**
|
|
1861
|
+
* chant#2442 — the registration form came from a HOST the caller named
|
|
1862
|
+
* ({@link FoldSession.lexiconPackages}), not from chant.
|
|
1863
|
+
*
|
|
1864
|
+
* The members are then the host's own, so they are returned as the factory
|
|
1865
|
+
* produced them rather than wrapped in chant's {@link Composite}, whose
|
|
1866
|
+
* member validation asks for chant's own `Declarable` and would reject an
|
|
1867
|
+
* entity carrying a different marker. Chant's own form is untouched.
|
|
1868
|
+
*/
|
|
1869
|
+
hostForm: boolean;
|
|
1872
1870
|
}
|
|
1873
1871
|
|
|
1874
1872
|
/**
|
|
@@ -1939,27 +1937,6 @@ function factoryModuleScopeResolved(
|
|
|
1939
1937
|
return scope.resolved;
|
|
1940
1938
|
}
|
|
1941
1939
|
|
|
1942
|
-
/**
|
|
1943
|
-
* True when a module-level `const` is (transitively) bound to a
|
|
1944
|
-
* `new Type(...)`. Mirrors `fold()`'s own `resolvesToResource`, but follows an
|
|
1945
|
-
* identifier chain (`const a = new T(); const b = a;`) so aliasing can't smuggle
|
|
1946
|
-
* a module-level resource into a factory body — see
|
|
1947
|
-
* {@link FactoryModuleScope.consts}.
|
|
1948
|
-
*/
|
|
1949
|
-
function constResolvesToResource(
|
|
1950
|
-
consts: Map<string, ts.Expression>,
|
|
1951
|
-
name: string,
|
|
1952
|
-
seen: Set<string>,
|
|
1953
|
-
): boolean {
|
|
1954
|
-
if (seen.has(name)) return false;
|
|
1955
|
-
seen.add(name);
|
|
1956
|
-
const init = consts.get(name);
|
|
1957
|
-
if (init === undefined) return false;
|
|
1958
|
-
if (ts.isNewExpression(init)) return true;
|
|
1959
|
-
if (ts.isIdentifier(init)) return constResolvesToResource(consts, init.text, seen);
|
|
1960
|
-
return false;
|
|
1961
|
-
}
|
|
1962
|
-
|
|
1963
1940
|
/**
|
|
1964
1941
|
* Find `export const <exportName> = Composite(<fn>, "<name>")` at the top level
|
|
1965
1942
|
* of `scope`'s module, verifying that `Composite` really is chant's own in that
|
|
@@ -1970,7 +1947,7 @@ function findCompositeDefinition(
|
|
|
1970
1947
|
scope: FactoryModuleScope,
|
|
1971
1948
|
exportName: string,
|
|
1972
1949
|
ctx: ResolveCtx,
|
|
1973
|
-
): { fn: ts.ArrowFunction | ts.FunctionExpression; compositeName: string } | undefined {
|
|
1950
|
+
): { fn: ts.ArrowFunction | ts.FunctionExpression; compositeName: string; hostForm: boolean } | undefined {
|
|
1974
1951
|
for (const statement of scope.sourceFile.statements) {
|
|
1975
1952
|
if (!ts.isVariableStatement(statement)) continue;
|
|
1976
1953
|
if (!hasExportModifier(statement)) continue;
|
|
@@ -1988,7 +1965,16 @@ function findCompositeDefinition(
|
|
|
1988
1965
|
// call to it is not a registered composite.
|
|
1989
1966
|
const compositeBinding = scope.imports.get(init.expression.text);
|
|
1990
1967
|
if (!compositeBinding || compositeBinding.imported !== "Composite") return undefined;
|
|
1991
|
-
|
|
1968
|
+
// chant's own is the answer for every chant project, and #1082's
|
|
1969
|
+
// provenance question is why: a project-local `function Composite(...)`
|
|
1970
|
+
// shadowing the name is not chant's. chant#2442 adds the case chant had
|
|
1971
|
+
// never had to consider, a HOST supplying the form (`F-Host-Composite`),
|
|
1972
|
+
// which only a caller that named its own host packages can reach. For a
|
|
1973
|
+
// chant build the set holds only `@intentius/chant-lexicon-*`, and no
|
|
1974
|
+
// lexicon exports a `Composite`, so nothing about a chant project moves.
|
|
1975
|
+
const chantOwned = isChantOwnedHelperBinding(compositeBinding, { ...ctx, file: scope.file });
|
|
1976
|
+
const hostOwned = activeLexiconPackage(compositeBinding.specifier, ctx.lexiconPackages) !== undefined;
|
|
1977
|
+
if (!chantOwned && !hostOwned) return undefined;
|
|
1992
1978
|
|
|
1993
1979
|
const [fnArg, nameArg] = init.arguments;
|
|
1994
1980
|
if (!fnArg || (!ts.isArrowFunction(fnArg) && !ts.isFunctionExpression(fnArg))) return undefined;
|
|
@@ -1996,7 +1982,7 @@ function findCompositeDefinition(
|
|
|
1996
1982
|
// COR017 requires the literal in practice; anything that is not a plain
|
|
1997
1983
|
// string literal is not something to guess at.
|
|
1998
1984
|
if (nameArg !== undefined && !ts.isStringLiteral(nameArg)) return undefined;
|
|
1999
|
-
return { fn: fnArg, compositeName: nameArg ? nameArg.text : "anonymous" };
|
|
1985
|
+
return { fn: fnArg, compositeName: nameArg ? nameArg.text : "anonymous", hostForm: !chantOwned };
|
|
2000
1986
|
}
|
|
2001
1987
|
}
|
|
2002
1988
|
return undefined;
|
|
@@ -2044,7 +2030,7 @@ async function resolveInterpretableFactory(
|
|
|
2044
2030
|
// Rules 3-5.
|
|
2045
2031
|
if (findFactorySubsetViolation(definition.fn) !== undefined) return undefined;
|
|
2046
2032
|
|
|
2047
|
-
return { scope, fn: definition.fn, compositeName: definition.compositeName };
|
|
2033
|
+
return { scope, fn: definition.fn, compositeName: definition.compositeName, hostForm: definition.hostForm };
|
|
2048
2034
|
}
|
|
2049
2035
|
|
|
2050
2036
|
/**
|
|
@@ -2398,10 +2384,17 @@ async function interpretCompositeFactory(
|
|
|
2398
2384
|
// one per module, as the run path has) is the single visible difference:
|
|
2399
2385
|
// `_id` is a fresh symbol, which nothing outside `CompositeRegistry` — used
|
|
2400
2386
|
// only by tests — reads.
|
|
2401
|
-
|
|
2402
|
-
|
|
2387
|
+
// chant#2442 — a HOST's registration form produces the host's own members,
|
|
2388
|
+
// so they are returned as the factory built them. Wrapping them in chant's
|
|
2389
|
+
// `Composite` would run chant's member validation, which asks for chant's
|
|
2390
|
+
// own `Declarable` and rejects an entity carrying a different marker —
|
|
2391
|
+
// rejecting it for not being chant's rather than for being malformed.
|
|
2392
|
+
// Chant's own form still goes through `Composite`, unchanged.
|
|
2393
|
+
const value = factory.hostForm
|
|
2394
|
+
? members
|
|
2395
|
+
: (Composite<void, CompositeMembers>(() => members as CompositeMembers, factory.compositeName))();
|
|
2403
2396
|
executionCounts.factoryInterpretations += 1;
|
|
2404
|
-
return { value
|
|
2397
|
+
return { value };
|
|
2405
2398
|
} catch (err) {
|
|
2406
2399
|
// Every other failure here means "this body did not interpret" and the
|
|
2407
2400
|
// caller invokes instead. Exhausting the depth bound means "we stopped
|
|
@@ -4099,6 +4092,20 @@ export interface FoldProjectOptions {
|
|
|
4099
4092
|
readonly buildParams?: Readonly<Record<string, BuildParamValue>>;
|
|
4100
4093
|
/** chant #1093: this build asked for the sandbox, so fold may not reach outside the trusted allowlist. */
|
|
4101
4094
|
readonly sandbox?: boolean;
|
|
4095
|
+
/**
|
|
4096
|
+
* chant#2438 — package specifiers to follow a bare import into, verbatim,
|
|
4097
|
+
* alongside whatever `lexicons` names.
|
|
4098
|
+
*
|
|
4099
|
+
* For a chant project this is never needed: an active package is always
|
|
4100
|
+
* `@intentius/chant-lexicon-<name>`, which `lexicons` already expresses. It
|
|
4101
|
+
* is here for a caller driving the fold path against a host of its own, where
|
|
4102
|
+
* the package has a name chant's convention cannot spell. The specification's
|
|
4103
|
+
* conformance harness is the first such caller, with `@tsad/shapes`.
|
|
4104
|
+
*
|
|
4105
|
+
* The boundary does not move. This is still an allowlist the caller states
|
|
4106
|
+
* outright, matched by text, and absent it nothing bare resolves at all.
|
|
4107
|
+
*/
|
|
4108
|
+
readonly lexiconPackages?: readonly string[];
|
|
4102
4109
|
}
|
|
4103
4110
|
|
|
4104
4111
|
/** One file's place in a whole-build fold, as {@link foldProject} reports it. */
|
|
@@ -4146,6 +4153,7 @@ export async function foldProject(
|
|
|
4146
4153
|
options.buildParams,
|
|
4147
4154
|
options.lexicons ?? [],
|
|
4148
4155
|
options.sandbox ?? false,
|
|
4156
|
+
options.lexiconPackages ?? [],
|
|
4149
4157
|
);
|
|
4150
4158
|
const attempts = new Map<string, FoldFileResult>();
|
|
4151
4159
|
for (const file of files) attempts.set(file, await tryFoldFile(file, intrinsics, session));
|
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
|