///
{ readonly path: P; readonly schema: S }
export namespace Route {
export type Any = Route (path: P): Route =>
({ path, schema: {} } as Route );
}
`,
"utf8",
);
writeFileSync(bootstrap, `import * as Route from "./route.js"; void Route; export {};`, "utf8");
writeFileSync(
main,
`import * as Route from "./route.js"; export const r = Route.Parse("/status");`,
"utf8",
);
const program = makeProgram([bootstrap, main]);
const specs = [
{ id: "Route", module: "./route.js", exportName: "Route", typeMember: "Any" },
] as const;
const targets = resolveTypeTargetsFromSpecs(program, ts, specs);
expect(targets).toHaveLength(1);
expect(targets[0]!.id).toBe("Route");
const session = createTypeInfoApiSession({
ts,
program,
typeTargetSpecs: specs,
failWhenNoTargetsResolved: false,
});
const result = session.api.file("./main.ts", { baseDir: dir });
expect(result.ok).toBe(true);
if (!result.ok) return;
const rExport = result.snapshot.exports.find((e) => e.name === "r");
expect(rExport).toBeDefined();
expect(session.api.isAssignableTo(rExport!.type, "Route")).toBe(true);
});
});
const makeProgram = (rootFiles: readonly string[]): ts.Program =>
ts.createProgram(rootFiles, {
strict: true,
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
skipLibCheck: true,
noEmit: true,
});
describe("createTypeInfoApiSession", () => {
it("serializes rich structural type information", () => {
const dir = createTempDir();
const filePath = join(dir, "types.ts");
writeFileSync(
filePath,
`
export type U = string | number;
export interface Box { readonly value: U; optional?: number }
export const tuple = [1, "x"] as const;
export const fn = (input: Box): U => input.value;
`,
"utf8",
);
const program = makeProgram([filePath]);
const session = createTypeInfoApiSession({ ts, program });
const result = session.api.file("./types.ts", { baseDir: dir });
expect(result.ok).toBe(true);
if (!result.ok) return;
const snapshot = result.snapshot;
const byName = new Map(snapshot.exports.map((entry) => [entry.name, entry]));
expect(byName.get("U")?.type.kind).toBe("union");
expect(byName.get("tuple")?.type.kind).toBe("tuple");
expect(byName.get("fn")?.type.kind).toBe("function");
expect(byName.get("Box")?.type.kind).toBe("object");
});
it("supports relativeGlobs directory queries and watch descriptors", () => {
const dir = createTempDir();
writeFileSync(join(dir, "a.ts"), `export const a = 1;`, "utf8");
writeFileSync(join(dir, "b.ts"), `export const b = 2;`, "utf8");
mkdirSync(join(dir, "nested"), { recursive: true });
writeFileSync(join(dir, "nested", "nested.ts"), `export const nested = 3;`, "utf8");
const program = makeProgram([
join(dir, "a.ts"),
join(dir, "b.ts"),
join(dir, "nested", "nested.ts"),
]);
const session = createTypeInfoApiSession({ ts, program });
const nonRecursive = session.api.directory("*.ts", {
baseDir: dir,
recursive: false,
watch: true,
});
const recursive = session.api.directory("*.ts", {
baseDir: dir,
recursive: true,
watch: true,
});
expect(nonRecursive.length).toBe(2);
expect(recursive.length).toBeGreaterThan(nonRecursive.length);
const dependencies = session.consumeDependencies();
expect(dependencies.some((descriptor) => descriptor.type === "glob")).toBe(true);
});
it("returns path-escapes-base when relativePath escapes baseDir", () => {
const dir = createTempDir();
const filePath = join(dir, "types.ts");
writeFileSync(filePath, "export const x = 1;", "utf8");
const program = makeProgram([filePath]);
const session = createTypeInfoApiSession({ ts, program });
const result = session.api.file("../../../other/types.ts", { baseDir: dir });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBe("path-escapes-base");
});
it("returns path-escapes-base when path resolves via symlink outside baseDir", () => {
const baseDir = createTempDir();
const outsideDir = createTempDir();
const outsideFile = join(outsideDir, "outside.ts");
writeFileSync(outsideFile, "export const x = 1;", "utf8");
const linkInsideBase = join(baseDir, "link-to-outside");
let symlinkCreated = false;
try {
symlinkSync(outsideDir, linkInsideBase);
symlinkCreated = true;
} catch {
// Symlinks may require privileges on some platforms (e.g. Windows); skip if unavailable
}
if (!symlinkCreated) return;
const program = makeProgram([join(baseDir, "dummy.ts")]);
writeFileSync(join(baseDir, "dummy.ts"), "export const d = 1;", "utf8");
const session = createTypeInfoApiSession({ ts, program });
const result = session.api.file("./link-to-outside/outside.ts", { baseDir });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBe("path-escapes-base");
});
it("returns file-not-in-program when file is not in the program", () => {
const dir = createTempDir();
const inProgram = join(dir, "in-program.ts");
const notInProgram = join(dir, "not-in-program.ts");
writeFileSync(inProgram, "export const a = 1;", "utf8");
writeFileSync(notInProgram, "export const b = 2;", "utf8");
const program = makeProgram([inProgram]);
const session = createTypeInfoApiSession({ ts, program });
const result = session.api.file("./not-in-program.ts", { baseDir: dir });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBe("file-not-in-program");
});
it("returns empty array from directory() when baseDir is invalid", () => {
const dir = createTempDir();
const filePath = join(dir, "types.ts");
writeFileSync(filePath, "export const x = 1;", "utf8");
const program = makeProgram([filePath]);
const session = createTypeInfoApiSession({ ts, program });
const emptyBase = session.api.directory("*.ts", { baseDir: "", recursive: false });
expect(emptyBase).toEqual([]);
});
it("caches directory() results: identical calls return the same array reference", () => {
const dir = createTempDir();
const filePath = join(dir, "types.ts");
writeFileSync(filePath, "export const x = 1;", "utf8");
const program = makeProgram([filePath]);
const session = createTypeInfoApiSession({ ts, program });
const first = session.api.directory("*.ts", { baseDir: dir, recursive: false });
const second = session.api.directory("*.ts", { baseDir: dir, recursive: false });
expect(first).toBe(second);
expect(first.length).toBeGreaterThan(0);
});
it("directory() cache is keyed by baseDir and globs: different query yields different result", () => {
const dir = createTempDir();
const tsPath = join(dir, "a.ts");
writeFileSync(tsPath, "export const x = 1;", "utf8");
const program = makeProgram([tsPath]);
const session = createTypeInfoApiSession({ ts, program });
const withTs = session.api.directory("*.ts", { baseDir: dir, recursive: false });
const withTsx = session.api.directory("*.tsx", { baseDir: dir, recursive: false });
expect(withTs).not.toBe(withTsx);
expect(withTs.length).toBeGreaterThan(0);
expect(withTsx.length).toBe(0);
});
it("returns invalid-input for empty baseDir or invalid relativePath", () => {
const dir = createTempDir();
const filePath = join(dir, "types.ts");
writeFileSync(filePath, "export const x = 1;", "utf8");
const program = makeProgram([filePath]);
const session = createTypeInfoApiSession({ ts, program });
const emptyBase = session.api.file("./types.ts", { baseDir: "" });
expect(emptyBase.ok).toBe(false);
if (emptyBase.ok) return;
expect(emptyBase.error).toBe("invalid-input");
const nullByte = session.api.file("types.ts\0", { baseDir: dir });
expect(nullByte.ok).toBe(false);
if (nullByte.ok) return;
expect(nullByte.error).toBe("invalid-input");
});
it("resolves re-exports to the type from the target file", () => {
const dir = createTempDir();
const routeShape = `export const route = { ast: null, path: "/", paramsSchema: null, pathSchema: null, querySchema: null };`;
const bPath = join(dir, "b.ts");
const aPath = join(dir, "a.ts");
writeFileSync(bPath, routeShape, "utf8");
writeFileSync(aPath, 'export { route } from "./b";', "utf8");
const program = makeProgram([aPath, bPath]);
const session = createTypeInfoApiSession({ ts, program });
const result = session.api.file("./a.ts", { baseDir: dir });
expect(result.ok).toBe(true);
if (!result.ok) return;
const routeExport = result.snapshot.exports.find((e) => e.name === "route");
expect(routeExport).toBeDefined();
expect(routeExport!.type.kind).toBe("object");
const obj = routeExport!.type as {
kind: "object";
properties: ReadonlyArray<{ name: string }>;
};
const names = new Set(obj.properties.map((p) => p.name));
expect(names.has("ast")).toBe(true);
expect(names.has("path")).toBe(true);
expect(names.has("paramsSchema")).toBe(true);
expect(names.has("pathSchema")).toBe(true);
expect(names.has("querySchema")).toBe(true);
});
it("resolveExport returns export by name from a file", () => {
const dir = createTempDir();
const filePath = join(dir, "mod.ts");
writeFileSync(filePath, "export const foo = 1; export type Bar = string;", "utf8");
const program = makeProgram([filePath]);
const session = createTypeInfoApiSession({ ts, program });
const foo = session.api.resolveExport(dir, "./mod.ts", "foo");
expect(foo).toBeDefined();
expect(foo!.name).toBe("foo");
expect(["primitive", "literal"]).toContain(foo!.type.kind);
const bar = session.api.resolveExport(dir, "./mod.ts", "Bar");
expect(bar).toBeDefined();
expect(bar!.name).toBe("Bar");
const missing = session.api.resolveExport(dir, "./mod.ts", "nonexistent");
expect(missing).toBeUndefined();
});
it("includes imports in file snapshot when present", () => {
const dir = createTempDir();
const filePath = join(dir, "mod.ts");
writeFileSync(
filePath,
'import { a, b } from "./other"; import * as ns from "./ns"; export const x = a;',
"utf8",
);
const otherPath = join(dir, "other.ts");
writeFileSync(otherPath, "export const a = 1; export const b = 2;", "utf8");
const nsPath = join(dir, "ns.ts");
writeFileSync(nsPath, "export const y = 3;", "utf8");
const program = makeProgram([filePath, otherPath, nsPath]);
const session = createTypeInfoApiSession({ ts, program });
const result = session.api.file("./mod.ts", { baseDir: dir });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.snapshot.imports).toBeDefined();
expect(result.snapshot.imports!.length).toBe(2);
const named = result.snapshot.imports!.find((i) => i.importedNames);
expect(named?.moduleSpecifier).toBe("./other");
expect(named?.importedNames).toEqual(["a", "b"]);
const ns = result.snapshot.imports!.find((i) => i.namespaceImport);
expect(ns?.moduleSpecifier).toBe("./ns");
expect(ns?.namespaceImport).toBe("ns");
});
it("accepts typeTargetSpecs and resolves them for assignableTo", () => {
const dir = createTempDir();
const filePath = join(dir, "mod.ts");
writeFileSync(filePath, "export const x: number = 1;", "utf8");
const program = makeProgram([filePath]);
const session = createTypeInfoApiSession({
ts,
program,
typeTargetSpecs: [{ id: "Num", module: "effect/Number", exportName: "Number" }],
failWhenNoTargetsResolved: false,
});
const result = session.api.file("./mod.ts", { baseDir: dir });
expect(result.ok).toBe(true);
if (!result.ok) return;
const xExport = result.snapshot.exports.find((e) => e.name === "x");
expect(xExport).toBeDefined();
expect(xExport!.type.kind).toBe("primitive");
});
describe("isAssignableTo dynamic API", () => {
it("returns true for direct assignability check", () => {
const dir = createTempDir();
const routeMod = join(dir, "route.ts");
const bootstrap = join(dir, "bootstrap.ts");
const main = join(dir, "main.ts");
writeFileSync(
routeMod,
`
export interface Route { readonly path: P; readonly schema: S }
export namespace Route {
export type Any = Route (path: P): Route =>
({ path, schema: {} } as Route );
}
`,
"utf8",
);
writeFileSync(bootstrap, `import * as Route from "./route.js"; void Route; export {};`, "utf8");
writeFileSync(
main,
`import * as Route from "./route.js"; export const r = Route.Parse("/status");`,
"utf8",
);
const program = makeProgram([bootstrap, main]);
const specs = [
{ id: "Route", module: "./route.js", exportName: "Route", typeMember: "Any" },
] as const;
const session = createTypeInfoApiSession({
ts,
program,
typeTargetSpecs: specs,
failWhenNoTargetsResolved: false,
});
const result = session.api.file("./main.ts", { baseDir: dir });
expect(result.ok).toBe(true);
if (!result.ok) return;
const routeExport = result.snapshot.exports.find((e) => e.name === "r");
expect(routeExport).toBeDefined();
expect(session.api.isAssignableTo(routeExport!.type, "Route")).toBe(true);
});
it("returns false for non-matching target", () => {
const dir = createTempDir();
const routeMod = join(dir, "route.ts");
const bootstrap = join(dir, "bootstrap.ts");
const main = join(dir, "main.ts");
writeFileSync(
routeMod,
`
export interface Route { readonly path: P; readonly schema: S }
export namespace Route {
export type Any = Route (path: P): Route =>
({ path, schema: {} } as Route );
}
`,
"utf8",
);
writeFileSync(bootstrap, `import * as Route from "./route.js"; void Route; export {};`, "utf8");
writeFileSync(
main,
`import * as Route from "./route.js"; export const r = Route.Parse("/status"); export const n: number = 1;`,
"utf8",
);
const program = makeProgram([bootstrap, main]);
const specs = [
{ id: "Route", module: "./route.js", exportName: "Route", typeMember: "Any" },
] as const;
const session = createTypeInfoApiSession({
ts,
program,
typeTargetSpecs: specs,
failWhenNoTargetsResolved: false,
});
const result = session.api.file("./main.ts", { baseDir: dir });
expect(result.ok).toBe(true);
if (!result.ok) return;
const nExport = result.snapshot.exports.find((e) => e.name === "n");
expect(nExport).toBeDefined();
expect(session.api.isAssignableTo(nExport!.type, "Route")).toBe(false);
});
it("returns true for projected check (returnType)", () => {
const dir = createTempDir();
const routeMod = join(dir, "route.ts");
const bootstrap = join(dir, "bootstrap.ts");
const main = join(dir, "main.ts");
writeFileSync(
routeMod,
`
export interface Route { readonly path: P; readonly schema: S }
export namespace Route {
export type Any = Route (path: P): Route =>
({ path, schema: {} } as Route );
}
`,
"utf8",
);
writeFileSync(bootstrap, `import * as Route from "./route.js"; void Route; export {};`, "utf8");
writeFileSync(
main,
`import * as Route from "./route.js";
export const r = Route.Parse("/status");
export function getRoute(): Route.Route { readonly path: P; readonly schema: S }
export namespace Route {
export type Any = Route (path: P): Route =>
({ path, schema: {} } as Route );
}
`,
"utf8",
);
writeFileSync(bootstrap, `import * as Route from "./route.js"; void Route; export {};`, "utf8");
writeFileSync(
main,
`import * as Route from "./route.js";
export function getRoute(): Route.Route { readonly path: P; readonly schema: S }
export namespace Route {
export type Any = Route (path: P): Route =>
({ path, schema: {} } as Route );
}
`,
"utf8",
);
writeFileSync(bootstrap, `import * as Route from "./route.js"; void Route; export {};`, "utf8");
writeFileSync(
main,
`import * as Route from "./route.js"; export const r = Route.Parse("/status");`,
"utf8",
);
const program = makeProgram([bootstrap, main]);
const specs = [
{ id: "Route", module: "./route.js", exportName: "Route", typeMember: "Any" },
] as const;
const session = createTypeInfoApiSession({
ts,
program,
typeTargetSpecs: specs,
failWhenNoTargetsResolved: false,
});
const result = session.api.file("./main.ts", { baseDir: dir });
expect(result.ok).toBe(true);
if (!result.ok) return;
const rExport = result.snapshot.exports.find((e) => e.name === "r");
expect(rExport).toBeDefined();
expect(
session.api.isAssignableTo(rExport!.type, "Route", [{ kind: "returnType" }]),
).toBe(false);
});
it("returns false for unregistered node", () => {
const dir = createTempDir();
const routeMod = join(dir, "route.ts");
const bootstrap = join(dir, "bootstrap.ts");
const main = join(dir, "main.ts");
writeFileSync(
routeMod,
`
export interface Route { readonly path: P; readonly schema: S }
export namespace Route {
export type Any = Route (path: P): Route =>
({ path, schema: {} } as Route );
}
`,
"utf8",
);
writeFileSync(bootstrap, `import * as Route from "./route.js"; void Route; export {};`, "utf8");
writeFileSync(
main,
`import * as Route from "./route.js"; export const r = Route.Parse("/status");`,
"utf8",
);
const program = makeProgram([bootstrap, main]);
const specs = [
{ id: "Route", module: "./route.js", exportName: "Route", typeMember: "Any" },
] as const;
const session = createTypeInfoApiSession({
ts,
program,
typeTargetSpecs: specs,
failWhenNoTargetsResolved: false,
});
const result = session.api.file("./main.ts", { baseDir: dir });
expect(result.ok).toBe(true);
if (!result.ok) return;
const unregisteredNode = { kind: "primitive" as const, text: "number" } as TypeNode;
expect(session.api.isAssignableTo(unregisteredNode, "Route")).toBe(false);
});
it("ensure passes and continues chain - (...) => Effect