| 1 | // @effect-diagnostics nodeBuiltinImport:off - Runs the real Knip CLI against a disposable on-disk project. |
| 2 | import * as NodeChildProcess from "node:child_process"; |
| 3 | import * as NodeFS from "node:fs"; |
| 4 | import * as NodeModule from "node:module"; |
| 5 | import * as NodePath from "node:path"; |
| 6 | import * as NodeProcess from "node:process"; |
| 7 | import { expect, it } from "vite-plus/test"; |
| 8 | |
| 9 | const require = NodeModule.createRequire(import.meta.url); |
| 10 | const cli = NodePath.join(NodePath.dirname(require.resolve("knip")), "cli.js"); |
| 11 | const preprocessor = NodePath.join(import.meta.dirname, "knip-schemas.ts"); |
| 12 | |
| 13 | it("allows types and schemas through the real Knip CLI without hiding runtime or file findings", () => { |
| 14 | // Keeping the disposable project here gives it the same Effect installation as the scripts. |
| 15 | const cwd = NodeFS.mkdtempSync(NodePath.join(import.meta.dirname, ".knip-test-")); |
| 16 | const write = (file: string, content: string) => |
| 17 | NodeFS.writeFileSync(NodePath.join(cwd, file), content); |
| 18 | const run = (filtered: boolean) => |
| 19 | NodeChildProcess.spawnSync( |
| 20 | NodeProcess.execPath, |
| 21 | [ |
| 22 | cli, |
| 23 | "--directory", |
| 24 | cwd, |
| 25 | "--config", |
| 26 | NodePath.join(cwd, "knip.json"), |
| 27 | "--no-config-hints", |
| 28 | "--include", |
| 29 | "files,dependencies,exports,nsExports,types,nsTypes,duplicates", |
| 30 | "--reporter", |
| 31 | "json", |
| 32 | ...(filtered ? ["--preprocessor", preprocessor] : []), |
| 33 | ], |
| 34 | { encoding: "utf8" }, |
| 35 | ); |
| 36 | try { |
| 37 | write( |
| 38 | "package.json", |
| 39 | JSON.stringify({ private: true, type: "module", dependencies: { effect: "*" } }), |
| 40 | ); |
| 41 | write( |
| 42 | "tsconfig.json", |
| 43 | JSON.stringify({ compilerOptions: { module: "NodeNext", strict: true } }), |
| 44 | ); |
| 45 | write( |
| 46 | "knip.json", |
| 47 | JSON.stringify({ |
| 48 | entry: ["entry.ts"], |
| 49 | project: ["*.ts"], |
| 50 | includeEntryExports: true, |
| 51 | include: [ |
| 52 | "exports", |
| 53 | "nsExports", |
| 54 | "types", |
| 55 | "nsTypes", |
| 56 | "duplicates", |
| 57 | "files", |
| 58 | "dependencies", |
| 59 | ], |
| 60 | rules: { types: "off", nsTypes: "off" }, |
| 61 | }), |
| 62 | ); |
| 63 | write( |
| 64 | "entry.ts", |
| 65 | ` |
| 66 | import * as S from "effect/Schema"; |
| 67 | import * as ns from "./namespace.ts"; |
| 68 | console.log(ns); |
| 69 | export { Remote as Reexported } from "./remote.ts"; |
| 70 | export const Text = S.String; |
| 71 | export const Alias = Text; |
| 72 | const Internal = S.Boolean; |
| 73 | export type Internal = typeof Internal.Type; |
| 74 | export const PublicAlias = Internal; |
| 75 | export default Text; |
| 76 | export const Record = S.Struct({ name: Text }).annotate({ title: "record" }); |
| 77 | export const Branded = S.String.pipe(S.brand("Name")); |
| 78 | export class Failure extends S.TaggedError<Failure>()("Failure", { reason: Text }) {} |
| 79 | export class Person extends S.Class<Person>("Person")({ name: Text }) {} |
| 80 | export type UnusedType = { name: string }; |
| 81 | export interface UnusedInterface { name: string } |
| 82 | `, |
| 83 | ); |
| 84 | write( |
| 85 | "remote.ts", |
| 86 | ` |
| 87 | import { Schema as S } from "effect"; |
| 88 | export const Remote = S.Struct({ id: S.Number }); |
| 89 | throw new Error("The preprocessor must never evaluate application modules"); |
| 90 | `, |
| 91 | ); |
| 92 | write( |
| 93 | "namespace.ts", |
| 94 | ` |
| 95 | import * as S from "effect/Schema"; |
| 96 | export const Unused = S.Number; |
| 97 | export type UnusedType = string; |
| 98 | `, |
| 99 | ); |
| 100 | const baseline = run(false); |
| 101 | expect(baseline.status, baseline.stderr).toBe(1); |
| 102 | expect(baseline.stdout).toContain('"Text"'); |
| 103 | const allowed = run(true); |
| 104 | expect(allowed.status, allowed.stderr + allowed.stdout).toBe(0); |
| 105 | expect(JSON.parse(allowed.stdout).issues).toEqual([]); |
| 106 | |
| 107 | NodeFS.appendFileSync( |
| 108 | NodePath.join(cwd, "entry.ts"), |
| 109 | ` |
| 110 | export const decode = S.decodeUnknownSync(Text); |
| 111 | export const makeSchema = () => S.String; |
| 112 | export const LooksLikeSchema = { ast: "not a schema" }; |
| 113 | export const ordinary = 123; |
| 114 | export const duplicate = ordinary; |
| 115 | `, |
| 116 | ); |
| 117 | NodeFS.appendFileSync(NodePath.join(cwd, "namespace.ts"), `export const helper = () => 1;`); |
| 118 | write("unused.ts", `export const orphan = 1;`); |
| 119 | write( |
| 120 | "package.json", |
| 121 | JSON.stringify({ |
| 122 | private: true, |
| 123 | type: "module", |
| 124 | dependencies: { effect: "*", "unused-knip-fixture-dependency": "*" }, |
| 125 | }), |
| 126 | ); |
| 127 | const rejected = run(true); |
| 128 | expect(rejected.status, rejected.stderr).toBe(1); |
| 129 | const issues = JSON.parse(rejected.stdout).issues; |
| 130 | const entry = issues.find((issue: { file: string }) => issue.file === "entry.ts"); |
| 131 | expect(entry.exports.map((issue: { name: string }) => issue.name).sort()).toEqual([ |
| 132 | "LooksLikeSchema", |
| 133 | "decode", |
| 134 | "duplicate", |
| 135 | "makeSchema", |
| 136 | "ordinary", |
| 137 | ]); |
| 138 | expect(entry.duplicates).toHaveLength(1); |
| 139 | expect( |
| 140 | issues.find((issue: { file: string }) => issue.file === "namespace.ts").nsExports, |
| 141 | ).toEqual([expect.objectContaining({ name: "helper" })]); |
| 142 | expect(issues.find((issue: { file: string }) => issue.file === "unused.ts").files).toHaveLength( |
| 143 | 1, |
| 144 | ); |
| 145 | expect( |
| 146 | issues.find((issue: { file: string }) => issue.file === "package.json").dependencies, |
| 147 | ).toEqual([expect.objectContaining({ name: "unused-knip-fixture-dependency" })]); |
| 148 | } finally { |
| 149 | NodeFS.rmSync(cwd, { recursive: true, force: true }); |
| 150 | } |
| 151 | }); |