1// @effect-diagnostics nodeBuiltinImport:off - Knip and the TypeScript compiler host use synchronous Node paths.
2import * as NodePath from "node:path";
3import type { Preprocessor } from "knip";
4// Knip needs the legacy compiler API, which TypeScript 7 no longer exports.
5import ts from "typescript-legacy";
6
7// Effect 4 schemas carry this marker, including aliases and Schema.Class constructors.
8// Checking the type avoids evaluating application modules or exempting schema factories/decoders.
9const schemaTypeId = "~effect/Schema/Schema";
10
11const preprocess: Preprocessor = (options) => {
12 const categories = ["exports", "nsExports", "duplicates"] as const;
13 const projects = new Map<string | undefined, Set<string>>();
14 for (const category of categories) {
15 for (const [filePath, issues] of Object.entries(options.issues[category])) {
16 if (Object.keys(issues).length === 0) continue;
17 const configPath = ts.findConfigFile(
18 NodePath.dirname(NodePath.resolve(options.cwd, filePath)),
19 ts.sys.fileExists,
20 );
21 const files = projects.get(configPath) ?? new Set<string>();
22 files.add(filePath);
23 projects.set(configPath, files);
24 }
25 }
26
27 for (const [configPath, files] of projects) {
28 const config = configPath
29 ? ts.getParsedCommandLineOfConfigFile(
30 configPath,
31 {},
32 {
33 ...ts.sys,
34 onUnRecoverableConfigFileDiagnostic: (diagnostic) => {
35 throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
36 },
37 },
38 )
39 : undefined;
40 if (config?.errors.length) {
41 throw new Error(
42 config.errors
43 .map((error) => ts.flattenDiagnosticMessageText(error.messageText, "\n"))
44 .join("\n"),
45 );
46 }
47 const program = ts.createProgram(
48 [...files].map((file) => NodePath.resolve(options.cwd, file)),
49 {
50 module: ts.ModuleKind.NodeNext,
51 allowJs: true,
52 ...config?.options,
53 noEmit: true,
54 },
55 );
56 const checker = program.getTypeChecker();
57 for (const filePath of files) {
58 const source = program.getSourceFile(NodePath.resolve(options.cwd, filePath));
59 const module = source && checker.getSymbolAtLocation(source);
60 if (!source || !module) continue;
61 const schemas = new Set(
62 checker.getExportsOfModule(module).flatMap((symbol) => {
63 const exported =
64 symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
65 // Duplicate exports can name a private value with a public type of the same name.
66 const target =
67 exported.flags & ts.SymbolFlags.Value
68 ? exported
69 : checker.resolveName(symbol.name, source, ts.SymbolFlags.Value, false);
70 if (!target) return [];
71 const type = checker.getTypeOfSymbolAtLocation(target, target.valueDeclaration ?? source);
72 const marker = type.getProperty(schemaTypeId);
73 if (!marker) return [];
74 const markerType = checker.getTypeOfSymbolAtLocation(marker, source);
75 return markerType.isStringLiteral() && markerType.value === schemaTypeId
76 ? [symbol.name]
77 : [];
78 }),
79 );
80 for (const category of categories) {
81 const issues = options.issues[category][filePath];
82 if (!issues) continue;
83 for (const [key, issue] of Object.entries(issues)) {
84 const symbols = issue.symbols ?? [{ symbol: issue.symbol }];
85 if (symbols.length > 0 && symbols.every(({ symbol }) => schemas.has(symbol))) {
86 delete issues[key];
87 options.counters[category]--;
88 }
89 }
90 if (Object.keys(issues).length === 0) delete options.issues[category][filePath];
91 }
92 }
93 }
94 return options;
95};
96
97export default preprocess;