diff --git a/.changeset/config.json b/.changeset/config.json index e0517abd71..fa1c87c45b 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,10 +8,7 @@ "eslint-plugin-react-doctor", "oxlint-plugin-react-doctor", "@react-doctor/core", - "@react-doctor/api", - "@react-doctor/language-server", - "deslop-js", - "deslop-cli" + "@react-doctor/api" ] ], "linked": [], diff --git a/.changeset/few-berries-drum.md b/.changeset/few-berries-drum.md new file mode 100644 index 0000000000..a12a3058e0 --- /dev/null +++ b/.changeset/few-berries-drum.md @@ -0,0 +1,10 @@ +--- +"oxlint-plugin-react-doctor": patch +"react-doctor": patch +--- + +Keep the interactive score header intact in narrow split views and invalidate locally stale scan results when rule implementations change. + +Report standalone Three.js render loops that use `requestAnimationFrame` instead of the renderer-managed `setAnimationLoop` API. + +Include standalone Three.js, supported React framework, Remotion, and React Three Fiber ecosystem packages in automatic workspace project discovery. diff --git a/.changeset/quiet-agent-stop-hooks.md b/.changeset/quiet-agent-stop-hooks.md new file mode 100644 index 0000000000..e92fd9f275 --- /dev/null +++ b/.changeset/quiet-agent-stop-hooks.md @@ -0,0 +1,5 @@ +--- +"react-doctor": patch +--- + +Run installed Claude Code and Cursor hooks once at the end of an agent turn, include untracked files in the changed-file scan, and migrate existing per-tool React Doctor hooks automatically. diff --git a/.changeset/react-cleanup-engine.md b/.changeset/react-cleanup-engine.md new file mode 100644 index 0000000000..2cd76906c6 --- /dev/null +++ b/.changeset/react-cleanup-engine.md @@ -0,0 +1,6 @@ +--- +"react-doctor": patch +"oxlint-plugin-react-doctor": patch +--- + +Make React cleanup a first-class part of React Doctor with diagnostics for complex React functions and repeated JSX composition. Keep whole-project unused file, export, type, dependency, and import-cycle analysis as explicit opt-in rules while removing the separate Deslop packages, experimental language server, and IDE extensions. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecc0d8542c..59b5b6f02a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,11 +75,6 @@ jobs: if: ${{ matrix.os == 'windows-latest' }} run: pnpm test --concurrency=1 - # deslop-js/-cli assert on POSIX-separator paths, so their suites are - # build-only on Windows (mirrors the deslop upstream matrix). - - run: pnpm test:deslop - if: ${{ matrix.os != 'windows-latest' }} - # lint + typecheck run as dedicated jobs in code-quality.yml; don't pay for # them again here. - name: Check formatting diff --git a/AGENTS.md b/AGENTS.md index 012fa9b677..e851ed7437 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,9 +60,10 @@ packages/ refs.ts Context.Reference for ambient env config run-inspect.ts streaming orchestrator (the heart) build-diagnostic-pipeline per-element filter pipeline (single source of truth) - services/ 10 Context.Service classes (Files, Git, Project, - Config, Linter, DeadCode, Score, Reporter, Progress, - NodeResolver, StagedFiles) + LintPartialFailures + services/ Context.Service implementations (Files, Git, Project, + Config, Linter, Maintainability, Score, Reporter, + Progress, NodeResolver, StagedFiles, SupplyChain) + + LintPartialFailures ... rest of the lint / score / suppression engine api/ PRIVATE programmatic diagnose() (Effect.runPromise shell) react-doctor/ PUBLISHED CLI + public inspect() + bin @@ -157,7 +158,7 @@ for this codebase) for canonical examples. - `layerCapture` for the test layer that records calls into a `Ref` exposed via a sibling `*Capture` service (e.g. `ReporterCapture`, `ProgressCapture`). - `layerNoop` for the production layer that has void-return / discard semantics - (Reporter, Progress). Analyzers (Linter, DeadCode) use `layerOf([])` instead. + (Reporter, Progress). Analyzers (Linter, Maintainability) use `layerOf([])` instead. - Implementation-specific names: `layerOxlint`, `layerHttp`, `layerOra(factory)`. ### Schemas @@ -228,9 +229,7 @@ for spans: the CLI pins `tracesSampleRate: 0` and Sentry never records a span. metrics exporter passes `maxBatchSize: "disabled"` internally, which skips Effect's empty-buffer short-circuit — it POSTs on every scope close whether or not anything was recorded, so on a firewalled machine that request cannot fail - fast. The language server overrides `exportIntervalMs` (see - `LSP_TELEMETRY_EXPORT_INTERVAL_MS`) because an editor session may never shut - down cleanly. + fast. - **Anonymization.** Telemetry must stay anonymized, and OTLP has **no** `beforeSend`-style hook — the safety net Sentry gave us for free had to be rebuilt. Two mechanisms now carry it: @@ -292,7 +291,7 @@ for spans: the CLI pins `tracesSampleRate: 0` and Sentry never records a span. (`outcome.wouldBlock`/`outcome.blocking`/`outcome.clean`/`outcome.skippedChecks`), findings (`diag.total`, `diag.errors`/`diag.warnings`, `diag.affectedFiles`, `diag.distinctRules`, `diag.topRule`, per-category `diag.category.*`), - `score.value`/`score.label`/`score.available`, the `lint.*`/`deadCode.*`/ + `score.value`/`score.label`/`score.available`, the `lint.*`/`maintainability.*`/ `supplyChain.*` pass outcomes, `timing.*` durations, and the CI/PR specifics (`action.actorAssociation`, `action.runnerOs`, and the forwarded action knobs `action.comment`/`action.reviewComments`/`action.versionPin`). Typing matters @@ -305,7 +304,7 @@ for spans: the CLI pins `tracesSampleRate: 0` and Sentry never records a span. outcome dimensions on the wide event (wrapped in `withNamespace`), **not** new counters — the `scan.completed`/`scan.duration`/`rule.fired` counters stay as the cheap floor alongside `cli.invoked`/`cli.error`. Score reachability is - derivable (`!score.available && !lint.failed && !deadCode.failed && !scan.noScore`) + derivable (`!score.available && !lint.failed && !maintainability.failed && !scan.noScore`) and score latency is the `Score.compute` child span's duration, so neither needs a dedicated field. CI detection + the official-action marker and forwarded inputs live in `cli/utils/is-ci-environment.ts`; `action.yml` sets the diff --git a/docs/HOW_TO_WRITE_A_RULE.md b/docs/HOW_TO_WRITE_A_RULE.md index a0b1659c71..c0a42dfc9e 100644 --- a/docs/HOW_TO_WRITE_A_RULE.md +++ b/docs/HOW_TO_WRITE_A_RULE.md @@ -554,7 +554,7 @@ Resource-informed implementation rules: - From Babel: nested structures are common; explicitly prune or model nested functions, classes, and blocks. - From OXC/Babex: expect Babel-compatible vocabulary, but verify parser-specific node shapes for TypeScript, JSX, optional chaining, and computed members. - From React Compiler: document unsupported JavaScript/control-flow cases instead of pretending every pattern is modeled soundly. -- From Deslop: think in confidence tiers; strong diagnostics should be high-confidence findings. +- Think in confidence tiers; strong diagnostics should be high-confidence findings. ## AST Vocabulary @@ -924,7 +924,6 @@ Spend 1-2 hours asking an agent Q/A about these resources. Use them to build AST | Babel handbook | https://github.com/jamiebuilds/babel-handbook | ASTs, visitors, paths, scopes, bindings, traversal state, nested structures, and plugin testing patterns. | | Babel plugin handbook | https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md | Practical plugin authoring guidance: visitors, path APIs, scope/binding lookups, traversal performance, and unit testing. | | OXC | https://github.com/oxc-project/oxc | Oxlint/parser context, high-performance AST tooling, and rule implementation patterns to compare against React Doctor rules. | -| Deslop | https://github.com/millionco/deslop-js/ | Confidence tiers, syntactic vs semantic findings, structured analysis errors, and CI-gating strategy for high-signal findings. | | React Compiler | https://github.com/facebook/react/tree/main/compiler | React rule semantics, conservative modeling, control-flow needs, React rules validation, and why unsupported JavaScript features should be explicit non-goals. | | React Doctor | https://github.com/millionco/react-doctor | Product context: deterministic React scans across state/effects, performance, architecture, security, and accessibility. | | Babex | https://github.com/millionco/babex | Babel-compatible APIs backed by OXC; useful for understanding parser/traverse compatibility and the shape of fast AST tooling. | @@ -947,5 +946,5 @@ Resource takeaways: - Traversal is expensive. Prefer a single visitor or direct child lookup when that is enough. - Nested structures are easy to mishandle. Explicitly skip nested functions unless the rule intends to inspect them. - React Compiler is conservative about unsupported or hard-to-model JavaScript. React Doctor rules should also document v1 unsupported cases. -- Deslop-style confidence tiers are a useful mental model: only high-confidence findings should block or produce strong diagnostics. +- Confidence tiers are a useful mental model: only high-confidence findings should block or produce strong diagnostics. - Babex shows the compatibility target: Babel-style parse/traverse APIs can sit on top of OXC, so rule authors should understand both Babel vocabulary and OXC-powered parsing. diff --git a/packages/deslop-cli/CHANGELOG.md b/docs/archive/deslop-cli/CHANGELOG.md similarity index 100% rename from packages/deslop-cli/CHANGELOG.md rename to docs/archive/deslop-cli/CHANGELOG.md diff --git a/packages/deslop-js/CHANGELOG.md b/docs/archive/deslop-js/CHANGELOG.md similarity index 100% rename from packages/deslop-js/CHANGELOG.md rename to docs/archive/deslop-js/CHANGELOG.md diff --git a/package.json b/package.json index 42c9835249..686b9163af 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,8 @@ "performance:stress": "tsx scripts/performance/run-stress-performance.ts", "performance:profile": "tsx scripts/performance/analyze-cpu-profile.ts", "performance:memory": "tsx scripts/performance/analyze-heap-profile.ts", - "test": "turbo run test --filter=react-doctor --filter=@react-doctor/core --filter=@react-doctor/api --filter=@react-doctor/language-server --filter=oxlint-plugin-react-doctor --filter=eslint-plugin-react-doctor --filter=@react-doctor/fuzz --filter=@react-doctor/evals", + "test": "turbo run test --filter=react-doctor --filter=@react-doctor/core --filter=@react-doctor/api --filter=oxlint-plugin-react-doctor --filter=eslint-plugin-react-doctor --filter=@react-doctor/fuzz --filter=@react-doctor/evals", "fuzz": "pnpm --filter @react-doctor/fuzz fuzz", - "test:deslop": "turbo run test --filter=deslop-js --filter=deslop-cli", "test:public-react-repos": "REACT_DOCTOR_PUBLIC_REPOS=1 vp test run packages/react-doctor/tests/public-react-repos.test.ts", "typecheck": "turbo run typecheck", "lint": "vp lint", diff --git a/packages/api/src/diagnose.ts b/packages/api/src/diagnose.ts index c4ab06e289..85fdce7ab9 100644 --- a/packages/api/src/diagnose.ts +++ b/packages/api/src/diagnose.ts @@ -7,7 +7,6 @@ import { createOxlintSpawnSlots, DEFAULT_PROJECT_SCAN_CONCURRENCY, DEFAULT_SHOW_WARNINGS, - DeadCode, detectAiTrainingEnvironment, Files, Git, @@ -15,6 +14,7 @@ import { layerUserOtlp, Linter, LintPartialFailures, + Maintainability, mapWithConcurrency, mergeReactDoctorConfigs, OxlintConcurrency, @@ -26,6 +26,7 @@ import { restoreLegacyThrow, runInspect, Score, + shouldUseMaintainabilityLayer, SupplyChain, type InspectOutput, type ResolvedScanTarget, @@ -90,7 +91,12 @@ const buildDiagnoseLayer = (input: DiagnoseLayerInput) => { return Layer.mergeAll( Project.layerNode, configLayer, - input.shouldRunDeadCode ? DeadCode.layerNode : DeadCode.layerOf([]), + shouldUseMaintainabilityLayer({ + shouldRunDuplicateJsx: input.shouldRunDeadCode, + userConfig: input.config, + }) + ? Maintainability.layerNode + : Maintainability.layerOf([]), Files.layerNode, Git.layerNode, input.shouldRunLint ? Linter.layerOxlint : Linter.layerOf([]), diff --git a/packages/api/src/project-analysis-worker.ts b/packages/api/src/project-analysis-worker.ts new file mode 100644 index 0000000000..d9157d4b77 --- /dev/null +++ b/packages/api/src/project-analysis-worker.ts @@ -0,0 +1,3 @@ +import { startProjectAnalysisWorker } from "../../core/src/start-project-analysis-worker.js"; + +startProjectAnalysisWorker(); diff --git a/packages/api/tests/diagnose.test.ts b/packages/api/tests/diagnose.test.ts index 7830535bf2..b966adb758 100644 --- a/packages/api/tests/diagnose.test.ts +++ b/packages/api/tests/diagnose.test.ts @@ -351,6 +351,55 @@ describe("diagnose({ projects })", () => { expect(result.projects[0].ok).toBe(true); }); + it("runs explicitly enabled project rules through the public API", async () => { + const projectDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rdc-project-analysis-")); + const sourceDirectory = path.join(projectDirectory, "src"); + fs.mkdirSync(sourceDirectory); + fs.writeFileSync( + path.join(projectDirectory, "package.json"), + JSON.stringify({ + name: "project-analysis-api-test", + main: "src/index.ts", + dependencies: { react: "19.2.5" }, + }), + ); + fs.writeFileSync( + path.join(sourceDirectory, "index.ts"), + 'import { usedValue } from "./library.js";\nconsole.log(usedValue);\n', + ); + fs.writeFileSync( + path.join(sourceDirectory, "library.ts"), + "export const usedValue = 1;\nexport const unusedValue = 2;\n", + ); + try { + const result = await diagnose({ + projects: [ + { + directory: projectDirectory, + config: { rules: { "react-doctor/unused-export": "warn" } }, + }, + ], + deadCode: false, + lint: false, + }); + + const projectResult = result.projects[0]; + expect(projectResult.ok).toBe(true); + if (!projectResult.ok) return; + expect(projectResult.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + plugin: "react-doctor", + rule: "unused-export", + message: expect.stringContaining("unusedValue"), + }), + ]), + ); + } finally { + fs.rmSync(projectDirectory, { recursive: true, force: true }); + } + }); + it("respects batch config lint: false", async () => { const result = await diagnose({ projects: [{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") }], diff --git a/packages/api/vite.config.ts b/packages/api/vite.config.ts index 85e2007f47..23ddcbe0c4 100644 --- a/packages/api/vite.config.ts +++ b/packages/api/vite.config.ts @@ -3,10 +3,12 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ pack: [ { - entry: { index: "./src/index.ts" }, + entry: { + index: "./src/index.ts", + "project-analysis-worker": "./src/project-analysis-worker.ts", + }, deps: { neverBundle: [ - "deslop-js", "effect", "oxc-parser", "oxc-resolver", diff --git a/packages/core/package.json b/packages/core/package.json index 2520ff247d..87d3ad694d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,15 +25,26 @@ "@astrojs/compiler": "^4.0.0", "@effect/platform-node-shared": "4.0.0-beta.102", "@jridgewell/trace-mapping": "^0.3.31", + "acorn": "^8.18.0", + "acorn-jsx": "^5.3.2", "browserslist": "^4.28.1", "confbox": "^0.2.4", - "deslop-js": "workspace:*", "effect": "4.0.0-beta.102", "eslint-plugin-react-hooks": "^7.1.1", + "fast-glob": "^3.3.3", "jiti": "^2.7.0", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-mdx-expression": "^2.0.1", + "mdast-util-mdx-jsx": "^3.2.0", + "mdast-util-mdxjs-esm": "^2.0.1", + "micromark-extension-mdx-expression": "^3.0.1", + "micromark-extension-mdx-jsx": "^3.0.2", + "micromark-extension-mdxjs-esm": "^3.0.0", + "oxc-parser": "^0.143.0", "oxc-resolver": "^11.24.2", "oxlint": ">=1.77.0 <1.78.0", "oxlint-plugin-react-doctor": "workspace:*", + "parse5": "^8.0.1", "picomatch": "^4.0.4", "semver": "^7.7.4", "typescript": ">=5.0.4 <7" diff --git a/packages/core/src/check-dead-code.ts b/packages/core/src/check-dead-code.ts deleted file mode 100644 index e461e73b85..0000000000 --- a/packages/core/src/check-dead-code.ts +++ /dev/null @@ -1,730 +0,0 @@ -import { spawn } from "node:child_process"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import type { Diagnostic } from "./types/index.js"; -import type { DeadCodeSummaryCacheStats } from "./types/dead-code.js"; -import { collectDeadCodePatterns } from "./dead-code/collect-dead-code-patterns.js"; -import { - collectAnalyzedFileStats, - computeDeadCodeCacheKey, - lookupDeadCodeResultCache, - storeDeadCodeResultCache, -} from "./dead-code/dead-code-result-cache.js"; -import { withDeadCodeWorkerSlot } from "./dead-code/dead-code-worker-slots.js"; -import { - CORE_PACKAGE_VERSION, - DEAD_CODE_SUMMARY_CACHE_FILENAME, - DEAD_CODE_WORKER_MAX_OLD_SPACE_MB, - DEAD_CODE_WORKER_TIMEOUT_MS, - MILLISECONDS_PER_SECOND, - TSCONFIG_FILENAMES, -} from "./constants.js"; -import { isRecord } from "./utils/is-record.js"; -import { resolveReactDoctorCacheDir } from "./utils/resolve-react-doctor-cache-dir.js"; -import { toCanonicalPath } from "./utils/to-canonical-path.js"; -import { toRelativePath } from "./utils/to-relative-path.js"; - -// The plugin id and category every dead-code diagnostic carries. -// Centralized so severity-control checks (e.g. deciding whether to run -// the analysis at all when warnings are hidden) stay in sync with the -// diagnostics actually emitted below. -export const DEAD_CODE_PLUGIN = "deslop"; -export const DEAD_CODE_CATEGORY = "Maintainability"; - -// react-doctor's own toolchain is used via the CLI, git hooks, CI, and the agent -// skill — never imported in source — so deslop's import-graph scan can't see the -// usage and flags it as unused after `react-doctor install` (especially via -// `bunx`, where the package is declared but not in node_modules, so deslop can't -// read its `bin` either). react-doctor never reports its own tooling as unused. -const REACT_DOCTOR_TOOLCHAIN_PACKAGES: ReadonlySet = new Set([ - "react-doctor", - "eslint-plugin-react-doctor", - "oxlint-plugin-react-doctor", -]); - -interface CheckDeadCodeOptions { - readonly rootDirectory: string; - readonly deslopJsModuleSpecifier?: string; - readonly createWorker?: DeadCodeWorkerFactory; - readonly workerTimeoutMs?: number; - /** - * Caps deslop's internal parse worker pool (`DESLOP_PARSE_CONCURRENCY`). The - * orchestrator sets this when dead-code overlaps lint so the two pools share - * the cores instead of each claiming all of them — the oversubscription that - * otherwise starves the parse pass past `workerTimeoutMs`. Omitted → deslop - * uses `os.availableParallelism()` (the strictly-sequential / full-CPU path). - */ - readonly parseConcurrency?: number; - /** - * Aborts the in-flight worker. The orchestrator threads - * `Effect.tryPromise`'s signal here so interrupting the dead-code fiber - * (e.g. when lint fails and dead-code becomes wasted work, or when the - * scan is cancelled) SIGKILLs the 8 GB child PROCESS immediately via its - * `terminate` handle instead of orphaning it until - * `DEAD_CODE_WORKER_TIMEOUT_MS`. - */ - readonly abortSignal?: AbortSignal; - /** - * Whether to consult the dead-code caches. Defaults OFF so direct callers - * (and existing tests) keep their fresh-analysis semantics; the `DeadCode` - * service passes the `DeadCodeResultCacheEnabled` Reference here. Gates - * both layers: the whole-project result cache (a hit replays the stored - * diagnostics without spawning the analysis worker; a fresh COMPLETE pass - * is stored on success — a crashed, timed-out, or aborted worker rejects - * before the store) and, on a miss, deslop's incremental summary cache - * inside the worker (per-file parse summaries so a changed-files re-analysis - * only re-parses what changed). - */ - readonly cacheEnabled?: boolean; - /** - * Reports the cache outcome (`true` = hit, `false` = miss) once per call. - * Not invoked when `cacheEnabled` is off, so the orchestrator's telemetry - * distinguishes "no cache" from a miss. - */ - readonly onCacheOutcome?: (didHitCache: boolean) => void; - /** - * Reports deslop's incremental summary-cache outcome (files served from - * cached parse summaries vs freshly parsed) once per ANALYSIS run. Not - * invoked on a whole-result cache hit (no analysis ran) or when caching is - * off (the worker analyzes without the incremental store), so the - * orchestrator's telemetry distinguishes "no cache" from a 0% hit rate. - */ - readonly onSummaryCacheStats?: (stats: DeadCodeSummaryCacheStats) => void; -} - -interface DeadCodeWorkerInput { - readonly rootDirectory: string; - readonly entryPatterns: ReadonlyArray; - readonly tsConfigPath?: string; - readonly ignorePatterns: ReadonlyArray; - readonly deslopJsModuleSpecifier: string; - /** Caps deslop's parse pool via `DESLOP_PARSE_CONCURRENCY` on the child env. */ - readonly parseConcurrency?: number; - /** - * `DeslopConfig.incrementalCachePath` for the worker's `analyze()` call. - * Omitted when caching is off — deslop then analyzes from scratch. - */ - readonly incrementalCachePath?: string; -} - -interface DeadCodeWorkerHandle { - readonly result: Promise; - readonly terminate?: () => void | Promise; -} - -interface DeadCodeWorkerFactory { - (input: DeadCodeWorkerInput): DeadCodeWorkerHandle; -} - -interface DeadCodeWorkerUnusedFile { - readonly path: string; -} - -interface DeadCodeWorkerUnusedExport { - readonly path: string; - readonly name: string; - readonly line: number; - readonly column: number; - readonly isTypeOnly: boolean; -} - -interface DeadCodeWorkerUnusedDependency { - readonly name: string; - readonly isDevDependency: boolean; -} - -interface DeadCodeWorkerCircularDependency { - readonly files: ReadonlyArray; -} - -interface DeadCodeWorkerResult { - readonly unusedFiles: ReadonlyArray; - readonly unusedExports: ReadonlyArray; - readonly unusedDependencies: ReadonlyArray; - readonly circularDependencies: ReadonlyArray; - readonly summaryCacheStats?: DeadCodeSummaryCacheStats; -} - -interface DeadCodeWorkerError { - readonly name?: string; - readonly message: string; - readonly stack?: string; -} - -interface DeadCodeWorkerSuccessMessage { - readonly ok: true; - readonly result: unknown; -} - -interface DeadCodeWorkerFailureMessage { - readonly ok: false; - readonly error: DeadCodeWorkerError; -} - -// Runs in a child PROCESS (node -e), not a worker_thread — see -// `createDeadCodeWorker`. Reads the worker input as JSON on stdin and -// writes the normalized result (or a serialized error) as JSON on -// stdout, then exits once stdout has flushed. -const DEAD_CODE_WORKER_SCRIPT = ` -const inputChunks = []; -process.stdin.on("data", (chunk) => inputChunks.push(chunk)); -process.stdin.on("end", () => { - const workerInput = JSON.parse(Buffer.concat(inputChunks).toString("utf8")); - - const normalizeResult = (result) => ({ - unusedFiles: result.unusedFiles.map((unusedFile) => ({ - path: unusedFile.path, - })), - unusedExports: result.unusedExports.map((unusedExport) => ({ - path: unusedExport.path, - name: unusedExport.name, - line: unusedExport.line, - column: unusedExport.column, - isTypeOnly: unusedExport.isTypeOnly, - })), - unusedDependencies: result.unusedDependencies.map((unusedDependency) => ({ - name: unusedDependency.name, - isDevDependency: unusedDependency.isDevDependency, - })), - circularDependencies: result.circularDependencies.map((cycle) => ({ - files: cycle.files, - })), - ...(result.incrementalCacheStats - ? { - summaryCacheStats: { - hits: result.incrementalCacheStats.summaryHits, - misses: result.incrementalCacheStats.summaryMisses, - }, - } - : {}), - }); - - const serializeError = (error) => - error instanceof Error - ? { name: error.name, message: error.message, stack: error.stack } - : { message: String(error) }; - - const emit = (message) => { - process.stdout.write(JSON.stringify(message), () => process.exit(0)); - }; - - (async () => { - try { - const { analyze, defineConfig } = await import(workerInput.deslopJsModuleSpecifier); - const config = { - rootDir: workerInput.rootDirectory, - ...(workerInput.entryPatterns.length > 0 - ? { entryPatterns: workerInput.entryPatterns } - : {}), - ...(workerInput.tsConfigPath ? { tsConfigPath: workerInput.tsConfigPath } : {}), - ...(workerInput.ignorePatterns.length > 0 - ? { ignorePatterns: workerInput.ignorePatterns } - : {}), - ...(workerInput.incrementalCachePath - ? { incrementalCachePath: workerInput.incrementalCachePath } - : {}), - // We consume only deslop's GRAPH-based findings (unusedFiles, unusedExports, - // unusedDependencies, circularDependencies). Everything else deslop can compute - // is pure wasted work for us, and it's the bulk of the runtime: - // - semantic: a full TS Program for unusedTypes/enum/class-members/ - // misclassifiedDependencies (~37-45% of the phase). - // - reportCodeQuality: the duplicate-block, complexity, feature-flag, - // TypeScript-smell, private-type-leak and re-export-cycle detectors. These - // are the single most expensive pass — duplicate-block detection alone was - // ~83s of a ~130s Sentry scan — so skipping them is an ~8.5x dead-code - // speedup on a large repo. - // - reportRedundancy: the DRY-pattern detectors (duplicate types/constants, - // simplifiable functions, identity wrappers, …) — ~120 ms of discarded - // output per scan, and skipping them lets the incremental cache drop the - // DRY-pattern summary fields (the largest slice of the cache file). - // All are provably safe: the consumed graph findings are computed by their own - // detectors, independent of these passes (confirmed byte-identical on - // excalidraw + mui-material + sentry; re-verified on sentry after the - // reportRedundancy flip). tsConfigPath stays — the module resolver needs it - // for path-alias resolution in the import graph. - semantic: { enabled: false }, - reportCodeQuality: false, - reportRedundancy: false, - }; - const result = await analyze(defineConfig(config)); - emit({ ok: true, result: normalizeResult(result) }); - } catch (error) { - emit({ ok: false, error: serializeError(error) }); - } - })(); -}); -`; - -const resolveTsConfigPath = (rootDirectory: string): string | undefined => { - for (const filename of TSCONFIG_FILENAMES) { - const candidate = path.join(rootDirectory, filename); - if (fs.existsSync(candidate)) return candidate; - } - return undefined; -}; - -// HACK: route through `toRelativePath` (which normalizes backslashes to -// forward slashes) so deslop output matches every other diagnostic on -// Windows. Downstream picomatch ignore-pattern matching requires POSIX -// separators or `src/**` overrides silently miss. -const toRelativeFilePath = (rootDirectory: string, filePath: string): string => { - const relative = toRelativePath(filePath, rootDirectory); - return relative.length > 0 ? relative : filePath.replace(/\\/g, "/"); -}; - -const parseArray = (value: unknown, label: string): unknown[] => { - if (!Array.isArray(value)) { - throw new Error(`Dead-code worker returned invalid ${label}.`); - } - return value; -}; - -const parseString = (value: unknown, label: string): string => { - if (typeof value !== "string") { - throw new Error(`Dead-code worker returned invalid ${label}.`); - } - return value; -}; - -const parseNumber = (value: unknown, label: string): number => { - if (typeof value !== "number") { - throw new Error(`Dead-code worker returned invalid ${label}.`); - } - return value; -}; - -const parseBoolean = (value: unknown, label: string): boolean => { - if (typeof value !== "boolean") { - throw new Error(`Dead-code worker returned invalid ${label}.`); - } - return value; -}; - -const parseStringArray = (value: unknown, label: string): string[] => { - const values = parseArray(value, label); - return values.map((entry, index) => parseString(entry, `${label}[${index}]`)); -}; - -const parseUnusedFiles = (value: unknown): DeadCodeWorkerUnusedFile[] => { - const values = parseArray(value, "unusedFiles"); - const unusedFiles: DeadCodeWorkerUnusedFile[] = []; - for (const [index, entry] of values.entries()) { - if (!isRecord(entry)) { - throw new Error(`Dead-code worker returned invalid unusedFiles[${index}].`); - } - unusedFiles.push({ - path: parseString(entry.path, `unusedFiles[${index}].path`), - }); - } - return unusedFiles; -}; - -const parseUnusedExports = (value: unknown): DeadCodeWorkerUnusedExport[] => { - const values = parseArray(value, "unusedExports"); - const unusedExports: DeadCodeWorkerUnusedExport[] = []; - for (const [index, entry] of values.entries()) { - if (!isRecord(entry)) { - throw new Error(`Dead-code worker returned invalid unusedExports[${index}].`); - } - unusedExports.push({ - path: parseString(entry.path, `unusedExports[${index}].path`), - name: parseString(entry.name, `unusedExports[${index}].name`), - line: parseNumber(entry.line, `unusedExports[${index}].line`), - column: parseNumber(entry.column, `unusedExports[${index}].column`), - isTypeOnly: parseBoolean(entry.isTypeOnly, `unusedExports[${index}].isTypeOnly`), - }); - } - return unusedExports; -}; - -const parseUnusedDependencies = (value: unknown): DeadCodeWorkerUnusedDependency[] => { - const values = parseArray(value, "unusedDependencies"); - const unusedDependencies: DeadCodeWorkerUnusedDependency[] = []; - for (const [index, entry] of values.entries()) { - if (!isRecord(entry)) { - throw new Error(`Dead-code worker returned invalid unusedDependencies[${index}].`); - } - unusedDependencies.push({ - name: parseString(entry.name, `unusedDependencies[${index}].name`), - isDevDependency: parseBoolean( - entry.isDevDependency, - `unusedDependencies[${index}].isDevDependency`, - ), - }); - } - return unusedDependencies; -}; - -const parseCircularDependencies = (value: unknown): DeadCodeWorkerCircularDependency[] => { - const values = parseArray(value, "circularDependencies"); - const circularDependencies: DeadCodeWorkerCircularDependency[] = []; - for (const [index, entry] of values.entries()) { - if (!isRecord(entry)) { - throw new Error(`Dead-code worker returned invalid circularDependencies[${index}].`); - } - circularDependencies.push({ - files: parseStringArray(entry.files, `circularDependencies[${index}].files`), - }); - } - return circularDependencies; -}; - -// Telemetry-only, so malformed stats degrade to `undefined` instead of -// rejecting the scan like the diagnostic fields above do. -const parseSummaryCacheStats = (value: unknown): DeadCodeSummaryCacheStats | undefined => { - if (!isRecord(value)) return undefined; - if (typeof value.hits !== "number" || typeof value.misses !== "number") return undefined; - return { hits: value.hits, misses: value.misses }; -}; - -const parseDeadCodeWorkerResult = (value: unknown): DeadCodeWorkerResult => { - if (!isRecord(value)) { - throw new Error("Dead-code worker returned an invalid result."); - } - const summaryCacheStats = parseSummaryCacheStats(value.summaryCacheStats); - return { - unusedFiles: parseUnusedFiles(value.unusedFiles), - unusedExports: parseUnusedExports(value.unusedExports), - unusedDependencies: parseUnusedDependencies(value.unusedDependencies), - circularDependencies: parseCircularDependencies(value.circularDependencies), - ...(summaryCacheStats ? { summaryCacheStats } : {}), - }; -}; - -const parseDeadCodeWorkerError = (value: unknown): DeadCodeWorkerError => { - if (!isRecord(value) || typeof value.message !== "string") { - return { message: "Dead-code worker failed." }; - } - return { - ...(typeof value.name === "string" ? { name: value.name } : {}), - message: value.message, - ...(typeof value.stack === "string" ? { stack: value.stack } : {}), - }; -}; - -const parseDeadCodeWorkerMessage = ( - value: unknown, -): DeadCodeWorkerSuccessMessage | DeadCodeWorkerFailureMessage => { - if (!isRecord(value)) { - throw new Error("Dead-code worker returned an invalid message."); - } - if (value.ok === true) { - return { ok: true, result: value.result }; - } - if (value.ok === false) { - return { ok: false, error: parseDeadCodeWorkerError(value.error) }; - } - throw new Error("Dead-code worker returned an invalid status."); -}; - -const buildDeadCodeWorkerError = (workerError: DeadCodeWorkerError): Error => { - const error = new Error(workerError.message); - if (workerError.name !== undefined) error.name = workerError.name; - if (workerError.stack !== undefined) error.stack = workerError.stack; - return error; -}; - -const createDeadCodeWorker: DeadCodeWorkerFactory = (input) => { - // HACK: run deslop in a child PROCESS (node -e), not a worker_thread. - // deslop loads native (oxc) NAPI addons; force-terminating a - // worker_thread that holds native handles intermittently crashes the - // *host* process on Windows — when the dead-code scan runs inside a - // vitest fork this surfaced as a silent "Worker exited unexpectedly" - // and failed CI (see issue: #537 moved deslop inline -> worker_thread). - // A child process can be killed cleanly on every platform (the same - // reason the oxlint runner uses child_process + SIGKILL), so teardown - // on success or timeout never takes the parent down with it. Input - // goes in as JSON on stdin; the normalized result comes back as JSON - // on stdout. - const child = spawn( - process.execPath, - [`--max-old-space-size=${DEAD_CODE_WORKER_MAX_OLD_SPACE_MB}`, "-e", DEAD_CODE_WORKER_SCRIPT], - { - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - env: - input.parseConcurrency === undefined - ? process.env - : { - ...process.env, - DESLOP_PARSE_CONCURRENCY: String(input.parseConcurrency), - }, - }, - ); - - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - child.stdout.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)); - child.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); - - let didSettle = false; - - const result = new Promise((resolve, reject) => { - const settle = (callback: () => void): void => { - if (didSettle) return; - didSettle = true; - callback(); - }; - - child.once("error", (error) => { - settle(() => reject(error)); - }); - - child.once("close", (exitCode) => { - const stdout = Buffer.concat(stdoutChunks).toString("utf8").trim(); - if (stdout.length === 0) { - const stderr = Buffer.concat(stderrChunks).toString("utf8").trim(); - settle(() => - reject( - new Error( - `Dead-code worker exited with code ${exitCode ?? "null"}${ - stderr ? `: ${stderr}` : "" - }.`, - ), - ), - ); - return; - } - try { - const parsedMessage = parseDeadCodeWorkerMessage(JSON.parse(stdout)); - if (parsedMessage.ok) { - settle(() => resolve(parsedMessage.result)); - return; - } - settle(() => reject(buildDeadCodeWorkerError(parsedMessage.error))); - } catch (error) { - settle(() => reject(error)); - } - }); - }); - - // Swallow EPIPE: if the child is killed (timeout) before we finish - // writing input, the real failure surfaces via the close/error - // handlers above. - child.stdin.on("error", () => {}); - child.stdin.end(JSON.stringify(input)); - - return { - result, - terminate: () => { - didSettle = true; - child.kill("SIGKILL"); - }, - }; -}; - -const runDeadCodeWorkerWithTimeout = ( - handle: DeadCodeWorkerHandle, - timeoutMs: number, - abortSignal?: AbortSignal, -): Promise => - new Promise((resolve, reject) => { - let didSettle = false; - - // Centralizes the teardown every exit path shares: stop the timer, detach - // the abort listener, and SIGKILL the child via its `terminate` handle. - const settle = (finish: () => void): void => { - if (didSettle) return; - didSettle = true; - clearTimeout(timeoutHandle); - abortSignal?.removeEventListener("abort", onAbort); - void handle.terminate?.(); - finish(); - }; - - const onAbort = (): void => settle(() => reject(new Error("Dead-code worker aborted."))); - const timeoutHandle = setTimeout( - () => - settle(() => - reject( - new Error(`Dead-code worker timed out after ${timeoutMs / MILLISECONDS_PER_SECOND}s.`), - ), - ), - timeoutMs, - ); - timeoutHandle.unref?.(); - - if (abortSignal?.aborted) { - onAbort(); - return; - } - abortSignal?.addEventListener("abort", onAbort, { once: true }); - - handle.result.then( - (value) => settle(() => resolve(value)), - (error: unknown) => settle(() => reject(error)), - ); - }); - -export const checkDeadCode = async (options: CheckDeadCodeOptions): Promise => { - // Canonicalize up front so the deslop graph and its resolver share one - // path space (see `toCanonicalPath` for why a symlinked root breaks it). - const rootDirectory = toCanonicalPath(options.rootDirectory); - if (!fs.existsSync(path.join(rootDirectory, "package.json"))) return []; - - const { entryPatterns, ignorePatterns } = await collectDeadCodePatterns(rootDirectory); - const tsConfigPath = resolveTsConfigPath(rootDirectory); - const deslopJsModuleSpecifier = - options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js"); - - // Result cache: replay the last complete pass when nothing the analysis - // reads changed. The stat snapshot is taken BEFORE the (long) analysis so a - // stored result is verified — and stored — against the tree it started - // from; `storeDeadCodeResultCache` re-verifies the stats at store time so - // an edit racing the analysis skips the store instead of landing a stale - // entry. - const fileStatsSnapshot = options.cacheEnabled ? collectAnalyzedFileStats(rootDirectory) : null; - const cacheKey = - fileStatsSnapshot === null - ? null - : computeDeadCodeCacheKey({ - rootDirectory, - entryPatterns, - ignorePatterns, - tsConfigPath, - deslopJsModuleSpecifier, - coreVersion: CORE_PACKAGE_VERSION, - }); - if (cacheKey !== null && fileStatsSnapshot !== null) { - const cachedDiagnostics = lookupDeadCodeResultCache({ - cacheDirectory: resolveReactDoctorCacheDir(rootDirectory), - cacheKey, - rootDirectory, - currentFileStats: fileStatsSnapshot, - }); - options.onCacheOutcome?.(cachedDiagnostics !== null); - if (cachedDiagnostics !== null) return [...cachedDiagnostics]; - } - - // `runDeadCodeWorkerWithTimeout` owns the abort wiring: when the surrounding - // Effect fiber is interrupted (lint failed / dead-code phase timeout / scan - // cancelled), `Effect.tryPromise` aborts `options.abortSignal`, which its - // `settle()` path turns into an immediate worker SIGKILL — rather than - // orphaning the child until the in-worker timer expires. - // The incremental summary cache serves the case the whole-result cache just - // missed on: something changed, and the worker should only re-parse what - // changed. Same per-project cache directory, same `cacheEnabled` switch - // (`REACT_DOCTOR_NO_CACHE` / `REACT_DOCTOR_NO_DEAD_CODE_CACHE`); a - // whole-result hit above returns before this line, so it never touches the - // incremental store. - const incrementalCachePath = - options.cacheEnabled === true - ? path.join(resolveReactDoctorCacheDir(rootDirectory), DEAD_CODE_SUMMARY_CACHE_FILENAME) - : undefined; - - const spawnAndRun = (): Promise => { - const workerHandle = (options.createWorker ?? createDeadCodeWorker)({ - rootDirectory, - entryPatterns, - tsConfigPath, - ignorePatterns, - deslopJsModuleSpecifier, - parseConcurrency: options.parseConcurrency, - incrementalCachePath, - }); - return runDeadCodeWorkerWithTimeout( - workerHandle, - options.workerTimeoutMs ?? DEAD_CODE_WORKER_TIMEOUT_MS, - options.abortSignal, - ); - }; - // A REAL deslop spawn passes through the process-global memory-budgeted - // semaphore so a multi-project scan never runs more concurrent 8 GB-ceiling - // children than memory allows (on a roomy box the cap exceeds the project - // count, so nothing serializes). Injected test workers bypass it — they're - // fakes, not real children, and gating them would only serialize the suite. - const rawResult = - options.createWorker === undefined - ? await withDeadCodeWorkerSlot(spawnAndRun, options.abortSignal) - : await spawnAndRun(); - const result = parseDeadCodeWorkerResult(rawResult); - if (result.summaryCacheStats !== undefined) { - options.onSummaryCacheStats?.(result.summaryCacheStats); - } - const toRelative = (filePath: string): string => toRelativeFilePath(rootDirectory, filePath); - const diagnostics: Diagnostic[] = []; - - for (const unusedFile of result.unusedFiles) { - diagnostics.push({ - filePath: toRelative(unusedFile.path), - plugin: DEAD_CODE_PLUGIN, - rule: "unused-file", - severity: "warning", - message: - "Unused file is not reachable from any entry point, so it adds maintenance surface without shipping any code.", - help: "Delete the file if it is truly unreachable, or import it from an entry point.", - line: 0, - column: 0, - category: DEAD_CODE_CATEGORY, - }); - } - - for (const unusedExport of result.unusedExports) { - const label = unusedExport.isTypeOnly ? "type export" : "export"; - diagnostics.push({ - filePath: toRelative(unusedExport.path), - plugin: DEAD_CODE_PLUGIN, - rule: unusedExport.isTypeOnly ? "unused-type" : "unused-export", - severity: "warning", - message: `Unused ${label}: \`${unusedExport.name}\` is exported but no module imports it, so it expands the public surface and can mislead callers about supported API.`, - help: "Drop the `export` keyword (or remove the declaration) if no other module uses this symbol.", - line: unusedExport.line, - column: unusedExport.column, - category: DEAD_CODE_CATEGORY, - }); - } - - for (const unusedDependency of result.unusedDependencies) { - if (REACT_DOCTOR_TOOLCHAIN_PACKAGES.has(unusedDependency.name)) continue; - const label = unusedDependency.isDevDependency ? "devDependency" : "dependency"; - // Every unused dependency reports at `package.json` with no line, so the - // renderer lists all of them under one location. Keep the per-item message - // to just the name and carry the shared rationale in `help` (shown once) - // rather than repeating the same sentence for each name. - diagnostics.push({ - filePath: "package.json", - plugin: DEAD_CODE_PLUGIN, - rule: unusedDependency.isDevDependency ? "unused-dev-dependency" : "unused-dependency", - severity: "warning", - message: `Unused ${label}: \`${unusedDependency.name}\``, - help: `An unused ${label} adds install time and supply-chain surface without being used; remove it from package.json if it is genuinely unused.`, - line: 0, - column: 0, - category: DEAD_CODE_CATEGORY, - }); - } - - for (const cycle of result.circularDependencies) { - if (cycle.files.length === 0) continue; - diagnostics.push({ - filePath: toRelative(cycle.files[0]), - plugin: DEAD_CODE_PLUGIN, - rule: "circular-dependency", - severity: "warning", - message: `Circular import cycle: ${cycle.files - .map(toRelative) - .join( - " → ", - )}. Modules in the cycle can observe partially initialized exports, causing order-dependent bugs.`, - help: "Break the cycle by extracting the shared code into a third module that both files import.", - line: 0, - column: 0, - category: DEAD_CODE_CATEGORY, - }); - } - - // Only a COMPLETE successful pass reaches this line — a crashed, timed-out, - // or aborted worker rejects above — so a stored entry never replays a - // truncated result (mirroring `shouldStoreScanPayload`). - if (cacheKey !== null && fileStatsSnapshot !== null) { - storeDeadCodeResultCache({ - cacheDirectory: resolveReactDoctorCacheDir(rootDirectory), - cacheKey, - rootDirectory, - snapshotFileStats: fileStatsSnapshot, - diagnostics, - }); - } - - return diagnostics; -}; diff --git a/packages/core/src/check-project-analysis.ts b/packages/core/src/check-project-analysis.ts new file mode 100644 index 0000000000..2b31d0dc9b --- /dev/null +++ b/packages/core/src/check-project-analysis.ts @@ -0,0 +1,447 @@ +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + MAINTAINABILITY_CATEGORY, + MAINTAINABILITY_PLUGIN, + PROJECT_ANALYSIS_WORKER_MAX_OLD_SPACE_MB, + PROJECT_ANALYSIS_WORKER_TIMEOUT_MS, + TSCONFIG_FILENAMES, +} from "./constants.js"; +import { withProjectAnalysisWorkerSlot } from "./project-analysis/project-analysis-worker-slots.js"; +import type { Diagnostic } from "./types/index.js"; +import { isRecord } from "./utils/is-record.js"; +import { toCanonicalPath } from "./utils/to-canonical-path.js"; +import { toRelativePath } from "./utils/to-relative-path.js"; + +export interface ProjectAnalysisWorkerHandle { + readonly result: Promise; + readonly terminate?: () => void | Promise; +} + +export interface ProjectAnalysisWorkerInput { + readonly rootDirectory: string; + readonly tsConfigPath?: string; + readonly ignorePatterns?: ReadonlyArray; +} + +export interface CheckProjectAnalysisOptions { + readonly rootDirectory: string; + readonly enabledRuleIds: ReadonlySet; + readonly abortSignal?: AbortSignal; + readonly excludedProjectDirectories?: ReadonlyArray; + readonly ignorePatterns?: ReadonlyArray; + readonly workerTimeoutMs?: number; + readonly createWorker?: (input: ProjectAnalysisWorkerInput) => ProjectAnalysisWorkerHandle; +} + +interface ProjectAnalysisUnusedFile { + readonly path: string; +} + +interface ProjectAnalysisUnusedExport { + readonly path: string; + readonly name: string; + readonly line: number; + readonly column: number; + readonly isTypeOnly: boolean; +} + +interface ProjectAnalysisUnusedDependency { + readonly name: string; + readonly isDevDependency: boolean; +} + +interface ProjectAnalysisCircularDependency { + readonly files: ReadonlyArray; +} + +interface ProjectAnalysisError { + readonly code: string; + readonly module: string; + readonly severity: "fatal" | "warning" | "info"; + readonly message: string; +} + +interface ProjectAnalysisResult { + readonly unusedFiles: ReadonlyArray; + readonly unusedExports: ReadonlyArray; + readonly unusedDependencies: ReadonlyArray; + readonly circularDependencies: ReadonlyArray; + readonly analysisErrors: ReadonlyArray; +} + +interface SerializedProjectAnalysisError { + readonly name?: string; + readonly message: string; + readonly stack?: string; +} + +interface ProjectAnalysisWorkerSuccess { + readonly ok: true; + readonly result: unknown; +} + +interface ProjectAnalysisWorkerFailure { + readonly ok: false; + readonly error: SerializedProjectAnalysisError; +} + +const REACT_DOCTOR_TOOLCHAIN_PACKAGES: ReadonlySet = new Set([ + "react-doctor", + "eslint-plugin-react-doctor", + "oxlint-plugin-react-doctor", +]); + +const parseArray = (value: unknown, label: string): unknown[] => { + if (!Array.isArray(value)) throw new Error(`Project analysis returned invalid ${label}.`); + return value; +}; + +const parseString = (value: unknown, label: string): string => { + if (typeof value !== "string") throw new Error(`Project analysis returned invalid ${label}.`); + return value; +}; + +const parseNumber = (value: unknown, label: string): number => { + if (typeof value !== "number") throw new Error(`Project analysis returned invalid ${label}.`); + return value; +}; + +const parseBoolean = (value: unknown, label: string): boolean => { + if (typeof value !== "boolean") throw new Error(`Project analysis returned invalid ${label}.`); + return value; +}; + +const parseUnusedFiles = (value: unknown): ProjectAnalysisUnusedFile[] => + parseArray(value, "unusedFiles").map((entry, index) => { + if (!isRecord(entry)) + throw new Error(`Project analysis returned invalid unusedFiles[${index}].`); + return { path: parseString(entry.path, `unusedFiles[${index}].path`) }; + }); + +const parseUnusedExports = (value: unknown): ProjectAnalysisUnusedExport[] => + parseArray(value, "unusedExports").map((entry, index) => { + if (!isRecord(entry)) { + throw new Error(`Project analysis returned invalid unusedExports[${index}].`); + } + return { + path: parseString(entry.path, `unusedExports[${index}].path`), + name: parseString(entry.name, `unusedExports[${index}].name`), + line: parseNumber(entry.line, `unusedExports[${index}].line`), + column: parseNumber(entry.column, `unusedExports[${index}].column`), + isTypeOnly: parseBoolean(entry.isTypeOnly, `unusedExports[${index}].isTypeOnly`), + }; + }); + +const parseUnusedDependencies = (value: unknown): ProjectAnalysisUnusedDependency[] => + parseArray(value, "unusedDependencies").map((entry, index) => { + if (!isRecord(entry)) { + throw new Error(`Project analysis returned invalid unusedDependencies[${index}].`); + } + return { + name: parseString(entry.name, `unusedDependencies[${index}].name`), + isDevDependency: parseBoolean( + entry.isDevDependency, + `unusedDependencies[${index}].isDevDependency`, + ), + }; + }); + +const parseCircularDependencies = (value: unknown): ProjectAnalysisCircularDependency[] => + parseArray(value, "circularDependencies").map((entry, index) => { + if (!isRecord(entry)) { + throw new Error(`Project analysis returned invalid circularDependencies[${index}].`); + } + return { + files: parseArray(entry.files, `circularDependencies[${index}].files`).map( + (filePath, fileIndex) => + parseString(filePath, `circularDependencies[${index}].files[${fileIndex}]`), + ), + }; + }); + +const parseAnalysisErrors = (value: unknown): ProjectAnalysisError[] => + parseArray(value, "analysisErrors").map((entry, index) => { + if (!isRecord(entry)) { + throw new Error(`Project analysis returned invalid analysisErrors[${index}].`); + } + const severity = parseString(entry.severity, `analysisErrors[${index}].severity`); + if (severity !== "fatal" && severity !== "warning" && severity !== "info") { + throw new Error(`Project analysis returned invalid analysisErrors[${index}].severity.`); + } + return { + code: parseString(entry.code, `analysisErrors[${index}].code`), + module: parseString(entry.module, `analysisErrors[${index}].module`), + severity, + message: parseString(entry.message, `analysisErrors[${index}].message`), + }; + }); + +const parseProjectAnalysisResult = (value: unknown): ProjectAnalysisResult => { + if (!isRecord(value)) throw new Error("Project analysis returned an invalid result."); + return { + unusedFiles: parseUnusedFiles(value.unusedFiles), + unusedExports: parseUnusedExports(value.unusedExports), + unusedDependencies: parseUnusedDependencies(value.unusedDependencies), + circularDependencies: parseCircularDependencies(value.circularDependencies), + analysisErrors: parseAnalysisErrors(value.analysisErrors), + }; +}; + +const parseWorkerMessage = ( + value: unknown, +): ProjectAnalysisWorkerSuccess | ProjectAnalysisWorkerFailure => { + if (!isRecord(value)) throw new Error("Project analysis worker returned an invalid message."); + if (value.ok === true) return { ok: true, result: value.result }; + if (value.ok !== false || !isRecord(value.error) || typeof value.error.message !== "string") { + throw new Error("Project analysis worker returned an invalid status."); + } + return { + ok: false, + error: { + message: value.error.message, + ...(typeof value.error.name === "string" ? { name: value.error.name } : {}), + ...(typeof value.error.stack === "string" ? { stack: value.error.stack } : {}), + }, + }; +}; + +const buildWorkerError = (serializedError: SerializedProjectAnalysisError): Error => { + const error = new Error(serializedError.message); + if (serializedError.name !== undefined) error.name = serializedError.name; + if (serializedError.stack !== undefined) error.stack = serializedError.stack; + return error; +}; + +const createProjectAnalysisWorker = ( + input: ProjectAnalysisWorkerInput, +): ProjectAnalysisWorkerHandle => { + const workerPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "project-analysis-worker.js", + ); + const child = spawn( + process.execPath, + [`--max-old-space-size=${PROJECT_ANALYSIS_WORKER_MAX_OLD_SPACE_MB}`, workerPath], + { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }, + ); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); + let didSettle = false; + const result = new Promise((resolve, reject) => { + const settle = (complete: () => void): void => { + if (didSettle) return; + didSettle = true; + complete(); + }; + child.once("error", (error) => settle(() => reject(error))); + child.once("close", (exitCode) => { + const stdout = Buffer.concat(stdoutChunks).toString("utf8").trim(); + if (stdout.length === 0) { + const stderr = Buffer.concat(stderrChunks).toString("utf8").trim(); + settle(() => + reject( + new Error( + `Project analysis worker exited with code ${exitCode ?? "null"}${ + stderr.length > 0 ? `: ${stderr}` : "" + }.`, + ), + ), + ); + return; + } + try { + const message = parseWorkerMessage(JSON.parse(stdout)); + settle(() => + message.ok ? resolve(message.result) : reject(buildWorkerError(message.error)), + ); + } catch (error) { + settle(() => reject(error)); + } + }); + }); + const ignoreClosedWorkerInput = (): void => undefined; + child.stdin.on("error", ignoreClosedWorkerInput); + child.stdin.end(JSON.stringify(input)); + return { + result, + terminate: () => { + didSettle = true; + child.kill("SIGKILL"); + }, + }; +}; + +const runWorker = async ( + workerHandle: ProjectAnalysisWorkerHandle, + abortSignal?: AbortSignal, + timeoutMs = PROJECT_ANALYSIS_WORKER_TIMEOUT_MS, +): Promise => + new Promise((resolve, reject) => { + let didSettle = false; + const settle = (complete: () => void): void => { + if (didSettle) return; + didSettle = true; + clearTimeout(timeoutHandle); + abortSignal?.removeEventListener("abort", onAbort); + void workerHandle.terminate?.(); + complete(); + }; + const onAbort = (): void => settle(() => reject(new Error("Project analysis was cancelled."))); + const timeoutHandle = setTimeout( + () => settle(() => reject(new Error("Project analysis worker timed out."))), + timeoutMs, + ); + timeoutHandle.unref(); + if (abortSignal?.aborted) { + onAbort(); + return; + } + abortSignal?.addEventListener("abort", onAbort, { once: true }); + workerHandle.result.then( + (value) => settle(() => resolve(value)), + (error: unknown) => settle(() => reject(error)), + ); + }); + +const resolveTsConfigPath = (rootDirectory: string): string | undefined => { + for (const filename of TSCONFIG_FILENAMES) { + const candidatePath = path.join(rootDirectory, filename); + if (fs.existsSync(candidatePath)) return candidatePath; + } + return undefined; +}; + +const toRelativeFilePath = (rootDirectory: string, filePath: string): string => { + const relativePath = toRelativePath(filePath, rootDirectory); + return relativePath.length > 0 ? relativePath : filePath.replaceAll("\\", "/"); +}; + +const buildDiagnostics = ( + rootDirectory: string, + result: ProjectAnalysisResult, + enabledRuleIds: ReadonlySet, +): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const toRelative = (filePath: string): string => toRelativeFilePath(rootDirectory, filePath); + if (enabledRuleIds.has("unused-file")) { + for (const unusedFile of result.unusedFiles) { + diagnostics.push({ + filePath: toRelative(unusedFile.path), + plugin: MAINTAINABILITY_PLUGIN, + rule: "unused-file", + severity: "warning", + title: "Source file is unreachable", + message: "No discovered application, package, or framework entry point reaches this file.", + help: "Delete the file if it is obsolete, or import or register it from the correct entry path.", + line: 0, + column: 0, + category: MAINTAINABILITY_CATEGORY, + }); + } + } + for (const unusedExport of result.unusedExports) { + const ruleId = unusedExport.isTypeOnly ? "unused-type" : "unused-export"; + if (!enabledRuleIds.has(ruleId)) continue; + const exportKind = unusedExport.isTypeOnly ? "type export" : "value export"; + diagnostics.push({ + filePath: toRelative(unusedExport.path), + plugin: MAINTAINABILITY_PLUGIN, + rule: ruleId, + severity: "warning", + title: unusedExport.isTypeOnly + ? "Type export has no importer" + : "Value export has no importer", + message: `Unused ${exportKind}: \`${unusedExport.name}\` has no importer in the analyzed project graph.`, + help: "Remove the export or make it module-private after checking external, generated, and dynamic consumers.", + line: unusedExport.line, + column: unusedExport.column, + category: MAINTAINABILITY_CATEGORY, + }); + } + for (const unusedDependency of result.unusedDependencies) { + if (REACT_DOCTOR_TOOLCHAIN_PACKAGES.has(unusedDependency.name)) continue; + const ruleId = unusedDependency.isDevDependency ? "unused-dev-dependency" : "unused-dependency"; + if (!enabledRuleIds.has(ruleId)) continue; + const dependencyKind = unusedDependency.isDevDependency ? "devDependency" : "dependency"; + diagnostics.push({ + filePath: "package.json", + plugin: MAINTAINABILITY_PLUGIN, + rule: ruleId, + severity: "warning", + title: unusedDependency.isDevDependency + ? "Development dependency has no discovered use" + : "Dependency has no discovered use", + message: `Unused ${dependencyKind}: \`${unusedDependency.name}\``, + help: `Remove this ${dependencyKind} after checking source, scripts, configuration, CI, and generated consumers.`, + line: 0, + column: 0, + category: MAINTAINABILITY_CATEGORY, + }); + } + if (enabledRuleIds.has("circular-dependency")) { + for (const cycle of result.circularDependencies) { + if (cycle.files.length === 0) continue; + diagnostics.push({ + filePath: toRelative(cycle.files[0]), + plugin: MAINTAINABILITY_PLUGIN, + rule: "circular-dependency", + severity: "warning", + title: "Runtime import cycle", + message: `Runtime import cycle: ${cycle.files.map(toRelative).join(" → ")}. Modules in the cycle can observe partially initialized exports.`, + help: "Break the cycle by extracting shared code into a lower-level module or inverting one dependency.", + line: 0, + column: 0, + category: MAINTAINABILITY_CATEGORY, + }); + } + } + return diagnostics; +}; + +const assertCompleteProjectAnalysis = (result: ProjectAnalysisResult): void => { + const fatalErrors = result.analysisErrors.filter((error) => error.severity === "fatal"); + if (fatalErrors.length === 0) return; + const firstError = fatalErrors[0]; + throw new Error( + `Project analysis was incomplete (${fatalErrors.length} fatal issue${fatalErrors.length === 1 ? "" : "s"}): ${firstError.code}: ${firstError.message}`, + ); +}; + +export const checkProjectAnalysis = async ( + options: CheckProjectAnalysisOptions, +): Promise => { + if (options.enabledRuleIds.size === 0) return []; + const rootDirectory = toCanonicalPath(options.rootDirectory); + if (!fs.existsSync(path.join(rootDirectory, "package.json"))) return []; + const tsConfigPath = resolveTsConfigPath(rootDirectory); + const ignorePatterns = [ + ...(options.ignorePatterns ?? []), + ...(options.excludedProjectDirectories ?? []).map( + (directory) => `${toRelativeFilePath(rootDirectory, toCanonicalPath(directory))}/**`, + ), + ]; + const workerInput: ProjectAnalysisWorkerInput = { + rootDirectory, + ...(tsConfigPath === undefined ? {} : { tsConfigPath }), + ...(ignorePatterns.length === 0 ? {} : { ignorePatterns }), + }; + const spawnAndRunWorker = async (): Promise => { + const workerHandle = (options.createWorker ?? createProjectAnalysisWorker)(workerInput); + return runWorker(workerHandle, options.abortSignal, options.workerTimeoutMs); + }; + const rawResult = + options.createWorker === undefined + ? await withProjectAnalysisWorkerSlot(spawnAndRunWorker, options.abortSignal) + : await spawnAndRunWorker(); + const result = parseProjectAnalysisResult(rawResult); + assertCompleteProjectAnalysis(result); + return buildDiagnostics(rootDirectory, result, options.enabledRuleIds); +}; diff --git a/packages/core/src/check-react-server-components-advisory.ts b/packages/core/src/check-react-server-components-advisory.ts index fc36567ae3..e5c89f62c2 100644 --- a/packages/core/src/check-react-server-components-advisory.ts +++ b/packages/core/src/check-react-server-components-advisory.ts @@ -67,9 +67,9 @@ const buildAdvisoryDiagnostic = (input: BuildAdvisoryDiagnosticInput): Diagnosti }); // Every workspace package directory under `workspaceRoot`, unfiltered — unlike -// `listWorkspacePackages`, which keeps only React-bearing packages. A workspace -// that declares only a `react-server-dom-*` package (or `next` solely under -// `optionalDependencies`) must still have its `node_modules` probed. +// `listWorkspacePackages`, which keeps only React- or Three-bearing packages. A +// workspace that declares only a `react-server-dom-*` package (or `next` solely +// under `optionalDependencies`) must still have its `node_modules` probed. const enumerateWorkspaceDirectories = (workspaceRoot: string): string[] => { const patterns = getWorkspacePatterns( workspaceRoot, diff --git a/packages/core/src/compute-diagnostic-delta.ts b/packages/core/src/compute-diagnostic-delta.ts index 51de3ee9da..f511fef154 100644 --- a/packages/core/src/compute-diagnostic-delta.ts +++ b/packages/core/src/compute-diagnostic-delta.ts @@ -1,6 +1,8 @@ import { createHash } from "node:crypto"; import type { Diagnostic } from "./types/index.js"; +export const DIAGNOSTIC_DELTA_IDENTITY = Symbol.for("react-doctor/diagnostic-delta-identity"); + export interface DiagnosticDelta { /** Diagnostics present in head with no base match — introduced by the change. */ readonly newDiagnostics: Diagnostic[]; @@ -42,16 +44,23 @@ const getDiagnosticMatchKeys = ( const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`; const messageFingerprint = fingerprintText(`${diagnostic.title ?? ""}\0${diagnostic.message}`); const normalizedEvidence = evidence === null ? "" : normalizeEvidence(evidence); + const explicitIdentity = Reflect.get(diagnostic, DIAGNOSTIC_DELTA_IDENTITY); + const explicitIdentityKey = + typeof explicitIdentity === "string" + ? `identity\0${ruleKey}\0${fingerprintText(explicitIdentity)}` + : null; const stableEvidenceKey = - normalizedEvidence.length > 0 + explicitIdentityKey ?? + (normalizedEvidence.length > 0 ? `evidence\0${ruleKey}\0${messageFingerprint}\0${fingerprintText(normalizedEvidence)}` - : null; + : null); return { stableEvidenceKey, sameFileStableEvidenceKey: stableEvidenceKey === null ? null : `${diagnostic.filePath}\0${stableEvidenceKey}`, sameFileFallbackKey: - diagnostic.matchByOccurrence || normalizedEvidence.length === 0 + explicitIdentityKey === null && + (diagnostic.matchByOccurrence || normalizedEvidence.length === 0) ? `fallback\0${diagnostic.filePath}\0${ruleKey}\0${messageFingerprint}` : null, }; @@ -101,10 +110,11 @@ const buildMatchCandidates = ( /** * Diffs a head scan against a base scan using a multiset of construct-level - * evidence. Stable identities combine plugin/rule, the diagnostic message, - * and normalized diagnosed source, so unchanged findings can move across - * files while changed constructs or messages remain new. Cardinality is - * retained for identical findings. Diagnostics explicitly marked + * evidence. Detector-provided identities take precedence when source text is + * intentionally normalized. Otherwise, stable identities combine plugin/rule, + * the diagnostic message, and normalized diagnosed source, so unchanged + * findings can move across files while changed constructs or messages remain + * new. Cardinality is retained for identical findings. Diagnostics explicitly marked * `matchByOccurrence` may fall back to same-file plugin/rule/message matching * after same-file strict evidence matching. Cross-file evidence matching runs * last so a copy cannot consume a reformatted local occurrence. Unreadable diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 9df09a17a3..953d9f46d2 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -37,6 +37,12 @@ export const DEFAULT_SHOW_WARNINGS = true; export const MILLISECONDS_PER_SECOND = 1000; +export const PROJECT_ANALYSIS_WORKER_TIMEOUT_MS = 120_000; +export const PROJECT_ANALYSIS_WORKER_TIMEOUT_MS_PER_SOURCE_FILE = 30; +export const PROJECT_ANALYSIS_WORKER_TIMEOUT_CEILING_MS = 600_000; +export const PROJECT_ANALYSIS_WORKER_MAX_OLD_SPACE_MB = 8192; +export const PROJECT_ANALYSIS_WORKER_MEMORY_BUDGET_BYTES = 2 * 1024 * 1024 * 1024; + export const HTTP_SUCCESS_STATUS_CODE_MIN = 200; export const HTTP_SUCCESS_STATUS_CODE_MAX_EXCLUSIVE = 300; @@ -574,8 +580,6 @@ export const COOPERATIVE_YIELD_BUDGET_MS = 12; // so the bin (parent) and the spawned oxlint batches (children) share one tree. export const NODE_COMPILE_CACHE_DIR_NAME = "node-compile-cache"; -export const DEAD_CODE_WORKER_TIMEOUT_MS = 120_000; - // Cumulative wall-clock budget across ALL binary-split retries of one // batch pass. A pathological file recurses through ~log2(200)≈8 // split levels, and each level re-waits a full OXLINT_SPAWN_TIMEOUT_MS; @@ -611,48 +615,6 @@ export const ABORT_EXIT_CODES: ReadonlySet = new Set([134, 0xc0000409]); // still-pathological file can burn the budget. export const OXLINT_OOM_RESCUE_BUDGET_MS = 60_000; -// deslop's semantic pass builds a full TypeScript program and walks -// every identifier through the type checker. On type-heavy projects -// (large tRPC routers, Effect/Zod schemas, deep generics) the checker -// instantiates enormous types and the child can exceed Node's default -// ~4 GB heap, dying with an uncatchable "heap out of memory" — which -// surfaces as a silent "Scanning failed (dead-code analysis)". Raise -// the child's heap so those projects complete instead of crashing. -export const DEAD_CODE_WORKER_MAX_OLD_SPACE_MB = 8192; - -// Memory budgeted per concurrent dead-code worker when sizing the global -// `withDeadCodeWorkerSlot` semaphore (`resolveDeadCodeConcurrency`). Deliberately -// well below the worker's `--max-old-space-size` ceiling above (that's a crash -// guard, not steady-state use): a deslop graph on a few-hundred-file project -// peaks around 1–1.5 GB, so 2 GB leaves headroom while still collapsing the -// concurrency toward 1 on a small CI runner — capping how many 8 GB-ceiling -// children a multi-project scan starts at once. -export const DEAD_CODE_WORKER_MEM_BUDGET_BYTES = 2 * 1024 * 1024 * 1024; - -// Dead-code timeout scales with the work. deslop is CPU-bound and roughly -// linear in source-file count, so a single fixed timeout is at once too -// generous for a small repo and too tight for a large one — on a multi-thousand -// file repo the graph build legitimately approaches the old fixed 120s cap, so -// any contention (a still-running supply-chain pass, an overlapped lint pool) -// tips it over and the findings are silently dropped. The worker timeout is -// `max(DEAD_CODE_WORKER_TIMEOUT_MS floor, fileCount * this)` capped at the -// ceiling; the phase timeout sits a margin above it. -export const DEAD_CODE_TIMEOUT_MS_PER_SOURCE_FILE = 30; -export const DEAD_CODE_TIMEOUT_CEILING_MS = 600_000; - -// When dead-code is explicitly overlapped with lint (`DeadCodeOverlap="on"`), -// the two CPU-bound worker pools must SHARE the cores rather than each claiming -// all of them — uncoordinated, deslop's parse pool (`os.availableParallelism()`) -// and the oxlint pool (one child per core) sum to ~2x the cores and thrash, -// starving the parse pass past its timeout. The dead-code parse pool gets this -// fraction of the scan's worker budget and lint gets the rest, so the two sum to -// the budget instead of doubling it. (Overlap is OFF by default: dead-code is -// CPU-bound, so a sequential full-core pass is both faster per-phase and never -// oversubscribes — overlapping it with lint buys no wall-clock and only risks -// the starvation. This split exists for operators who force overlap on.) -export const DEAD_CODE_OVERLAP_PARSE_SHARE = 0.4; -export const MIN_DEAD_CODE_PARSE_CONCURRENCY = 1; - // HACK: lookahead cap for JSX opener-span scanning; bounds worst-case // work on pathological files. Real openers stay well under this. export const JSX_OPENER_SCAN_MAX_LINES = 32; @@ -693,7 +655,7 @@ export const VERCEL_NEXTJS_SECURITY_RELEASE_URL = // The closed set of user-facing diagnostic categories. Every rule // (collapsed at codegen via `CATEGORY_BUCKET` in // `generate-rule-registry.mjs`) and every directly-constructed -// diagnostic (dead-code, reduced-motion, pnpm-hardening) must report one +// diagnostic (maintainability, reduced-motion, pnpm-hardening) must report one // of these — the renderer, JSON output, and `categories` severity // overrides all assume this set is exhaustive. `rule-metadata.test.ts` // asserts the registry never drifts outside it. @@ -839,7 +801,7 @@ export const MAX_GLOB_PATTERN_WILDCARD_COUNT = 24; // repeated `inspect()` calls (one per project in a monorepo loop) don't // reload the same `react-doctor.config.json` each time. Capacity bounds // memory on monorepos with hundreds of workspace packages; TTL handles -// long-running consumers (watch-mode tools, language servers). +// long-running consumers such as watch-mode tools. export const CONFIG_CACHE_CAPACITY = 16; export const CONFIG_CACHE_TTL_MS = 5 * 60 * 1_000; @@ -920,22 +882,6 @@ export const PLUGIN_FINGERPRINT_LENGTH_CHARS = 16; // so an upgrade must never replay entries shaped by an older core. export const CORE_PACKAGE_VERSION = process.env.REACT_DOCTOR_CORE_VERSION ?? "0.0.0"; -// Whole-project dead-code result cache (`dead-code/dead-code-result-cache.ts`). -// Replays deslop's diagnostics — skipping the analysis worker entirely — when -// nothing the analysis reads has changed since the stored run. -// Bumped to 2: entries carry a per-file `files` map (mtime, size, content -// hash) instead of folding the file stats into the key, so a fresh checkout's -// bumped mtimes can be repaired against unchanged content. -export const DEAD_CODE_CACHE_SCHEMA_VERSION = 2; - -export const DEAD_CODE_CACHE_FILENAME = "dead-code-cache.json"; - -// deslop's incremental analysis store (`DeslopConfig.incrementalCachePath`) — -// per-file parse summaries + collect/resolution/package-fact layers, written -// by the analysis WORKER for the changed-files case the whole-result cache -// above can't serve. Lives in the same per-project cache directory. -export const DEAD_CODE_SUMMARY_CACHE_FILENAME = "dead-code-summaries.json"; - // Plugin / rule / category identity for the diagnostics the supply-chain // check emits. `plugin: "socket"` keeps Socket findings visually distinct // from the `react-doctor` lint surface in the printed list and JSON report. @@ -1000,3 +946,20 @@ export const SPACE_UTF8_BYTE = 32; export const EXPO_PLATFORM_TREE_SHAKING_MINIMUM_SDK_VERSION = 54; export const REANIMATED_WORKLETS_MINIMUM_MAJOR_VERSION = 4; + +export const JSX_DUPLICATION_DEFAULT_MAX_SOURCE_FILES = 5_000; +export const JSX_DUPLICATION_DEFAULT_MAX_SOURCE_LENGTH_CHARS = 1_000_000; +export const UTF8_MAX_BYTES_PER_UTF16_CODE_UNIT = 3; +export const JSX_DUPLICATION_SOURCE_READ_SENTINEL_BYTES = 1; +export const JSX_DUPLICATION_DEFAULT_MAX_JSX_NODES = 50_000; +export const JSX_DUPLICATION_DEFAULT_MAX_FAMILIES = 20; +export const JSX_DUPLICATION_FAMILY_PROCESSING_MULTIPLIER = 10; +export const JSX_DUPLICATION_MAX_COMPOSITION_PATH_DEPTH = 20; +export const JSX_DUPLICATION_DEFAULT_MINIMUM_NODE_COUNT = 6; +export const JSX_DUPLICATION_DEFAULT_MINIMUM_DEPTH = 3; +export const JSX_DUPLICATION_DEFAULT_MINIMUM_OCCURRENCES = 2; +export const JSX_DUPLICATION_DEFAULT_MINIMUM_DISTINCT_FILES = 1; +export const JSX_DUPLICATION_SOURCE_FILE_PATTERN = /\.[cm]?[jt]sx?$/; +export const MAINTAINABILITY_PLUGIN = "react-doctor"; +export const MAINTAINABILITY_DUPLICATE_JSX_RULE = "duplicate-jsx-subtree"; +export const MAINTAINABILITY_CATEGORY = "Maintainability"; diff --git a/packages/core/src/dead-code/collect-dead-code-patterns.ts b/packages/core/src/dead-code/collect-dead-code-patterns.ts deleted file mode 100644 index ed1c7967b0..0000000000 --- a/packages/core/src/dead-code/collect-dead-code-patterns.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { createJiti } from "jiti"; -import path from "node:path"; -import { collectIgnorePatterns } from "../collect-ignore-patterns.js"; -import { isFile } from "../project-info/index.js"; -import { readIgnoreFile } from "../read-ignore-file.js"; -import { importDefaultExport } from "../utils/import-default-export.js"; -import { isRecord } from "../utils/is-record.js"; -import { readJson5File } from "../utils/read-json5-file.js"; -import { KNIP_CONFIG_FILENAMES, KNIP_DATA_CONFIG_FILENAMES } from "./knip-config-filenames.js"; - -interface KnipWorkspaceConfig { - readonly entry?: unknown; - readonly ignore?: unknown; -} - -interface KnipConfig { - readonly entry?: unknown; - readonly ignore?: unknown; - readonly workspaces?: unknown; -} - -interface DeadCodePatterns { - readonly entryPatterns: ReadonlyArray; - readonly ignorePatterns: ReadonlyArray; -} - -const jiti = createJiti(import.meta.url, { moduleCache: false }); - -const resolveKnipConfigExport = async (configExport: unknown): Promise => { - const config = typeof configExport === "function" ? await configExport() : await configExport; - return isRecord(config) ? config : null; -}; - -const loadKnipConfigFile = async ( - configFilename: string, - filePath: string, -): Promise => { - try { - const configExport = KNIP_DATA_CONFIG_FILENAMES.has(configFilename) - ? readJson5File(filePath) - : await importDefaultExport(jiti, filePath); - return await resolveKnipConfigExport(configExport); - } catch { - return null; - } -}; - -const loadKnipConfig = async (rootDirectory: string): Promise => { - for (const configFilename of KNIP_CONFIG_FILENAMES) { - const filePath = path.join(rootDirectory, configFilename); - if (!isFile(filePath)) continue; - const config = await loadKnipConfigFile(configFilename, filePath); - if (config) return config; - } - - try { - const packageJson = readJson5File(path.join(rootDirectory, "package.json")); - const packageKnipConfig = isRecord(packageJson) ? packageJson.knip : null; - return isRecord(packageKnipConfig) ? packageKnipConfig : null; - } catch { - return null; - } -}; - -const normalizePatternList = (value: unknown): string[] => { - if (typeof value === "string" && value.length > 0) return [value]; - if (!Array.isArray(value)) return []; - return value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); -}; - -const prefixWorkspacePatterns = ( - workspacePattern: string, - patterns: ReadonlyArray, -): string[] => { - const normalizedWorkspacePattern = workspacePattern.replace(/\/+$/, ""); - return patterns.map((pattern) => - pattern.startsWith("!") - ? `!${normalizedWorkspacePattern}/${pattern.slice(1)}` - : `${normalizedWorkspacePattern}/${pattern}`, - ); -}; - -const collectKnipWorkspacePatterns = ( - workspaces: unknown, - settingName: keyof KnipWorkspaceConfig, -): string[] => { - if (!isRecord(workspaces)) return []; - const patterns: string[] = []; - for (const [workspacePattern, workspaceConfig] of Object.entries(workspaces)) { - if (!isRecord(workspaceConfig)) continue; - patterns.push( - ...prefixWorkspacePatterns( - workspacePattern, - normalizePatternList(workspaceConfig[settingName]), - ), - ); - } - return patterns; -}; - -const collectKnipPatterns = ( - config: KnipConfig | null, - settingName: keyof Pick, -): string[] => { - if (!config) return []; - return [ - ...normalizePatternList(config[settingName]), - ...collectKnipWorkspacePatterns(config.workspaces, settingName), - ]; -}; - -// `ignore.files` is intentionally excluded: it suppresses reporting through -// the diagnostic pipeline, so ignored importers must stay in the graph and -// keep their imported files reachable (react-doctor#830). -const collectDeadCodeIgnorePatterns = ( - rootDirectory: string, - config: KnipConfig | null, -): string[] => { - const seen = new Set(); - const sources = [ - readIgnoreFile(path.join(rootDirectory, ".gitignore")), - collectIgnorePatterns(rootDirectory), - collectKnipPatterns(config, "ignore"), - ]; - for (const source of sources) { - for (const pattern of source) seen.add(pattern); - } - return [...seen].filter((pattern) => pattern.length > 0); -}; - -const collectDeadCodeEntryPatterns = (config: KnipConfig | null): string[] => - [...new Set(collectKnipPatterns(config, "entry"))].filter((pattern) => pattern.length > 0); - -export const collectDeadCodePatterns = async (rootDirectory: string): Promise => { - const config = await loadKnipConfig(rootDirectory); - return { - entryPatterns: collectDeadCodeEntryPatterns(config), - ignorePatterns: collectDeadCodeIgnorePatterns(rootDirectory, config), - }; -}; diff --git a/packages/core/src/dead-code/dead-code-result-cache.ts b/packages/core/src/dead-code/dead-code-result-cache.ts deleted file mode 100644 index 2d6c2ed341..0000000000 --- a/packages/core/src/dead-code/dead-code-result-cache.ts +++ /dev/null @@ -1,283 +0,0 @@ -import crypto from "node:crypto"; -import * as fs from "node:fs"; -import { createRequire } from "node:module"; -import * as path from "node:path"; -import * as Schema from "effect/Schema"; -import { ANALYZED_MANIFEST_FILENAMES, DEFAULT_EXTENSIONS } from "deslop-js/analyzed-inputs"; -import type { Diagnostic } from "../types/index.js"; -import { DEAD_CODE_CACHE_FILENAME, DEAD_CODE_CACHE_SCHEMA_VERSION } from "../constants.js"; -import { Diagnostic as DiagnosticSchema } from "../schemas.js"; -import { KNIP_CONFIG_FILENAMES } from "./knip-config-filenames.js"; -import { atomicWriteJson } from "../utils/atomic-write-json.js"; -import { failOpenReadJson } from "../utils/fail-open-read-json.js"; -import { hashFileContents } from "../utils/hash-file-contents.js"; -import { isRecord } from "../utils/is-record.js"; -import { walkSourceTreeFiles } from "../utils/walk-source-tree-files.js"; - -/** - * Whole-project dead-code result cache. Dead-code reachability is a - * whole-project property, so the cache holds ONE entry: the diagnostics of the - * last complete, successful pass, keyed by everything the analysis reads. Any - * input change makes the stored entry unreachable — so there is nothing to - * gain from keeping history. - * - * The entry records every analyzed file as (mtime, size, content hash). A - * lookup verifies files by stat first — ~100-200 ms to stat ~9k files versus - * seconds to hash them — and REPAIRS a stat mismatch by hashing the file's - * current content: identical content accepts the entry and refreshes the - * stored stat (the ninja/restat pattern), so a fresh CI checkout — where every - * mtime is checkout time but content is unchanged — pays the hash once per - * checkout, not a full re-analysis (and not once per run). Additions and - * deletions always invalidate — path-set equality is checked both ways. The - * accepted blind spot, shared with deslop's summary cache: an edit DURING the - * analysis that lands between store-time hash and stat re-verification. - * - * Every operation fails open: a missing or corrupt cache degrades to a fresh - * analysis, never to a wrong result. - */ - -interface AnalyzedFileStat { - readonly mtimeMs: number; - readonly size: number; -} - -interface DeadCodeCacheKeyInput { - /** Canonicalized project root (`checkDeadCode` realpaths it first). */ - readonly rootDirectory: string; - readonly entryPatterns: ReadonlyArray; - readonly ignorePatterns: ReadonlyArray; - readonly tsConfigPath: string | undefined; - readonly deslopJsModuleSpecifier: string; - /** - * `@react-doctor/core`'s own version (`CORE_PACKAGE_VERSION`). Cached - * entries store diagnostics AFTER `checkDeadCode`'s post-processing - * (message text, toolchain-dependency filtering), so a core upgrade must - * invalidate them even when the analyzed tree is unchanged. - */ - readonly coreVersion: string; -} - -/** Persisted per-file identity: `[mtimeMs, size, contentHash]`. */ -type PersistedFileIdentity = readonly [number, number, string]; - -interface PersistedDeadCodeResultCache { - readonly version: number; - readonly key: string; - readonly files: Record; - readonly diagnostics: ReadonlyArray; -} - -// The fingerprinted file sets come straight from the analyzer package -// (`deslop-js/analyzed-inputs`): the extensions its import-graph walk parses -// and every manifest/lockfile/.gitignore name its analysis reads. The worker -// resolves deslop-js from the same install, so these constants are exactly -// what the analysis will use — and a deslop version bump also rotates the key -// via the `deslopVersion` field (belt and suspenders). -const ANALYZED_FILE_EXTENSIONS = new Set(DEFAULT_EXTENSIONS); - -// Beyond what deslop itself reads, the dead-code PASS also depends on: -// Knip configuration (read core-side by `collect-dead-code-patterns.ts` to -// derive the entry/ignore patterns) and `deno.lock` (an extra proxy for installed -// `node_modules` metadata — deslop reads installed packages' bin/peer fields, -// which only change through an install that rewrites a lockfile). -const CORE_SIDE_MANIFEST_NAMES = [...KNIP_CONFIG_FILENAMES, "deno.lock"]; - -const ANALYZED_MANIFEST_NAMES = new Set([ - ...ANALYZED_MANIFEST_FILENAMES, - ...CORE_SIDE_MANIFEST_NAMES, -]); - -// tsconfig/jsconfig files anywhere in the tree — path-alias resolution reads -// the root config, and `extends` chains reach the rest. -const isTsConfigLikeFile = (fileName: string): boolean => - (fileName.startsWith("tsconfig") || fileName.startsWith("jsconfig")) && - fileName.endsWith(".json"); - -const isFingerprintedFile = (fileName: string): boolean => - ANALYZED_FILE_EXTENSIONS.has(path.extname(fileName).toLowerCase()) || - ANALYZED_MANIFEST_NAMES.has(fileName) || - isTsConfigLikeFile(fileName); - -/** - * Stat snapshot of every file the analysis reads, keyed by root-relative - * `/`-separated path. Taken BEFORE the (long) analysis so a stored result is - * verified against the tree it started from. - */ -export const collectAnalyzedFileStats = ( - rootDirectory: string, -): ReadonlyMap => { - const statByRelativePath = new Map(); - for (const { absolutePath, name } of walkSourceTreeFiles(rootDirectory)) { - if (!isFingerprintedFile(name)) continue; - try { - const fileStat = fs.statSync(absolutePath); - const relativePath = path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"); - statByRelativePath.set(relativePath, { mtimeMs: fileStat.mtimeMs, size: fileStat.size }); - } catch { - // Vanished between walk and stat — same contribution as deleted. - } - } - return statByRelativePath; -}; - -const bundledRequire = createRequire(import.meta.url); - -const resolveDeslopVersion = (): string => { - try { - const packageJson = JSON.parse( - fs.readFileSync(bundledRequire.resolve("deslop-js/package.json"), "utf8"), - ); - return isRecord(packageJson) && typeof packageJson.version === "string" - ? packageJson.version - : "unknown"; - } catch { - return "unknown"; - } -}; - -// Everything that changes what a stored entry MEANS besides the analyzed -// files themselves, which are carried per-entry (see `files`) so they can be -// verified — and mtime-repaired — file by file. -export const computeDeadCodeCacheKey = (input: DeadCodeCacheKeyInput): string => - crypto - .createHash("sha1") - .update( - JSON.stringify({ - schemaVersion: DEAD_CODE_CACHE_SCHEMA_VERSION, - coreVersion: input.coreVersion, - deslopVersion: resolveDeslopVersion(), - deslopJsModuleSpecifier: input.deslopJsModuleSpecifier, - entryPatterns: input.entryPatterns, - ignorePatterns: input.ignorePatterns, - // Which tsconfig filename resolved (its CONTENT rides in the per-file - // identities; existence/choice is what this captures). - tsConfigFile: - input.tsConfigPath === undefined - ? null - : path.relative(input.rootDirectory, input.tsConfigPath).replace(/\\/g, "/"), - }), - ) - .digest("hex"); - -const validateDiagnostic = Schema.decodeUnknownSync(DiagnosticSchema); - -// Returns `null` if ANY stored entry is malformed, so a corrupt file degrades -// to a whole-pass miss rather than a partial diagnostic set. The records were -// serialized straight from `checkDeadCode`'s `Diagnostic[]`, so the validated -// array replays as-is in its original (deterministic) order. -const decodeCachedDiagnostics = (raw: ReadonlyArray): ReadonlyArray | null => { - try { - for (const entry of raw) validateDiagnostic(entry); - return raw as ReadonlyArray; - } catch { - return null; - } -}; - -const isPersistedFileIdentity = (value: unknown): value is PersistedFileIdentity => - Array.isArray(value) && - value.length === 3 && - typeof value[0] === "number" && - typeof value[1] === "number" && - typeof value[2] === "string"; - -export interface DeadCodeResultCacheLookupInput { - readonly cacheDirectory: string; - readonly cacheKey: string; - readonly rootDirectory: string; - /** The pre-analysis stat snapshot (`collectAnalyzedFileStats`). */ - readonly currentFileStats: ReadonlyMap; -} - -export const lookupDeadCodeResultCache = ( - input: DeadCodeResultCacheLookupInput, -): ReadonlyArray | null => { - const cacheFilePath = path.join(input.cacheDirectory, DEAD_CODE_CACHE_FILENAME); - const persisted = failOpenReadJson(cacheFilePath, null); - if ( - persisted === null || - !isRecord(persisted) || - persisted.version !== DEAD_CODE_CACHE_SCHEMA_VERSION || - persisted.key !== input.cacheKey || - !isRecord(persisted.files) || - !Array.isArray(persisted.diagnostics) - ) { - return null; - } - const storedFileEntries = Object.entries(persisted.files); - // Path-set equality both ways: equal counts plus every stored path present - // means neither additions nor deletions can slip through. - if (storedFileEntries.length !== input.currentFileStats.size) return null; - const repairedFiles: Record = {}; - let repairedCount = 0; - for (const [relativePath, storedIdentity] of storedFileEntries) { - if (!isPersistedFileIdentity(storedIdentity)) return null; - const currentStat = input.currentFileStats.get(relativePath); - if (currentStat === undefined) return null; - const [storedMtimeMs, storedSize, storedContentHash] = storedIdentity; - if (currentStat.mtimeMs === storedMtimeMs && currentStat.size === storedSize) { - repairedFiles[relativePath] = storedIdentity; - continue; - } - // A size change is a content change; only a same-size stat mismatch (the - // fresh-checkout case) is worth the hash-and-repair read. - if (currentStat.size !== storedSize) return null; - const currentContentHash = hashFileContents(path.join(input.rootDirectory, relativePath)); - if (currentContentHash === null || currentContentHash !== storedContentHash) return null; - repairedFiles[relativePath] = [currentStat.mtimeMs, currentStat.size, storedContentHash]; - repairedCount += 1; - } - const diagnostics = decodeCachedDiagnostics(persisted.diagnostics); - if (diagnostics === null) return null; - if (repairedCount > 0) { - // Persist the refreshed stats so the repair cost is paid once per - // checkout: the next lookup takes the stat fast path. - atomicWriteJson(cacheFilePath, { - version: DEAD_CODE_CACHE_SCHEMA_VERSION, - key: input.cacheKey, - files: repairedFiles, - diagnostics: persisted.diagnostics, - }); - } - return diagnostics; -}; - -export interface DeadCodeResultCacheStoreInput { - readonly cacheDirectory: string; - readonly cacheKey: string; - readonly rootDirectory: string; - /** The pre-analysis stat snapshot (`collectAnalyzedFileStats`). */ - readonly snapshotFileStats: ReadonlyMap; - readonly diagnostics: ReadonlyArray; -} - -export const storeDeadCodeResultCache = (input: DeadCodeResultCacheStoreInput): void => { - const persistedFiles: Record = {}; - for (const [relativePath, snapshotStat] of input.snapshotFileStats) { - const absolutePath = path.join(input.rootDirectory, relativePath); - // Hash first, stat second: a file edited during the analysis either fails - // the stat re-verification below (edit before the hash) or changed after - // the hash captured it — in which case the recorded hash matches the - // pre-edit content and the next lookup misses on it. Either way a racing - // edit can't produce a repairable stale entry. (Files added during the - // analysis need no handling: they miss the lookup's path-set equality.) - const contentHash = hashFileContents(absolutePath); - if (contentHash === null) return; - let currentStat: fs.Stats; - try { - currentStat = fs.statSync(absolutePath); - } catch { - return; - } - if (currentStat.mtimeMs !== snapshotStat.mtimeMs || currentStat.size !== snapshotStat.size) { - return; - } - persistedFiles[relativePath] = [snapshotStat.mtimeMs, snapshotStat.size, contentHash]; - } - atomicWriteJson(path.join(input.cacheDirectory, DEAD_CODE_CACHE_FILENAME), { - version: DEAD_CODE_CACHE_SCHEMA_VERSION, - key: input.cacheKey, - files: persistedFiles, - diagnostics: input.diagnostics, - }); -}; diff --git a/packages/core/src/dead-code/dead-code-worker-slots.ts b/packages/core/src/dead-code/dead-code-worker-slots.ts deleted file mode 100644 index 19e912a044..0000000000 --- a/packages/core/src/dead-code/dead-code-worker-slots.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { resolveDeadCodeConcurrency } from "../utils/resolve-dead-code-concurrency.js"; -import { createWorkerSlots } from "../utils/create-worker-slots.js"; -import type { WorkerSlots } from "../utils/create-worker-slots.js"; - -let deadCodeWorkerSlots: WorkerSlots | null = null; - -export const withDeadCodeWorkerSlot = async ( - task: () => Promise, - abortSignal?: AbortSignal, -): Promise => { - deadCodeWorkerSlots ??= createWorkerSlots({ - slotCount: resolveDeadCodeConcurrency(), - createAbortError: () => new Error("Dead-code worker aborted."), - }); - return deadCodeWorkerSlots.run(task, abortSignal); -}; diff --git a/packages/core/src/dead-code/knip-config-filenames.ts b/packages/core/src/dead-code/knip-config-filenames.ts deleted file mode 100644 index e9325fd47d..0000000000 --- a/packages/core/src/dead-code/knip-config-filenames.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const KNIP_DATA_CONFIG_FILENAMES: ReadonlySet = new Set([ - "knip.json", - "knip.jsonc", - ".knip.json", - ".knip.jsonc", -]); - -export const KNIP_CONFIG_FILENAMES: ReadonlyArray = [ - ...KNIP_DATA_CONFIG_FILENAMES, - "knip.ts", - "knip.js", - "knip.config.ts", - "knip.config.js", -]; diff --git a/packages/core/src/editor-scan.ts b/packages/core/src/editor-scan.ts deleted file mode 100644 index 7fdeed845b..0000000000 --- a/packages/core/src/editor-scan.ts +++ /dev/null @@ -1,239 +0,0 @@ -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; -import type { Diagnostic, ProjectInfo, ReactDoctorConfig } from "./types/index.js"; -import { MIN_SCAN_CONCURRENCY } from "./constants.js"; -import { isReactDoctorError, type ReactDoctorError } from "./errors.js"; -import { layerUserOtlp } from "./observability.js"; -import { isProjectDiscoveryError } from "./project-info/index.js"; -import { OxlintConcurrency } from "./refs.js"; -import { runInspect, type InspectOutput } from "./run-inspect.js"; -import { messageFromUnknown } from "./utils/message-from-unknown.js"; -import { Config, type ResolvedConfig } from "./services/config.js"; -import { DeadCode } from "./services/dead-code.js"; -import { Files } from "./services/files.js"; -import { Git } from "./services/git.js"; -import { Linter, LintPartialFailures } from "./services/linter.js"; -import { Progress } from "./services/progress.js"; -import { Project } from "./services/project.js"; -import { Reporter } from "./services/reporter.js"; -import { Score } from "./services/score.js"; -import { SupplyChain } from "./services/supply-chain.js"; - -/** - * Plain-Promise scan tailored for long-lived editor integrations (the - * language server). It runs the canonical `runInspect` orchestrator but - * with editor-appropriate layers: no hosted Score network call - * (`Score.layerOf(null)`), no git subprocess metadata - * (`Git.layerOf({})`), and a no-op `Progress` / `Reporter`. All Effect - * wiring stays inside `@react-doctor/core`, so editor packages depend on - * a plain async function instead of pulling the Effect runtime into - * their own dependency graph. - */ -export interface EditorScanInput { - /** Project directory to scan (already resolved to a React project root). */ - readonly directory: string; - /** - * Source files to lint, relative to `directory`. Empty / omitted runs - * a whole-project scan. Linted verbatim (no JSX-only narrowing) so the - * exact buffer the user edits is analyzed regardless of extension. - */ - readonly includePaths?: ReadonlyArray; - /** Run dead-code analysis alongside lint. Defaults to `false` (file scans). */ - readonly runDeadCode?: boolean; - /** Run the linter. Defaults to `true`. Set `false` to skip oxlint entirely. */ - readonly lint?: boolean; - /** Honor inline `// react-doctor-disable*` comments. Defaults to config / `true`. */ - readonly respectInlineDisables?: boolean; - /** Node binary able to load the oxlint native binding (from `NodeResolver`). */ - readonly nodeBinaryPath?: string; - /** - * Pre-resolved config override. When provided, the on-disk - * `react-doctor.config.json` is not loaded for this scan. - */ - readonly configOverride?: ReactDoctorConfig | null; - /** Source directory of `configOverride` (anchors `config.plugins` resolution). */ - readonly configSourceDirectory?: string | null; -} - -export interface EditorScanResult { - /** `true` when the scan produced a usable result (including a graceful skip). */ - readonly ok: boolean; - /** `true` when the directory is not an analyzable React project. */ - readonly skipped: boolean; - readonly diagnostics: Diagnostic[]; - readonly project: ProjectInfo | null; - readonly resolvedDirectory: string; - readonly didLintFail: boolean; - readonly lintFailureReason: string | null; - readonly didDeadCodeFail: boolean; - readonly deadCodeFailureReason: string | null; - readonly lintPartialFailures: string[]; - /** Human-readable failure message when `ok` is `false`. */ - readonly error: string | null; -} - -interface EditorScanSettings { - readonly lint: boolean; - readonly runDeadCode: boolean; - readonly respectInlineDisables: boolean; - readonly adoptExistingLintConfig: boolean; - readonly customRulesOnly: boolean; - readonly ignoredTags: ReadonlySet; - readonly warnings: boolean; -} - -const skippedResult = (directory: string): EditorScanResult => ({ - ok: true, - skipped: true, - diagnostics: [], - project: null, - resolvedDirectory: directory, - didLintFail: false, - lintFailureReason: null, - didDeadCodeFail: false, - deadCodeFailureReason: null, - lintPartialFailures: [], - error: null, -}); - -const isGracefulSkip = (error: unknown): boolean => { - if (isProjectDiscoveryError(error)) return true; - if (isReactDoctorError(error)) { - const tag = error.reason._tag; - return tag === "NoReactDependency" || tag === "ProjectNotFound" || tag === "AmbiguousProject"; - } - return false; -}; - -const resolveBooleanSetting = ( - override: boolean | undefined, - configured: boolean | undefined, - defaultValue: boolean, -): boolean => { - if (override !== undefined) return override; - if (configured !== undefined) return configured; - return defaultValue; -}; - -const resolveEditorScanSettings = ( - input: EditorScanInput, - userConfig: ReactDoctorConfig | null, -): EditorScanSettings => ({ - lint: resolveBooleanSetting(input.lint, userConfig?.lint, true), - runDeadCode: resolveBooleanSetting(input.runDeadCode, undefined, false), - respectInlineDisables: resolveBooleanSetting( - input.respectInlineDisables, - userConfig?.respectInlineDisables, - true, - ), - adoptExistingLintConfig: resolveBooleanSetting( - undefined, - userConfig?.adoptExistingLintConfig, - true, - ), - customRulesOnly: resolveBooleanSetting(undefined, userConfig?.customRulesOnly, false), - ignoredTags: new Set(userConfig?.ignore?.tags), - warnings: resolveBooleanSetting(undefined, userConfig?.warnings, true), -}); - -const editorScanResultFromOutput = (output: InspectOutput): EditorScanResult => ({ - ok: true, - skipped: false, - diagnostics: [...output.diagnostics], - project: output.project, - resolvedDirectory: output.resolvedDirectory, - didLintFail: output.didLintFail, - lintFailureReason: output.lintFailureReason, - didDeadCodeFail: output.didDeadCodeFail, - deadCodeFailureReason: output.deadCodeFailureReason, - lintPartialFailures: [...output.lintPartialFailures], - error: null, -}); - -const failedEditorScanResult = (input: EditorScanInput, error: unknown): EditorScanResult => ({ - ok: false, - skipped: false, - diagnostics: [], - project: null, - resolvedDirectory: input.directory, - didLintFail: false, - lintFailureReason: null, - didDeadCodeFail: false, - deadCodeFailureReason: null, - lintPartialFailures: [], - error: messageFromUnknown(error), -}); - -const editorScanResultFromExit = ( - input: EditorScanInput, - exit: Exit.Exit, -): EditorScanResult => { - if (Exit.isSuccess(exit)) return editorScanResultFromOutput(exit.value); - const error: unknown = Cause.squash(exit.cause); - if (isGracefulSkip(error)) return skippedResult(input.directory); - return failedEditorScanResult(input, error); -}; - -const resolveEditorConfig = (input: EditorScanInput): Effect.Effect => { - if (input.configOverride !== undefined) { - return Effect.succeed({ - config: input.configOverride, - resolvedDirectory: input.directory, - configSourceDirectory: input.configSourceDirectory ?? null, - }); - } - return Effect.gen(function* () { - const configService = yield* Config; - return yield* configService.resolve(input.directory); - }).pipe(Effect.provide(Config.layerNode)); -}; - -const runEditorScanEffect = (input: EditorScanInput): Effect.Effect => - Effect.gen(function* () { - const resolvedConfig = yield* resolveEditorConfig(input); - const userConfig = resolvedConfig.config; - const settings = resolveEditorScanSettings(input, userConfig); - yield* Effect.annotateCurrentSpan({ - "editor.lint": settings.lint, - "editor.runDeadCode": settings.runDeadCode, - }); - - const layers = Layer.mergeAll( - Project.layerNode, - Config.layerOf(resolvedConfig), - Files.layerNode, - Git.layerOf({}), - settings.lint ? Linter.layerOxlint : Linter.layerOf([]), - LintPartialFailures.layerLive, - settings.runDeadCode ? DeadCode.layerNode : DeadCode.layerOf([]), - Progress.layerNoop, - Reporter.layerNoop, - Score.layerOf(null), - SupplyChain.layerOf([]), - Layer.succeed(OxlintConcurrency, MIN_SCAN_CONCURRENCY), - ); - - const exit = yield* Effect.exit( - runInspect({ - directory: input.directory, - includePaths: input.includePaths ?? [], - customRulesOnly: settings.customRulesOnly, - respectInlineDisables: settings.respectInlineDisables, - adoptExistingLintConfig: settings.adoptExistingLintConfig, - ignoredTags: settings.ignoredTags, - ...(input.nodeBinaryPath !== undefined ? { nodeBinaryPath: input.nodeBinaryPath } : {}), - runDeadCode: settings.runDeadCode, - warnings: settings.warnings, - isCi: false, - resolveLocalGithubViewerPermission: false, - skipExplicitIncludePathFilter: true, - }).pipe(Effect.provide(layers)), - ); - - return editorScanResultFromExit(input, exit); - }).pipe(Effect.withSpan("runEditorScan")); - -export const runEditorScan = (input: EditorScanInput): Promise => - Effect.runPromise(runEditorScanEffect(input).pipe(Effect.provide(layerUserOtlp))); diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 16c76e932c..5c1a36c430 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -134,14 +134,14 @@ export class ProjectDiscoveryFailed extends Schema.TaggedErrorClass()( - "DeadCodeAnalysisFailed", +export class MaintainabilityAnalysisFailed extends Schema.TaggedErrorClass()( + "MaintainabilityAnalysisFailed", { cause: Schema.Unknown, }, ) { get message() { - return `Dead-code analysis failed: ${Cause.pretty(Cause.fail(this.cause))}`; + return `Maintainability analysis failed: ${Cause.pretty(Cause.fail(this.cause))}`; } } @@ -191,7 +191,7 @@ export const ReactDoctorErrorReason = Schema.Union([ NoReactDependency, AmbiguousProject, ProjectDiscoveryFailed, - DeadCodeAnalysisFailed, + MaintainabilityAnalysisFailed, GitInvocationFailed, GitBaseBranchMissing, GitBaseBranchInvalid, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fb4ab4ed91..203b177092 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,7 @@ export * from "./services/dead-code.js"; export * from "./services/files.js"; export * from "./services/git.js"; export * from "./services/linter.js"; +export * from "./services/maintainability.js"; export * from "./services/node-resolver.js"; export * from "./services/progress.js"; export * from "./services/project.js"; @@ -39,8 +40,8 @@ export * from "./build-json-report.js"; export * from "./build-skipped-checks.js"; export * from "./calculate-score.js"; export * from "./can-oxlint-extend-config.js"; -export * from "./check-dead-code.js"; export * from "./check-expo-project.js"; +export * from "./check-project-analysis.js"; export * from "./check-pnpm-hardening.js"; export * from "./check-react-native-project.js"; export * from "./check-react-server-components-advisory.js"; @@ -53,7 +54,6 @@ export * from "./compute-diagnostic-delta.js"; export * from "./constants.js"; export * from "./detect-user-lint-config.js"; export * from "./diagnostic-surface.js"; -export * from "./editor-scan.js"; export * from "./evaluate-suppression.js"; export * from "./filter-for-surface.js"; export * from "./find-enclosing-jsx-opener.js"; @@ -78,6 +78,7 @@ export * from "./read-ignore-file.js"; export * from "./resolve-compatible-node.js"; export * from "./resolve-config-root-dir.js"; export * from "./resolve-diagnose-target.js"; +export * from "./resolve-project-rule-selections.js"; export * from "./resolve-rule-severity-override.js"; export * from "./rule-metadata.js"; export * from "./resolve-lint-include-paths.js"; diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts index 4c4ebe5b8e..c6e92cb470 100644 --- a/packages/core/src/observability.ts +++ b/packages/core/src/observability.ts @@ -120,7 +120,7 @@ export const layerAxiomTraces = (options: AxiomTelemetryOptions): Layer.Layer; + readonly ignorePatterns?: ReadonlyArray; + readonly tsConfigPath?: string; +} + +export interface ProjectAnalysisResult { + readonly unusedFiles: ReadonlyArray; + readonly unusedExports: ReadonlyArray; + readonly unusedDependencies: ReadonlyArray; + readonly circularDependencies: ReadonlyArray; + readonly analysisErrors: ReadonlyArray; + readonly totalFiles: number; + readonly totalExports: number; + readonly analysisTimeMs: number; +} + +interface ProjectAnalysisWorkerResult extends ProjectAnalysisResult { + readonly skippedDependencies: ReadonlyArray; +} + +interface ProjectAnalysisInternalResult extends ProjectAnalysisWorkerResult { + readonly verifiedUnusedFiles: ReadonlyArray; +} + +const REACT_NATIVE_ENABLERS = ["react-native", "expo"]; +const TARO_ENABLER_PREFIX = "@tarojs/"; + +interface PlatformCapabilities { + hasReactNative: boolean; + hasTaro: boolean; +} + +interface PlatformCapabilityRoot extends PlatformCapabilities { + directory: string; +} + +const detectPlatformCapabilities = (directory: string): PlatformCapabilities => { + const packageJsonPath = resolve(directory, "package.json"); + if (!existsSync(packageJsonPath)) return { hasReactNative: false, hasTaro: false }; + const content = readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(content); + const allDependencies = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.optionalDependencies, + }; + const dependencyNames = Object.keys(allDependencies); + return { + hasReactNative: REACT_NATIVE_ENABLERS.some((enabler) => enabler in allDependencies), + hasTaro: dependencyNames.some((dependencyName) => + dependencyName.startsWith(TARO_ENABLER_PREFIX), + ), + }; +}; + +const buildEmptyProjectAnalysisResult = ( + errors: ReadonlyArray, + elapsedMs: number, +): ProjectAnalysisInternalResult => ({ + unusedFiles: [], + verifiedUnusedFiles: [], + unusedExports: [], + unusedDependencies: [], + skippedDependencies: [], + circularDependencies: [], + analysisErrors: errors, + totalFiles: 0, + totalExports: 0, + analysisTimeMs: elapsedMs, +}); + +const validateConfig = (config: ProjectAnalysisConfig): ProjectAnalysisError | undefined => { + if (!config.rootDir || typeof config.rootDir !== "string") { + return new ConfigError({ message: "config.rootDir must be a non-empty string" }); + } + if (!existsSync(config.rootDir)) { + return new ConfigError({ + message: `config.rootDir does not exist: ${config.rootDir}`, + path: config.rootDir, + }); + } + return undefined; +}; + +const analyzeProjectConfig = async ( + config: ProjectAnalysisConfig, +): Promise => { + const pipelineStartTime = performance.now(); + const setupErrors: ProjectAnalysisError[] = []; + + const configValidationError = validateConfig(config); + if (configValidationError) { + return buildEmptyProjectAnalysisResult( + [configValidationError], + performance.now() - pipelineStartTime, + ); + } + + let workspaceDiscovery: ReturnType; + try { + workspaceDiscovery = resolveWorkspaces(resolve(config.rootDir)); + } catch (workspaceError) { + setupErrors.push( + new WorkspaceError({ + code: "workspace-discovery-failed", + message: "resolveWorkspaces threw — falling back to single-package mode", + path: config.rootDir, + detail: describeUnknownError(workspaceError), + }), + ); + workspaceDiscovery = { + packages: [], + excludedDirectories: [], + hasRootLevelWorkspacePatterns: false, + }; + } + const workspacePackages = [...workspaceDiscovery.packages]; + + let monorepoRoot: string | undefined; + try { + monorepoRoot = findMonorepoRoot(config.rootDir); + } catch (monorepoError) { + setupErrors.push( + new WorkspaceError({ + code: "monorepo-discovery-failed", + message: "findMonorepoRoot threw", + path: config.rootDir, + detail: describeUnknownError(monorepoError), + }), + ); + monorepoRoot = undefined; + } + if (monorepoRoot) { + try { + const monorepoWorkspaces = resolveWorkspaces(monorepoRoot); + const existingDirectories = new Set( + workspacePackages.map((workspacePackage) => workspacePackage.directory), + ); + for (const monorepoPackage of monorepoWorkspaces.packages) { + if (!existingDirectories.has(monorepoPackage.directory)) { + workspacePackages.push(monorepoPackage); + } + } + } catch (monorepoWorkspaceError) { + setupErrors.push( + new WorkspaceError({ + code: "workspace-discovery-failed", + message: "resolveWorkspaces threw on monorepo root", + path: monorepoRoot, + detail: describeUnknownError(monorepoWorkspaceError), + }), + ); + } + } + + let frameworkIgnorePatterns: string[] = []; + try { + frameworkIgnorePatterns = getFrameworkExclusions(config.rootDir); + } catch (frameworkError) { + setupErrors.push( + new WorkspaceError({ + code: "workspace-discovery-failed", + message: "getFrameworkExclusions failed — proceeding without framework exclusion patterns", + path: config.rootDir, + detail: describeUnknownError(frameworkError), + }), + ); + } + + const absoluteRoot = resolve(config.rootDir); + const outputDirectoryExclusions = OUTPUT_DIRECTORIES.flatMap((outputDirectory) => [ + `${absoluteRoot}/${outputDirectory}/**`, + `${absoluteRoot}/**/${outputDirectory}/**`, + ]); + + const allExclusionPatterns = [ + ...workspaceDiscovery.excludedDirectories.map((directory) => `${directory}/**`), + ...frameworkIgnorePatterns, + ...outputDirectoryExclusions, + ]; + + const configWithExclusions = + allExclusionPatterns.length > 0 + ? { + ...config, + ignorePatterns: [...config.ignorePatterns, ...allExclusionPatterns], + } + : config; + + const entriesPromise = resolveEntries(configWithExclusions).catch( + (entriesError: unknown): Awaited> => { + setupErrors.push( + new WorkspaceError({ + code: "workspace-discovery-failed", + message: "resolveEntries failed — defaulting to empty entry set", + path: config.rootDir, + detail: describeUnknownError(entriesError), + }), + ); + return { + productionEntries: [], + authoritativeProductionEntries: [], + explicitProductionEntries: [], + testEntries: [], + alwaysUsedFiles: [], + externallyConsumedFiles: [], + analysisExcludedFiles: [], + }; + }, + ); + + let files: Awaited>; + try { + files = await collectSourceFiles(configWithExclusions); + } catch (collectError) { + setupErrors.push( + new WorkspaceError({ + code: "workspace-discovery-failed", + severity: "fatal", + message: "collectSourceFiles failed", + path: config.rootDir, + detail: describeUnknownError(collectError), + }), + ); + return buildEmptyProjectAnalysisResult(setupErrors, performance.now() - pipelineStartTime); + } + const gitIgnoreResult = collectGitIgnoredPaths( + resolve(config.rootDir), + files.map((file) => file.path), + ); + const gitIgnoredFileSet = gitIgnoreResult.ignoredPaths; + if (gitIgnoreResult.gitUnavailable) { + setupErrors.push( + new WorkspaceError({ + code: "gitignore-check-failed", + severity: "info", + message: "git unavailable — .gitignore filtering skipped", + path: config.rootDir, + }), + ); + } + + const platformCapabilityRoots: PlatformCapabilityRoot[] = [ + absoluteRoot, + ...workspacePackages.map((workspacePackage) => workspacePackage.directory), + ] + .map((directory) => { + try { + return { directory, ...detectPlatformCapabilities(directory) }; + } catch { + return { directory, hasReactNative: false, hasTaro: false }; + } + }) + .sort((leftRoot, rightRoot) => rightRoot.directory.length - leftRoot.directory.length); + const hasReactNativePackage = platformCapabilityRoots.some( + (capabilityRoot) => capabilityRoot.hasReactNative, + ); + + let moduleResolver: ReturnType; + try { + moduleResolver = createResolver( + config, + workspacePackages.map((workspacePackage) => ({ + name: workspacePackage.name, + directory: workspacePackage.directory, + })), + { hasReactNative: hasReactNativePackage, monorepoRoot }, + ); + } catch (resolverError) { + setupErrors.push( + new ResolverError({ + message: "createResolver failed", + path: config.rootDir, + detail: describeUnknownError(resolverError), + }), + ); + return buildEmptyProjectAnalysisResult(setupErrors, performance.now() - pipelineStartTime); + } + const parsedModules = files.map((file) => parseSourceFile(file.path)); + const autoImportReferencesByModuleIndex = collectUnpluginAutoImportReferences( + absoluteRoot, + files, + ); + for (const [moduleIndex, autoImportReferences] of autoImportReferencesByModuleIndex) { + parsedModules[moduleIndex].imports.push(...autoImportReferences); + } + + const discoveredEntries = await entriesPromise; + const buildScriptConsumedFiles = extractBuildScriptConsumedFiles(absoluteRoot); + const relativeFilePaths = files.map((file) => toPosixPath(relative(absoluteRoot, file.path))); + const linguistIgnoredPaths = collectGitLinguistIgnoredPaths(absoluteRoot, relativeFilePaths); + const analysisExcludedFiles = new Set(discoveredEntries.analysisExcludedFiles); + for (let fileIndex = 0; fileIndex < files.length; fileIndex++) { + if (linguistIgnoredPaths.has(relativeFilePaths[fileIndex])) { + analysisExcludedFiles.add(files[fileIndex].path); + } + } + const moduleLinkInputsResult = buildModuleLinkInputs({ + projectRootDirectories: [ + absoluteRoot, + ...workspacePackages.map((workspacePackage) => workspacePackage.directory), + ], + files, + parsedModules, + resolvedEntries: { + ...discoveredEntries, + alwaysUsedFiles: [...discoveredEntries.alwaysUsedFiles, ...buildScriptConsumedFiles], + analysisExcludedFiles: [...analysisExcludedFiles], + }, + gitIgnoredFilePaths: gitIgnoredFileSet, + resolveModule: moduleResolver.resolveModule, + }); + setupErrors.push(...moduleLinkInputsResult.errors); + + let moduleGraph: ReturnType; + try { + moduleGraph = buildDependencyGraph(moduleLinkInputsResult.graphInputs); + } catch (graphError) { + setupErrors.push( + new DetectorError({ + module: "linker", + severity: "fatal", + message: "buildDependencyGraph threw", + detail: describeUnknownError(graphError), + }), + ); + return buildEmptyProjectAnalysisResult(setupErrors, performance.now() - pipelineStartTime); + } + + try { + resolveReExportChains(moduleGraph); + } catch (reExportError) { + setupErrors.push( + new DetectorError({ + module: "linker", + message: "resolveReExportChains threw — re-export propagation skipped", + detail: describeUnknownError(reExportError), + }), + ); + } + + markFilenameRegistryEntries(moduleGraph); + + let platformSiblingIndex = new Map(); + try { + platformSiblingIndex = buildPlatformSiblingIndex(moduleGraph, (filePath) => { + const containingCapabilityRoots = platformCapabilityRoots.filter((capabilityRoot) => + isPathInsideDirectoryOrEqual(filePath, capabilityRoot.directory), + ); + return [ + ...(containingCapabilityRoots.some((capabilityRoot) => capabilityRoot.hasReactNative) + ? REACT_NATIVE_ADDITIONAL_PLATFORM_SUFFIXES + : []), + ...(containingCapabilityRoots.some((capabilityRoot) => capabilityRoot.hasTaro) + ? TARO_PLATFORM_SUFFIXES + : []), + ]; + }); + traceReachability(moduleGraph, platformSiblingIndex); + } catch (reachabilityError) { + setupErrors.push( + new DetectorError({ + module: "linker", + message: "traceReachability threw — every module marked reachable to avoid over-reporting", + detail: describeUnknownError(reachabilityError), + }), + ); + for (const module of moduleGraph.modules) module.isReachable = true; + } + + markCompletePackageGraphs({ + graph: moduleGraph, + packageRootDirectories: [ + absoluteRoot, + ...workspacePackages.map((workspacePackage) => workspacePackage.directory), + ], + resolvedLocalImportSpecifiersByFilePath: + moduleLinkInputsResult.resolvedLocalImportSpecifiersByFilePath, + unresolvedImportingFilePaths: moduleLinkInputsResult.unresolvedImportingFilePaths, + setupErrors, + }); + + const runReportDetector = ( + detectorName: string, + detector: () => Result, + fallback: Result, + ): Result => + runSafeDetector({ + detectorName, + detector, + fallback, + errorSink: setupErrors, + module: "report", + contextDescription: "while building project findings", + }); + const unusedFiles = runReportDetector( + "detectOrphanFiles", + () => detectOrphanFiles(moduleGraph), + [], + ); + const verifiedUnusedFiles = runReportDetector( + "detectVerifiedOrphanFiles", + () => detectOrphanFiles(moduleGraph, { requireCompletePackageGraph: true }), + [], + ); + const unusedExports = runReportDetector( + "detectDeadExports", + () => detectDeadExports(moduleGraph, config, platformSiblingIndex), + [], + ); + const stalePackageReport = runReportDetector( + "detectStalePackages", + () => detectStalePackages(moduleGraph, config), + { unusedDependencies: [], skippedDependencies: [] }, + ); + const circularDependencies = runReportDetector( + "detectCycles", + () => detectCycles(moduleGraph), + [], + ); + const analysisResult: ProjectAnalysisInternalResult = { + unusedFiles, + verifiedUnusedFiles, + unusedExports, + unusedDependencies: stalePackageReport.unusedDependencies, + skippedDependencies: stalePackageReport.skippedDependencies, + circularDependencies, + analysisErrors: setupErrors, + totalFiles: moduleGraph.modules.length, + totalExports: moduleGraph.modules.reduce( + (exportCount, module) => + exportCount + + module.exports.filter( + (exportInfo) => !(exportInfo.name === "*" && exportInfo.isNamespaceReExport), + ).length, + 0, + ), + analysisTimeMs: performance.now() - pipelineStartTime, + }; + + return analysisResult; +}; + +const defineAnalyzeProjectConfig = (input: AnalyzeProjectInput): ProjectAnalysisConfig => + defineProjectAnalysisConfig({ + rootDir: input.rootDirectory, + entryPatterns: input.entryPatterns === undefined ? undefined : [...input.entryPatterns], + ignorePatterns: input.ignorePatterns === undefined ? undefined : [...input.ignorePatterns], + tsConfigPath: input.tsConfigPath, + reportTypes: true, + }); + +const toPublicProjectAnalysisResult = ( + result: ProjectAnalysisInternalResult, +): ProjectAnalysisResult => ({ + unusedFiles: result.unusedFiles, + unusedExports: result.unusedExports, + unusedDependencies: result.unusedDependencies, + circularDependencies: result.circularDependencies, + analysisErrors: result.analysisErrors, + totalFiles: result.totalFiles, + totalExports: result.totalExports, + analysisTimeMs: result.analysisTimeMs, +}); + +const toWorkerProjectAnalysisResult = ( + result: ProjectAnalysisInternalResult, +): ProjectAnalysisWorkerResult => ({ + unusedFiles: result.verifiedUnusedFiles, + unusedExports: result.unusedExports, + unusedDependencies: result.unusedDependencies, + skippedDependencies: result.skippedDependencies, + circularDependencies: result.circularDependencies, + analysisErrors: result.analysisErrors, + totalFiles: result.totalFiles, + totalExports: result.totalExports, + analysisTimeMs: result.analysisTimeMs, +}); + +export const analyzeProject = async (input: AnalyzeProjectInput): Promise => + toPublicProjectAnalysisResult(await analyzeProjectConfig(defineAnalyzeProjectConfig(input))); + +export const analyzeProjectForWorker = ( + input: AnalyzeProjectInput, +): Promise => + analyzeProjectConfig(defineAnalyzeProjectConfig(input)).then(toWorkerProjectAnalysisResult); diff --git a/packages/core/src/project-analysis/collect/build-script-consumed-files.ts b/packages/core/src/project-analysis/collect/build-script-consumed-files.ts new file mode 100644 index 0000000000..390a0a669d --- /dev/null +++ b/packages/core/src/project-analysis/collect/build-script-consumed-files.ts @@ -0,0 +1,1403 @@ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import fg from "fast-glob"; +import ts from "typescript"; +import { + BUILD_SCRIPT_DIRECTORY_SCAN_MAX_DEPTH, + BUILD_SCRIPT_PACKAGE_SCAN_MAX_DEPTH, +} from "../constants.js"; +import { parseSourceFile } from "./parse.js"; +import { resolveEntryWithExtensions } from "../utils/resolve-entry-with-extensions.js"; +import { extractScriptFileReferences } from "../utils/extract-script-file-references.js"; +import { extractPreviewRegistryNamesFromMdx } from "../utils/extract-preview-registry-names-from-mdx.js"; +import { areSourceFilesStructurallyEquivalent } from "../utils/are-source-files-structurally-equivalent.js"; +import { isPathInsideDirectoryOrEqual } from "../utils/is-path-inside-directory-or-equal.js"; +import { toPosixPath } from "../utils/to-posix-path.js"; +import { unwrapTypescriptExpression as unwrapExpression } from "../../utils/unwrap-typescript-expression.js"; + +interface InvokedScriptFile { + filePath: string; + workingDirectory: string; +} + +export interface ExpandBuildScriptPathsInput { + projectRoot: string; + initialPaths: ReadonlyArray; +} + +interface ScriptAnalysis { + scriptFile: InvokedScriptFile; + sourceFile: ts.SourceFile; + liveNodes: Set; + localFunctions: Map; + importedFunctions: Map; +} + +interface ImportedFunction { + analysisKey: string; + exportName: string; +} + +interface PendingFunctionBody { + analysis: ScriptAnalysis; + body: ts.ConciseBody; +} + +interface DirectoryConsumption { + consumesAllEntries: boolean; + recursivelyTraverses: boolean; +} + +const SOURCE_FILE_EXTENSION_PATTERN = /\.(?:[cm]?[jt]sx?)$/; +const GULP_INVOCATION_PATTERN = /(?:^|[\s;&|])gulp(?:\s|$)/; +const buildScriptAnalysisKey = (scriptFile: InvokedScriptFile): string => + `${scriptFile.workingDirectory}\0${scriptFile.filePath}`; + +const resolveBuildReference = ( + reference: string, + workingDirectory: string, + projectRoot: string, +): string => + reference.startsWith("/") + ? resolve(projectRoot, reference.replace(/^\/+/, "")) + : resolve(workingDirectory, reference); + +const getPropertyName = (expression: ts.Expression): string | undefined => { + const unwrappedExpression = unwrapExpression(expression); + if (ts.isPropertyAccessExpression(unwrappedExpression)) return unwrappedExpression.name.text; + if ( + ts.isElementAccessExpression(unwrappedExpression) && + unwrappedExpression.argumentExpression && + ts.isStringLiteralLike(unwrappedExpression.argumentExpression) + ) { + return unwrappedExpression.argumentExpression.text; + } + return undefined; +}; + +const resolveImportedScriptPath = ( + specifier: string, + scriptFile: InvokedScriptFile, + projectRoot: string, +): string | undefined => { + const importedPath = specifier.startsWith(".") + ? resolve(dirname(scriptFile.filePath), specifier) + : specifier.startsWith("@/registry/") + ? resolve(scriptFile.workingDirectory, "src/registry", specifier.slice("@/registry/".length)) + : undefined; + if (!importedPath) return undefined; + const sourceImportedPath = importedPath.replace(/\.[cm]?js$/, ""); + const resolvedImportedPath = + existsSync(importedPath) && statSync(importedPath).isDirectory() + ? resolveEntryWithExtensions(join(importedPath, "index")) + : (resolveEntryWithExtensions(importedPath) ?? + resolveEntryWithExtensions(sourceImportedPath)); + if (!resolvedImportedPath || !isPathInsideDirectoryOrEqual(resolvedImportedPath, projectRoot)) { + return undefined; + } + return resolvedImportedPath; +}; + +const collectWebpackEntrySpecifiers = (filePath: string): string[] => { + let sourceText: string; + try { + sourceText = readFileSync(filePath, "utf8"); + } catch { + return []; + } + + const sourceFile = ts.createSourceFile( + filePath, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const specifiers = new Set(); + const collectStringLiterals = (node: ts.Node): void => { + if (ts.isStringLiteralLike(node) && node.text.startsWith(".")) { + specifiers.add(node.text); + return; + } + ts.forEachChild(node, collectStringLiterals); + }; + const visitNode = (node: ts.Node): void => { + if ( + ts.isPropertyAssignment(node) && + ((ts.isIdentifier(node.name) && node.name.text === "entry") || + (ts.isStringLiteralLike(node.name) && node.name.text === "entry")) + ) { + collectStringLiterals(node.initializer); + } + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(node.left) && + node.left.name.text === "entry" + ) { + collectStringLiterals(node.right); + } + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === "concat" && + node.getText(sourceFile).includes(".entry") + ) { + collectStringLiterals(node); + } + ts.forEachChild(node, visitNode); + }; + visitNode(sourceFile); + return [...specifiers]; +}; + +const readJson = (filePath: string): unknown => { + try { + return JSON.parse(readFileSync(filePath, "utf8")); + } catch { + return undefined; + } +}; + +const collectManifestPathPatterns = (value: unknown, patterns: Set): void => { + if (Array.isArray(value)) { + for (const item of value) collectManifestPathPatterns(item, patterns); + return; + } + if (typeof value !== "object" || value === null) return; + + for (const [key, nestedValue] of Object.entries(value)) { + if (key === "path" && typeof nestedValue === "string") { + patterns.add(nestedValue); + continue; + } + collectManifestPathPatterns(nestedValue, patterns); + } +}; + +const expandManifestPaths = ( + manifestPath: string, + projectRoot: string, + recursivelyExpandWildcardDirectories = false, +): string[] => { + const patterns = new Set(); + collectManifestPathPatterns(readJson(manifestPath), patterns); + const filePaths = new Set(); + + for (const pattern of patterns) { + const isProjectRootPattern = pattern.startsWith("/") || pattern.startsWith("packages/"); + const patternWorkingDirectories = [isProjectRootPattern ? projectRoot : dirname(manifestPath)]; + const normalizedPattern = pattern.replace(/^\/+/, ""); + if ( + normalizedPattern.includes("*") || + normalizedPattern.includes("?") || + normalizedPattern.includes("[") + ) { + let didExpandWildcardDirectory = false; + if (recursivelyExpandWildcardDirectories && normalizedPattern.includes("*")) { + for (const patternWorkingDirectory of patternWorkingDirectories) { + const wildcardDirectory = resolve( + patternWorkingDirectory, + normalizedPattern.slice(0, normalizedPattern.indexOf("*")), + ); + if ( + isPathInsideDirectoryOrEqual(wildcardDirectory, projectRoot) && + existsSync(wildcardDirectory) && + statSync(wildcardDirectory).isDirectory() + ) { + for (const filePath of fg.sync("**/*.{js,jsx,ts,tsx,mjs,mts,cjs,cts}", { + cwd: wildcardDirectory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + deep: BUILD_SCRIPT_DIRECTORY_SCAN_MAX_DEPTH, + })) { + filePaths.add(filePath); + } + didExpandWildcardDirectory = true; + break; + } + } + } + if (didExpandWildcardDirectory) continue; + for (const patternWorkingDirectory of patternWorkingDirectories) { + const matchedFileCount = filePaths.size; + for (const filePath of fg.sync(normalizedPattern, { + cwd: patternWorkingDirectory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + deep: BUILD_SCRIPT_DIRECTORY_SCAN_MAX_DEPTH, + })) { + if ( + isPathInsideDirectoryOrEqual(filePath, projectRoot) && + SOURCE_FILE_EXTENSION_PATTERN.test(filePath) + ) { + filePaths.add(filePath); + } + } + if (filePaths.size > matchedFileCount) break; + } + continue; + } + + for (const patternWorkingDirectory of patternWorkingDirectories) { + const filePath = resolve(patternWorkingDirectory, normalizedPattern); + if ( + isPathInsideDirectoryOrEqual(filePath, projectRoot) && + existsSync(filePath) && + statSync(filePath).isFile() && + SOURCE_FILE_EXTENSION_PATTERN.test(filePath) + ) { + filePaths.add(filePath); + break; + } + } + } + + return [...filePaths]; +}; + +const collectPackageJsonPaths = (projectRoot: string): string[] => + fg.sync(["package.json", "**/package.json"], { + cwd: projectRoot, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], + deep: BUILD_SCRIPT_PACKAGE_SCAN_MAX_DEPTH, + }); + +const extractInvokedScriptFiles = ( + projectRoot: string, + packageJsonPaths: ReadonlyArray, +): InvokedScriptFile[] => { + const scriptFiles = new Map(); + + for (const packageJsonPath of packageJsonPaths) { + const workingDirectory = dirname(packageJsonPath); + const packageJson = readJson(packageJsonPath); + if (typeof packageJson !== "object" || packageJson === null) continue; + const scripts = Object.entries(packageJson).find(([key]) => key === "scripts")?.[1]; + if (typeof scripts !== "object" || scripts === null) continue; + + for (const command of Object.values(scripts)) { + if (typeof command !== "string") continue; + if (GULP_INVOCATION_PATTERN.test(command)) { + for (const gulpFilePath of fg.sync("gulpfile.{js,ts,mjs,cjs}", { + cwd: workingDirectory, + absolute: true, + onlyFiles: true, + })) { + scriptFiles.set(`${workingDirectory}\0${gulpFilePath}`, { + filePath: gulpFilePath, + workingDirectory, + }); + } + } + for (const scriptReference of extractScriptFileReferences(command)) { + const scriptPath = resolveBuildReference(scriptReference, workingDirectory, projectRoot); + if ( + isPathInsideDirectoryOrEqual(scriptPath, projectRoot) && + existsSync(scriptPath) && + statSync(scriptPath).isFile() + ) { + scriptFiles.set(`${workingDirectory}\0${scriptPath}`, { + filePath: scriptPath, + workingDirectory, + }); + } + } + } + } + + return [...scriptFiles.values()]; +}; + +const expandInvokedScriptFiles = ( + initialScriptFiles: ReadonlyArray, + projectRoot: string, +): InvokedScriptFile[] => { + const scriptFiles = new Map( + initialScriptFiles.map((scriptFile) => [buildScriptAnalysisKey(scriptFile), scriptFile]), + ); + const pendingScriptFiles = [...initialScriptFiles]; + + for (let scriptIndex = 0; scriptIndex < pendingScriptFiles.length; scriptIndex++) { + const scriptFile = pendingScriptFiles[scriptIndex]; + const parsedScript = parseSourceFile(scriptFile.filePath); + const staticScriptSpecifiers = parsedScript.imports.flatMap((importInfo) => { + const hasOnlyTypeBindings = + importInfo.importedNames.length > 0 && + importInfo.importedNames.every((importedName) => importedName.isTypeOnly); + return importInfo.isTypeOnly || hasOnlyTypeBindings ? [] : [importInfo.specifier]; + }); + const webpackEntrySpecifiers = collectWebpackEntrySpecifiers(scriptFile.filePath); + for (const scriptSpecifier of [...staticScriptSpecifiers, ...webpackEntrySpecifiers]) { + let resolvedImportedPath = resolveImportedScriptPath( + scriptSpecifier, + scriptFile, + projectRoot, + ); + if (!resolvedImportedPath && webpackEntrySpecifiers.includes(scriptSpecifier)) { + resolvedImportedPath = resolveImportedScriptPath( + scriptSpecifier, + { + filePath: join(scriptFile.workingDirectory, "webpack-entry.js"), + workingDirectory: scriptFile.workingDirectory, + }, + projectRoot, + ); + } + if (!resolvedImportedPath) continue; + const importedScriptFile = { + filePath: resolvedImportedPath, + workingDirectory: scriptFile.workingDirectory, + }; + const analysisKey = buildScriptAnalysisKey(importedScriptFile); + if (scriptFiles.has(analysisKey)) continue; + scriptFiles.set(analysisKey, importedScriptFile); + pendingScriptFiles.push(importedScriptFile); + } + } + + return [...scriptFiles.values()]; +}; + +const findScriptWorkingDirectory = (filePath: string, projectRoot: string): string => { + let currentDirectory = dirname(resolve(filePath)); + const absoluteProjectRoot = resolve(projectRoot); + while (isPathInsideDirectoryOrEqual(currentDirectory, absoluteProjectRoot)) { + if (existsSync(join(currentDirectory, "package.json"))) return currentDirectory; + if (currentDirectory === absoluteProjectRoot) break; + const parentDirectory = dirname(currentDirectory); + if (parentDirectory === currentDirectory) break; + currentDirectory = parentDirectory; + } + return absoluteProjectRoot; +}; + +export const expandBuildScriptPaths = ({ + projectRoot, + initialPaths, +}: ExpandBuildScriptPathsInput): string[] => + expandInvokedScriptFiles( + initialPaths + .map((filePath) => resolve(filePath)) + .filter((filePath) => existsSync(filePath) && statSync(filePath).isFile()) + .map((filePath) => ({ + filePath, + workingDirectory: findScriptWorkingDirectory(filePath, projectRoot), + })), + resolve(projectRoot), + ).map((scriptFile) => toPosixPath(scriptFile.filePath)); + +const buildScriptAnalyses = ( + invokedScriptFiles: ReadonlyArray, + projectRoot: string, +): Map => { + const analyses = new Map(); + for (const scriptFile of invokedScriptFiles) { + let content: string; + try { + content = readFileSync(scriptFile.filePath, "utf8"); + } catch { + continue; + } + const sourceFile = ts.createSourceFile( + scriptFile.filePath, + content, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const localFunctions = new Map(); + const defaultExportExpressions: ts.Expression[] = []; + for (const statement of sourceFile.statements) { + if (ts.isFunctionDeclaration(statement) && statement.body) { + if (statement.name) localFunctions.set(statement.name.text, statement.body); + if ( + statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) + ) { + localFunctions.set("default", statement.body); + } + } + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) && + declaration.initializer && + (ts.isArrowFunction(declaration.initializer) || + ts.isFunctionExpression(declaration.initializer)) + ) { + localFunctions.set(declaration.name.text, declaration.initializer.body); + } + } + } + if (ts.isExportAssignment(statement) && !statement.isExportEquals) { + defaultExportExpressions.push(statement.expression); + } + } + for (const defaultExportExpression of defaultExportExpressions) { + const unwrappedDefaultExport = unwrapExpression(defaultExportExpression); + if ( + ts.isArrowFunction(unwrappedDefaultExport) || + ts.isFunctionExpression(unwrappedDefaultExport) + ) { + localFunctions.set("default", unwrappedDefaultExport.body); + } else if (ts.isIdentifier(unwrappedDefaultExport)) { + const defaultFunctionBody = localFunctions.get(unwrappedDefaultExport.text); + if (defaultFunctionBody) localFunctions.set("default", defaultFunctionBody); + } + } + analyses.set(buildScriptAnalysisKey(scriptFile), { + scriptFile, + sourceFile, + liveNodes: new Set(), + localFunctions, + importedFunctions: new Map(), + }); + } + + for (const analysis of analyses.values()) { + for (const statement of analysis.sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + const importClause = statement.importClause; + if (!importClause || importClause.isTypeOnly) continue; + const importedFilePath = resolveImportedScriptPath( + statement.moduleSpecifier.text, + analysis.scriptFile, + projectRoot, + ); + if (!importedFilePath) continue; + const importedAnalysisKey = buildScriptAnalysisKey({ + filePath: importedFilePath, + workingDirectory: analysis.scriptFile.workingDirectory, + }); + if (!analyses.has(importedAnalysisKey)) continue; + if (importClause.name) { + analysis.importedFunctions.set(importClause.name.text, { + analysisKey: importedAnalysisKey, + exportName: "default", + }); + } + if (!importClause.namedBindings || !ts.isNamedImports(importClause.namedBindings)) continue; + for (const element of importClause.namedBindings.elements) { + if (element.isTypeOnly) continue; + analysis.importedFunctions.set(element.name.text, { + analysisKey: importedAnalysisKey, + exportName: element.propertyName?.text ?? element.name.text, + }); + } + } + } + + const pendingFunctionBodies: PendingFunctionBody[] = []; + const queuedFunctionBodies = new Set(); + const queueFunctionBody = (analysis: ScriptAnalysis, body: ts.ConciseBody): void => { + if (queuedFunctionBodies.has(body)) return; + queuedFunctionBodies.add(body); + pendingFunctionBodies.push({ analysis, body }); + }; + const visitLiveNode = (analysis: ScriptAnalysis, rootNode: ts.Node): void => { + const visitNode = (node: ts.Node): void => { + analysis.liveNodes.add(node); + if ( + node !== rootNode && + (ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) || + ts.isMethodDeclaration(node)) + ) { + return; + } + if (ts.isCallExpression(node)) { + const calledExpression = unwrapExpression(node.expression); + if (ts.isFunctionExpression(calledExpression) || ts.isArrowFunction(calledExpression)) { + queueFunctionBody(analysis, calledExpression.body); + } + if (ts.isIdentifier(calledExpression)) { + const localFunctionBody = analysis.localFunctions.get(calledExpression.text); + if (localFunctionBody) queueFunctionBody(analysis, localFunctionBody); + const importedFunction = analysis.importedFunctions.get(calledExpression.text); + const importedAnalysis = importedFunction + ? analyses.get(importedFunction.analysisKey) + : undefined; + const importedFunctionBody = importedFunction + ? importedAnalysis?.localFunctions.get(importedFunction.exportName) + : undefined; + if (importedAnalysis && importedFunctionBody) { + queueFunctionBody(importedAnalysis, importedFunctionBody); + } + } + for (const argument of node.arguments) { + const unwrappedArgument = unwrapExpression(argument); + if (ts.isFunctionExpression(unwrappedArgument) || ts.isArrowFunction(unwrappedArgument)) { + queueFunctionBody(analysis, unwrappedArgument.body); + } + } + } + ts.forEachChild(node, visitNode); + }; + visitNode(rootNode); + }; + + for (const analysis of analyses.values()) visitLiveNode(analysis, analysis.sourceFile); + for (let pendingIndex = 0; pendingIndex < pendingFunctionBodies.length; pendingIndex++) { + const pendingFunctionBody = pendingFunctionBodies[pendingIndex]; + visitLiveNode(pendingFunctionBody.analysis, pendingFunctionBody.body); + } + return analyses; +}; + +const collectDirectorySourceFiles = ( + directoryPath: string, + projectRoot: string, + consumedFiles: Set, + recursively: boolean, +): void => { + if ( + !isPathInsideDirectoryOrEqual(directoryPath, projectRoot) || + !existsSync(directoryPath) || + !statSync(directoryPath).isDirectory() + ) { + return; + } + for (const filePath of fg.sync("**/*.{js,jsx,ts,tsx,mjs,mts,cjs,cts}", { + cwd: directoryPath, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + deep: recursively ? BUILD_SCRIPT_DIRECTORY_SCAN_MAX_DEPTH : 1, + })) { + if (isPathInsideDirectoryOrEqual(filePath, projectRoot)) consumedFiles.add(filePath); + } +}; + +const findVariableDeclaration = ( + sourceFile: ts.SourceFile, + identifierName: string, + usePosition: number, +): ts.VariableDeclaration | undefined => { + let closestDeclaration: ts.VariableDeclaration | undefined; + const visitNode = (node: ts.Node): void => { + if (node.getStart(sourceFile) >= usePosition) return; + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { + if (node.name.text === identifierName) closestDeclaration = node; + } + ts.forEachChild(node, visitNode); + }; + visitNode(sourceFile); + return closestDeclaration; +}; + +const evaluatePathExpression = ( + expression: ts.Expression, + analysis: ScriptAnalysis, + projectRoot: string, + seenIdentifiers = new Set(), +): string | undefined => { + const unwrappedExpression = unwrapExpression(expression); + if (ts.isStringLiteralLike(unwrappedExpression)) { + return resolveBuildReference( + unwrappedExpression.text, + analysis.scriptFile.workingDirectory, + projectRoot, + ); + } + if (ts.isIdentifier(unwrappedExpression)) { + if (unwrappedExpression.text === "__dirname") return dirname(analysis.scriptFile.filePath); + if (seenIdentifiers.has(unwrappedExpression.text)) return undefined; + const declaration = findVariableDeclaration( + analysis.sourceFile, + unwrappedExpression.text, + unwrappedExpression.getStart(analysis.sourceFile), + ); + if (!declaration?.initializer) return undefined; + const nextSeenIdentifiers = new Set(seenIdentifiers); + nextSeenIdentifiers.add(unwrappedExpression.text); + return evaluatePathExpression( + declaration.initializer, + analysis, + projectRoot, + nextSeenIdentifiers, + ); + } + if (!ts.isCallExpression(unwrappedExpression)) return undefined; + if ( + ts.isPropertyAccessExpression(unwrappedExpression.expression) && + ts.isIdentifier(unwrappedExpression.expression.expression) && + unwrappedExpression.expression.expression.text === "process" && + unwrappedExpression.expression.name.text === "cwd" + ) { + return analysis.scriptFile.workingDirectory; + } + const pathMethodName = getPropertyName(unwrappedExpression.expression); + if (pathMethodName !== "join" && pathMethodName !== "resolve") return undefined; + const [baseExpression, ...segmentExpressions] = unwrappedExpression.arguments; + if (!baseExpression) return undefined; + const baseDirectory = evaluatePathExpression( + baseExpression, + analysis, + projectRoot, + seenIdentifiers, + ); + if (!baseDirectory) return undefined; + const pathSegments: string[] = []; + for (const segmentExpression of segmentExpressions) { + const unwrappedSegment = unwrapExpression(segmentExpression); + if (!ts.isStringLiteralLike(unwrappedSegment)) return undefined; + pathSegments.push(unwrappedSegment.text); + } + const rootRelativeSegment = pathSegments.find((pathSegment) => pathSegment.startsWith("/")); + if (rootRelativeSegment) { + const rootRelativeSegments = pathSegments.slice(pathSegments.indexOf(rootRelativeSegment)); + rootRelativeSegments[0] = rootRelativeSegments[0].replace(/^\/+/, ""); + return resolve(projectRoot, ...rootRelativeSegments); + } + return pathMethodName === "join" + ? join(baseDirectory, ...pathSegments) + : resolve(baseDirectory, ...pathSegments); +}; + +const expressionContainsIdentifier = (expression: ts.Node, identifierName: string): boolean => { + let containsIdentifier = false; + const visitNode = (node: ts.Node): void => { + if (ts.isIdentifier(node) && node.text === identifierName) { + containsIdentifier = true; + return; + } + if (!containsIdentifier) ts.forEachChild(node, visitNode); + }; + visitNode(expression); + return containsIdentifier; +}; + +const isConditionallyExecuted = (node: ts.Node, boundary: ts.Node): boolean => { + for (let ancestor = node.parent; ancestor && ancestor !== boundary; ancestor = ancestor.parent) { + if ( + ts.isIfStatement(ancestor) || + ts.isConditionalExpression(ancestor) || + ts.isSwitchStatement(ancestor) + ) { + return true; + } + } + return false; +}; + +const analyzeDirectoryConsumer = ( + body: ts.ConciseBody, + helperName: string, + parameterName: string, +): DirectoryConsumption => { + const directoryEntryCollectionNames = new Set(); + let unconditionallyConsumesEntries = false; + let recursivelyTraverses = false; + + const isDirectoryRead = (node: ts.Node): node is ts.CallExpression => + ts.isCallExpression(node) && + getPropertyName(node.expression) === "readdirSync" && + node.arguments[0] !== undefined && + ts.isIdentifier(unwrapExpression(node.arguments[0])) && + unwrapExpression(node.arguments[0]).getText() === parameterName; + + const visitNode = (node: ts.Node): void => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + isDirectoryRead(unwrapExpression(node.initializer)) + ) { + directoryEntryCollectionNames.add(node.name.text); + } + if (ts.isForOfStatement(node) && ts.isVariableDeclarationList(node.initializer)) { + const collectionExpression = unwrapExpression(node.expression); + const iteratesDirectoryEntries = + isDirectoryRead(collectionExpression) || + (ts.isIdentifier(collectionExpression) && + directoryEntryCollectionNames.has(collectionExpression.text)); + if (iteratesDirectoryEntries) { + const loopVariable = node.initializer.declarations[0]?.name; + if (loopVariable && ts.isIdentifier(loopVariable)) { + const entryDependentNames = new Set([loopVariable.text]); + const visitLoop = (loopNode: ts.Node): void => { + if ( + ts.isVariableDeclaration(loopNode) && + ts.isIdentifier(loopNode.name) && + loopNode.initializer && + [...entryDependentNames].some((identifierName) => + expressionContainsIdentifier(loopNode.initializer!, identifierName), + ) + ) { + entryDependentNames.add(loopNode.name.text); + } + if (ts.isCallExpression(loopNode)) { + const operationName = getPropertyName(loopNode.expression); + if ( + operationName?.match(/^(?:copyFile|readFile)(?:Sync)?$/) && + loopNode.arguments.some((argument) => + [...entryDependentNames].some((identifierName) => + expressionContainsIdentifier(argument, identifierName), + ), + ) + ) { + if (!isConditionallyExecuted(loopNode, node.statement)) { + unconditionallyConsumesEntries = true; + } + } + const calledExpression = unwrapExpression(loopNode.expression); + if ( + ts.isIdentifier(calledExpression) && + calledExpression.text === helperName && + loopNode.arguments.some((argument) => + [...entryDependentNames].some((identifierName) => + expressionContainsIdentifier(argument, identifierName), + ), + ) + ) { + recursivelyTraverses = true; + } + } + ts.forEachChild(loopNode, visitLoop); + }; + visitLoop(node.statement); + } + } + } + ts.forEachChild(node, visitNode); + }; + visitNode(body); + return { + consumesAllEntries: unconditionallyConsumesEntries || recursivelyTraverses, + recursivelyTraverses, + }; +}; + +const collectRecursiveInputDirectories = ( + analysis: ScriptAnalysis, + projectRoot: string, + consumedFiles: Set, +): void => { + for (const node of analysis.liveNodes) { + if (!ts.isCallExpression(node) || !ts.isIdentifier(unwrapExpression(node.expression))) continue; + const helperName = unwrapExpression(node.expression).getText(); + const helperBody = analysis.localFunctions.get(helperName); + const helperDeclaration = [...analysis.sourceFile.statements].find( + (statement) => ts.isFunctionDeclaration(statement) && statement.name?.text === helperName, + ); + if ( + !helperBody || + !helperDeclaration || + !ts.isFunctionDeclaration(helperDeclaration) || + !helperDeclaration.parameters[0] || + !ts.isIdentifier(helperDeclaration.parameters[0].name) + ) { + continue; + } + const directoryConsumption = analyzeDirectoryConsumer( + helperBody, + helperName, + helperDeclaration.parameters[0].name.text, + ); + if (!directoryConsumption.consumesAllEntries) continue; + const directoryArgument = node.arguments[0]; + if (!directoryArgument) continue; + const directoryPath = evaluatePathExpression(directoryArgument, analysis, projectRoot); + if (directoryPath) { + collectDirectorySourceFiles( + directoryPath, + projectRoot, + consumedFiles, + directoryConsumption.recursivelyTraverses, + ); + } + } +}; + +const evaluateCopySourceExpression = ( + expression: ts.Expression, + loopVariableName: string, + loopVariableValue: string, +): string | undefined => { + const unwrappedExpression = unwrapExpression(expression); + if (ts.isIdentifier(unwrappedExpression)) { + return unwrappedExpression.text === loopVariableName ? loopVariableValue : undefined; + } + if (ts.isStringLiteralLike(unwrappedExpression)) return unwrappedExpression.text; + if (ts.isTemplateExpression(unwrappedExpression)) { + let resolvedTemplate = unwrappedExpression.head.text; + for (const templateSpan of unwrappedExpression.templateSpans) { + const resolvedExpression = evaluateCopySourceExpression( + templateSpan.expression, + loopVariableName, + loopVariableValue, + ); + if (resolvedExpression === undefined) return undefined; + resolvedTemplate += `${resolvedExpression}${templateSpan.literal.text}`; + } + return resolvedTemplate; + } + if ( + ts.isBinaryExpression(unwrappedExpression) && + unwrappedExpression.operatorToken.kind === ts.SyntaxKind.PlusToken + ) { + const leftValue = evaluateCopySourceExpression( + unwrappedExpression.left, + loopVariableName, + loopVariableValue, + ); + const rightValue = evaluateCopySourceExpression( + unwrappedExpression.right, + loopVariableName, + loopVariableValue, + ); + return leftValue === undefined || rightValue === undefined + ? undefined + : `${leftValue}${rightValue}`; + } + return undefined; +}; + +const collectCopiedSourceFiles = ( + analysis: ScriptAnalysis, + projectRoot: string, + consumedFiles: Set, +): void => { + for (const node of analysis.liveNodes) { + if (!ts.isForOfStatement(node) || !analysis.liveNodes.has(node.statement)) continue; + if ( + !ts.isVariableDeclarationList(node.initializer) || + node.initializer.declarations.length !== 1 || + !ts.isIdentifier(node.initializer.declarations[0].name) || + !ts.isIdentifier(unwrapExpression(node.expression)) + ) { + continue; + } + const loopVariableName = node.initializer.declarations[0].name.text; + const arrayIdentifier = unwrapExpression(node.expression); + if (!ts.isIdentifier(arrayIdentifier)) continue; + const arrayDeclaration = findVariableDeclaration( + analysis.sourceFile, + arrayIdentifier.text, + node.getStart(analysis.sourceFile), + ); + if ( + !arrayDeclaration?.initializer || + !ts.isArrayLiteralExpression(arrayDeclaration.initializer) + ) { + continue; + } + const values = arrayDeclaration.initializer.elements.flatMap((element) => { + const unwrappedElement = unwrapExpression(element); + return ts.isStringLiteralLike(unwrappedElement) ? [unwrappedElement.text] : []; + }); + const visitLoopBody = (loopNode: ts.Node): void => { + if ( + ts.isCallExpression(loopNode) && + getPropertyName(loopNode.expression)?.match(/^copyFile(?:Sync)?$/) && + loopNode.arguments[0] + ) { + for (const value of values) { + const sourceReference = evaluateCopySourceExpression( + loopNode.arguments[0], + loopVariableName, + value, + ); + if (!sourceReference) continue; + const sourcePath = resolveBuildReference( + sourceReference, + analysis.scriptFile.workingDirectory, + projectRoot, + ); + if ( + isPathInsideDirectoryOrEqual(sourcePath, projectRoot) && + existsSync(sourcePath) && + statSync(sourcePath).isFile() && + SOURCE_FILE_EXTENSION_PATTERN.test(sourcePath) + ) { + consumedFiles.add(sourcePath); + } + } + } + ts.forEachChild(loopNode, visitLoopBody); + }; + visitLoopBody(node.statement); + } +}; + +const expressionContainsString = (expression: ts.Expression, value: string): boolean => { + let containsString = false; + const visitNode = (node: ts.Node): void => { + if (containsString) return; + if (ts.isStringLiteralLike(node) && node.text === value) { + containsString = true; + return; + } + ts.forEachChild(node, visitNode); + }; + visitNode(expression); + return containsString; +}; + +const collectObjectPathReferences = ( + expression: ts.Expression, + fileReferences: Set, +): void => { + const unwrappedExpression = unwrapExpression(expression); + if (ts.isStringLiteralLike(unwrappedExpression)) { + fileReferences.add(unwrappedExpression.text); + return; + } + if (ts.isArrayLiteralExpression(unwrappedExpression)) { + for (const element of unwrappedExpression.elements) { + collectObjectPathReferences(element, fileReferences); + } + return; + } + if (!ts.isObjectLiteralExpression(unwrappedExpression)) return; + for (const property of unwrappedExpression.properties) { + if (!ts.isPropertyAssignment(property)) continue; + const propertyName = + ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name) + ? property.name.text + : undefined; + if (propertyName === "path") collectObjectPathReferences(property.initializer, fileReferences); + } +}; + +interface StyleRegistryFanoutSources { + registryAnalysis: ScriptAnalysis; + stylesAnalysis: ScriptAnalysis; +} + +const findStyleRegistryFanoutSources = ( + analysis: ScriptAnalysis, + analyses: ReadonlyMap, +): StyleRegistryFanoutSources | undefined => { + for (const node of analysis.liveNodes) { + if ( + !ts.isForOfStatement(node) || + !ts.isVariableDeclarationList(node.initializer) || + node.initializer.declarations.length !== 1 || + !ts.isIdentifier(node.initializer.declarations[0].name) || + !ts.isIdentifier(unwrapExpression(node.expression)) + ) { + continue; + } + const styleVariableName = node.initializer.declarations[0].name.text; + const styleCollectionExpression = unwrapExpression(node.expression); + if (!ts.isIdentifier(styleCollectionExpression)) continue; + const stylesImport = analysis.importedFunctions.get(styleCollectionExpression.text); + const stylesAnalysis = stylesImport ? analyses.get(stylesImport.analysisKey) : undefined; + if (!stylesAnalysis) continue; + + let registryAnalysis: ScriptAnalysis | undefined; + const visitStyleLoop = (loopNode: ts.Node): void => { + if (registryAnalysis || !ts.isForOfStatement(loopNode)) { + if (!registryAnalysis) ts.forEachChild(loopNode, visitStyleLoop); + return; + } + if ( + !ts.isVariableDeclarationList(loopNode.initializer) || + loopNode.initializer.declarations.length !== 1 || + !ts.isIdentifier(loopNode.initializer.declarations[0].name) || + !ts.isIdentifier(unwrapExpression(loopNode.expression)) + ) { + ts.forEachChild(loopNode, visitStyleLoop); + return; + } + const registryItemName = loopNode.initializer.declarations[0].name.text; + const registryCollectionExpression = unwrapExpression(loopNode.expression); + if (!ts.isIdentifier(registryCollectionExpression)) return; + const loopBodyText = loopNode.statement.getText(analysis.sourceFile); + const normalizedLoopBodyText = loopBodyText.replace(/\s/g, ""); + if ( + !normalizedLoopBodyText.includes(`src/registry/\${${styleVariableName}.name}/`) || + !normalizedLoopBodyText.includes(`${registryItemName}.files`) || + !/\$\{[A-Za-z_$][\w$]*\.path\}/.test(normalizedLoopBodyText) + ) { + ts.forEachChild(loopNode, visitStyleLoop); + return; + } + const registryImport = analysis.importedFunctions.get(registryCollectionExpression.text); + registryAnalysis = registryImport ? analyses.get(registryImport.analysisKey) : undefined; + }; + visitStyleLoop(node.statement); + if (registryAnalysis) return { registryAnalysis, stylesAnalysis }; + } + return undefined; +}; + +const collectRegistryFileReferences = ( + registryAnalysis: ScriptAnalysis, + analyses: ReadonlyMap, +): Set => { + const registryFileReferences = new Set(); + const collectRegistryModule = (moduleAnalysis: ScriptAnalysis): void => { + const visitNode = (node: ts.Node): void => { + if (ts.isPropertyAssignment(node)) { + const propertyName = + ts.isIdentifier(node.name) || ts.isStringLiteralLike(node.name) + ? node.name.text + : undefined; + if (propertyName === "files") { + collectObjectPathReferences(node.initializer, registryFileReferences); + } + } + ts.forEachChild(node, visitNode); + }; + visitNode(moduleAnalysis.sourceFile); + }; + collectRegistryModule(registryAnalysis); + for (const importedFunction of registryAnalysis.importedFunctions.values()) { + const importedAnalysis = analyses.get(importedFunction.analysisKey); + if (importedAnalysis) collectRegistryModule(importedAnalysis); + } + return registryFileReferences; +}; + +const collectRegistryStyleNames = (stylesAnalysis: ScriptAnalysis): Set => { + const styleNames = new Set(); + const visitNode = (node: ts.Node): void => { + if ( + ts.isPropertyAssignment(node) && + (ts.isIdentifier(node.name) || ts.isStringLiteralLike(node.name)) && + node.name.text === "name" + ) { + const styleNameExpression = unwrapExpression(node.initializer); + if (ts.isStringLiteralLike(styleNameExpression)) styleNames.add(styleNameExpression.text); + } + ts.forEachChild(node, visitNode); + }; + visitNode(stylesAnalysis.sourceFile); + return styleNames; +}; + +const collectResolvedRegistryFiles = ( + registryFileReferences: ReadonlySet, + styleNames: ReadonlySet, + registryRoot: string, + projectRoot: string, + consumedFiles: Set, +): void => { + for (const styleName of styleNames) { + for (const registryFileReference of registryFileReferences) { + const registryFilePath = resolve(registryRoot, styleName, registryFileReference); + if ( + isPathInsideDirectoryOrEqual(registryFilePath, projectRoot) && + existsSync(registryFilePath) && + statSync(registryFilePath).isFile() + ) { + consumedFiles.add(registryFilePath); + } + } + } +}; + +const collectRegistryMetadataConsumedFiles = ( + analyses: ReadonlyMap, + projectRoot: string, + consumedFiles: Set, +): void => { + for (const analysis of analyses.values()) { + if (!basename(analysis.scriptFile.filePath).includes("registry")) continue; + const hasDynamicRead = [...analysis.liveNodes].some( + (node) => + ts.isCallExpression(node) && + getPropertyName(node.expression)?.match(/^readFile(?:Sync)?$/) && + node.arguments[0] !== undefined && + expressionContainsString(node.arguments[0], "src/registry"), + ); + if (!hasDynamicRead) continue; + const registryImport = analysis.importedFunctions.get("registry"); + const stylesImport = analysis.importedFunctions.get("styles"); + const registryAnalysis = registryImport ? analyses.get(registryImport.analysisKey) : undefined; + const stylesAnalysis = stylesImport ? analyses.get(stylesImport.analysisKey) : undefined; + if (!registryAnalysis || !stylesAnalysis) continue; + + collectResolvedRegistryFiles( + collectRegistryFileReferences(registryAnalysis, analyses), + collectRegistryStyleNames(stylesAnalysis), + resolve(analysis.scriptFile.workingDirectory, "src/registry"), + projectRoot, + consumedFiles, + ); + } + + for (const analysis of analyses.values()) { + const fanoutSources = findStyleRegistryFanoutSources(analysis, analyses); + if (!fanoutSources) continue; + collectResolvedRegistryFiles( + collectRegistryFileReferences(fanoutSources.registryAnalysis, analyses), + collectRegistryStyleNames(fanoutSources.stylesAnalysis), + resolve(analysis.scriptFile.workingDirectory, "src/registry"), + projectRoot, + consumedFiles, + ); + } +}; + +const nodeContainsNode = (rootNode: ts.Node, targetNode: ts.Node): boolean => { + let containsNode = false; + const visitNode = (node: ts.Node): void => { + if (node === targetNode) { + containsNode = true; + return; + } + if (!containsNode) ts.forEachChild(node, visitNode); + }; + visitNode(rootNode); + return containsNode; +}; + +const findAssignedVariableName = ( + analysis: ScriptAnalysis, + targetNode: ts.Node, +): string | undefined => { + let variableName: string | undefined; + let smallestInitializerWidth = Number.POSITIVE_INFINITY; + const visitNode = (node: ts.Node): void => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + nodeContainsNode(node.initializer, targetNode) && + node.initializer.end - node.initializer.pos < smallestInitializerWidth + ) { + variableName = node.name.text; + smallestInitializerWidth = node.initializer.end - node.initializer.pos; + } + ts.forEachChild(node, visitNode); + }; + visitNode(analysis.sourceFile); + return variableName; +}; + +const recursivelyReadsManifestWildcards = ( + analysis: ScriptAnalysis, + readCall: ts.CallExpression, +): boolean => { + const registryVariableName = findAssignedVariableName(analysis, readCall); + if (!registryVariableName) return false; + const hasDirectRecursiveRead = [...analysis.liveNodes].some( + (node) => + ts.isCallExpression(node) && + ts.isIdentifier(unwrapExpression(node.expression)) && + unwrapExpression(node.expression).getText() === "walk" && + node.arguments.length >= 1 && + ts.isIdentifier(unwrapExpression(node.arguments[0])) && + unwrapExpression(node.arguments[0]).getText() === "sourceBase", + ); + const sourceBaseDeclaration = findVariableDeclaration( + analysis.sourceFile, + "sourceBase", + analysis.sourceFile.end, + ); + if ( + hasDirectRecursiveRead && + sourceBaseDeclaration?.initializer && + sourceBaseDeclaration.initializer.getText(analysis.sourceFile).includes(registryVariableName) && + sourceBaseDeclaration.initializer.getText(analysis.sourceFile).includes("split") + ) { + return true; + } + const collectionNames = new Set([registryVariableName]); + for (const node of analysis.liveNodes) { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + ts.isPropertyAccessExpression(unwrapExpression(node.initializer)) + ) { + const propertyAccess = unwrapExpression(node.initializer); + if ( + ts.isPropertyAccessExpression(propertyAccess) && + ts.isIdentifier(propertyAccess.expression) && + collectionNames.has(propertyAccess.expression.text) + ) { + collectionNames.add(node.name.text); + } + } + } + for (const node of analysis.liveNodes) { + if ( + !ts.isForOfStatement(node) || + !ts.isVariableDeclarationList(node.initializer) || + node.initializer.declarations.length !== 1 || + !ts.isIdentifier(node.initializer.declarations[0].name) || + !ts.isIdentifier(unwrapExpression(node.expression)) + ) { + continue; + } + const collectionExpression = unwrapExpression(node.expression); + if (!ts.isIdentifier(collectionExpression) || !collectionNames.has(collectionExpression.text)) { + continue; + } + const componentName = node.initializer.declarations[0].name.text; + let hasRecursiveFileLoop = false; + const visitOuterLoop = (outerNode: ts.Node): void => { + if ( + ts.isForOfStatement(outerNode) && + ts.isPropertyAccessExpression(unwrapExpression(outerNode.expression)) + ) { + const filesExpression = unwrapExpression(outerNode.expression); + if ( + ts.isPropertyAccessExpression(filesExpression) && + ts.isIdentifier(filesExpression.expression) && + filesExpression.expression.text === componentName && + filesExpression.name.text === "files" + ) { + const innerLoopText = outerNode.statement.getText(analysis.sourceFile); + if ( + /\.includes\(\s*["']\*["']\s*\)/.test(innerLoopText) && + /\.split\(\s*["']\*["']\s*\)/.test(innerLoopText) && + /\bwalk\s*\(\s*sourceBase\s*,\s*sourceBase\s*\)/.test(innerLoopText) + ) { + hasRecursiveFileLoop = true; + } + } + } + if (!hasRecursiveFileLoop) ts.forEachChild(outerNode, visitOuterLoop); + }; + visitOuterLoop(node.statement); + if (hasRecursiveFileLoop) return true; + } + return false; +}; + +const collectReferencedManifestFiles = ( + analysis: ScriptAnalysis, + projectRoot: string, + consumedFiles: Set, +): void => { + for (const node of analysis.liveNodes) { + if ( + !ts.isCallExpression(node) || + !getPropertyName(node.expression)?.match(/^readFile(?:Sync)?$/) || + !node.arguments[0] + ) { + continue; + } + const manifestPath = evaluatePathExpression(node.arguments[0], analysis, projectRoot); + if (!manifestPath || basename(manifestPath) !== "registry.json") continue; + if (!isPathInsideDirectoryOrEqual(manifestPath, projectRoot)) continue; + if (!existsSync(manifestPath)) continue; + for (const filePath of expandManifestPaths( + manifestPath, + projectRoot, + recursivelyReadsManifestWildcards(analysis, node), + )) { + consumedFiles.add(filePath); + } + } +}; + +const collectShadcnRegistryFiles = ( + packageJsonPaths: ReadonlyArray, + projectRoot: string, + consumedFiles: Set, +): void => { + for (const packageJsonPath of packageJsonPaths) { + const packageJson = readJson(packageJsonPath); + if (typeof packageJson !== "object" || packageJson === null) continue; + const scripts = Object.entries(packageJson).find(([key]) => key === "scripts")?.[1]; + if (typeof scripts !== "object" || scripts === null) continue; + const invokesShadcnBuild = Object.values(scripts).some( + (command) => typeof command === "string" && /\bshadcn(?:@[^\s]+)?\s+build\b/.test(command), + ); + if (!invokesShadcnBuild) continue; + + const workingDirectory = dirname(packageJsonPath); + const manifestPath = resolve(workingDirectory, "registry.json"); + if (!existsSync(manifestPath)) continue; + for (const filePath of expandManifestPaths(manifestPath, projectRoot)) { + consumedFiles.add(filePath); + } + + const referencedRegistryNames = new Set(); + for (const documentationPath of fg.sync("**/*.{md,mdx}", { + cwd: workingDirectory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.next/**", "public/**"], + deep: BUILD_SCRIPT_PACKAGE_SCAN_MAX_DEPTH, + })) { + const documentationSource = readFileSync(documentationPath, "utf8"); + for (const registryName of extractPreviewRegistryNamesFromMdx(documentationSource)) { + referencedRegistryNames.add(registryName); + } + } + + for (const registryName of referencedRegistryNames) { + const publishedRegistryPath = resolve(workingDirectory, "public/r", `${registryName}.json`); + const publishedRegistry = readJson(publishedRegistryPath); + if ( + typeof publishedRegistry !== "object" || + publishedRegistry === null || + Object.entries(publishedRegistry).find(([key]) => key === "name")?.[1] !== registryName || + !String(Object.entries(publishedRegistry).find(([key]) => key === "type")?.[1]).startsWith( + "registry:", + ) + ) { + continue; + } + const publishedFiles = Object.entries(publishedRegistry).find( + ([key]) => key === "files", + )?.[1]; + if (!Array.isArray(publishedFiles)) continue; + for (const publishedFile of publishedFiles) { + if (typeof publishedFile !== "object" || publishedFile === null) continue; + const sourcePath = Object.entries(publishedFile).find(([key]) => key === "path")?.[1]; + const sourceContent = Object.entries(publishedFile).find(([key]) => key === "content")?.[1]; + if (typeof sourcePath !== "string" || typeof sourceContent !== "string") continue; + const absoluteSourcePath = resolve(workingDirectory, sourcePath); + if ( + isPathInsideDirectoryOrEqual(absoluteSourcePath, projectRoot) && + existsSync(absoluteSourcePath) && + statSync(absoluteSourcePath).isFile() && + SOURCE_FILE_EXTENSION_PATTERN.test(absoluteSourcePath) && + areSourceFilesStructurallyEquivalent( + absoluteSourcePath, + readFileSync(absoluteSourcePath, "utf8"), + sourceContent, + ) + ) { + consumedFiles.add(absoluteSourcePath); + } + } + } + } +}; + +export const extractInvokedBuildScriptPaths = (projectRoot: string): string[] => { + const packageJsonPaths = collectPackageJsonPaths(projectRoot); + return expandInvokedScriptFiles( + extractInvokedScriptFiles(projectRoot, packageJsonPaths), + projectRoot, + ).map((scriptFile) => toPosixPath(scriptFile.filePath)); +}; + +export const extractBuildScriptConsumedFiles = (projectRoot: string): string[] => { + const consumedFiles = new Set(); + const packageJsonPaths = collectPackageJsonPaths(projectRoot); + const invokedScriptFiles = expandInvokedScriptFiles( + extractInvokedScriptFiles(projectRoot, packageJsonPaths), + projectRoot, + ); + for (const invokedScriptFile of invokedScriptFiles) { + consumedFiles.add(invokedScriptFile.filePath); + } + const scriptAnalyses = buildScriptAnalyses(invokedScriptFiles, projectRoot); + + for (const analysis of scriptAnalyses.values()) { + collectRecursiveInputDirectories(analysis, projectRoot, consumedFiles); + collectReferencedManifestFiles(analysis, projectRoot, consumedFiles); + collectCopiedSourceFiles(analysis, projectRoot, consumedFiles); + } + + collectRegistryMetadataConsumedFiles(scriptAnalyses, projectRoot, consumedFiles); + collectShadcnRegistryFiles(packageJsonPaths, projectRoot, consumedFiles); + return [...consumedFiles].map(toPosixPath); +}; diff --git a/packages/core/src/project-analysis/collect/coffee-script-require-entries.ts b/packages/core/src/project-analysis/collect/coffee-script-require-entries.ts new file mode 100644 index 0000000000..1b0535e884 --- /dev/null +++ b/packages/core/src/project-analysis/collect/coffee-script-require-entries.ts @@ -0,0 +1,106 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import fg from "fast-glob"; +import { resolveEntryWithExtensions } from "../utils/resolve-entry-with-extensions.js"; +import { stripCoffeeScriptComment } from "../utils/strip-coffee-script-comment.js"; + +interface CoffeeScriptRequireFactory { + methodName: string; + parameterIndex: number; + requireTemplate: string; + parameterName: string; +} + +const METHOD_PATTERN = /^(\s*)@([A-Za-z_$][\w$]*)\s*=\s*\(([^)]*)\)\s*[-=]>/; +const REQUIRE_PATTERN = /\brequire\s+["']([^"']*#\{([A-Za-z_$][\w$]*)\}[^"']*)["']/; +const CALL_PATTERN = /^\s*@([A-Za-z_$][\w$]*)\s+(.+)$/; +const STRING_ARGUMENT_PATTERN = /(["'])(.*?)\1/g; +const STATIC_REQUIRE_PATTERN = /\brequire(?:\s*\(\s*|\s+)["']([^"'#]+)["']/g; + +const extractFactories = (lines: string[]): CoffeeScriptRequireFactory[] => { + const factories: CoffeeScriptRequireFactory[] = []; + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const methodMatch = METHOD_PATTERN.exec(lines[lineIndex]); + if (!methodMatch) continue; + const methodIndent = methodMatch[1].length; + const parameterNames = methodMatch[3].split(",").map((parameter) => parameter.trim()); + for (let bodyLineIndex = lineIndex + 1; bodyLineIndex < lines.length; bodyLineIndex++) { + const bodyLine = lines[bodyLineIndex]; + if (bodyLine.trim().length === 0) continue; + const bodyIndent = bodyLine.length - bodyLine.trimStart().length; + if (bodyIndent <= methodIndent) break; + const requireMatch = REQUIRE_PATTERN.exec(bodyLine); + if (!requireMatch) continue; + const parameterIndex = parameterNames.indexOf(requireMatch[2]); + if (parameterIndex === -1) continue; + factories.push({ + methodName: methodMatch[2], + parameterIndex, + requireTemplate: requireMatch[1], + parameterName: requireMatch[2], + }); + } + } + return factories; +}; + +const extractStringArguments = (source: string): string[] => { + const argumentsList: string[] = []; + STRING_ARGUMENT_PATTERN.lastIndex = 0; + let argumentMatch: RegExpExecArray | null; + while ((argumentMatch = STRING_ARGUMENT_PATTERN.exec(source)) !== null) { + argumentsList.push(argumentMatch[2]); + } + return argumentsList; +}; + +export const extractCoffeeScriptRequireEntries = (directory: string): string[] => { + const entries = new Set(); + const coffeeScriptPaths = fg.sync("**/*.{coffee,cjsx}", { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], + }); + for (const coffeeScriptPath of coffeeScriptPaths) { + let source = ""; + try { + source = readFileSync(coffeeScriptPath, "utf-8"); + } catch { + continue; + } + const lines = source.split(/\r?\n/).map(stripCoffeeScriptComment); + for (const line of lines) { + STATIC_REQUIRE_PATTERN.lastIndex = 0; + let staticRequireMatch: RegExpExecArray | null; + while ((staticRequireMatch = STATIC_REQUIRE_PATTERN.exec(line)) !== null) { + if (!staticRequireMatch[1].startsWith(".")) continue; + const resolvedEntry = resolveEntryWithExtensions( + resolve(dirname(coffeeScriptPath), staticRequireMatch[1]), + ); + if (resolvedEntry) entries.add(resolvedEntry); + } + } + const factories = extractFactories(lines); + if (factories.length === 0) continue; + for (const line of lines) { + const callMatch = CALL_PATTERN.exec(line); + if (!callMatch) continue; + const stringArguments = extractStringArguments(callMatch[2]); + for (const factory of factories) { + if (factory.methodName !== callMatch[1]) continue; + const parameterValue = stringArguments[factory.parameterIndex]; + if (!parameterValue) continue; + const relativePath = factory.requireTemplate.replace( + `#{${factory.parameterName}}`, + parameterValue, + ); + const resolvedEntry = resolveEntryWithExtensions( + resolve(dirname(coffeeScriptPath), relativePath), + ); + if (resolvedEntry) entries.add(resolvedEntry); + } + } + } + return [...entries]; +}; diff --git a/packages/core/src/project-analysis/collect/config-string-entries.ts b/packages/core/src/project-analysis/collect/config-string-entries.ts new file mode 100644 index 0000000000..67ee22d6fd --- /dev/null +++ b/packages/core/src/project-analysis/collect/config-string-entries.ts @@ -0,0 +1,772 @@ +import { readFileSync } from "node:fs"; +import { dirname, isAbsolute, resolve } from "node:path"; +import fg from "fast-glob"; +import { parseSync } from "oxc-parser"; +import { extractJitiLoadReferences } from "../utils/extract-jiti-load-references.js"; +import { getIdentifierName, isOxcAstNode, type OxcAstNode } from "../utils/oxc-ast-node.js"; +import { resolveEntryWithExtensions } from "../utils/resolve-entry-with-extensions.js"; +import { visitOxcAstWithBindings } from "../utils/visit-oxc-ast-with-bindings.js"; + +const CONFIG_STRING_ENTRY_GLOBS = [ + "webpack.config.{js,ts,mjs,cjs}", + "**/webpack*.config.{js,ts,mjs,cjs,babel.js}", + "**/webpack*.conf.{js,ts,mjs,cjs}", + "**/configs/webpack.config.{js,ts,mjs,cjs,babel.js}", + "**/configs/webpack*.config.{js,ts,mjs,cjs,babel.js}", + "jest.config.{js,ts,mjs,cjs,cts}", + "**/jest.config.{js,ts,mjs,cjs,cts}", + "vitest.config.{js,ts,mjs,mts}", + "**/vitest.config.{js,ts,mjs,mts}", + "**/vitest.*.config.{js,ts,mjs,mts}", + "vite.config.{js,ts,mjs,mts}", + "tailwind.config.{js,ts,cjs,mjs}", + "**/tailwind.config.{js,ts,cjs,mjs}", + "electron.vite.config.{js,ts,mjs}", + "electron-builder.config.{js,ts,cjs}", + "forge.config.{js,ts,cjs,mjs,mts}", + "esbuild*.ts", + "**/esbuild.entrypoints.ts", + "metro.config.{js,ts}", + "playwright.config.{js,ts}", + "cypress.config.{js,ts}", + "rollup.config.{js,ts,mjs,cjs}", + "rollup.*.config.js", + "**/.erb/configs/webpack*.config.{js,ts}", + "**/.erb/configs/webpack.config.*.{js,ts}", + "**/astro-tina-directive/register.js", + "rspack.config.{js,ts,mjs,cjs}", + "rsbuild.config.{js,ts,mjs,cjs}", + ".umirc.{js,ts,mjs,mts,cjs,cts}", + "config/config.{js,ts,mjs,mts,cjs,cts}", + "config/routes*.{js,ts,mjs,mts,cjs,cts}", + "config/router.config.{js,ts,mjs,mts,cjs,cts}", + "**/scripts/build.ts", + "**/scripts/utils/createJestConfig.js", +]; + +const NEXT_CONFIG_LOADER_GLOBS = ["next.config.{js,ts,mjs,mts,cjs,cts}"]; +const PATH_MODULE_NAMES = new Set(["node:path", "path"]); +const SPECIAL_PATH_PROPERTY_NAMES = new Set(["entryPoints", "entrypoint", "environment", "input"]); +const TRANSPARENT_EXPRESSION_TYPES = new Set([ + "ChainExpression", + "ParenthesizedExpression", + "TSAsExpression", + "TSInstantiationExpression", + "TSNonNullExpression", + "TSSatisfiesExpression", + "TSTypeAssertion", +]); + +interface ConfigAstState { + configDirectory: string; + entries: Set; + isUmiRouteModule: boolean; + pathFunctionBindings: Set; + pathNamespaceBindings: Set; + projectRootDirectory: string; +} + +interface ConfigExpressionContext { + initializers: ReadonlyMap; + isRouteCollection: boolean; + shadowedBindings: ReadonlySet; + visitedIdentifiers: ReadonlySet; +} + +interface ConfigAstResult { + hasAccessConfig: boolean; + loadingComponentPaths: string[]; + routeComponentPaths: string[]; +} + +interface PathBindings { + pathFunctionBindings: Set; + pathNamespaceBindings: Set; +} + +const shouldSkipConfigPath = (rawPath: string): boolean => { + if (rawPath.includes("*") || rawPath.includes("?")) return true; + if (rawPath.endsWith(".json") && !rawPath.includes("/src/")) return true; + if (rawPath.startsWith("node:")) return true; + if (rawPath.startsWith("@")) return true; + return false; +}; + +const addResolvedConfigPath = ( + rawPath: string, + configDirectory: string, + projectRootDirectory: string, + entries: Set, +): void => { + if (shouldSkipConfigPath(rawPath)) return; + + const rootDirectory = rawPath.startsWith(".") ? configDirectory : projectRootDirectory; + const normalizedPath = rawPath.startsWith(".") || isAbsolute(rawPath) ? rawPath : `./${rawPath}`; + const absolutePath = resolve(rootDirectory, normalizedPath); + const resolvedEntry = resolveEntryWithExtensions(absolutePath); + if (resolvedEntry) { + entries.add(resolvedEntry); + return; + } + + if (rawPath.startsWith(".")) { + const projectRootResolvedEntry = resolveEntryWithExtensions( + resolve(projectRootDirectory, rawPath), + ); + if (projectRootResolvedEntry) entries.add(projectRootResolvedEntry); + } +}; + +const getStaticString = ( + expression: unknown, + initializers: ReadonlyMap, + visitedIdentifiers = new Set(), + shadowedBindings: ReadonlySet = new Set(), +): string | undefined => { + if (!isOxcAstNode(expression)) return undefined; + if (TRANSPARENT_EXPRESSION_TYPES.has(expression.type)) { + return getStaticString( + expression.expression, + initializers, + visitedIdentifiers, + shadowedBindings, + ); + } + if (expression.type === "Literal" && typeof expression.value === "string") { + return expression.value; + } + if (expression.type === "TemplateLiteral") { + const expressions = Array.isArray(expression.expressions) ? expression.expressions : []; + const quasis = Array.isArray(expression.quasis) ? expression.quasis : []; + if (expressions.length > 0 || quasis.length !== 1 || !isOxcAstNode(quasis[0])) return undefined; + const quasiValue = quasis[0].value; + if (!quasiValue || typeof quasiValue !== "object") return undefined; + const cookedValue = Object.entries(quasiValue).find(([key]) => key === "cooked")?.[1]; + return typeof cookedValue === "string" ? cookedValue : undefined; + } + if (expression.type === "BinaryExpression" && expression.operator === "+") { + const leftValue = getStaticString( + expression.left, + initializers, + visitedIdentifiers, + shadowedBindings, + ); + const rightValue = getStaticString( + expression.right, + initializers, + visitedIdentifiers, + shadowedBindings, + ); + return leftValue === undefined || rightValue === undefined ? undefined : leftValue + rightValue; + } + const identifierName = getIdentifierName(expression); + if (!identifierName || visitedIdentifiers.has(identifierName)) { + return undefined; + } + const initializer = initializers.get(identifierName); + if (shadowedBindings.has(identifierName) && !initializer) return undefined; + return initializer + ? getStaticString( + initializer, + initializers, + new Set(visitedIdentifiers).add(identifierName), + shadowedBindings, + ) + : undefined; +}; + +const getPropertyName = (property: OxcAstNode): string | undefined => { + if (property.type !== "Property" || property.computed === true) return undefined; + const identifierName = getIdentifierName(property.key); + if (identifierName) return identifierName; + return isOxcAstNode(property.key) && + property.key.type === "Literal" && + typeof property.key.value === "string" + ? property.key.value + : undefined; +}; + +const getMemberPath = (expression: unknown): string[] => { + if (!isOxcAstNode(expression)) return []; + const identifierName = getIdentifierName(expression); + if (identifierName) return [identifierName]; + if (expression.type !== "MemberExpression" || expression.computed === true) return []; + const objectPath = getMemberPath(expression.object); + const propertyName = getIdentifierName(expression.property); + return propertyName ? [...objectPath, propertyName] : []; +}; + +const collectPatternBindingNames = (pattern: unknown, bindingNames: Set): void => { + if (!isOxcAstNode(pattern)) return; + const identifierName = getIdentifierName(pattern); + if (identifierName) { + bindingNames.add(identifierName); + return; + } + if (pattern.type === "AssignmentPattern") { + collectPatternBindingNames(pattern.left, bindingNames); + return; + } + if (pattern.type === "RestElement") { + collectPatternBindingNames(pattern.argument, bindingNames); + return; + } + const childValues = + pattern.type === "ArrayPattern" + ? pattern.elements + : pattern.type === "ObjectPattern" + ? pattern.properties + : []; + if (!Array.isArray(childValues)) return; + for (const childValue of childValues) { + if (!isOxcAstNode(childValue)) continue; + collectPatternBindingNames( + childValue.type === "Property" ? childValue.value : childValue.argument, + bindingNames, + ); + } +}; + +const collectVariableInitializers = ( + statements: unknown[], + inheritedInitializers: ReadonlyMap = new Map(), +): Map => { + const initializers = new Map(inheritedInitializers); + for (const statementValue of statements) { + if (!isOxcAstNode(statementValue)) continue; + const statement = + statementValue.type === "ExportNamedDeclaration" && isOxcAstNode(statementValue.declaration) + ? statementValue.declaration + : statementValue; + if (statement.type !== "VariableDeclaration") continue; + const declarations = Array.isArray(statement.declarations) ? statement.declarations : []; + for (const declaration of declarations) { + if (!isOxcAstNode(declaration) || !isOxcAstNode(declaration.init)) continue; + const identifierName = getIdentifierName(declaration.id); + if (identifierName) initializers.set(identifierName, declaration.init); + } + } + return initializers; +}; + +const getRequiredModuleName = (expression: unknown): string | undefined => { + if (!isOxcAstNode(expression) || expression.type !== "CallExpression") return undefined; + if (getIdentifierName(expression.callee) !== "require") return undefined; + const argumentsList = Array.isArray(expression.arguments) ? expression.arguments : []; + return getStaticString(argumentsList[0], new Map()); +}; + +const collectPathBindings = (statements: unknown[]): PathBindings => { + const pathFunctionBindings = new Set(); + const pathNamespaceBindings = new Set(); + for (const statement of statements) { + if (!isOxcAstNode(statement)) continue; + if (statement.type === "ImportDeclaration") { + const moduleName = getStaticString(statement.source, new Map()); + if (!moduleName || !PATH_MODULE_NAMES.has(moduleName)) continue; + const specifiers = Array.isArray(statement.specifiers) ? statement.specifiers : []; + for (const specifier of specifiers) { + if (!isOxcAstNode(specifier)) continue; + const localName = getIdentifierName(specifier.local); + if (!localName) continue; + if ( + specifier.type === "ImportDefaultSpecifier" || + specifier.type === "ImportNamespaceSpecifier" + ) { + pathNamespaceBindings.add(localName); + continue; + } + const importedName = getIdentifierName(specifier.imported); + if (importedName === "join" || importedName === "resolve") { + pathFunctionBindings.add(localName); + } + } + continue; + } + if (statement.type !== "VariableDeclaration") continue; + const declarations = Array.isArray(statement.declarations) ? statement.declarations : []; + for (const declaration of declarations) { + if (!isOxcAstNode(declaration) || !isOxcAstNode(declaration.init)) continue; + const moduleName = getRequiredModuleName(declaration.init); + const identifierName = getIdentifierName(declaration.id); + if (moduleName && PATH_MODULE_NAMES.has(moduleName) && identifierName) { + pathNamespaceBindings.add(identifierName); + continue; + } + if (!moduleName || !PATH_MODULE_NAMES.has(moduleName) || !isOxcAstNode(declaration.id)) { + continue; + } + const properties = + declaration.id.type === "ObjectPattern" && Array.isArray(declaration.id.properties) + ? declaration.id.properties + : []; + for (const property of properties) { + if (!isOxcAstNode(property) || property.type !== "Property") continue; + const importedName = getIdentifierName(property.key); + const localName = getIdentifierName(property.value); + if ((importedName === "join" || importedName === "resolve") && localName) { + pathFunctionBindings.add(localName); + } + } + } + } + return { pathFunctionBindings, pathNamespaceBindings }; +}; + +const isTrustedPathCall = ( + callExpression: OxcAstNode, + state: ConfigAstState, + shadowedBindings: ReadonlySet, +): boolean => { + if (callExpression.type !== "CallExpression" || !isOxcAstNode(callExpression.callee)) { + return false; + } + const directCalleeName = getIdentifierName(callExpression.callee); + if (directCalleeName) { + return ( + state.pathFunctionBindings.has(directCalleeName) && !shadowedBindings.has(directCalleeName) + ); + } + const memberPath = getMemberPath(callExpression.callee); + return ( + memberPath.length === 2 && + state.pathNamespaceBindings.has(memberPath[0]) && + !shadowedBindings.has(memberPath[0]) && + (memberPath[1] === "join" || memberPath[1] === "resolve") + ); +}; + +const evaluatePathExpression = ( + expression: unknown, + state: ConfigAstState, + context: ConfigExpressionContext, +): string | undefined => { + if (!isOxcAstNode(expression)) return undefined; + if (getIdentifierName(expression) === "__dirname" && !context.shadowedBindings.has("__dirname")) { + return state.configDirectory; + } + const staticString = getStaticString(expression, context.initializers); + if (staticString !== undefined) return staticString; + if (!isTrustedPathCall(expression, state, context.shadowedBindings)) return undefined; + const argumentsList = Array.isArray(expression.arguments) ? expression.arguments : []; + const pathSegments = argumentsList.map((argument) => + evaluatePathExpression(argument, state, context), + ); + if (pathSegments.some((segment) => segment === undefined)) return undefined; + const resolvedSegments = pathSegments.flatMap((segment) => (segment ? [segment] : [])); + return resolve(state.configDirectory, ...resolvedSegments); +}; + +const addStaticConfigPath = (rawPath: string, state: ConfigAstState): void => { + const rootDirectoryMarker = "/"; + const normalizedPath = rawPath.startsWith(rootDirectoryMarker) + ? rawPath.slice(rootDirectoryMarker.length) + : rawPath; + if (!normalizedPath.startsWith(".") && !normalizedPath.startsWith("config/")) { + return; + } + addResolvedConfigPath( + normalizedPath, + state.configDirectory, + state.projectRootDirectory, + state.entries, + ); +}; + +const collectStaticStrings = ( + expression: unknown, + state: ConfigAstState, + context: ConfigExpressionContext, +): void => { + if (!isOxcAstNode(expression)) return; + const staticString = getStaticString( + expression, + context.initializers, + new Set(), + context.shadowedBindings, + ); + if (staticString !== undefined) { + addResolvedConfigPath( + staticString, + state.configDirectory, + state.projectRootDirectory, + state.entries, + ); + return; + } + if (expression.type !== "ArrayExpression") return; + const elements = Array.isArray(expression.elements) ? expression.elements : []; + for (const element of elements) { + if (!isOxcAstNode(element)) continue; + collectStaticStrings( + element.type === "SpreadElement" ? element.argument : element, + state, + context, + ); + } +}; + +const collectFunctionBindings = (functionExpression: OxcAstNode): Set => { + const bindingNames = new Set(); + const parameters = Array.isArray(functionExpression.params) ? functionExpression.params : []; + for (const parameter of parameters) collectPatternBindingNames(parameter, bindingNames); + if (!isOxcAstNode(functionExpression.body) || functionExpression.body.type !== "BlockStatement") { + return bindingNames; + } + const statements = Array.isArray(functionExpression.body.body) + ? functionExpression.body.body + : []; + for (const statement of statements) { + if (!isOxcAstNode(statement)) continue; + if (statement.type === "VariableDeclaration") { + const declarations = Array.isArray(statement.declarations) ? statement.declarations : []; + for (const declaration of declarations) { + if (isOxcAstNode(declaration)) collectPatternBindingNames(declaration.id, bindingNames); + } + } else if (statement.type === "FunctionDeclaration" || statement.type === "ClassDeclaration") { + collectPatternBindingNames(statement.id, bindingNames); + } + } + return bindingNames; +}; + +const collectReturnedConfigExpressions = ( + value: unknown, + collectExpression: (expression: unknown) => void, +): void => { + if (Array.isArray(value)) { + for (const child of value) collectReturnedConfigExpressions(child, collectExpression); + return; + } + if (!isOxcAstNode(value)) return; + if (value.type === "ReturnStatement") { + collectExpression(value.argument); + return; + } + if ( + value.type === "ArrowFunctionExpression" || + value.type === "FunctionExpression" || + value.type === "FunctionDeclaration" + ) { + return; + } + for (const child of Object.values(value)) { + collectReturnedConfigExpressions(child, collectExpression); + } +}; + +const collectConfigExpression = ( + expression: unknown, + state: ConfigAstState, + result: ConfigAstResult, + context: ConfigExpressionContext, +): void => { + if (!isOxcAstNode(expression)) return; + if (TRANSPARENT_EXPRESSION_TYPES.has(expression.type)) { + collectConfigExpression(expression.expression, state, result, context); + return; + } + const identifierName = getIdentifierName(expression); + if (identifierName) { + if (context.visitedIdentifiers.has(identifierName)) { + return; + } + const initializer = context.initializers.get(identifierName); + if (context.shadowedBindings.has(identifierName) && !initializer) return; + if (!initializer) return; + collectConfigExpression(initializer, state, result, { + ...context, + visitedIdentifiers: new Set(context.visitedIdentifiers).add(identifierName), + }); + return; + } + const staticString = getStaticString( + expression, + context.initializers, + new Set(), + context.shadowedBindings, + ); + if (staticString !== undefined) { + addStaticConfigPath(staticString, state); + return; + } + if (isTrustedPathCall(expression, state, context.shadowedBindings)) { + const pathValue = evaluatePathExpression(expression, state, context); + if (pathValue) { + addResolvedConfigPath( + pathValue, + state.configDirectory, + state.projectRootDirectory, + state.entries, + ); + } + const argumentsList = Array.isArray(expression.arguments) ? expression.arguments : []; + const firstArgumentPath = getMemberPath(argumentsList[0]); + const trailingPath = getStaticString( + argumentsList[1], + context.initializers, + new Set(), + context.shadowedBindings, + ); + if (trailingPath && firstArgumentPath.join(".") === "webpackPaths.srcRendererPath") { + addResolvedConfigPath( + `src/renderer/${trailingPath}`, + state.configDirectory, + state.projectRootDirectory, + state.entries, + ); + } + if (trailingPath && firstArgumentPath.join(".") === "webpackPaths.srcMainPath") { + addResolvedConfigPath( + `src/main/${trailingPath}`, + state.configDirectory, + state.projectRootDirectory, + state.entries, + ); + } + return; + } + if (expression.type === "ArrayExpression") { + const elements = Array.isArray(expression.elements) ? expression.elements : []; + for (const element of elements) { + if (!isOxcAstNode(element)) continue; + collectConfigExpression( + element.type === "SpreadElement" ? element.argument : element, + state, + result, + context, + ); + } + return; + } + if (expression.type === "ObjectExpression") { + const properties = Array.isArray(expression.properties) ? expression.properties : []; + for (const property of properties) { + if (!isOxcAstNode(property)) continue; + if (property.type === "SpreadElement") { + collectConfigExpression(property.argument, state, result, context); + continue; + } + const propertyName = getPropertyName(property); + if (!propertyName) continue; + if (SPECIAL_PATH_PROPERTY_NAMES.has(propertyName)) { + collectStaticStrings(property.value, state, context); + } + const propertyString = getStaticString( + property.value, + context.initializers, + new Set(), + context.shadowedBindings, + ); + if ( + propertyName === "component" && + context.isRouteCollection && + propertyString && + (propertyString.startsWith("@/") || + (state.isUmiRouteModule && propertyString.startsWith("."))) + ) { + result.routeComponentPaths.push(propertyString); + } + if (propertyName === "loadingComponent" && propertyString) { + result.loadingComponentPaths.push(propertyString); + } + if ( + propertyName === "access" && + isOxcAstNode(property.value) && + property.value.type === "ObjectExpression" + ) { + result.hasAccessConfig = true; + } + collectConfigExpression(property.value, state, result, { + ...context, + isRouteCollection: propertyName === "routes", + }); + } + return; + } + if ( + expression.type === "ArrowFunctionExpression" || + expression.type === "FunctionExpression" || + expression.type === "FunctionDeclaration" + ) { + const shadowedBindings = new Set(context.shadowedBindings); + const functionBindings = collectFunctionBindings(expression); + for (const functionBinding of functionBindings) shadowedBindings.add(functionBinding); + const inheritedInitializers = new Map(context.initializers); + for (const functionBinding of functionBindings) inheritedInitializers.delete(functionBinding); + if (!isOxcAstNode(expression.body)) return; + if (expression.body.type !== "BlockStatement") { + collectConfigExpression(expression.body, state, result, { + ...context, + initializers: inheritedInitializers, + shadowedBindings, + }); + return; + } + const statements = Array.isArray(expression.body.body) ? expression.body.body : []; + const localInitializers = collectVariableInitializers(statements); + const initializers = new Map(inheritedInitializers); + for (const [bindingName, initializer] of localInitializers) { + initializers.set(bindingName, initializer); + } + collectReturnedConfigExpressions(expression.body, (returnedExpression) => { + collectConfigExpression(returnedExpression, state, result, { + ...context, + initializers, + shadowedBindings, + }); + }); + return; + } + if (expression.type === "CallExpression") { + const argumentsList = Array.isArray(expression.arguments) ? expression.arguments : []; + for (const argument of argumentsList) { + collectConfigExpression(argument, state, result, context); + } + return; + } + for (const [key, value] of Object.entries(expression)) { + if (["callee", "key", "type"].includes(key)) continue; + if (Array.isArray(value)) { + for (const childValue of value) { + if (isOxcAstNode(childValue)) collectConfigExpression(childValue, state, result, context); + } + } else if (isOxcAstNode(value)) { + collectConfigExpression(value, state, result, context); + } + } +}; + +const isModuleExportsAssignment = (expression: OxcAstNode): boolean => + expression.type === "AssignmentExpression" && + expression.operator === "=" && + getMemberPath(expression.left).join(".") === "module.exports"; + +const collectConfigAstEntries = ( + content: string, + configPath: string, + projectRootDirectory: string, + entries: Set, + isUmiRouteModule: boolean, +): ConfigAstResult => { + const result: ConfigAstResult = { + hasAccessConfig: false, + loadingComponentPaths: [], + routeComponentPaths: [], + }; + const parsedModule = parseSync(configPath, content, { sourceType: "unambiguous" }); + if (parsedModule.errors.some((error) => error.severity === "Error")) return result; + const statements = parsedModule.program.body; + const topLevelInitializers = collectVariableInitializers(statements); + const { pathFunctionBindings, pathNamespaceBindings } = collectPathBindings(statements); + const state: ConfigAstState = { + configDirectory: dirname(configPath), + entries, + isUmiRouteModule, + pathFunctionBindings, + pathNamespaceBindings, + projectRootDirectory, + }; + const context: ConfigExpressionContext = { + initializers: topLevelInitializers, + isRouteCollection: isUmiRouteModule, + shadowedBindings: new Set(), + visitedIdentifiers: new Set(), + }; + for (const statement of statements) { + if (!isOxcAstNode(statement)) continue; + if (statement.type === "ExportDefaultDeclaration") { + collectConfigExpression(statement.declaration, state, result, context); + } else if ( + statement.type === "ExpressionStatement" && + isOxcAstNode(statement.expression) && + isModuleExportsAssignment(statement.expression) + ) { + collectConfigExpression(statement.expression.right, state, result, context); + } + } + visitOxcAstWithBindings(parsedModule.program, (node, bindingNames) => { + if (node.type === "ImportDeclaration") return false; + if (node.type !== "CallExpression") return; + const calleeName = getIdentifierName(node.callee); + if (calleeName !== "addPreamble" || bindingNames.has("addPreamble")) return; + const argumentsList = Array.isArray(node.arguments) ? node.arguments : []; + collectStaticStrings(argumentsList[0], state, context); + }); + return result; +}; + +export const extractConfigStringReferencedEntries = (directory: string): string[] => { + const entries = new Set(); + let hasDeclaredUmiAccessPlugin = false; + try { + const packageJson = JSON.parse(readFileSync(resolve(directory, "package.json"), "utf-8")); + const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies }; + hasDeclaredUmiAccessPlugin = + "@umijs/plugin-access" in dependencies || "@umijs/preset-ant-design-pro" in dependencies; + } catch {} + + const configPaths = fg.sync(CONFIG_STRING_ENTRY_GLOBS, { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**"], + deep: 6, + }); + const nextConfigPaths = fg.sync(NEXT_CONFIG_LOADER_GLOBS, { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**"], + }); + + for (const configPath of nextConfigPaths) { + try { + const content = readFileSync(configPath, "utf-8"); + for (const loaderPath of extractJitiLoadReferences(content).flatMap((reference) => + reference.path ? [reference.path] : [], + )) { + addResolvedConfigPath(loaderPath, dirname(configPath), directory, entries); + } + } catch { + continue; + } + } + + for (const configPath of configPaths) { + try { + const content = readFileSync(configPath, "utf-8"); + const isUmiRouteModule = + /(?:^|[\\/])config[\\/](?:routes[^\\/]*|router\.config)\.[^\\/]+$/.test(configPath); + const astResult = collectConfigAstEntries( + content, + configPath, + directory, + entries, + isUmiRouteModule, + ); + for (const routeComponentPath of astResult.routeComponentPaths) { + const sourceRelativePath = routeComponentPath.startsWith("@/") + ? `src/${routeComponentPath.slice(2)}` + : `src/pages/${routeComponentPath}`; + addResolvedConfigPath(sourceRelativePath, directory, directory, entries); + } + const isUmiConfig = /(?:^|[\\/])(?:\.umirc\.|config[\\/]config\.)/.test(configPath); + if (isUmiConfig) { + for (const loadingComponentPath of astResult.loadingComponentPaths) { + const sourceRelativePath = loadingComponentPath.startsWith("@/") + ? `src/${loadingComponentPath.slice(2)}` + : `src/${loadingComponentPath.replace(/^\.\//, "")}`; + addResolvedConfigPath(sourceRelativePath, directory, directory, entries); + } + } + if (isUmiConfig && (hasDeclaredUmiAccessPlugin || astResult.hasAccessConfig)) { + addResolvedConfigPath("src/access", directory, directory, entries); + } + } catch { + continue; + } + } + + return [...entries]; +}; diff --git a/packages/core/src/project-analysis/collect/entries.ts b/packages/core/src/project-analysis/collect/entries.ts new file mode 100644 index 0000000000..b4632ef454 --- /dev/null +++ b/packages/core/src/project-analysis/collect/entries.ts @@ -0,0 +1,3288 @@ +import fg from "fast-glob"; +import { parseJSONC, parseTOML, parseYAML } from "confbox"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { readFileSync, existsSync } from "node:fs"; +import { parseSync } from "oxc-parser"; +import ts from "typescript"; +import type { + SourceFile, + ProjectAnalysisConfig, + ResolvedEntries, + ViteProjectScope, +} from "../types.js"; +import { + DEFAULT_EXTENSIONS, + DEFAULT_EXCLUSIONS, + HIDDEN_DIRECTORY_ALLOWLIST, + LEGACY_GRAPH_ONLY_PATTERNS, + SCRIPT_FILE_PATTERN, + SCRIPT_EXTENSIONLESS_FILE_PATTERN, + SCRIPT_CONFIG_FILE_PATTERN, + SHALLOW_WORKSPACE_MAX_DEPTH, +} from "../constants.js"; +import { resolveWorkspaces, detectFrameworkEntries } from "./workspaces.js"; +import type { WorkspacePackage } from "./workspaces.js"; +import { extractExpoConfigPluginEntries } from "./expo-config-plugin-entries.js"; +import { resolveSourcePath } from "../resolver/source-path.js"; +import { findMonorepoRoot } from "../utils/find-monorepo-root.js"; +import { extractConfigStringReferencedEntries } from "./config-string-entries.js"; +import { extractGraphqlCodegenEntries } from "./graphql-codegen-entries.js"; +import { extractTaroPageEntries } from "./taro-page-entries.js"; +import { extractSectionsModuleEntries } from "./sections-module-entries.js"; +import { extractSiblingWorkspaceImportEntries } from "./sibling-workspace-import-entries.js"; +import { extractUmiDvaModelEntries } from "./umi-dva-model-entries.js"; +import { extractCoffeeScriptRequireEntries } from "./coffee-script-require-entries.js"; +import { extractRuntimeConsumedDirectoryFiles } from "./runtime-consumed-directory-files.js"; +import { extractSupabaseFunctionEntries } from "./supabase-function-entries.js"; +import { extractStaticGlobbyEntries } from "./static-globby-entries.js"; +import { extractNetlifyFunctionEntries } from "./netlify-function-entries.js"; +import { extractMuiDocsMetadataEntries } from "./mui-docs-metadata-entries.js"; +import { extractWordPressScriptEntries } from "./wordpress-script-entries.js"; +import { extractReactEmailTemplateEntries } from "./react-email-template-entries.js"; +import { extractPackageJsonEntries, findDefaultIndexEntry } from "./package-json-entries.js"; +import { resolveEntryWithExtensions } from "../utils/resolve-entry-with-extensions.js"; +import { toCanonicalPath } from "../../utils/to-canonical-path.js"; +import { toPosixPath } from "../utils/to-posix-path.js"; +import { extractLocalScriptFileReference } from "../utils/extract-local-script-file-reference.js"; +import { collectExecutableMarkdownFilePaths } from "../utils/collect-executable-markdown-file-paths.js"; +import { collectStringProperties } from "../utils/collect-string-properties.js"; +import { collectHtmlElementAttributes } from "../utils/collect-html-element-attributes.js"; +import { evaluateStaticConfig } from "../utils/evaluate-static-config.js"; +import { extractScriptInvocations } from "../utils/extract-script-binary-names.js"; +import { getIdentifierName, isOxcAstNode, type OxcAstNode } from "../utils/oxc-ast-node.js"; +import { visitOxcAstWithBindings } from "../utils/visit-oxc-ast-with-bindings.js"; + +export const collectSourceFiles = async (config: ProjectAnalysisConfig): Promise => { + const extensions = + config.includeExtensions.length > 0 ? config.includeExtensions : DEFAULT_EXTENSIONS; + + const extensionGlob = + extensions.length === 1 ? `**/*${extensions[0]}` : `**/*{${extensions.join(",")}}`; + + const ignorePatterns = [...DEFAULT_EXCLUSIONS, ...config.ignorePatterns].map(toPosixPath); + const absoluteRoot = resolve(config.rootDir); + + const mainFiles = await fg(extensionGlob, { + cwd: absoluteRoot, + absolute: true, + ignore: ignorePatterns, + dot: false, + onlyFiles: true, + }); + + const allowedHiddenGlobs = HIDDEN_DIRECTORY_ALLOWLIST.flatMap((directory) => [ + `${directory}/**/*{${extensions.join(",")}}`, + `**/${directory}/**/*{${extensions.join(",")}}`, + ]); + const hiddenFiles = + allowedHiddenGlobs.length > 0 + ? await fg(allowedHiddenGlobs, { + cwd: absoluteRoot, + absolute: true, + ignore: ignorePatterns, + dot: true, + onlyFiles: true, + }) + : []; + + const executableMarkdownFiles = collectExecutableMarkdownFilePaths(absoluteRoot, ignorePatterns); + + const files = [ + ...new Set([...mainFiles, ...hiddenFiles, ...executableMarkdownFiles].map(toPosixPath)), + ]; + + const sortedFiles = files.sort(); + + return sortedFiles.map((filePath, fileIndex) => ({ + index: fileIndex, + path: filePath, + })); +}; + +export const getFrameworkExclusions = (rootDir: string): string[] => { + const absoluteRoot = resolve(rootDir); + const workspacePackages = resolveWorkspaces(absoluteRoot).packages; + const directoriesToCheck = [ + absoluteRoot, + ...workspacePackages.map((workspacePackage) => workspacePackage.directory), + ]; + const ignorePatterns: string[] = []; + + for (const directory of directoriesToCheck) { + const packageJsonPath = join(directory, "package.json"); + if (!existsSync(packageJsonPath)) continue; + + let allDependencies: Record = {}; + try { + const content = readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(content); + allDependencies = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.optionalDependencies, + }; + } catch { + continue; + } + + for (const plugin of FRAMEWORK_PATTERNS) { + if (plugin.contentIgnorePatterns && isToolingPluginEnabled(plugin, allDependencies)) { + for (const pattern of plugin.contentIgnorePatterns) { + const absolutePattern = join(directory, pattern); + ignorePatterns.push(absolutePattern); + } + } + } + } + + return ignorePatterns; +}; + +export const resolveEntries = async (config: ProjectAnalysisConfig): Promise => { + const absoluteRoot = resolve(config.rootDir); + + const entryFiles = + config.entryPatterns.length > 0 + ? await fg(config.entryPatterns, { + cwd: absoluteRoot, + absolute: true, + onlyFiles: true, + }) + : []; + + const packageJsonPath = resolve(absoluteRoot, "package.json"); + const packageJsonEntries = await extractPackageJsonEntries(packageJsonPath); + + const workspaceDiscovery = resolveWorkspaces(absoluteRoot); + const workspacePackages = workspaceDiscovery.packages; + const isEntryEligible = (workspacePackage: WorkspacePackage): boolean => { + if (workspaceDiscovery.hasRootLevelWorkspacePatterns) return true; + return workspacePackage.depthFromRoot <= SHALLOW_WORKSPACE_MAX_DEPTH; + }; + + const hasDeclaredWorkspaces = workspacePackages.some( + (workspacePackage) => workspacePackage.isDeclaredWorkspace, + ); + + const workspaceEntries: string[] = []; + const authoritativeWorkspaceEntries: string[] = []; + const workspacePublicAssetFiles: string[] = []; + for (const workspacePackage of workspacePackages) { + const isEligible = isEntryEligible(workspacePackage); + + const shouldRunFrameworkDetection = + workspaceDiscovery.hasRootLevelWorkspacePatterns && hasDeclaredWorkspaces + ? workspacePackage.isDeclaredWorkspace && isEligible + : isEligible; + if (shouldRunFrameworkDetection) { + const workspaceFrameworkEntries = detectFrameworkEntries(workspacePackage.directory); + workspaceEntries.push(...workspaceFrameworkEntries); + authoritativeWorkspaceEntries.push(...workspaceFrameworkEntries); + const workspaceDependencies = readPackageJsonDependencies( + join(workspacePackage.directory, "package.json"), + ); + const hasPublicAssetHost = [ + "next", + "vite", + "gatsby", + "astro", + "nuxt", + "react-scripts", + "@sveltejs/kit", + "@react-router/dev", + "@remix-run/dev", + ].some((dependencyName) => dependencyName in workspaceDependencies); + if (hasPublicAssetHost) { + workspacePublicAssetFiles.push( + ...fg.sync("public/**/*", { + cwd: workspacePackage.directory, + absolute: true, + onlyFiles: true, + }), + ); + } + } + + const shouldExtractEntries = + isEligible && + (workspacePackage.isDeclaredWorkspace || !workspaceDiscovery.hasRootLevelWorkspacePatterns); + if (shouldExtractEntries) { + const workspacePackageJsonPath = resolve(workspacePackage.directory, "package.json"); + const workspacePackageJsonEntries = await extractPackageJsonEntries(workspacePackageJsonPath); + const hasValidEntries = workspacePackageJsonEntries.some((entryPath) => + existsSync(entryPath), + ); + if (hasValidEntries) { + workspaceEntries.push(...workspacePackageJsonEntries); + authoritativeWorkspaceEntries.push(...workspacePackageJsonEntries); + } else { + const defaultFallback = findDefaultIndexEntry(workspacePackage.directory); + if (defaultFallback) { + workspaceEntries.push(defaultFallback); + } + } + } + } + + const frameworkEntries = detectFrameworkEntries(absoluteRoot); + + const entryEligiblePackages = workspacePackages.filter(isEntryEligible); + + const monorepoRootForEntries = findMonorepoRoot(absoluteRoot); + const ancestorPackageJsonRoots = + monorepoRootForEntries && monorepoRootForEntries !== absoluteRoot + ? [monorepoRootForEntries] + : []; + + const scriptEntries = extractScriptEntries(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + scriptEntries.push(...extractScriptEntries(workspacePackage.directory)); + } + for (const ancestorRoot of ancestorPackageJsonRoots) { + for (const entryPath of extractScriptEntries(ancestorRoot)) { + if (entryPath.startsWith(`${absoluteRoot}/`)) scriptEntries.push(entryPath); + } + } + + const webpackEntries = extractWebpackEntryPoints(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + webpackEntries.push(...extractWebpackEntryPoints(workspacePackage.directory)); + } + + const viteProjectScopes = extractViteProjectScopes(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + viteProjectScopes.push(...extractViteProjectScopes(workspacePackage.directory)); + } + const viteEntries = viteProjectScopes.flatMap((viteProjectScope) => viteProjectScope.entryPaths); + + const bundlerConfigEntries = extractBundlerConfigEntryPoints(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + bundlerConfigEntries.push(...extractBundlerConfigEntryPoints(workspacePackage.directory)); + } + + const htmlScriptEntries = extractHtmlScriptEntries(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + htmlScriptEntries.push(...extractHtmlScriptEntries(workspacePackage.directory)); + } + + const allDiscoveredEntries = [ + ...scriptEntries, + ...webpackEntries, + ...viteEntries, + ...bundlerConfigEntries, + ]; + for (const entryPath of allDiscoveredEntries) { + if (entryPath.endsWith(".html") && existsSync(entryPath)) { + htmlScriptEntries.push(...extractScriptTagsFromHtmlFile(entryPath)); + } + } + + const angularEntries = extractAngularEntryPoints(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + angularEntries.push(...extractAngularEntryPoints(workspacePackage.directory)); + } + + const browserExtensionEntries = extractBrowserExtensionEntries(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + browserExtensionEntries.push(...extractBrowserExtensionEntries(workspacePackage.directory)); + } + + const webWorkerEntries = extractWebWorkerEntries(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + webWorkerEntries.push(...extractWebWorkerEntries(workspacePackage.directory)); + } + + const tsConfigIncludeEntries = extractTsConfigIncludeFilesEntries(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + tsConfigIncludeEntries.push(...extractTsConfigIncludeFilesEntries(workspacePackage.directory)); + } + + const configStringEntries = extractConfigStringReferencedEntries(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + configStringEntries.push(...extractConfigStringReferencedEntries(workspacePackage.directory)); + } + + const graphqlCodegenEntries = extractGraphqlCodegenEntries(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + const workspaceGraphqlCodegenEntries = extractGraphqlCodegenEntries(workspacePackage.directory); + graphqlCodegenEntries.schemaEntries.push(...workspaceGraphqlCodegenEntries.schemaEntries); + graphqlCodegenEntries.documentEntries.push(...workspaceGraphqlCodegenEntries.documentEntries); + graphqlCodegenEntries.generatedEntries.push(...workspaceGraphqlCodegenEntries.generatedEntries); + } + + const rootPackageDependencies = readPackageJsonDependencies(join(absoluteRoot, "package.json")); + const taroPageEntries = extractTaroPageEntries(absoluteRoot, rootPackageDependencies); + const expoConfigPluginCollection = extractExpoConfigPluginEntries( + absoluteRoot, + rootPackageDependencies, + absoluteRoot, + false, + ); + const expoConfigPluginEntries = [...expoConfigPluginCollection.filePaths]; + for (const workspacePackage of entryEligiblePackages) { + const workspacePackageDependencies = readPackageJsonDependencies( + join(workspacePackage.directory, "package.json"), + ); + taroPageEntries.push( + ...extractTaroPageEntries(workspacePackage.directory, workspacePackageDependencies), + ); + const workspaceExpoCollection = extractExpoConfigPluginEntries( + workspacePackage.directory, + workspacePackageDependencies, + absoluteRoot, + ); + expoConfigPluginEntries.push(...workspaceExpoCollection.filePaths); + } + + const sectionsModuleEntries = extractSectionsModuleEntries(absoluteRoot); + + const coffeeScriptRequireEntries = extractCoffeeScriptRequireEntries(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + coffeeScriptRequireEntries.push( + ...extractCoffeeScriptRequireEntries(workspacePackage.directory), + ); + } + + const siblingWorkspaceImportEntries = extractSiblingWorkspaceImportEntries(absoluteRoot); + + const wranglerEntries = extractWranglerEntries(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + wranglerEntries.push(...extractWranglerEntries(workspacePackage.directory)); + } + + const testSetupEntries = extractTestSetupFiles(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + testSetupEntries.push(...extractTestSetupFiles(workspacePackage.directory)); + } + + const pluginFileEntries = extractNextConfigPluginFiles(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + pluginFileEntries.push(...extractNextConfigPluginFiles(workspacePackage.directory)); + } + + const testRunnerDiscovery = discoverTestRunnerEntryPoints(absoluteRoot, entryEligiblePackages); + const toolingDiscovery = discoverToolingEntryPoints(absoluteRoot, entryEligiblePackages); + for (const toolingEntry of [...toolingDiscovery.entryFiles]) { + if (toolingEntry.endsWith(".html")) { + toolingDiscovery.entryFiles.push(...extractScriptTagsFromHtmlFile(toolingEntry)); + } + } + const runtimeConsumedDirectoryFiles = extractRuntimeConsumedDirectoryFiles(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + runtimeConsumedDirectoryFiles.push( + ...extractRuntimeConsumedDirectoryFiles(workspacePackage.directory), + ); + } + const umiDvaModelEntries = extractUmiDvaModelEntries(absoluteRoot, rootPackageDependencies); + for (const workspacePackage of entryEligiblePackages) { + umiDvaModelEntries.push( + ...extractUmiDvaModelEntries( + workspacePackage.directory, + readPackageJsonDependencies(join(workspacePackage.directory, "package.json")), + ), + ); + } + const ciEntries = extractCiWorkflowEntries(absoluteRoot); + const supabaseFunctionEntries = extractSupabaseFunctionEntries(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + supabaseFunctionEntries.push(...extractSupabaseFunctionEntries(workspacePackage.directory)); + } + const staticGlobbyEntries = extractStaticGlobbyEntries( + [...packageJsonEntries, ...workspaceEntries, ...frameworkEntries], + absoluteRoot, + ); + const netlifyFunctionEntries = extractNetlifyFunctionEntries(absoluteRoot); + const muiDocsMetadataEntries = extractMuiDocsMetadataEntries(absoluteRoot); + const wordPressScriptEntries = extractWordPressScriptEntries(absoluteRoot); + const reactEmailTemplateEntries = extractReactEmailTemplateEntries(absoluteRoot); + for (const workspacePackage of entryEligiblePackages) { + reactEmailTemplateEntries.push(...extractReactEmailTemplateEntries(workspacePackage.directory)); + } + + const normalizedEntryPathByPath = new Map(); + const normalizeEntryPath = (entryPath: string): string => { + const cachedEntryPath = normalizedEntryPathByPath.get(entryPath); + if (cachedEntryPath) return cachedEntryPath; + const normalizedEntryPath = toPosixPath(toCanonicalPath(entryPath)); + normalizedEntryPathByPath.set(entryPath, normalizedEntryPath); + return normalizedEntryPath; + }; + const testEntries = [ + ...new Set([...testRunnerDiscovery.entryFiles, ...testSetupEntries].map(normalizeEntryPath)), + ]; + const testEntryPathSet = new Set(testEntries); + const productionEntries = [ + ...new Set( + [ + ...entryFiles, + ...packageJsonEntries, + ...workspaceEntries, + ...frameworkEntries, + ...scriptEntries, + ...webpackEntries, + ...viteEntries, + ...bundlerConfigEntries, + ...htmlScriptEntries, + ...angularEntries, + ...browserExtensionEntries, + ...webWorkerEntries, + ...tsConfigIncludeEntries, + ...configStringEntries, + ...graphqlCodegenEntries.schemaEntries, + ...taroPageEntries, + ...expoConfigPluginEntries, + ...sectionsModuleEntries, + ...coffeeScriptRequireEntries, + ...siblingWorkspaceImportEntries, + ...wranglerEntries, + ...pluginFileEntries, + ...toolingDiscovery.entryFiles, + ...umiDvaModelEntries, + ...ciEntries, + ...supabaseFunctionEntries, + ...staticGlobbyEntries, + ...netlifyFunctionEntries, + ...muiDocsMetadataEntries, + ...wordPressScriptEntries, + ...reactEmailTemplateEntries, + ].map(normalizeEntryPath), + ), + ].filter((entryPath) => !testEntryPathSet.has(entryPath)); + const authoritativeProductionEntries = [ + ...new Set( + [ + ...(config.hasExplicitEntryPatterns ? entryFiles : []), + ...packageJsonEntries, + ...authoritativeWorkspaceEntries, + ...frameworkEntries, + ...scriptEntries, + ...webpackEntries, + ...viteEntries, + ...bundlerConfigEntries, + ...htmlScriptEntries, + ...angularEntries, + ...browserExtensionEntries, + ...webWorkerEntries, + ...configStringEntries, + ...graphqlCodegenEntries.schemaEntries, + ...taroPageEntries, + ...expoConfigPluginEntries, + ...sectionsModuleEntries, + ...coffeeScriptRequireEntries, + ...siblingWorkspaceImportEntries, + ...wranglerEntries, + ...pluginFileEntries, + ...toolingDiscovery.entryFiles, + ...umiDvaModelEntries, + ...ciEntries, + ...supabaseFunctionEntries, + ...staticGlobbyEntries, + ...netlifyFunctionEntries, + ...muiDocsMetadataEntries, + ...wordPressScriptEntries, + ...reactEmailTemplateEntries, + ].map(normalizeEntryPath), + ), + ].filter((entryPath) => !testEntryPathSet.has(entryPath)); + const alwaysUsedFiles = [ + ...new Set( + [ + ...toolingDiscovery.alwaysUsedFiles, + ...testRunnerDiscovery.alwaysUsedFiles, + ...runtimeConsumedDirectoryFiles, + ].map(normalizeEntryPath), + ), + ]; + + const externallyConsumedFiles = [ + ...new Set(graphqlCodegenEntries.documentEntries.map(normalizeEntryPath)), + ]; + + const legacyGraphOnlyFiles = fg.sync(LEGACY_GRAPH_ONLY_PATTERNS, { + cwd: absoluteRoot, + absolute: true, + onlyFiles: true, + ignore: [...DEFAULT_EXCLUSIONS, ...config.ignorePatterns], + }); + + const analysisExcludedFiles = [ + ...new Set( + [ + ...graphqlCodegenEntries.generatedEntries, + ...workspacePublicAssetFiles, + ...legacyGraphOnlyFiles, + ].map(normalizeEntryPath), + ), + ]; + + return { + productionEntries, + authoritativeProductionEntries, + explicitProductionEntries: config.hasExplicitEntryPatterns + ? entryFiles.map(normalizeEntryPath) + : [], + testEntries, + alwaysUsedFiles, + externallyConsumedFiles, + analysisExcludedFiles, + viteProjectScopes: [ + ...new Map( + viteProjectScopes.map((viteProjectScope) => [ + viteProjectScope.configPath, + viteProjectScope, + ]), + ).values(), + ], + }; +}; + +const SCRIPT_MULTIPLEXERS = new Set([ + "concurrently", + "run-s", + "run-p", + "npm-run-all", + "npm-run-all2", + "wireit", + "turbo", + "lerna", + "ultra", +]); + +const TSCONFIG_PROJECT_FLAGS = new Set(["--project", "-p"]); + +const CONFIG_LIKE_FLAGS = new Set([ + "--config", + "-c", + "--format", + "--formatter", + "--tsconfig", + "--project", + "-p", + "--setup", + "--global-setup", +]); + +const IGNORED_CLI_TOOLS = new Set([ + "prettier", + "eslint", + "tslint", + "stylelint", + "biome", + "oxlint", + "oxfmt", + "tsc", + "tsup", + "tsdown", + "rollup", + "webpack", + "rimraf", + "del-cli", + "shx", + "cpy-cli", + "cpx", + "echo", + "cat", + "mkdir", + "rm", + "cp", + "mv", + "ls", + "pwd", + "test", + + "husky", + "lint-staged", + "commitlint", + "changeset", + "changesets", + "typedoc", + "api-extractor", + "madge", + "depcheck", + "sort-package-json", + "pnpm", + "npm", + "yarn", + "ni", + "nr", + "nun", + "next", + "nuxt", + "astro", + "vite", + "svelte-kit", + "prisma", + "drizzle-kit", + "formatjs", + "i18next", + "i18next-parser", + "lingui", + "storybook", + "chromatic", + "msw", + "patch-package", + "syncpack", + "manypkg", + "jest", + "vitest", + "mocha", + "ava", + "tap", + "c8", + "nyc", + "playwright", + "cypress", + "puppeteer", + "webdriver", + "sequelize", + "typeorm", + "mikro-orm", + "wait-on", + "start-server-and-test", + "remark", + "markdownlint", + "markdownlint-cli2", + "textlint", + "alex", + "cspell", + "ncu", + "npm-check-updates", + "size-limit", + "bundlewatch", + "dbdocs", + "lobe-i18n", + "lobe-seo", +]); + +const looksLikeFilePath = (token: string): boolean => { + if (token.startsWith("-") || token.includes("${{") || token.includes("://")) return false; + if (token.includes("}}") && !token.includes("{{")) return false; + const hasKnownExtension = + /\.(?:[cm]?[jt]sx?|css|scss|json|yaml|yml|toml|html|mjs|cjs|mts|cts|graphql|gql|mdx|astro|vue|svelte)$/.test( + token, + ); + if (hasKnownExtension) return true; + const hasGlobWithExtension = /\.\{[^}]+\}$/.test(token); + if (hasGlobWithExtension) return true; + if (token.startsWith("./") || token.startsWith("../")) return true; + return token.includes("/") && !token.startsWith("@"); +}; + +const isGlobPattern = (token: string): boolean => { + return token.includes("*") || token.includes("{") || token.includes("?"); +}; + +const extractScriptFileArguments = (scriptCommand: string, directory: string): string[] => { + const entries: string[] = []; + for (const invocation of extractScriptInvocations(scriptCommand)) { + const binaryName = invocation.binaryName; + if (SCRIPT_MULTIPLEXERS.has(binaryName)) continue; + const isNonEntryBinary = IGNORED_CLI_TOOLS.has(binaryName); + const tokens = invocation.argumentValues; + + for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex++) { + const token = tokens[tokenIndex]; + + if (CONFIG_LIKE_FLAGS.has(token)) { + if (tokenIndex + 1 < tokens.length && !tokens[tokenIndex + 1].startsWith("-")) { + const configPath = tokens[tokenIndex + 1]; + if (looksLikeFilePath(configPath)) { + const absoluteConfigPath = resolve(directory, configPath); + if (existsSync(absoluteConfigPath)) { + const isTscProjectFlag = + TSCONFIG_PROJECT_FLAGS.has(token) && + TSCONFIG_PROJECT_PATTERN.test(absoluteConfigPath); + if (isTscProjectFlag) { + entries.push(...expandTsConfigProjectEntries(absoluteConfigPath)); + } else { + entries.push(absoluteConfigPath); + } + } + } + tokenIndex++; + } + continue; + } + + const equalsIndex = token.indexOf("="); + if (equalsIndex > 0 && CONFIG_LIKE_FLAGS.has(token.slice(0, equalsIndex))) { + const configValue = token.slice(equalsIndex + 1); + const flagName = token.slice(0, equalsIndex); + if (configValue && looksLikeFilePath(configValue)) { + const absoluteConfigPath = resolve(directory, configValue); + if (existsSync(absoluteConfigPath)) { + const isTscProjectFlag = + TSCONFIG_PROJECT_FLAGS.has(flagName) && + TSCONFIG_PROJECT_PATTERN.test(absoluteConfigPath); + if (isTscProjectFlag) { + entries.push(...expandTsConfigProjectEntries(absoluteConfigPath)); + } else { + entries.push(absoluteConfigPath); + } + } + } + continue; + } + + if (token.startsWith("-")) continue; + + if (isNonEntryBinary) continue; + + if (!looksLikeFilePath(token)) continue; + + if (isGlobPattern(token)) { + const expandedFiles = fg.sync(token, { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + }); + entries.push(...expandedFiles); + } else { + const absoluteFilePath = resolve(directory, token); + if (existsSync(absoluteFilePath)) { + entries.push(absoluteFilePath); + } else { + const sourcePath = resolveSourcePath(absoluteFilePath, directory); + if (sourcePath) { + entries.push(sourcePath); + } + } + } + } + } + + return entries; +}; + +const EXTENSIONLESS_SCRIPT_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".cjs"]; + +const resolveExtensionlessScriptPath = (basePath: string): string | undefined => { + for (const extension of EXTENSIONLESS_SCRIPT_EXTENSIONS) { + const candidate = basePath + extension; + if (existsSync(candidate)) return candidate; + } + const indexCandidate = resolve(basePath, "index.ts"); + if (existsSync(indexCandidate)) return indexCandidate; + return undefined; +}; + +const parseOxcProgram = (filePath: string, sourceText: string): OxcAstNode | undefined => { + try { + const parsedModule = parseSync(filePath, sourceText, { sourceType: "unambiguous" }); + if (parsedModule.errors.some((error) => error.severity === "Error")) return undefined; + return isOxcAstNode(parsedModule.program) ? parsedModule.program : undefined; + } catch { + return undefined; + } +}; + +const getOxcStaticString = (value: unknown): string | undefined => { + if (!isOxcAstNode(value)) return undefined; + if (value.type === "Literal" && typeof value.value === "string") return value.value; + if ( + value.type === "TemplateLiteral" && + Array.isArray(value.expressions) && + value.expressions.length === 0 && + Array.isArray(value.quasis) && + value.quasis.length === 1 && + isOxcAstNode(value.quasis[0]) && + value.quasis[0].value && + typeof value.quasis[0].value === "object" && + "cooked" in value.quasis[0].value && + typeof value.quasis[0].value.cooked === "string" + ) { + return value.quasis[0].value.cooked; + } + return undefined; +}; + +const extractExtensionlessScriptImports = (scriptPath: string): string[] => { + const entries: string[] = []; + let sourceText = ""; + try { + sourceText = readFileSync(scriptPath, "utf-8"); + } catch { + return entries; + } + const program = parseOxcProgram(scriptPath, sourceText); + if (!program) return entries; + visitOxcAstWithBindings(program, (node, bindingNames) => { + if ( + node.type === "CallExpression" && + getIdentifierName(node.callee) === "require" && + !bindingNames.has("require") && + Array.isArray(node.arguments) && + node.arguments.length === 1 + ) { + const requirePath = getOxcStaticString(node.arguments[0]); + if (requirePath && (requirePath.startsWith("./") || requirePath.startsWith("../"))) { + const resolvedEntry = resolveEntryWithExtensions(resolve(dirname(scriptPath), requirePath)); + if (resolvedEntry) entries.push(resolvedEntry); + } + } + }); + return entries; +}; + +const extractScriptEntries = (directory: string): string[] => { + const packageJsonPath = resolve(directory, "package.json"); + if (!existsSync(packageJsonPath)) return []; + + const entries: string[] = []; + try { + const content = readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(content); + const scripts = packageJson.scripts; + if (scripts && typeof scripts === "object") { + for (const scriptCommand of Object.values(scripts)) { + if (typeof scriptCommand !== "string") continue; + + const localScriptReference = extractLocalScriptFileReference(scriptCommand); + if (localScriptReference) { + const localScriptPath = resolve(directory, localScriptReference); + if (existsSync(localScriptPath)) { + entries.push(...extractExtensionlessScriptImports(localScriptPath)); + } + } + + const match = scriptCommand.match(SCRIPT_FILE_PATTERN); + if (match?.[1]) { + const scriptFilePath = resolve(directory, match[1]); + if (existsSync(scriptFilePath)) { + entries.push(scriptFilePath); + } else { + const sourcePath = resolveSourcePath(scriptFilePath, directory); + if (sourcePath) { + entries.push(sourcePath); + } + } + } else { + const extensionlessMatch = scriptCommand.match(SCRIPT_EXTENSIONLESS_FILE_PATTERN); + if (extensionlessMatch?.[1]) { + const extensionlessPath = extensionlessMatch[1]; + const resolved = resolveExtensionlessScriptPath(resolve(directory, extensionlessPath)); + if (resolved) { + entries.push(resolved); + } + } + } + + const configMatch = scriptCommand.match(SCRIPT_CONFIG_FILE_PATTERN); + if (configMatch?.[1]) { + const configFilePath = resolve(directory, configMatch[1]); + if (existsSync(configFilePath)) { + entries.push(configFilePath); + } else { + const sourcePath = resolveSourcePath(configFilePath, directory); + if (sourcePath) { + entries.push(sourcePath); + } + } + } + + entries.push(...extractScriptFileArguments(scriptCommand, directory)); + } + } + } catch {} + + return entries; +}; + +const extractCiRunCommands = (content: string): string[] => { + const workflow = parseYAML(content); + return collectStringProperties(workflow, "run"); +}; + +const extractCiWorkflowEntries = (rootDir: string): string[] => { + const entries: string[] = []; + const workflowsDir = join(rootDir, ".github", "workflows"); + if (!existsSync(workflowsDir)) return entries; + + // Standalone tool packages vendored under .github (a workflow `cp`s the + // directory and runs `npm run build` inside it) reference their scripts + // through their own package.json, not the workflow yml. + const nestedToolPackageJsonPaths = fg.sync("**/package.json", { + cwd: join(rootDir, ".github"), + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + }); + for (const nestedPackageJsonPath of nestedToolPackageJsonPaths) { + entries.push(...extractScriptEntries(dirname(nestedPackageJsonPath))); + } + + const workflowFiles = fg.sync("*.{yml,yaml}", { + cwd: workflowsDir, + absolute: true, + onlyFiles: true, + }); + + for (const workflowFile of workflowFiles) { + try { + const content = readFileSync(workflowFile, "utf-8"); + const runCommands = extractCiRunCommands(content); + for (const command of runCommands) { + const scriptMatch = command.match(SCRIPT_FILE_PATTERN); + if (scriptMatch?.[1]) { + const scriptFilePath = resolve(rootDir, scriptMatch[1]); + if (existsSync(scriptFilePath)) { + entries.push(scriptFilePath); + } + } + const configMatch = command.match(SCRIPT_CONFIG_FILE_PATTERN); + if (configMatch?.[1]) { + const configFilePath = resolve(rootDir, configMatch[1]); + if (existsSync(configFilePath)) { + entries.push(configFilePath); + } + } + } + } catch {} + } + + return entries; +}; + +interface StaticConfigObject { + [propertyName: string]: unknown; +} + +const isStaticConfigObject = (value: unknown): value is StaticConfigObject => + value !== null && typeof value === "object" && !Array.isArray(value); + +const collectStaticConfigObjects = (value: unknown): StaticConfigObject[] => { + if (Array.isArray(value)) return value.flatMap(collectStaticConfigObjects); + return isStaticConfigObject(value) ? [value] : []; +}; + +const getStaticConfigValue = (value: unknown, propertyPath: string[]): unknown => { + let currentValue = value; + for (const propertyName of propertyPath) { + if (!isStaticConfigObject(currentValue)) return undefined; + currentValue = currentValue[propertyName]; + } + return currentValue; +}; + +const collectStaticStringValues = (value: unknown): string[] => { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.flatMap(collectStaticStringValues); + if (!isStaticConfigObject(value)) return []; + return Object.values(value).flatMap(collectStaticStringValues); +}; + +const extractViteRoot = (config: unknown, configDirectory: string): string => { + const rootValue = collectStaticConfigObjects(config) + .map((configObject) => getStaticConfigValue(configObject, ["root"])) + .find((value): value is string => typeof value === "string"); + return rootValue + ? isAbsolute(rootValue) + ? rootValue + : resolve(configDirectory, rootValue) + : configDirectory; +}; + +const extractViteProjectScopes = (directory: string): ViteProjectScope[] => { + const viteProjectScopes: ViteProjectScope[] = []; + const viteConfigPaths = fg.sync("vite.config.{js,ts,mjs,mts}", { + cwd: directory, + absolute: true, + onlyFiles: true, + }); + + for (const configPath of viteConfigPaths) { + try { + const entries: string[] = []; + const content = readFileSync(configPath, "utf-8"); + const configDirectory = dirname(configPath); + const config = evaluateStaticConfig(content, configPath); + const viteRoot = extractViteRoot(config, configDirectory); + const defaultHtmlEntry = resolve(viteRoot, "index.html"); + if (existsSync(defaultHtmlEntry)) entries.push(defaultHtmlEntry); + const inputPaths = collectStaticConfigObjects(config).flatMap((configObject) => + collectStaticStringValues( + getStaticConfigValue(configObject, ["build", "rollupOptions", "input"]), + ), + ); + for (const entryPath of inputPaths) { + const absoluteEntryPath = isAbsolute(entryPath) + ? entryPath + : resolve(viteRoot, entryPath.replace(/^\//, "")); + if (existsSync(absoluteEntryPath)) entries.push(absoluteEntryPath); + } + viteProjectScopes.push({ + configPath, + configDirectory, + rootDirectory: viteRoot, + entryPaths: entries, + }); + } catch {} + } + + return viteProjectScopes; +}; + +const extractBundlerConfigEntryPoints = (directory: string): string[] => { + const entries: string[] = []; + const configPaths = fg.sync(["tsdown.config.{ts,js,cjs,mjs}", "tsup.config.{ts,js,cjs,mjs}"], { + cwd: directory, + absolute: true, + onlyFiles: true, + }); + + for (const configPath of configPaths) { + try { + const content = readFileSync(configPath, "utf-8"); + const config = evaluateStaticConfig(content, configPath); + const entryPaths = collectStaticConfigObjects(config).flatMap((configObject) => + collectStaticStringValues(getStaticConfigValue(configObject, ["entry"])), + ); + for (const entryPath of entryPaths) { + if (entryPath.includes("*")) { + entries.push( + ...fg.sync(entryPath, { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], + }), + ); + continue; + } + const absoluteEntryPath = isAbsolute(entryPath) ? entryPath : resolve(directory, entryPath); + const resolvedPath = resolveEntryWithExtensions(absoluteEntryPath); + if (resolvedPath) { + entries.push(resolvedPath); + } + } + } catch {} + } + + return entries; +}; + +const extractLiteralWebpackEntries = ( + sourceText: string, + configPath: string, + projectDirectory: string, +): string[] => { + const entries: string[] = []; + const staticConfig = evaluateStaticConfig(sourceText, configPath); + const entryPaths = collectStaticConfigObjects(staticConfig).flatMap((configObject) => + collectStaticStringValues(getStaticConfigValue(configObject, ["entry"])), + ); + for (const entryPath of entryPaths) { + const absoluteEntryPath = isAbsolute(entryPath) + ? entryPath + : resolve(projectDirectory, entryPath); + const resolvedEntry = resolveEntryWithExtensions(absoluteEntryPath); + if (resolvedEntry) entries.push(resolvedEntry); + } + return entries; +}; + +const collectWebpackConfigModules = (configPath: string): string[] => { + const configModulePaths: string[] = []; + const pendingModulePaths = [configPath]; + const visitedModulePaths = new Set(); + + for (let moduleIndex = 0; moduleIndex < pendingModulePaths.length; moduleIndex++) { + const modulePath = pendingModulePaths[moduleIndex]; + if (visitedModulePaths.has(modulePath)) continue; + visitedModulePaths.add(modulePath); + configModulePaths.push(modulePath); + + const sourceFile = ts.createSourceFile( + modulePath, + readFileSync(modulePath, "utf-8"), + ts.ScriptTarget.Latest, + true, + ); + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + const specifier = statement.moduleSpecifier.text; + if (!specifier.startsWith(".")) continue; + const importedModulePath = resolveEntryWithExtensions( + resolve(dirname(modulePath), specifier), + ); + if (importedModulePath) pendingModulePaths.push(importedModulePath); + } + } + + return configModulePaths; +}; + +const extractComputedWebpackEntries = (configPath: string, projectDirectory: string): string[] => { + const entries: string[] = []; + for (const modulePath of collectWebpackConfigModules(configPath)) { + const sourceText = readFileSync(modulePath, "utf-8"); + const sourceFile = ts.createSourceFile(modulePath, sourceText, ts.ScriptTarget.Latest, true); + const program = parseOxcProgram(modulePath, sourceText); + if (program) { + visitOxcAstWithBindings(program, (node, bindingNames) => { + if ( + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression" + ) { + return false; + } + if ( + node.type !== "CallExpression" || + getIdentifierName(node.callee) !== "require" || + bindingNames.has("require") || + !Array.isArray(node.arguments) || + node.arguments.length !== 1 + ) { + return; + } + const requirePath = getOxcStaticString(node.arguments[0]); + if (!requirePath?.startsWith(".")) return; + const resolvedEntry = resolveEntryWithExtensions(resolve(dirname(modulePath), requirePath)); + if (resolvedEntry) entries.push(resolvedEntry); + }); + } + const objectInitializers = new Map(); + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) && + declaration.initializer && + ts.isObjectLiteralExpression(declaration.initializer) + ) { + objectInitializers.set(declaration.name.text, declaration.initializer); + } + } + } + const exportedObjects = sourceFile.statements.flatMap((statement) => { + let exportedExpression: ts.Expression | undefined; + if (ts.isExportAssignment(statement)) { + exportedExpression = statement.expression; + } else if ( + ts.isExpressionStatement(statement) && + ts.isBinaryExpression(statement.expression) && + statement.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(statement.expression.left) && + ts.isIdentifier(statement.expression.left.expression) && + statement.expression.left.expression.text === "module" && + statement.expression.left.name.text === "exports" + ) { + exportedExpression = statement.expression.right; + } + if (!exportedExpression) return []; + if (ts.isObjectLiteralExpression(exportedExpression)) return [exportedExpression]; + if (ts.isIdentifier(exportedExpression)) { + const objectInitializer = objectInitializers.get(exportedExpression.text); + return objectInitializer ? [objectInitializer] : []; + } + return []; + }); + for (const exportedObject of exportedObjects) { + const entryProperty = exportedObject.properties.find( + (property): property is ts.PropertyAssignment => + ts.isPropertyAssignment(property) && + ((ts.isIdentifier(property.name) && property.name.text === "entry") || + (ts.isStringLiteral(property.name) && property.name.text === "entry")), + ); + const pathSegments: string[] = []; + if (entryProperty && ts.isCallExpression(entryProperty.initializer)) { + for (const argument of entryProperty.initializer.arguments) { + if (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument)) { + pathSegments.push(argument.text); + } + } + } + const entryCallExpression = + entryProperty && ts.isCallExpression(entryProperty.initializer) + ? entryProperty.initializer + : undefined; + const entryPathObjectName = + entryCallExpression && + ts.isPropertyAccessExpression(entryCallExpression.expression) && + ts.isIdentifier(entryCallExpression.expression.expression) + ? entryCallExpression.expression.expression.text + : undefined; + const entryPathMethodName = + entryCallExpression && ts.isPropertyAccessExpression(entryCallExpression.expression) + ? entryCallExpression.expression.name.text + : undefined; + if ( + !entryProperty || + !entryCallExpression || + !entryPathObjectName || + !entryPathMethodName || + !/path/i.test(entryPathObjectName) || + pathSegments.length !== entryCallExpression.arguments.length + ) { + continue; + } + const candidatePaths = + entryPathObjectName === "path" && + (entryPathMethodName === "join" || entryPathMethodName === "resolve") + ? [resolve(dirname(modulePath), ...pathSegments)] + : [ + resolve(projectDirectory, entryPathMethodName, ...pathSegments), + resolve(projectDirectory, "src", entryPathMethodName, ...pathSegments), + ]; + for (const candidatePath of candidatePaths) { + const resolvedEntry = resolveEntryWithExtensions(candidatePath); + if (resolvedEntry) entries.push(resolvedEntry); + } + } + } + return entries; +}; + +const extractWebpackEntryPoints = (directory: string): string[] => { + const entries: string[] = []; + const webpackConfigPaths = fg.sync( + [ + "webpack.config.{js,ts,mjs,cjs}", + "**/webpack*.config.{js,ts,mjs,cjs}", + "**/webpack.config*.{js,ts,mjs,cjs}", + "**/webpack*.config*.babel.{js,ts}", + "**/webpack*.conf.{js,ts,mjs,cjs}", + ], + { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + deep: 3, + }, + ); + + for (const configPath of webpackConfigPaths) { + try { + entries.push(...extractComputedWebpackEntries(configPath, directory)); + const content = readFileSync(configPath, "utf-8"); + entries.push(...extractLiteralWebpackEntries(content, configPath, directory)); + } catch {} + } + + return entries; +}; + +const HTML_SCRIPT_SOURCE_EXTENSION_PATTERN = /\.(?:ts|tsx|js|jsx|mts|mjs)$/i; + +const extractHtmlScriptSources = (content: string): string[] => + collectHtmlElementAttributes(content, "script").flatMap((attributes) => { + const source = attributes.get("src")?.split(/[?#]/, 1)[0]; + return source && HTML_SCRIPT_SOURCE_EXTENSION_PATTERN.test(source) ? [source] : []; + }); + +const extractHtmlScriptEntries = (directory: string): string[] => { + const entries: string[] = []; + const htmlFiles = fg.sync(["index.html", "*.html"], { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], + deep: 1, + }); + + for (const htmlPath of htmlFiles) { + try { + const content = readFileSync(htmlPath, "utf-8"); + for (const source of extractHtmlScriptSources(content)) { + const scriptSrc = source.replace(/^\//, ""); + const htmlDirectory = htmlPath.replace(/\/[^/]+$/, ""); + const absoluteScriptPath = resolve(htmlDirectory, scriptSrc); + if (existsSync(absoluteScriptPath)) { + entries.push(absoluteScriptPath); + } + } + } catch {} + } + + return entries; +}; + +const extractScriptTagsFromHtmlFile = (htmlFilePath: string): string[] => { + const entries: string[] = []; + try { + const content = readFileSync(htmlFilePath, "utf-8"); + for (const source of extractHtmlScriptSources(content)) { + const scriptSrc = source.replace(/^\//, ""); + const htmlDirectory = dirname(htmlFilePath); + const absoluteScriptPath = resolve(htmlDirectory, scriptSrc); + if (existsSync(absoluteScriptPath)) { + entries.push(absoluteScriptPath); + } + } + } catch {} + return entries; +}; + +const TSCONFIG_FILENAME_GLOBS = ["tsconfig.json", "tsconfig.*.json"]; +const TSCONFIG_PROJECT_PATTERN = /(?:^|[\\/])tsconfig(?:\.[^.]+)?\.json$/; + +const stripJsoncCommentsLocal = (sourceText: string): string => { + let result = ""; + let insideString = false; + let index = 0; + while (index < sourceText.length) { + const ch = sourceText[index]; + if (insideString) { + if (ch === "\\" && index + 1 < sourceText.length) { + result += sourceText[index] + sourceText[index + 1]; + index += 2; + continue; + } + if (ch === '"') insideString = false; + result += ch; + index++; + continue; + } + if (ch === '"') { + insideString = true; + result += ch; + index++; + continue; + } + if (ch === "/" && index + 1 < sourceText.length) { + if (sourceText[index + 1] === "/") { + while (index < sourceText.length && sourceText[index] !== "\n") index++; + continue; + } + if (sourceText[index + 1] === "*") { + index += 2; + while ( + index + 1 < sourceText.length && + !(sourceText[index] === "*" && sourceText[index + 1] === "/") + ) + index++; + index += 2; + continue; + } + } + result += ch; + index++; + } + return result.replace(/,(\s*[}\]])/g, "$1"); +}; + +const extractTsConfigIncludeFilesEntries = (directory: string): string[] => { + const entries: string[] = []; + const tsconfigPaths = fg.sync(TSCONFIG_FILENAME_GLOBS, { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], + deep: 1, + }); + + for (const tsconfigPath of tsconfigPaths) { + try { + const rawText = readFileSync(tsconfigPath, "utf-8"); + const cleaned = stripJsoncCommentsLocal(rawText); + const tsconfigJson = JSON.parse(cleaned); + const tsconfigDir = dirname(tsconfigPath); + const collectPaths = (rawList: unknown): void => { + if (!Array.isArray(rawList)) return; + for (const item of rawList) { + if (typeof item !== "string") continue; + if (item.includes("*") || item.includes("?")) continue; + const candidatePath = resolve(tsconfigDir, item); + if (existsSync(candidatePath)) { + entries.push(candidatePath); + } + } + }; + collectPaths(tsconfigJson.include); + collectPaths(tsconfigJson.files); + } catch {} + } + + return entries; +}; + +const expandTsConfigProjectEntries = (tsconfigAbsolutePath: string): string[] => { + const entries: string[] = []; + try { + const rawText = readFileSync(tsconfigAbsolutePath, "utf-8"); + const cleaned = stripJsoncCommentsLocal(rawText); + const tsconfigJson = JSON.parse(cleaned); + const tsconfigDir = dirname(tsconfigAbsolutePath); + + if (Array.isArray(tsconfigJson.files)) { + for (const fileItem of tsconfigJson.files) { + if (typeof fileItem !== "string") continue; + const candidatePath = resolve(tsconfigDir, fileItem); + if (existsSync(candidatePath)) entries.push(candidatePath); + } + } + + if (Array.isArray(tsconfigJson.include)) { + for (const includePattern of tsconfigJson.include) { + if (typeof includePattern !== "string") continue; + const expandedFiles = fg.sync(includePattern, { + cwd: tsconfigDir, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], + }); + entries.push(...expandedFiles); + } + } + } catch {} + return entries; +}; + +const extractWranglerEntries = (directory: string): string[] => { + const entries: string[] = []; + const wranglerPaths = fg.sync(["wrangler.toml", "wrangler.json", "wrangler.jsonc"], { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + deep: 1, + }); + + for (const wranglerPath of wranglerPaths) { + try { + const content = readFileSync(wranglerPath, "utf-8"); + const wranglerDir = dirname(wranglerPath); + const workerConfig = wranglerPath.endsWith(".toml") + ? parseTOML(content) + : parseJSONC(content, { allowTrailingComma: true }); + if (!workerConfig || typeof workerConfig !== "object" || Array.isArray(workerConfig)) { + continue; + } + if ("main" in workerConfig && typeof workerConfig.main === "string") { + const candidatePath = resolve(wranglerDir, workerConfig.main); + if (existsSync(candidatePath)) entries.push(candidatePath); + else { + const sourceCandidate = resolveSourcePath(candidatePath, wranglerDir); + if (sourceCandidate) entries.push(sourceCandidate); + } + } + const serviceEntryPoints = collectStringProperties(workerConfig, "entry_point"); + for (const serviceEntryPoint of serviceEntryPoints) { + const candidatePath = resolve(wranglerDir, serviceEntryPoint); + if (existsSync(candidatePath)) entries.push(candidatePath); + } + } catch {} + } + + return entries; +}; + +const WORKER_FILE_GLOBS = [ + "**/*.worker.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", + "**/*.sw.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", + "**/sw.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", + "**/service-worker.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", +]; + +const extractWebWorkerEntries = (directory: string): string[] => { + const workerFiles = fg.sync(WORKER_FILE_GLOBS, { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.next/**", "**/out/**"], + deep: 8, + }); + return workerFiles; +}; + +const collectBrowserExtensionManifestPaths = (manifest: unknown): string[] => { + const candidatePaths: string[] = []; + if (typeof manifest !== "object" || manifest === null) return candidatePaths; + const manifestRecord = manifest as Record; + + const background = manifestRecord.background; + if (typeof background === "object" && background !== null) { + const backgroundRecord = background as Record; + if (typeof backgroundRecord.service_worker === "string") { + candidatePaths.push(backgroundRecord.service_worker); + } + if (typeof backgroundRecord.page === "string") { + candidatePaths.push(backgroundRecord.page); + } + if (typeof backgroundRecord.scripts === "string") { + candidatePaths.push(backgroundRecord.scripts); + } + if (Array.isArray(backgroundRecord.scripts)) { + for (const scriptPath of backgroundRecord.scripts) { + if (typeof scriptPath === "string") candidatePaths.push(scriptPath); + } + } + } + + const contentScripts = manifestRecord.content_scripts; + if (Array.isArray(contentScripts)) { + for (const contentScript of contentScripts) { + if (typeof contentScript !== "object" || contentScript === null) continue; + const contentScriptRecord = contentScript as Record; + if (Array.isArray(contentScriptRecord.js)) { + for (const scriptPath of contentScriptRecord.js) { + if (typeof scriptPath === "string") candidatePaths.push(scriptPath); + } + } + if (Array.isArray(contentScriptRecord.css)) { + for (const stylePath of contentScriptRecord.css) { + if (typeof stylePath === "string") candidatePaths.push(stylePath); + } + } + } + } + + const action = + manifestRecord.action ?? manifestRecord.browser_action ?? manifestRecord.page_action; + if (typeof action === "object" && action !== null) { + const actionRecord = action as Record; + if (typeof actionRecord.default_popup === "string") { + candidatePaths.push(actionRecord.default_popup); + } + } + + if (typeof manifestRecord.devtools_page === "string") { + candidatePaths.push(manifestRecord.devtools_page); + } + if (typeof manifestRecord.options_page === "string") { + candidatePaths.push(manifestRecord.options_page); + } + if (typeof manifestRecord.options_ui === "object" && manifestRecord.options_ui !== null) { + const optionsRecord = manifestRecord.options_ui as Record; + if (typeof optionsRecord.page === "string") { + candidatePaths.push(optionsRecord.page); + } + } + if (typeof manifestRecord.sandbox === "object" && manifestRecord.sandbox !== null) { + const sandboxRecord = manifestRecord.sandbox as Record; + if (Array.isArray(sandboxRecord.pages)) { + for (const pagePath of sandboxRecord.pages) { + if (typeof pagePath === "string") candidatePaths.push(pagePath); + } + } + } + + return candidatePaths; +}; + +const isLikelyBrowserExtensionManifest = (manifest: unknown): boolean => { + if (typeof manifest !== "object" || manifest === null) return false; + const manifestRecord = manifest as Record; + return typeof manifestRecord.manifest_version === "number"; +}; + +const extractBrowserExtensionEntries = (directory: string): string[] => { + const entries: string[] = []; + const manifestPaths = fg.sync( + [ + "manifest.json", + "manifest.*.json", + "src/manifest.json", + "src/manifest.*.json", + "public/manifest.json", + "public/manifest.*.json", + "static/manifest.json", + ], + { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], + deep: 3, + }, + ); + + for (const manifestPath of manifestPaths) { + try { + const content = readFileSync(manifestPath, "utf-8"); + const manifest = JSON.parse(content); + if (!isLikelyBrowserExtensionManifest(manifest)) continue; + + const manifestDir = dirname(manifestPath); + const candidatePaths = collectBrowserExtensionManifestPaths(manifest); + const resolutionRoots = [manifestDir, resolve(manifestDir, ".."), directory]; + + for (const candidatePath of candidatePaths) { + for (const resolutionRoot of resolutionRoots) { + const candidateAbsolutePath = resolve(resolutionRoot, candidatePath); + if (existsSync(candidateAbsolutePath)) { + entries.push(candidateAbsolutePath); + break; + } + const sourceFile = resolveSourcePath(candidateAbsolutePath, resolutionRoot); + if (sourceFile) { + entries.push(sourceFile); + break; + } + } + } + } catch {} + } + + return entries; +}; + +const ANGULAR_ENTRY_KEYS = ["main", "polyfills", "styles"] as const; + +const extractAngularEntryPoints = (directory: string): string[] => { + const entries: string[] = []; + const angularJsonPaths = fg.sync( + ["angular.json", ".angular-cli.json", "**/angular.json", "**/.angular-cli.json"], + { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], + }, + ); + + for (const angularJsonPath of angularJsonPaths) { + try { + const content = readFileSync(angularJsonPath, "utf-8"); + const angularConfig = JSON.parse(content); + const projects = angularConfig.projects ?? {}; + const angularDir = angularJsonPath.replace(/\/[^/]+$/, ""); + + for (const projectConfig of Object.values(projects)) { + const projectRecord = projectConfig as Record; + const architect = projectRecord.architect as + | Record> + | undefined; + if (architect) { + for (const targetConfig of Object.values(architect)) { + const options = targetConfig.options as Record | undefined; + if (!options) continue; + + for (const entryKey of ANGULAR_ENTRY_KEYS) { + const entryValue = options[entryKey]; + if (typeof entryValue === "string") { + const absolutePath = resolve(angularDir, entryValue); + if (existsSync(absolutePath)) { + entries.push(absolutePath); + } + } + if (Array.isArray(entryValue)) { + for (const entryItem of entryValue) { + if (typeof entryItem === "string") { + const absolutePath = resolve(angularDir, entryItem); + if (existsSync(absolutePath)) { + entries.push(absolutePath); + } + } + } + } + } + } + } + + const projectRoot = typeof projectRecord.root === "string" ? projectRecord.root : ""; + const projectDir = resolve(angularDir, projectRoot); + const ngPackagePaths = fg.sync(["ng-package.json", "**/ng-package.json"], { + cwd: projectDir, + absolute: true, + onlyFiles: true, + deep: 2, + ignore: ["**/node_modules/**"], + }); + for (const ngPackagePath of ngPackagePaths) { + try { + const ngContent = readFileSync(ngPackagePath, "utf-8"); + const ngPackage = JSON.parse(ngContent); + const ngDir = ngPackagePath.replace(/\/[^/]+$/, ""); + const libEntry = ngPackage?.lib?.entryFile; + if (typeof libEntry === "string") { + const absoluteEntry = resolve(ngDir, libEntry); + if (existsSync(absoluteEntry)) { + entries.push(absoluteEntry); + } + } + } catch {} + } + } + } catch {} + } + + return entries; +}; + +const NEXT_CONFIG_PLUGIN_EXPORTS_BY_MODULE: ReadonlyMap> = new Map([ + ["next-intl/plugin", new Set(["createNextIntlPlugin"])], + ["@next/mdx", new Set(["createMDX"])], + ["next-contentlayer/hooks", new Set(["withContentlayer"])], + ["next-contentlayer2/hooks", new Set(["withContentlayer"])], + ["@plaiceholder/next", new Set(["withPlaiceholder"])], +]); +const NEXT_INTL_DEFAULT_PATHS = [ + "src/i18n/request.ts", + "src/i18n/request.tsx", + "src/i18n/request.js", + "i18n/request.ts", + "i18n/request.tsx", + "i18n/request.js", + "i18n.ts", + "i18n.tsx", +]; + +const collectNextConfigPluginFileArguments = ( + sourceText: string, + configPath: string, +): readonly [ReadonlyArray, boolean] => { + const program = parseOxcProgram(configPath, sourceText); + if (!program || !Array.isArray(program.body)) return [[], false]; + const pluginNameByLocalBinding = new Map(); + const initializerByLocalBinding = new Map(); + const exportedConfigRoots: OxcAstNode[] = []; + for (const statementValue of program.body) { + if (!isOxcAstNode(statementValue)) continue; + if (statementValue.type === "ImportDeclaration") { + const supportedExports = NEXT_CONFIG_PLUGIN_EXPORTS_BY_MODULE.get( + getOxcStaticString(statementValue.source) ?? "", + ); + if (!supportedExports) continue; + const specifiers = Array.isArray(statementValue.specifiers) ? statementValue.specifiers : []; + for (const specifier of specifiers) { + if (!isOxcAstNode(specifier)) continue; + const localName = getIdentifierName(specifier.local); + if (!localName) continue; + if (specifier.type === "ImportDefaultSpecifier" && supportedExports.size === 1) { + pluginNameByLocalBinding.set(localName, [...supportedExports][0] ?? ""); + } + if (specifier.type === "ImportSpecifier") { + const importedName = getIdentifierName(specifier.imported); + if (importedName && supportedExports.has(importedName)) { + pluginNameByLocalBinding.set(localName, importedName); + } + } + } + continue; + } + if (statementValue.type === "VariableDeclaration") { + const declarations = Array.isArray(statementValue.declarations) + ? statementValue.declarations + : []; + for (const declaration of declarations) { + if (!isOxcAstNode(declaration) || !isOxcAstNode(declaration.init)) continue; + const localName = getIdentifierName(declaration.id); + if (localName) initializerByLocalBinding.set(localName, declaration.init); + if ( + declaration.init.type !== "CallExpression" || + getIdentifierName(declaration.init.callee) !== "require" || + !Array.isArray(declaration.init.arguments) || + declaration.init.arguments.length !== 1 + ) { + continue; + } + const moduleName = getOxcStaticString(declaration.init.arguments[0]); + const supportedExports = moduleName + ? NEXT_CONFIG_PLUGIN_EXPORTS_BY_MODULE.get(moduleName) + : undefined; + if (localName && supportedExports?.size === 1) { + pluginNameByLocalBinding.set(localName, [...supportedExports][0] ?? ""); + } + } + continue; + } + if ( + (statementValue.type === "FunctionDeclaration" || + statementValue.type === "ClassDeclaration") && + getIdentifierName(statementValue.id) + ) { + initializerByLocalBinding.set(getIdentifierName(statementValue.id) ?? "", statementValue); + continue; + } + if ( + statementValue.type === "ExportDefaultDeclaration" && + isOxcAstNode(statementValue.declaration) + ) { + exportedConfigRoots.push(statementValue.declaration); + continue; + } + if ( + statementValue.type === "ExpressionStatement" && + isOxcAstNode(statementValue.expression) && + statementValue.expression.type === "AssignmentExpression" && + isOxcAstNode(statementValue.expression.left) && + statementValue.expression.left.type === "MemberExpression" && + getIdentifierName(statementValue.expression.left.object) === "module" && + getIdentifierName(statementValue.expression.left.property) === "exports" && + isOxcAstNode(statementValue.expression.right) + ) { + exportedConfigRoots.push(statementValue.expression.right); + } + } + const filePaths: string[] = []; + let didCallNextIntlPlugin = false; + let didCallNextIntlPluginWithPath = false; + const visitedInitializers = new Set(); + const visitReachableConfig = (root: OxcAstNode): void => { + visitOxcAstWithBindings( + root, + (node, bindingNames, parentNode) => { + const identifierName = getIdentifierName(node); + const isNonReferenceIdentifier = + parentNode?.type === "MemberExpression" && + parentNode.property === node && + !parentNode.computed; + if (identifierName && !bindingNames.has(identifierName) && !isNonReferenceIdentifier) { + const initializer = initializerByLocalBinding.get(identifierName); + if (initializer && !visitedInitializers.has(initializer)) { + visitedInitializers.add(initializer); + visitReachableConfig(initializer); + } + } + if ( + node.type === "CallExpression" && + Array.isArray(node.arguments) && + isOxcAstNode(node.callee) + ) { + const calleeName = getIdentifierName(node.callee); + const pluginName = calleeName ? pluginNameByLocalBinding.get(calleeName) : undefined; + const filePath = getOxcStaticString(node.arguments[0]); + if (calleeName && pluginName && !bindingNames.has(calleeName)) { + if (filePath !== undefined) filePaths.push(filePath); + if (pluginName === "createNextIntlPlugin") { + didCallNextIntlPlugin = true; + if (filePath !== undefined) didCallNextIntlPluginWithPath = true; + } + } + } + }, + new Set(), + false, + ); + }; + for (const exportedConfigRoot of exportedConfigRoots) { + visitReachableConfig(exportedConfigRoot); + } + return [filePaths, didCallNextIntlPlugin && !didCallNextIntlPluginWithPath]; +}; + +const extractNextConfigPluginFiles = (directory: string): string[] => { + const entries: string[] = []; + const nextConfigPaths = fg.sync(["next.config.{ts,js,mjs,mts}"], { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + }); + + for (const configPath of nextConfigPaths) { + try { + const content = readFileSync(configPath, "utf-8"); + const configDirectory = dirname(configPath); + const [pluginFileArguments, shouldUseNextIntlDefaultPath] = + collectNextConfigPluginFileArguments(content, configPath); + for (const filePath of pluginFileArguments) { + const resolvedPluginPath = resolveEntryWithExtensions(resolve(configDirectory, filePath)); + if (resolvedPluginPath) entries.push(resolvedPluginPath); + } + + if (shouldUseNextIntlDefaultPath) { + for (const defaultPath of NEXT_INTL_DEFAULT_PATHS) { + const absolutePath = resolve(configDirectory, defaultPath); + if (existsSync(absolutePath)) { + entries.push(absolutePath); + break; + } + } + } + } catch {} + } + + return entries; +}; + +const extractJestTestMatchPatterns = (directory: string): string[] => { + const configPaths = fg.sync(["jest.config.{ts,js,mjs,cjs}"], { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + }); + + if (configPaths.length === 0) { + try { + const packageJsonPath = join(directory, "package.json"); + const packageContent = readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(packageContent); + if (packageJson.jest?.testMatch) { + return convertJestTestMatchToGlobs(packageJson.jest.testMatch); + } + } catch {} + return []; + } + + for (const configPath of configPaths) { + try { + const content = readFileSync(configPath, "utf-8"); + const config = evaluateStaticConfig(content, configPath); + const patterns = collectStaticConfigObjects(config).flatMap((configObject) => + collectStaticStringValues(getStaticConfigValue(configObject, ["testMatch"])), + ); + if (patterns.length > 0) { + return convertJestTestMatchToGlobs(patterns); + } + } catch {} + } + return []; +}; + +const convertJestTestMatchToGlobs = (patterns: string[]): string[] => { + return patterns.map((pattern) => { + let converted = pattern.replace(/\/?/g, ""); + converted = converted.replace(/\?\(\*\.\)/g, "*."); + converted = converted.replace(/\?\(([^)]+)\)/g, (_, group: string) => { + const options = group.includes("|") ? group.split("|") : [group]; + return `{${[...options, ""].join(",")}}`; + }); + converted = converted.replace(/\+\(([^)]+)\)/g, (_, group: string) => { + return group.includes("|") ? `{${group.replace(/\|/g, ",")}}` : group; + }); + converted = converted.replace(/\(([^)]+)\)/g, (_, group: string) => { + return group.includes("|") ? `{${group.replace(/\|/g, ",")}}` : group; + }); + return converted; + }); +}; + +const extractVitestIncludePatterns = (directory: string): string[] => { + const configPaths = fg.sync( + [ + "vitest.config.{ts,js,mts,mjs}", + "vitest.web.config.{ts,js,mts,mjs}", + "vite.config.{ts,js,mts,mjs}", + "vite.*.config.{ts,js,mts,mjs}", + ], + { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + }, + ); + + const patterns: string[] = []; + for (const configPath of configPaths) { + try { + const content = readFileSync(configPath, "utf-8"); + const config = evaluateStaticConfig(content, configPath); + patterns.push( + ...collectStaticConfigObjects(config).flatMap((configObject) => + collectStaticStringValues(getStaticConfigValue(configObject, ["test", "include"])), + ), + ); + } catch {} + } + return patterns; +}; + +const TEST_SETUP_PROPERTY_NAMES = [ + "setupFiles", + "setupFilesAfterEnv", + "globalSetup", + "globalTeardown", +]; + +const extractTestSetupFiles = (directory: string): string[] => { + const entries: string[] = []; + const configPaths = fg.sync( + [ + "vitest.config.{ts,js,mts,mjs}", + "vitest.web.config.{ts,js,mts,mjs}", + "vite.config.{ts,js,mts,mjs}", + "jest.config.{ts,js,mjs,cjs}", + "**/vitest.config.{ts,js,mts,mjs}", + ], + { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + deep: 3, + }, + ); + + for (const configPath of configPaths) { + try { + const content = readFileSync(configPath, "utf-8"); + const config = evaluateStaticConfig(content, configPath); + const isJestConfig = basename(configPath).startsWith("jest.config."); + for (const configObject of collectStaticConfigObjects(config)) { + for (const propertyName of TEST_SETUP_PROPERTY_NAMES) { + const propertyPath = isJestConfig ? [propertyName] : ["test", propertyName]; + for (const setupPath of collectStaticStringValues( + getStaticConfigValue(configObject, propertyPath), + )) { + const absolutePath = isAbsolute(setupPath) + ? setupPath + : resolve(dirname(configPath), setupPath); + const resolvedPath = resolveEntryWithExtensions(absolutePath); + if (resolvedPath) entries.push(resolvedPath); + } + } + } + } catch {} + } + + return entries; +}; + +interface TestRunnerDefinition { + enablers: string[]; + configFileActivators: string[]; + entryPatterns: string[]; + fixturePatterns: string[]; + alwaysUsed: string[]; +} + +const TEST_FRAMEWORK_PATTERNS: TestRunnerDefinition[] = [ + { + enablers: ["vitest", "@vitest/runner", "vite-plus"], + configFileActivators: [ + "vitest.config.ts", + "vitest.config.js", + "vitest.config.mts", + "vitest.config.mjs", + ], + entryPatterns: [ + "**/*.test.{ts,tsx,js,jsx}", + "**/*.spec.{ts,tsx,js,jsx}", + "**/__tests__/**/*.{ts,tsx,js,jsx}", + "**/*.bench.{ts,tsx,js,jsx}", + ], + fixturePatterns: [ + "**/__fixtures__/**/*.{ts,tsx,js,jsx,json}", + "**/fixtures/**/*.{ts,tsx,js,jsx,json}", + ], + alwaysUsed: [ + "vitest.config.{ts,js,mts,mjs}", + "vitest.setup.{ts,js}", + "vitest.workspace.{ts,js}", + "**/src/setupTests.{ts,tsx,js,jsx}", + "**/src/test-setup.{ts,tsx,js,jsx}", + ], + }, + { + enablers: ["jest", "@jest/core", "ts-jest", "react-scripts", "react-app-rewired"], + configFileActivators: [ + "jest.config.ts", + "jest.config.js", + "jest.config.mjs", + "jest.config.cjs", + ], + entryPatterns: [ + "**/*.test.{ts,tsx,js,jsx}", + "**/*.spec.{ts,tsx,js,jsx}", + "**/__tests__/**/*.{ts,tsx,js,jsx}", + "**/__mocks__/**/*.{ts,tsx,js,jsx,mjs,cjs}", + ], + fixturePatterns: [ + "**/__fixtures__/**/*.{ts,tsx,js,jsx,json}", + "**/fixtures/**/*.{ts,tsx,js,jsx,json}", + ], + alwaysUsed: ["jest.config.{ts,js,mjs,cjs}", "jest.setup.{ts,js,tsx,jsx}"], + }, + { + enablers: ["@playwright/test", "playwright"], + configFileActivators: ["playwright.config.ts", "playwright.config.js"], + entryPatterns: [ + "**/*.spec.{ts,tsx,js,jsx}", + "**/*.test.{ts,tsx,js,jsx}", + "tests/**/*.{ts,tsx,js,jsx}", + "e2e/**/*.{ts,tsx,js,jsx}", + ], + fixturePatterns: ["**/fixtures/**/*.{ts,tsx,js,jsx,json}"], + alwaysUsed: ["playwright.config.{ts,js}"], + }, + { + enablers: ["mocha"], + configFileActivators: [".mocharc.js", ".mocharc.yaml", ".mocharc.yml", ".mocharc.json"], + entryPatterns: [ + "test/**/*.{ts,tsx,js,jsx}", + "tests/**/*.{ts,tsx,js,jsx}", + "spec/**/*.{ts,tsx,js,jsx}", + "**/*.test.{ts,tsx,js,jsx}", + "**/*.spec.{ts,tsx,js,jsx}", + ], + fixturePatterns: [], + alwaysUsed: [".mocharc.*"], + }, + { + enablers: ["jasmine", "jasmine-core", "jasmine-tagged"], + configFileActivators: ["jasmine.json", "spec/support/jasmine.json"], + entryPatterns: [ + "spec/**/*.{ts,tsx,js,jsx}", + "**/*-spec.{ts,tsx,js,jsx}", + "**/*.spec.{ts,tsx,js,jsx}", + ], + fixturePatterns: ["**/fixtures/**/*.{ts,tsx,js,jsx,json}"], + alwaysUsed: ["jasmine.json", "spec/support/jasmine.json"], + }, + { + enablers: ["ava", "@ava/typescript"], + configFileActivators: ["ava.config.js", "ava.config.cjs", "ava.config.mjs"], + entryPatterns: [ + "test/**/*.{ts,tsx,js,jsx}", + "tests/**/*.{ts,tsx,js,jsx}", + "**/*.test.{ts,tsx,js,jsx}", + "**/*.spec.{ts,tsx,js,jsx}", + ], + fixturePatterns: [], + alwaysUsed: ["ava.config.{js,cjs,mjs}"], + }, + { + enablers: ["cypress"], + configFileActivators: ["cypress.config.ts", "cypress.config.js"], + entryPatterns: [ + "**/*.cy.{ts,tsx,js,jsx}", + "cypress/**/*.{ts,tsx,js,jsx}", + "cypress/support/**/*.{ts,js}", + ], + fixturePatterns: ["**/fixtures/**/*.{ts,tsx,js,jsx,json}"], + alwaysUsed: ["cypress.config.{ts,js}", "cypress.config.*.{ts,js}"], + }, +]; + +interface ToolingPluginDefinition { + enablers: string[]; + enablerPrefixes: string[]; + entryPatterns: string[]; + alwaysUsed: string[]; + contentIgnorePatterns?: string[]; +} + +const JS_TS_COMPONENT_EXTENSIONS = "{ts,tsx,js,jsx}"; +const INERTIA_COMPONENT_EXTENSIONS = "{ts,tsx,js,jsx,vue,svelte}"; +const VIKE_ROUTE_EXTENSIONS = "{ts,tsx,js,jsx,md,mdx}"; + +const FRAMEWORK_PATTERNS: ToolingPluginDefinition[] = [ + { + enablers: ["storybook"], + enablerPrefixes: ["@storybook/"], + entryPatterns: ["**/*.stories.{ts,tsx,js,jsx,mdx}", ".storybook/**/*.{ts,tsx,js,jsx}"], + alwaysUsed: [ + ".storybook/main.{ts,js,mjs,cjs}", + ".storybook/preview.{ts,tsx,js,jsx}", + ".storybook/manager.{ts,tsx,js,jsx}", + ], + }, + { + enablers: ["msw"], + enablerPrefixes: [], + entryPatterns: [ + "mocks/**/*.{ts,tsx,js,jsx}", + "src/mocks/**/*.{ts,tsx,js,jsx}", + "**/mocks/**/*.{ts,tsx,js,jsx}", + ], + alwaysUsed: [], + }, + { + enablers: ["typeorm"], + enablerPrefixes: [], + entryPatterns: [ + "migrations/**/*.{ts,js}", + "src/migrations/**/*.{ts,js}", + "src/migration/**/*.{ts,js}", + "migration/**/*.{ts,js}", + "src/entity/**/*.{ts,js}", + ], + alwaysUsed: ["ormconfig.{ts,js,json}"], + }, + { + enablers: ["knex"], + enablerPrefixes: [], + entryPatterns: ["migrations/**/*.{ts,js}", "seeds/**/*.{ts,js}"], + alwaysUsed: ["knexfile.{ts,js}"], + }, + { + enablers: ["drizzle-orm"], + enablerPrefixes: [], + entryPatterns: ["drizzle/**/*.{ts,js}"], + alwaysUsed: ["drizzle.config.{ts,js,mjs}"], + }, + { + enablers: ["kysely"], + enablerPrefixes: [], + entryPatterns: ["migrations/**/*.{ts,js}", "src/migrations/**/*.{ts,js}"], + alwaysUsed: [], + }, + { + enablers: ["prisma", "@prisma/client"], + enablerPrefixes: [], + entryPatterns: ["prisma/**/*.{ts,js}", "prisma/seed.{ts,js}"], + alwaysUsed: [ + "prisma/schema.prisma", + "schema.prisma", + "prisma/schema/*.prisma", + "prisma.config.{ts,mts,cts,js,mjs,cjs}", + ".config/prisma.{ts,mts,cts,js,mjs,cjs}", + ], + }, + { + enablers: ["@nestjs/core"], + enablerPrefixes: ["@nestjs/"], + entryPatterns: [ + "src/main.ts", + "src/**/*.module.ts", + "src/**/*.controller.ts", + "src/**/*.service.ts", + "src/**/*.guard.ts", + "src/**/*.interceptor.ts", + "src/**/*.pipe.ts", + "src/**/*.filter.ts", + "src/**/*.middleware.ts", + "src/**/*.decorator.ts", + "src/**/*.gateway.ts", + "src/**/*.resolver.ts", + ], + alwaysUsed: ["nest-cli.json"], + }, + { + enablers: ["wrangler"], + enablerPrefixes: ["@cloudflare/"], + entryPatterns: ["src/index.{ts,js}", "src/worker.{ts,js}", "functions/**/*.{ts,js}"], + alwaysUsed: [], + }, + { + enablers: ["gatsby"], + enablerPrefixes: ["gatsby-"], + entryPatterns: [ + "src/pages/**/*.{ts,tsx,js,jsx}", + "src/templates/**/*.{ts,tsx,js,jsx}", + "src/api/**/*.{ts,js}", + ], + alwaysUsed: [ + "gatsby-config.{ts,js,mjs}", + "gatsby-node.{ts,js,mjs}", + "gatsby-browser.{ts,tsx,js,jsx}", + "gatsby-ssr.{ts,tsx,js,jsx}", + ], + }, + { + enablers: ["@angular/core"], + enablerPrefixes: ["@angular/"], + entryPatterns: [ + "src/main.ts", + "src/app/**/*.ts", + "src/environments/**/*.ts", + "src/polyfills.ts", + "src/test.ts", + ], + alwaysUsed: ["angular.json", "**/karma.conf.js"], + }, + { + enablers: [ + "@inertiajs/react", + "@inertiajs/inertia-react", + "@inertiajs/vue3", + "@inertiajs/inertia-vue3", + "@inertiajs/svelte", + "@inertiajs/inertia-svelte", + "@inertiajs/inertia", + ], + enablerPrefixes: [], + entryPatterns: [ + `resources/js/app.${INERTIA_COMPONENT_EXTENSIONS}`, + `resources/js/App.${INERTIA_COMPONENT_EXTENSIONS}`, + `resources/js/Pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, + `resources/js/pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, + `app/frontend/Pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, + `app/frontend/pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, + `app/frontend/entrypoints/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, + `app/javascript/Pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, + `app/javascript/pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, + `frontend/src/Pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, + `frontend/src/pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, + `inertia/Pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, + `inertia/pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, + `src/app.${INERTIA_COMPONENT_EXTENSIONS}`, + `src/App.${INERTIA_COMPONENT_EXTENSIONS}`, + `src/Pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, + `src/pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, + ], + alwaysUsed: [], + }, + { + enablers: ["@redwoodjs/router", "@redwoodjs/web"], + enablerPrefixes: [], + entryPatterns: [ + `web/src/App.${JS_TS_COMPONENT_EXTENSIONS}`, + `web/src/Routes.${JS_TS_COMPONENT_EXTENSIONS}`, + `web/src/index.${JS_TS_COMPONENT_EXTENSIONS}`, + `web/src/layouts/**/*.${JS_TS_COMPONENT_EXTENSIONS}`, + `web/src/pages/**/*.${JS_TS_COMPONENT_EXTENSIONS}`, + ], + alwaysUsed: [], + }, + { + enablers: ["react-scripts", "react-app-rewired"], + enablerPrefixes: [], + entryPatterns: ["src/index.{ts,tsx,js,jsx}"], + alwaysUsed: [ + "src/setupProxy.{ts,tsx,js,jsx}", + "src/setupTests.{ts,tsx,js,jsx}", + "src/reportWebVitals.{ts,tsx,js,jsx}", + "src/react-app-env.d.ts", + ], + }, + { + enablers: ["umi", "@umijs/max"], + enablerPrefixes: [], + entryPatterns: [ + ".umirc.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", + "config/config.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", + "config/config.*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", + "config/routes*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", + "config/router.config.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", + "src/app.{ts,tsx,js,jsx}", + "src/global.{ts,tsx,js,jsx}", + "src/loading.{ts,tsx,js,jsx}", + "src/locales/**/*.{ts,tsx,js,jsx}", + "mock/**/*.{ts,tsx,js,jsx}", + "src/pages/**/*.{ts,tsx,js,jsx}", + ], + alwaysUsed: [], + }, + { + enablers: ["@tarojs/cli", "@tarojs/react", "@tarojs/runtime"], + enablerPrefixes: [], + entryPatterns: [ + "config/index.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", + "src/app.{ts,tsx,js,jsx}", + "src/app.config.{ts,tsx,js,jsx}", + ], + alwaysUsed: [], + }, + { + enablers: [ + "@remix-run/node", + "@remix-run/react", + "@remix-run/cloudflare", + "@react-router/node", + "@react-router/serve", + "@react-router/dev", + ], + enablerPrefixes: ["@remix-run/", "@react-router/"], + entryPatterns: [ + "app/routes/**/*.{ts,tsx,js,jsx}", + "app/root.{ts,tsx,js,jsx}", + "app/entry.client.{ts,tsx,js,jsx}", + "app/entry.server.{ts,tsx,js,jsx}", + "app/routes.{ts,js,mts,mjs}", + "src/routes.{ts,js,mts,mjs}", + ], + alwaysUsed: ["react-router.config.{ts,js,mjs}", "remix.config.{ts,js,mjs}"], + }, + { + enablers: ["@docusaurus/core"], + enablerPrefixes: ["@docusaurus/"], + entryPatterns: [ + "**/*.mdx", + "docs/**/*.{md,mdx}", + "blog/**/*.{md,mdx}", + "versioned_docs/**/*.{md,mdx}", + "src/pages/**/*.{ts,tsx,js,jsx}", + "src/theme/**/*.{ts,tsx,js,jsx}", + "src/theme/**/index.{ts,tsx,js,jsx}", + "plugins/**/*.{ts,js,mjs}", + ], + alwaysUsed: [ + "docusaurus.config.{ts,js,mjs}", + "sidebars.{ts,js,mjs,cjs}", + "sidebar*.{ts,js,mjs,cjs}", + "*-sidebar.{ts,js,mjs,cjs}", + "*-sidebars.{ts,js,mjs,cjs}", + "*Sidebar*.{ts,js,mjs,cjs}", + "*sidebar*.{ts,js,mjs,cjs}", + ], + contentIgnorePatterns: ["versioned_sidebars/**"], + }, + { + enablers: ["fumadocs-core", "fumadocs-ui", "fumadocs-mdx"], + enablerPrefixes: ["fumadocs-"], + entryPatterns: ["content/**/*.{md,mdx}", "content/**/*.{ts,tsx,js,jsx}"], + alwaysUsed: ["source.config.{ts,js,mjs}"], + }, + { + enablers: ["nextra", "nextra-theme-docs", "nextra-theme-blog"], + enablerPrefixes: ["nextra-"], + entryPatterns: ["pages/**/*.{md,mdx}", "src/pages/**/*.{md,mdx}", "content/**/*.{md,mdx}"], + alwaysUsed: [], + }, + { + enablers: ["contentlayer", "contentlayer2", "contentlayer-source-files"], + enablerPrefixes: ["contentlayer"], + entryPatterns: ["content/**/*.{md,mdx}", "posts/**/*.{md,mdx}"], + alwaysUsed: ["contentlayer.config.{ts,js,mjs}"], + }, + { + enablers: ["@graphql-codegen/cli", "@graphql-codegen/core"], + enablerPrefixes: ["@graphql-codegen/"], + entryPatterns: ["**/*.graphql", "**/*.gql"], + alwaysUsed: [ + "codegen.{ts,js,yml,yaml}", + "codegen.config.{ts,js}", + ".graphqlrc.{ts,js,json,yml,yaml}", + "graphql.config.{ts,js,json,yml,yaml}", + ], + }, + { + enablers: ["eslint", "@eslint/js"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: ["eslint.config.{js,mjs,cjs,ts,mts,cts}", ".eslintrc.{js,cjs,mjs,json,yaml,yml}"], + }, + { + enablers: ["prettier"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: [".prettierrc.{js,cjs,mjs,json,yaml,yml}", "prettier.config.{js,mjs,cjs,ts}"], + }, + { + enablers: ["tailwindcss", "@tailwindcss/postcss"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: ["tailwind.config.{ts,js,cjs,mjs}"], + }, + { + enablers: ["postcss"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: ["postcss.config.{ts,js,cjs,mjs}"], + }, + { + enablers: ["typescript"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: ["tsconfig.json", "tsconfig.*.json"], + }, + { + enablers: ["lint-staged"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: [".lintstagedrc.{js,cjs,mjs,json}", "lint-staged.config.{js,mjs,cjs}"], + }, + { + enablers: ["husky"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: [".husky/**/*"], + }, + { + enablers: ["@biomejs/biome"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: ["biome.json", "biome.jsonc"], + }, + { + enablers: ["@commitlint/cli"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: ["commitlint.config.{js,cjs,mjs,ts}", ".commitlintrc.{js,cjs,mjs,json,yaml,yml}"], + }, + { + enablers: ["semantic-release"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: [".releaserc.{js,cjs,mjs,json,yaml,yml}", "release.config.{js,cjs,mjs,ts}"], + }, + { + enablers: ["@changesets/cli"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: [".changeset/**/*"], + }, + { + enablers: ["@mui/internal-bundle-size-checker"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: ["bundle-size-checker.config.{ts,mts,cts,js,mjs,cjs}"], + }, + { + enablers: ["next"], + enablerPrefixes: [], + entryPatterns: [ + "app/**/page.{ts,tsx,js,jsx}", + "app/**/layout.{ts,tsx,js,jsx}", + "app/**/loading.{ts,tsx,js,jsx}", + "app/**/error.{ts,tsx,js,jsx}", + "app/**/not-found.{ts,tsx,js,jsx}", + "app/**/template.{ts,tsx,js,jsx}", + "app/**/default.{ts,tsx,js,jsx}", + "app/**/route.{ts,tsx,js,jsx}", + "app/**/global-error.{ts,tsx,js,jsx}", + "app/**/forbidden.{ts,tsx,js,jsx}", + "app/**/unauthorized.{ts,tsx,js,jsx}", + "app/global-not-found.{ts,tsx,js,jsx}", + "app/**/opengraph-image.{ts,tsx,js,jsx}", + "app/**/twitter-image.{ts,tsx,js,jsx}", + "app/**/icon.{ts,tsx,js,jsx}", + "app/**/apple-icon.{ts,tsx,js,jsx}", + "app/**/manifest.{ts,tsx,js,jsx}", + "app/**/sitemap.{ts,tsx,js,jsx}", + "app/**/robots.{ts,tsx,js,jsx}", + "pages/**/*.{ts,tsx,js,jsx}", + "src/app/**/page.{ts,tsx,js,jsx}", + "src/app/**/layout.{ts,tsx,js,jsx}", + "src/app/**/loading.{ts,tsx,js,jsx}", + "src/app/**/error.{ts,tsx,js,jsx}", + "src/app/**/not-found.{ts,tsx,js,jsx}", + "src/app/**/template.{ts,tsx,js,jsx}", + "src/app/**/default.{ts,tsx,js,jsx}", + "src/app/**/route.{ts,tsx,js,jsx}", + "src/app/**/global-error.{ts,tsx,js,jsx}", + "src/app/**/forbidden.{ts,tsx,js,jsx}", + "src/app/**/unauthorized.{ts,tsx,js,jsx}", + "src/app/global-not-found.{ts,tsx,js,jsx}", + "src/app/**/opengraph-image.{ts,tsx,js,jsx}", + "src/app/**/twitter-image.{ts,tsx,js,jsx}", + "src/app/**/icon.{ts,tsx,js,jsx}", + "src/app/**/apple-icon.{ts,tsx,js,jsx}", + "src/app/**/manifest.{ts,tsx,js,jsx}", + "src/app/**/sitemap.{ts,tsx,js,jsx}", + "src/app/**/robots.{ts,tsx,js,jsx}", + "src/pages/**/*.{ts,tsx,js,jsx}", + "middleware.{ts,js}", + "src/middleware.{ts,js}", + "proxy.{ts,js}", + "src/proxy.{ts,js}", + "instrumentation.{ts,js}", + "instrumentation-client.{ts,js}", + "src/instrumentation.{ts,js}", + "src/instrumentation-client.{ts,js}", + ], + alwaysUsed: [ + "next.config.{ts,js,mjs,mts}", + "next-env.d.ts", + "mdx-components.{ts,tsx,js,jsx}", + "src/mdx-components.{ts,tsx,js,jsx}", + "src/i18n/request.{ts,js}", + "src/i18n/routing.{ts,js}", + "i18n/request.{ts,js}", + "i18n/routing.{ts,js}", + ], + }, + { + enablers: [ + "@tanstack/react-router", + "@tanstack/react-start", + "@tanstack/start", + "@tanstack/solid-router", + "@tanstack/solid-start", + ], + enablerPrefixes: ["@tanstack/router"], + entryPatterns: [ + "src/routes/**/*.{ts,tsx,js,jsx}", + "app/routes/**/*.{ts,tsx,js,jsx}", + "src/server.{ts,tsx,js,jsx}", + "src/client.{ts,tsx,js,jsx}", + "src/router.{ts,tsx,js,jsx}", + "src/routeTree.gen.{ts,js}", + ], + alwaysUsed: ["tsr.config.json", "app.config.{ts,js}"], + }, + { + enablers: ["waku"], + enablerPrefixes: [], + entryPatterns: [ + `src/pages/**/*.${JS_TS_COMPONENT_EXTENSIONS}`, + `src/waku.client.${JS_TS_COMPONENT_EXTENSIONS}`, + `src/waku.server.${JS_TS_COMPONENT_EXTENSIONS}`, + ], + alwaysUsed: [], + }, + { + enablers: ["vike", "vite-plugin-ssr"], + enablerPrefixes: [], + entryPatterns: [ + `pages/**/*.${VIKE_ROUTE_EXTENSIONS}`, + `renderer/**/*.${JS_TS_COMPONENT_EXTENSIONS}`, + `src/pages/**/*.${VIKE_ROUTE_EXTENSIONS}`, + `src/renderer/**/*.${JS_TS_COMPONENT_EXTENSIONS}`, + ], + alwaysUsed: [], + }, + { + enablers: ["rakkasjs"], + enablerPrefixes: [], + entryPatterns: [ + `src/client.${JS_TS_COMPONENT_EXTENSIONS}`, + `src/server.${JS_TS_COMPONENT_EXTENSIONS}`, + `src/routes/**/*.${JS_TS_COMPONENT_EXTENSIONS}`, + ], + alwaysUsed: [], + }, + { + enablers: [ + "@module-federation/enhanced", + "@module-federation/node", + "@module-federation/vite", + "@originjs/vite-plugin-federation", + ], + enablerPrefixes: [], + entryPatterns: [ + "federation.config.{ts,js,mjs,cjs,mts,cts}", + "module-federation.config.{ts,js,mjs,cjs,mts,cts}", + ], + alwaysUsed: [], + }, + { + enablers: [ + "vite", + "rolldown-vite", + "vite-plus", + "@voidzero-dev/vite-plus-core", + "@voidzero-dev/vite-plus-test", + ], + enablerPrefixes: ["@vitejs/", "@voidzero-dev/vite-plus"], + entryPatterns: ["src/main.{ts,tsx,js,jsx}", "src/index.{ts,tsx,js,jsx}", "index.html"], + alwaysUsed: ["vite.config.{ts,js,mts,mjs}"], + }, + { + enablers: ["vue", "@vue/cli-service"], + enablerPrefixes: ["@vue/"], + entryPatterns: ["src/main.{ts,js}", "src/App.vue"], + alwaysUsed: ["vue.config.{ts,js,mjs,cjs}"], + }, + { + enablers: ["nuxt", "nuxt3"], + enablerPrefixes: ["@nuxt/"], + entryPatterns: [ + "pages/**/*.vue", + "layouts/**/*.vue", + "components/**/*.vue", + "composables/**/*.{ts,js}", + "plugins/**/*.{ts,js}", + "middleware/**/*.{ts,js}", + "server/**/*.{ts,js}", + "app.vue", + ], + alwaysUsed: ["nuxt.config.{ts,js,mjs}"], + }, + { + enablers: ["svelte", "@sveltejs/kit"], + enablerPrefixes: ["@sveltejs/"], + entryPatterns: [ + "src/routes/**/*.svelte", + "src/lib/**/*.svelte", + "src/routes/**/+page.{ts,js,svelte}", + "src/routes/**/+layout.{ts,js,svelte}", + "src/routes/**/+server.{ts,js}", + ], + alwaysUsed: ["svelte.config.{ts,js,mjs}"], + }, + { + enablers: ["webpack", "webpack-cli"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: ["webpack.config.{ts,js,mjs,cjs}", "webpack.*.config.{ts,js,mjs,cjs}"], + }, + { + enablers: ["rollup"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: ["rollup.config.{ts,js,mjs,cjs}", "rollup.*.config.{ts,js,mjs,cjs}"], + }, + { + enablers: ["@rspack/core", "@rspack/cli"], + enablerPrefixes: ["@rspack/"], + entryPatterns: ["src/index.{ts,tsx,js,jsx}"], + alwaysUsed: ["rspack.config.{ts,js,mjs,cjs}", "rspack.*.config.{ts,js,mjs,cjs}"], + }, + { + enablers: ["@rsbuild/core"], + enablerPrefixes: ["@rsbuild/"], + entryPatterns: ["src/index.{ts,tsx,js,jsx}"], + alwaysUsed: ["rsbuild.config.{ts,js,mjs,cjs}"], + }, + { + enablers: ["tsup"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: ["tsup.config.{ts,js,cjs,mjs}"], + }, + { + enablers: ["tsdown"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: ["tsdown.config.{ts,js,cjs,mjs}"], + }, + { + enablers: ["@trigger.dev/sdk"], + enablerPrefixes: ["@trigger.dev/"], + entryPatterns: [], + alwaysUsed: ["trigger.config.{ts,js,mjs,mts}"], + }, + { + enablers: ["@swc/core"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: [".swcrc"], + }, + { + enablers: ["@babel/core"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: ["babel.config.{js,cjs,mjs,json}", ".babelrc.{js,cjs,mjs,json}"], + }, + { + enablers: ["sanity", "@sanity/cli"], + enablerPrefixes: ["@sanity/"], + entryPatterns: [], + alwaysUsed: ["sanity.config.{ts,js}", "sanity.cli.{ts,js}"], + }, + { + enablers: ["astro"], + enablerPrefixes: ["@astrojs/"], + entryPatterns: [ + "src/pages/**/*.{astro,ts,tsx,js,jsx,mts,mjs,cts,cjs,md,mdx}", + "src/content/**/*.{ts,js,mts,mjs,cts,cjs,md,mdx}", + "src/layouts/**/*.astro", + "src/middleware.{js,ts,mjs,mts,cjs,cts}", + "src/middleware/index.{js,ts,mjs,mts,cjs,cts}", + "src/actions/index.{js,ts,mjs,mts,cjs,cts}", + ], + alwaysUsed: [ + "astro.config.{ts,js,mjs,cjs}", + "src/content/config.{js,ts,mjs,mts,cjs,cts}", + "src/content.config.{js,ts,mjs,mts,cjs,cts}", + "src/live.config.{js,ts,mjs,mts,cjs,cts}", + ], + }, + { + enablers: ["i18next", "react-i18next", "vue-i18n", "next-i18next"], + enablerPrefixes: [], + entryPatterns: [ + "src/i18n.{ts,js,mjs}", + "src/i18n/index.{ts,js}", + "i18n.{ts,js,mjs}", + "i18n/index.{ts,js}", + ], + alwaysUsed: [ + "src/i18n.{ts,js,mjs}", + "src/i18n/index.{ts,js}", + "i18n.{ts,js,mjs}", + "i18n/index.{ts,js}", + "i18next.config.{js,ts,mjs}", + "next-i18next.config.{js,mjs}", + "locales/**/*.json", + "public/locales/**/*.json", + "src/locales/**/*.json", + ], + }, + { + enablers: ["turbo"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: ["turbo.json", "turbo/generators/config.{ts,js}"], + }, + { + enablers: ["@sentry/nextjs", "@sentry/react", "@sentry/node", "@sentry/browser"], + enablerPrefixes: ["@sentry/"], + entryPatterns: [], + alwaysUsed: [ + "sentry.client.config.{ts,js,mjs}", + "sentry.server.config.{ts,js,mjs}", + "sentry.edge.config.{ts,js,mjs}", + ], + }, + { + enablers: ["nodemon"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: ["nodemon.json", ".nodemonrc", ".nodemonrc.{json,yml,yaml}"], + }, + { + enablers: ["nx"], + enablerPrefixes: ["@nx/"], + entryPatterns: [], + alwaysUsed: ["nx.json", "**/project.json"], + }, + { + enablers: ["react-native"], + enablerPrefixes: ["@react-native/", "@react-native-community/"], + entryPatterns: ["index.{ts,tsx,js,jsx}", "App.{ts,tsx,js,jsx}", "src/App.{ts,tsx,js,jsx}"], + alwaysUsed: ["metro.config.{ts,js}", "react-native.config.{ts,js}", "app.json"], + }, + { + enablers: ["expo"], + enablerPrefixes: ["@expo/"], + entryPatterns: [ + "App.{ts,tsx,js,jsx}", + "app/_layout.{ts,tsx,js,jsx}", + "app/index.{ts,tsx,js,jsx}", + ], + alwaysUsed: ["app.json", "app.config.{ts,mts,cts,js,mjs,cjs}"], + }, + { + enablers: ["wrangler"], + enablerPrefixes: ["@cloudflare/"], + entryPatterns: ["src/index.{ts,js}", "src/worker.{ts,js}", "functions/**/*.{ts,js}"], + alwaysUsed: ["wrangler.toml", "wrangler.json", "wrangler.jsonc"], + }, + { + enablers: [ + "electron", + "electron-builder", + "@electron-forge/cli", + "electron-vite", + "electron-webpack", + "electron-next", + ], + enablerPrefixes: ["@electron-forge/", "@electron/"], + entryPatterns: [ + "src/main/**/*.{ts,tsx,js,jsx}", + "src/preload/**/*.{ts,tsx,js,jsx}", + "electron/main.{ts,js}", + "main/index.{ts,tsx,js,jsx}", + "renderer/pages/**/*.{ts,tsx,js,jsx}", + "static/index.html", + ], + alwaysUsed: [ + "electron-builder.{yml,yaml,json,json5,toml}", + "forge.config.{ts,js,cjs}", + "electron.vite.config.{ts,js,mjs}", + ], + }, + + { + enablers: ["lefthook"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: ["lefthook.yml", "lefthook.yaml", ".lefthook.yml"], + }, + { + enablers: ["syncpack"], + enablerPrefixes: [], + entryPatterns: [], + alwaysUsed: [".syncpackrc", ".syncpackrc.{json,yaml,yml}", "syncpack.config.{js,mjs,cjs}"], + }, + + { + enablers: ["@capacitor/core", "@capacitor/cli"], + enablerPrefixes: ["@capacitor/"], + entryPatterns: [], + alwaysUsed: ["capacitor.config.{ts,js,json}"], + }, +]; + +const detectNodeTestRunner = (directory: string): boolean => { + try { + const packageJsonPath = join(directory, "package.json"); + if (!existsSync(packageJsonPath)) return false; + const content = readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(content); + const scripts = packageJson.scripts ?? {}; + return Object.values(scripts).some( + (scriptValue) => typeof scriptValue === "string" && /\bnode\b.*\s--test\b/.test(scriptValue), + ); + } catch { + return false; + } +}; + +const detectBunTestRunner = (directory: string): boolean => { + try { + const packageJsonPath = join(directory, "package.json"); + if (!existsSync(packageJsonPath)) return false; + const content = readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(content); + const scripts = packageJson.scripts ?? {}; + return Object.values(scripts).some( + (scriptValue) => typeof scriptValue === "string" && /\bbun\s+test\b/.test(scriptValue), + ); + } catch { + return false; + } +}; + +interface TestRunnerDiscoveryResult { + entryFiles: string[]; + alwaysUsedFiles: string[]; +} + +const readPackageJsonDependencies = (packageJsonPath: string): Record => { + try { + const content = readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(content); + return { + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.optionalDependencies, + }; + } catch { + return {}; + } +}; + +const discoverTestRunnerEntryPoints = ( + rootDir: string, + workspacePackages: WorkspacePackage[], +): TestRunnerDiscoveryResult => { + const allEntries: string[] = []; + const allAlwaysUsed: string[] = []; + const directoriesToCheck = [ + rootDir, + ...workspacePackages.map((workspacePackage) => workspacePackage.directory), + ]; + + const monorepoRoot = findMonorepoRoot(rootDir); + const monorepoRootDeps = + monorepoRoot && monorepoRoot !== rootDir + ? readPackageJsonDependencies(join(monorepoRoot, "package.json")) + : {}; + + for (const directory of directoriesToCheck) { + const packageJsonPath = join(directory, "package.json"); + if (!existsSync(packageJsonPath)) continue; + + let allDependencies: Record = {}; + try { + const content = readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(content); + allDependencies = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.optionalDependencies, + }; + } catch { + continue; + } + + const activatedPatterns: string[] = []; + const activatedFixturePatterns: string[] = []; + const activatedAlwaysUsed: string[] = []; + + const isRunnerEnabled = ( + runner: TestRunnerDefinition, + dependencies: Record, + checkDirectory: string, + ): boolean => { + const hasDependency = runner.enablers.some((enabler) => { + return enabler in dependencies; + }); + if (hasDependency) return true; + return runner.configFileActivators.some((configFile) => + existsSync(join(checkDirectory, configFile)), + ); + }; + + for (const runner of TEST_FRAMEWORK_PATTERNS) { + const enabledLocally = isRunnerEnabled(runner, allDependencies, directory); + const enabledViaMonorepo = + !enabledLocally && + monorepoRoot && + (isRunnerEnabled(runner, monorepoRootDeps, monorepoRoot) || + runner.configFileActivators.some((configFile) => + existsSync(join(monorepoRoot, configFile)), + )); + if (enabledLocally || enabledViaMonorepo) { + const isVitestRunner = runner.enablers.includes("vitest"); + const isJestRunner = runner.enablers.includes("jest"); + let customPatterns: string[] = []; + if (isVitestRunner) { + customPatterns = extractVitestIncludePatterns(directory); + if (customPatterns.length === 0 && monorepoRoot) { + customPatterns = extractVitestIncludePatterns(monorepoRoot); + } + } else if (isJestRunner) { + customPatterns = extractJestTestMatchPatterns(directory); + if (customPatterns.length === 0 && monorepoRoot) { + customPatterns = extractJestTestMatchPatterns(monorepoRoot); + } + } + if (customPatterns.length > 0) { + activatedPatterns.push(...customPatterns); + // A custom `testMatch` narrows which SPEC files run, but Jest's + // `__mocks__` automock convention is independent of it — those + // files stay runner-consumed entries no matter what testMatch says. + if (isJestRunner) { + activatedPatterns.push("**/__mocks__/**/*.{ts,tsx,js,jsx,mjs,cjs}"); + } + } else { + activatedPatterns.push(...runner.entryPatterns); + } + activatedFixturePatterns.push(...runner.fixturePatterns); + activatedAlwaysUsed.push(...runner.alwaysUsed); + } + } + + if (activatedPatterns.length === 0 && directory !== rootDir) { + const rootPackageJsonPath = join(rootDir, "package.json"); + if (existsSync(rootPackageJsonPath)) { + try { + const rootContent = readFileSync(rootPackageJsonPath, "utf-8"); + const rootPackageJson = JSON.parse(rootContent); + const rootDeps = { + ...rootPackageJson.dependencies, + ...rootPackageJson.devDependencies, + ...rootPackageJson.optionalDependencies, + }; + for (const runner of TEST_FRAMEWORK_PATTERNS) { + if (isRunnerEnabled(runner, rootDeps, rootDir)) { + activatedPatterns.push(...runner.entryPatterns); + activatedFixturePatterns.push(...runner.fixturePatterns); + activatedAlwaysUsed.push(...runner.alwaysUsed); + } + } + } catch {} + } + } + + const hasNodeTestScript = detectNodeTestRunner(directory) || detectNodeTestRunner(rootDir); + if (hasNodeTestScript) { + activatedPatterns.push( + "**/*.test.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", + "**/*.spec.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", + "**/__tests__/**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", + ); + } + + const hasBunTestScript = detectBunTestRunner(directory) || detectBunTestRunner(rootDir); + if (hasBunTestScript) { + activatedPatterns.push( + "**/*.test.{ts,tsx,js,jsx,mts,mjs}", + "**/*.spec.{ts,tsx,js,jsx,mts,mjs}", + "**/*_test.{ts,tsx,js,jsx,mts,mjs}", + "**/*_spec.{ts,tsx,js,jsx,mts,mjs}", + "**/__tests__/**/*.{ts,tsx,js,jsx,mts,mjs}", + ); + } + + if (activatedPatterns.length === 0) continue; + + const uniquePatterns = [...new Set(activatedPatterns)]; + const testFiles = fg.sync(uniquePatterns, { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/*.gen.{ts,tsx,js,jsx}"], + }); + allEntries.push(...testFiles); + + const uniqueFixturePatterns = [...new Set(activatedFixturePatterns)]; + if (uniqueFixturePatterns.length > 0) { + const fixtureFiles = fg.sync(uniqueFixturePatterns, { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + }); + allEntries.push(...fixtureFiles); + } + + const uniqueAlwaysUsed = [...new Set(activatedAlwaysUsed)]; + if (uniqueAlwaysUsed.length > 0) { + const alwaysUsedFiles = fg.sync(uniqueAlwaysUsed, { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + dot: true, + }); + allAlwaysUsed.push(...alwaysUsedFiles); + } + } + + return { entryFiles: allEntries, alwaysUsedFiles: allAlwaysUsed }; +}; + +const isToolingPluginEnabled = ( + plugin: ToolingPluginDefinition, + dependencies: Record, +): boolean => { + if (plugin.enablers.some((enabler) => enabler in dependencies)) return true; + if (plugin.enablerPrefixes.length > 0) { + const depNames = Object.keys(dependencies); + return plugin.enablerPrefixes.some((prefix) => + depNames.some((depName) => depName.startsWith(prefix)), + ); + } + return false; +}; + +interface ToolingDiscoveryResult { + entryFiles: string[]; + alwaysUsedFiles: string[]; +} + +const FRAMEWORK_SCRIPT_BINARIES: Record = { + next: ["next"], + nuxt: ["nuxt"], + astro: ["astro"], + gatsby: ["gatsby"], + "@remix-run/dev": ["remix"], + "@react-router/dev": ["react-router"], + "@sveltejs/kit": ["svelte-kit", "vite-svelte-kit"], + "@docusaurus/core": ["docusaurus"], + "@angular/core": ["ng"], + "@nestjs/core": ["nest"], + storybook: ["storybook", "start-storybook", "build-storybook"], + gulp: ["gulp"], +}; + +const detectFrameworkFromScripts = (scripts: Record | undefined): Set => { + const enabledEnablers = new Set(); + if (!scripts || typeof scripts !== "object") return enabledEnablers; + for (const scriptValue of Object.values(scripts)) { + if (typeof scriptValue !== "string") continue; + const tokenized = scriptValue.split(/[\s|&;]+/); + for (const token of tokenized) { + const cleaned = token.replace(/^.*\//, ""); + for (const [enabler, binaries] of Object.entries(FRAMEWORK_SCRIPT_BINARIES)) { + if (binaries.includes(cleaned)) enabledEnablers.add(enabler); + } + } + } + return enabledEnablers; +}; + +const readPackageScripts = (directory: string): Record | undefined => { + const packageJsonPath = join(directory, "package.json"); + if (!existsSync(packageJsonPath)) return undefined; + try { + const content = readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(content); + return packageJson.scripts; + } catch { + return undefined; + } +}; + +const discoverToolingEntryPoints = ( + rootDir: string, + workspacePackages: WorkspacePackage[], +): ToolingDiscoveryResult => { + const allEntries: string[] = []; + const allAlwaysUsed: string[] = []; + const directoriesToCheck = [ + rootDir, + ...workspacePackages.map((workspacePackage) => workspacePackage.directory), + ]; + + let rootDependencies: Record = {}; + const rootPackageJsonPath = join(rootDir, "package.json"); + if (existsSync(rootPackageJsonPath)) { + try { + const rootContent = readFileSync(rootPackageJsonPath, "utf-8"); + const rootPackageJson = JSON.parse(rootContent); + rootDependencies = { + ...rootPackageJson.dependencies, + ...rootPackageJson.devDependencies, + ...rootPackageJson.optionalDependencies, + }; + } catch {} + } + + const monorepoRoot = findMonorepoRoot(rootDir); + const monorepoRootDeps = + monorepoRoot && monorepoRoot !== rootDir + ? readPackageJsonDependencies(join(monorepoRoot, "package.json")) + : {}; + + for (const directory of directoriesToCheck) { + const packageJsonPath = join(directory, "package.json"); + if (!existsSync(packageJsonPath)) continue; + + let workspaceDependencies: Record = {}; + try { + const content = readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(content); + workspaceDependencies = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.optionalDependencies, + }; + } catch { + continue; + } + + const workspaceScripts = readPackageScripts(directory); + const scriptDetectedEnablers = detectFrameworkFromScripts(workspaceScripts); + + const mergedDependencies: Record = { + ...workspaceDependencies, + }; + if (directory === rootDir) { + Object.assign(mergedDependencies, rootDependencies); + } + + if (scriptDetectedEnablers.has("gulp") && "gulp" in mergedDependencies) { + allAlwaysUsed.push( + ...fg.sync("gulpfile.{js,ts,mjs,cjs}", { + cwd: directory, + absolute: true, + onlyFiles: true, + }), + ); + } + + for (const enabler of scriptDetectedEnablers) { + if ( + enabler in workspaceDependencies || + enabler in rootDependencies || + enabler in monorepoRootDeps + ) { + mergedDependencies[enabler] = "*"; + } + } + + const activatedPatterns: string[] = []; + const activatedAlwaysUsed: string[] = []; + + for (const plugin of FRAMEWORK_PATTERNS) { + if (isToolingPluginEnabled(plugin, mergedDependencies)) { + activatedPatterns.push(...plugin.entryPatterns); + activatedAlwaysUsed.push(...plugin.alwaysUsed); + } + } + + if (activatedPatterns.length === 0 && activatedAlwaysUsed.length === 0) continue; + + const uniquePatterns = [...new Set(activatedPatterns)]; + const toolingFiles = fg.sync(uniquePatterns, { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + dot: true, + }); + allEntries.push(...toolingFiles); + + const uniqueAlwaysUsed = [...new Set(activatedAlwaysUsed)]; + if (uniqueAlwaysUsed.length > 0) { + const alwaysUsedFiles = fg.sync(uniqueAlwaysUsed, { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + dot: true, + }); + allAlwaysUsed.push(...alwaysUsedFiles); + } + } + + const rootActivatedGlobalPatterns: string[] = []; + for (const plugin of FRAMEWORK_PATTERNS) { + if (isToolingPluginEnabled(plugin, rootDependencies)) { + for (const pattern of plugin.alwaysUsed) { + if (!pattern.startsWith("**/")) { + rootActivatedGlobalPatterns.push(`**/${pattern}`); + } + } + } + } + + if (rootActivatedGlobalPatterns.length > 0) { + const globalAlwaysUsedFiles = fg.sync([...new Set(rootActivatedGlobalPatterns)], { + cwd: rootDir, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + dot: true, + }); + allAlwaysUsed.push(...globalAlwaysUsedFiles); + } + + return { entryFiles: allEntries, alwaysUsedFiles: allAlwaysUsed }; +}; diff --git a/packages/deslop-js/src/collect/expo-config-plugin-entries.ts b/packages/core/src/project-analysis/collect/expo-config-plugin-entries.ts similarity index 100% rename from packages/deslop-js/src/collect/expo-config-plugin-entries.ts rename to packages/core/src/project-analysis/collect/expo-config-plugin-entries.ts diff --git a/packages/core/src/project-analysis/collect/graphql-codegen-entries.ts b/packages/core/src/project-analysis/collect/graphql-codegen-entries.ts new file mode 100644 index 0000000000..d935e4f94b --- /dev/null +++ b/packages/core/src/project-analysis/collect/graphql-codegen-entries.ts @@ -0,0 +1,389 @@ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { basename, dirname, resolve } from "node:path"; +import { parseYAML } from "confbox"; +import fg from "fast-glob"; +import ts from "typescript"; +import { GRAPHQL_CODEGEN_CONFIG_SCAN_MAX_DEPTH, SOURCE_EXTENSIONS } from "../constants.js"; +import { evaluateStaticConfig } from "../utils/evaluate-static-config.js"; + +const GRAPHQL_CODEGEN_CONFIG_GLOBS = [ + "codegen.{ts,js,mts,mjs,cts,cjs,yml,yaml}", + "codegen-*.{ts,js,mts,mjs,cts,cjs,yml,yaml}", + "**/codegen.{ts,js,mts,mjs,cts,cjs,yml,yaml}", + "**/codegen-*.{ts,js,mts,mjs,cts,cjs,yml,yaml}", + ".graphqlrc.{ts,js,mts,mjs,cts,cjs,json,yml,yaml}", + "**/.graphqlrc.{ts,js,mts,mjs,cts,cjs,json,yml,yaml}", + "vite.config.{ts,js,mts,mjs,cts,cjs}", + "**/vite.config.{ts,js,mts,mjs,cts,cjs}", +]; + +export interface GraphqlCodegenEntries { + documentEntries: string[]; + generatedEntries: string[]; + schemaEntries: string[]; +} + +const resolveCodegenPatterns = (patterns: string[], configDirectory: string): string[] => + fg.sync( + patterns.filter( + (pattern) => + !pattern.includes("://") && !pattern.startsWith("@") && !pattern.startsWith("node:"), + ), + { + cwd: configDirectory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + }, + ); + +const resolveGeneratedOutputs = (patterns: string[], configDirectory: string): string[] => { + const generatedEntries = new Set(); + const sourceExtensionGlob = `**/*.{${SOURCE_EXTENSIONS.join(",")}}`; + + for (const pattern of patterns) { + const outputPath = resolve(configDirectory, pattern); + if (!existsSync(outputPath)) continue; + const outputStats = statSync(outputPath); + if (outputStats.isFile()) { + generatedEntries.add(outputPath); + continue; + } + if (!outputStats.isDirectory()) continue; + for (const generatedEntry of fg.sync(sourceExtensionGlob, { + cwd: outputPath, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + })) { + generatedEntries.add(generatedEntry); + } + } + + return [...generatedEntries]; +}; + +const extractVitePluginConfigContents = (content: string): string[] => { + const pluginConfigContents: string[] = []; + const sourceFile = ts.createSourceFile( + "vite.config.ts", + content, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const pluginImport = sourceFile.statements.find( + (statement): statement is ts.ImportDeclaration => + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text === "vite-plugin-graphql-codegen" && + statement.importClause?.name !== undefined, + ); + const pluginName = pluginImport?.importClause?.name?.text; + if (!pluginName) return []; + const exportAssignment = sourceFile.statements.find( + (statement): statement is ts.ExportAssignment => + ts.isExportAssignment(statement) && !statement.isExportEquals, + ); + if (!exportAssignment) return []; + const topLevelVariableInitializers = new Map(); + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name) && declaration.initializer) { + topLevelVariableInitializers.set(declaration.name.text, declaration.initializer); + } + } + } + const unwrapExpression = (expression: ts.Expression): ts.Expression => { + if ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) + ) { + return unwrapExpression(expression.expression); + } + return expression; + }; + const collectPluginExpression = ( + expression: ts.Expression, + variableInitializers: ReadonlyMap, + isPluginNameShadowed: boolean, + visitedIdentifiers = new Set(), + ): void => { + const unwrappedExpression = unwrapExpression(expression); + if ( + ts.isCallExpression(unwrappedExpression) && + ts.isIdentifier(unwrappedExpression.expression) && + unwrappedExpression.expression.text === pluginName + ) { + const configArgument = unwrappedExpression.arguments[0]; + if (configArgument && !isPluginNameShadowed) { + const bindingStatements = [...variableInitializers.entries()] + .map( + ([variableName, initializer]) => + `const ${variableName} = (${initializer.getText(sourceFile)});`, + ) + .join("\n"); + pluginConfigContents.push( + `${bindingStatements}\nexport default (${configArgument.getText(sourceFile)});`, + ); + } + return; + } + if (ts.isIdentifier(unwrappedExpression) && !visitedIdentifiers.has(unwrappedExpression.text)) { + const initializer = variableInitializers.get(unwrappedExpression.text); + if (!initializer) return; + collectPluginExpression( + initializer, + variableInitializers, + isPluginNameShadowed, + new Set(visitedIdentifiers).add(unwrappedExpression.text), + ); + return; + } + if (ts.isArrayLiteralExpression(unwrappedExpression)) { + for (const element of unwrappedExpression.elements) { + collectPluginExpression( + ts.isSpreadElement(element) ? element.expression : element, + variableInitializers, + isPluginNameShadowed, + visitedIdentifiers, + ); + } + return; + } + if (ts.isConditionalExpression(unwrappedExpression)) { + collectPluginExpression( + unwrappedExpression.whenTrue, + variableInitializers, + isPluginNameShadowed, + visitedIdentifiers, + ); + collectPluginExpression( + unwrappedExpression.whenFalse, + variableInitializers, + isPluginNameShadowed, + visitedIdentifiers, + ); + return; + } + if ( + ts.isBinaryExpression(unwrappedExpression) && + (unwrappedExpression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + unwrappedExpression.operatorToken.kind === ts.SyntaxKind.BarBarToken || + unwrappedExpression.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) + ) { + collectPluginExpression( + unwrappedExpression.left, + variableInitializers, + isPluginNameShadowed, + visitedIdentifiers, + ); + collectPluginExpression( + unwrappedExpression.right, + variableInitializers, + isPluginNameShadowed, + visitedIdentifiers, + ); + } + }; + const collectConfigPlugins = ( + expression: ts.Expression, + variableInitializers: ReadonlyMap, + isPluginNameShadowed: boolean, + visitedIdentifiers = new Set(), + ): void => { + const unwrappedExpression = unwrapExpression(expression); + if (ts.isIdentifier(unwrappedExpression) && !visitedIdentifiers.has(unwrappedExpression.text)) { + const initializer = variableInitializers.get(unwrappedExpression.text); + if (!initializer) return; + collectConfigPlugins( + initializer, + variableInitializers, + isPluginNameShadowed, + new Set(visitedIdentifiers).add(unwrappedExpression.text), + ); + return; + } + if (ts.isCallExpression(unwrappedExpression)) { + const calledExpression = unwrapExpression(unwrappedExpression.expression); + if (!ts.isIdentifier(calledExpression) || calledExpression.text !== "defineConfig") return; + const configArgument = unwrappedExpression.arguments[0]; + if (!configArgument) return; + const unwrappedArgument = unwrapExpression(configArgument); + if (ts.isArrowFunction(unwrappedArgument) || ts.isFunctionExpression(unwrappedArgument)) { + let callbackPluginNameShadowed = + isPluginNameShadowed || + unwrappedArgument.parameters.some( + (parameter) => ts.isIdentifier(parameter.name) && parameter.name.text === pluginName, + ); + if (!ts.isBlock(unwrappedArgument.body)) { + collectConfigPlugins( + unwrappedArgument.body, + variableInitializers, + callbackPluginNameShadowed, + ); + return; + } + const callbackVariableInitializers = new Map(variableInitializers); + for (const statement of unwrappedArgument.body.statements) { + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name)) continue; + if (declaration.name.text === pluginName) callbackPluginNameShadowed = true; + if (declaration.initializer) { + callbackVariableInitializers.set(declaration.name.text, declaration.initializer); + } + } + } + if (ts.isReturnStatement(statement) && statement.expression) { + collectConfigPlugins( + statement.expression, + callbackVariableInitializers, + callbackPluginNameShadowed, + ); + return; + } + } + return; + } + collectConfigPlugins(unwrappedArgument, variableInitializers, isPluginNameShadowed); + return; + } + if (!ts.isObjectLiteralExpression(unwrappedExpression)) return; + for (const property of unwrappedExpression.properties) { + if ( + ts.isPropertyAssignment(property) && + ((ts.isIdentifier(property.name) && property.name.text === "plugins") || + (ts.isStringLiteral(property.name) && property.name.text === "plugins")) + ) { + collectPluginExpression(property.initializer, variableInitializers, isPluginNameShadowed); + } + if (ts.isShorthandPropertyAssignment(property) && property.name.text === "plugins") { + collectPluginExpression(property.name, variableInitializers, isPluginNameShadowed); + } + } + }; + collectConfigPlugins(exportAssignment.expression, topLevelVariableInitializers, false); + + return pluginConfigContents; +}; + +const collectStructuredCodegenPatterns = (config: unknown): GraphqlCodegenEntries => { + const documentEntries: string[] = []; + const generatedEntries: string[] = []; + const schemaEntries: string[] = []; + const visitedValues = new WeakSet(); + const visitedPatternValues = new WeakSet(); + const visitedNestedStringValues = new WeakSet(); + const collectNestedStringValues = (value: unknown): string[] => { + if (typeof value === "string") return [value]; + if (typeof value !== "object" || value === null) return []; + if (visitedNestedStringValues.has(value)) return []; + visitedNestedStringValues.add(value); + if (Array.isArray(value)) return value.flatMap(collectNestedStringValues); + return Object.values(value).flatMap(collectNestedStringValues); + }; + const collectPatternValues = (value: unknown): string[] => { + if (typeof value === "string") return [value]; + if (typeof value !== "object" || value === null) return []; + if (visitedPatternValues.has(value)) return []; + visitedPatternValues.add(value); + if (Array.isArray(value)) return value.flatMap(collectPatternValues); + return [...Object.keys(value), ...Object.values(value).flatMap(collectNestedStringValues)]; + }; + const visitValue = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) visitValue(item); + return; + } + if (typeof value !== "object" || value === null || visitedValues.has(value)) return; + visitedValues.add(value); + for (const [key, nestedValue] of Object.entries(value)) { + if (key === "generates" && typeof nestedValue === "object" && nestedValue !== null) { + generatedEntries.push(...Object.keys(nestedValue)); + } else if (key === "documents") { + documentEntries.push(...collectPatternValues(nestedValue)); + } else if (key === "schema") { + schemaEntries.push(...collectPatternValues(nestedValue)); + } + visitValue(nestedValue); + } + }; + visitValue(config); + return { documentEntries, generatedEntries, schemaEntries }; +}; + +export const extractGraphqlCodegenEntries = (directory: string): GraphqlCodegenEntries => { + const documentEntries = new Set(); + const generatedEntries = new Set(); + const schemaEntries = new Set(); + const configPaths = fg.sync(GRAPHQL_CODEGEN_CONFIG_GLOBS, { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], + deep: GRAPHQL_CODEGEN_CONFIG_SCAN_MAX_DEPTH, + }); + + for (const configPath of configPaths) { + try { + const rawContent = readFileSync(configPath, "utf-8"); + const isViteConfig = basename(configPath).startsWith("vite.config."); + const isJsonConfig = configPath.endsWith(".json"); + const isYamlConfig = configPath.endsWith(".yml") || configPath.endsWith(".yaml"); + const isJavaScriptConfig = /\.[cm]?[jt]s$/.test(configPath); + const relevantContents = isViteConfig + ? extractVitePluginConfigContents(rawContent) + : [rawContent]; + if (relevantContents.length === 0) continue; + const configDirectory = dirname(configPath); + const structuredPatterns: GraphqlCodegenEntries = { + documentEntries: [], + generatedEntries: [], + schemaEntries: [], + }; + if (isJsonConfig) { + const patterns = collectStructuredCodegenPatterns(JSON.parse(rawContent)); + structuredPatterns.documentEntries.push(...patterns.documentEntries); + structuredPatterns.generatedEntries.push(...patterns.generatedEntries); + structuredPatterns.schemaEntries.push(...patterns.schemaEntries); + } else if (isYamlConfig) { + const patterns = collectStructuredCodegenPatterns(parseYAML(rawContent)); + structuredPatterns.documentEntries.push(...patterns.documentEntries); + structuredPatterns.generatedEntries.push(...patterns.generatedEntries); + structuredPatterns.schemaEntries.push(...patterns.schemaEntries); + } else if (isJavaScriptConfig) { + for (const relevantContent of relevantContents) { + const patterns = collectStructuredCodegenPatterns( + evaluateStaticConfig(relevantContent, configPath), + ); + structuredPatterns.documentEntries.push(...patterns.documentEntries); + structuredPatterns.generatedEntries.push(...patterns.generatedEntries); + structuredPatterns.schemaEntries.push(...patterns.schemaEntries); + } + } + const documentPatterns = structuredPatterns.documentEntries; + const schemaPatterns = structuredPatterns.schemaEntries; + const generatedOutputPatterns = structuredPatterns.generatedEntries; + for (const entryPath of resolveCodegenPatterns(documentPatterns, configDirectory)) { + documentEntries.add(entryPath); + } + for (const entryPath of resolveCodegenPatterns(schemaPatterns, configDirectory)) { + schemaEntries.add(entryPath); + } + for (const entryPath of resolveGeneratedOutputs(generatedOutputPatterns, configDirectory)) { + generatedEntries.add(entryPath); + } + } catch { + continue; + } + } + + return { + documentEntries: [...documentEntries], + generatedEntries: [...generatedEntries], + schemaEntries: [...schemaEntries], + }; +}; diff --git a/packages/core/src/project-analysis/collect/mui-docs-metadata-entries.ts b/packages/core/src/project-analysis/collect/mui-docs-metadata-entries.ts new file mode 100644 index 0000000000..ec82497b82 --- /dev/null +++ b/packages/core/src/project-analysis/collect/mui-docs-metadata-entries.ts @@ -0,0 +1,40 @@ +import { existsSync, readFileSync } from "node:fs"; +import fg from "fast-glob"; +import { findMonorepoRoot } from "../utils/find-monorepo-root.js"; + +const MUI_DOCS_INFRA_PACKAGE = "@mui/internal-docs-infra"; +const MUI_DOCS_METADATA_PATTERN = "**/*{DataAttributes,CssVars}.{ts,tsx}"; + +const packageJsonHasMuiDocsInfra = (packageJsonPath: string): boolean => { + if (!existsSync(packageJsonPath)) return false; + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + return Boolean( + packageJson.dependencies?.[MUI_DOCS_INFRA_PACKAGE] ?? + packageJson.devDependencies?.[MUI_DOCS_INFRA_PACKAGE] ?? + packageJson.optionalDependencies?.[MUI_DOCS_INFRA_PACKAGE], + ); + } catch { + return false; + } +}; + +export const extractMuiDocsMetadataEntries = (projectRoot: string): string[] => { + const monorepoRoot = findMonorepoRoot(projectRoot); + const dependencySearchRoot = monorepoRoot ?? projectRoot; + const hasMuiDocsInfra = fg + .sync(["package.json", "**/package.json"], { + cwd: dependencySearchRoot, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], + }) + .some(packageJsonHasMuiDocsInfra); + if (!hasMuiDocsInfra) return []; + return fg.sync(MUI_DOCS_METADATA_PATTERN, { + cwd: projectRoot, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], + }); +}; diff --git a/packages/core/src/project-analysis/collect/netlify-function-entries.ts b/packages/core/src/project-analysis/collect/netlify-function-entries.ts new file mode 100644 index 0000000000..a96a55cb92 --- /dev/null +++ b/packages/core/src/project-analysis/collect/netlify-function-entries.ts @@ -0,0 +1,42 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import fg from "fast-glob"; +import { parseTOML } from "confbox"; + +const NETLIFY_FUNCTION_SOURCE_PATTERN = "**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}"; + +export const extractNetlifyFunctionEntries = (projectRoot: string): string[] => { + const configPath = resolve(projectRoot, "netlify.toml"); + if (!existsSync(configPath)) return []; + + let config: unknown; + try { + config = parseTOML(readFileSync(configPath, "utf8")); + } catch { + return []; + } + const functionsConfig = + config && typeof config === "object" && !Array.isArray(config) && "functions" in config + ? config.functions + : undefined; + const configuredDirectory = + functionsConfig && + typeof functionsConfig === "object" && + !Array.isArray(functionsConfig) && + "directory" in functionsConfig && + typeof functionsConfig.directory === "string" + ? functionsConfig.directory + : undefined; + const functionsDirectory = resolve( + dirname(configPath), + configuredDirectory ?? "netlify/functions", + ); + if (!existsSync(functionsDirectory)) return []; + + return fg.sync(NETLIFY_FUNCTION_SOURCE_PATTERN, { + cwd: functionsDirectory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + }); +}; diff --git a/packages/deslop-js/src/collect/package-json-entries.ts b/packages/core/src/project-analysis/collect/package-json-entries.ts similarity index 76% rename from packages/deslop-js/src/collect/package-json-entries.ts rename to packages/core/src/project-analysis/collect/package-json-entries.ts index 13a7b24ed6..f1f1bdbf02 100644 --- a/packages/deslop-js/src/collect/package-json-entries.ts +++ b/packages/core/src/project-analysis/collect/package-json-entries.ts @@ -1,20 +1,25 @@ import { existsSync, readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import fg from "fast-glob"; import { resolveSourcePath } from "../resolver/source-path.js"; import { resolveEntryPathWithExtensions, resolveEntryWithExtensions, } from "../utils/resolve-entry-with-extensions.js"; +import { parseTypeScriptConfig } from "../utils/parse-typescript-config.js"; interface PackageJsonEntryFields { [key: string]: unknown; + private?: boolean | string; + description?: string; exports?: unknown; + imports?: unknown; bin?: unknown; sideEffects?: unknown; build?: unknown; jest?: unknown; + scripts?: unknown; } interface TypeScriptBuildDirectories { @@ -62,6 +67,8 @@ const IMPORTABLE_EXTENSION_SET = new Set([ ]); const PACKAGE_ENTRY_FIELDS = ["main", "module", "browser", "types", "typings", "style", "source"]; +const COMPONENT_COMPOSITION_REGISTRY_DESCRIPTION = "Registry for component compositions"; + export const findDefaultIndexEntry = (directory: string): string | undefined => { for (const pattern of DEFAULT_INDEX_PATTERNS) { const candidatePath = resolve(directory, pattern); @@ -100,20 +107,18 @@ const readTypeScriptBuildDirectories = ( ): TypeScriptBuildDirectories | undefined => { const tsconfigPath = join(rootDirectory, "tsconfig.json"); if (!existsSync(tsconfigPath)) return undefined; - const tsconfigContent = readFileSync(tsconfigPath, "utf-8") - .replace(/\/\/.*$/gm, "") - .replace(/\/\*[\s\S]*?\*\//g, ""); - const tsconfig = JSON.parse(tsconfigContent); + const tsconfig = parseTypeScriptConfig(tsconfigPath, readFileSync(tsconfigPath, "utf-8")); const outDirectory = tsconfig?.compilerOptions?.outDir; - if (!outDirectory) return undefined; + if (typeof outDirectory !== "string" || outDirectory.length === 0) return undefined; const configuredRootDirectory = tsconfig?.compilerOptions?.rootDir; return { absoluteOutDirectory: resolve(rootDirectory, outDirectory), - sourceRoot: configuredRootDirectory - ? resolve(rootDirectory, configuredRootDirectory) - : rootDirectory, - shouldSearchCommonSourceDirectories: !configuredRootDirectory, + sourceRoot: + typeof configuredRootDirectory === "string" + ? resolve(rootDirectory, configuredRootDirectory) + : rootDirectory, + shouldSearchCommonSourceDirectories: typeof configuredRootDirectory !== "string", }; }; @@ -195,7 +200,8 @@ const resolveEntryPath = (entryPath: string, rootDirectory: string): string => { const heuristicMatch = resolveEntryPathViaHeuristic(normalizedEntry, rootDirectory); if (heuristicMatch) return heuristicMatch; } - if (existsSync(absolutePath)) return absolutePath; + const directlyResolvedPath = resolveEntryWithExtensions(absolutePath); + if (directlyResolvedPath) return directlyResolvedPath; return ( resolveBuiltPathToSource(absolutePath, rootDirectory) ?? findSourceFile(rootDirectory, normalizedEntry) ?? @@ -212,9 +218,10 @@ const collectExportPaths = ( if (typeof exportValue === "string") { if (exportValue.includes("*")) { const normalizedPattern = exportValue.startsWith("./") ? exportValue.slice(2) : exportValue; - entries.push( - ...findImportableFiles(normalizedPattern, rootDirectory, ["**/node_modules/**"]), - ); + const recursivePattern = normalizedPattern.includes("/") + ? normalizedPattern.replaceAll("*", "**/*") + : normalizedPattern; + entries.push(...findImportableFiles(recursivePattern, rootDirectory, ["**/node_modules/**"])); } else { entries.push(resolveEntryPath(exportValue, rootDirectory)); } @@ -359,20 +366,77 @@ const collectJestEntries = (jestValue: unknown, rootDirectory: string, entries: } }; +const TYPE_TEST_DIRECTORY_PATTERN = /^(?:public-types|type-tests?|types-tests?|typetests|tsd)$/; +const TYPE_TEST_SCRIPT_PATTERN = /(?:^|\s)(?:tsc|tsgo)(?:\s|$)/; + +const collectTypeTestFixtureEntries = ( + packageJson: PackageJsonEntryFields, + rootDirectory: string, + entries: string[], +): void => { + const directoryName = rootDirectory.slice(rootDirectory.lastIndexOf(sep) + 1); + const isPrivate = packageJson.private === true || packageJson.private === "true"; + if (!isPrivate || !TYPE_TEST_DIRECTORY_PATTERN.test(directoryName)) return; + if (!packageJson.scripts || typeof packageJson.scripts !== "object") return; + const invokesTypeScript = Object.values(packageJson.scripts).some( + (scriptValue) => typeof scriptValue === "string" && TYPE_TEST_SCRIPT_PATTERN.test(scriptValue), + ); + if (!invokesTypeScript) return; + + const tsconfigPath = join(rootDirectory, "tsconfig.json"); + if (!existsSync(tsconfigPath)) return; + try { + const tsconfig = parseTypeScriptConfig(tsconfigPath, readFileSync(tsconfigPath, "utf8")); + if (tsconfig?.compilerOptions?.noEmit !== true || !Array.isArray(tsconfig.include)) return; + const includePatterns = tsconfig.include.filter( + (includePattern: unknown): includePattern is string => typeof includePattern === "string", + ); + if (includePatterns.length === 0) return; + const excludePatterns = Array.isArray(tsconfig.exclude) + ? tsconfig.exclude.filter( + (excludePattern: unknown): excludePattern is string => typeof excludePattern === "string", + ) + : []; + entries.push( + ...fg + .sync(includePatterns, { + cwd: rootDirectory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", ...excludePatterns], + }) + .filter(isImportableSourceFile), + ); + } catch {} +}; + +const collectComponentCompositionRegistryEntries = ( + packageJson: PackageJsonEntryFields, + rootDirectory: string, + entries: string[], +): void => { + const isPrivate = packageJson.private === true || packageJson.private === "true"; + if (!isPrivate || packageJson.description !== COMPONENT_COMPOSITION_REGISTRY_DESCRIPTION) return; + entries.push(...findImportableFiles("src/**/*", rootDirectory, ["**/node_modules/**"])); +}; + export const extractPackageJsonEntries = async (packageJsonPath: string): Promise => { const entries: string[] = []; try { const content = await readFile(packageJsonPath, "utf-8"); const packageJson: PackageJsonEntryFields = JSON.parse(content); - const rootDirectory = packageJsonPath.replace(/\/package\.json$/, ""); + const rootDirectory = dirname(packageJsonPath); collectFieldEntries(packageJson, rootDirectory, entries); collectPackageExportEntries(packageJson.exports, rootDirectory, entries); + collectPackageExportEntries(packageJson.imports, rootDirectory, entries); collectPackageBinEntries(packageJson.bin, rootDirectory, entries); collectSideEffectEntries(packageJson.sideEffects, rootDirectory, entries); collectBuildEntries(packageJson.build, rootDirectory, entries); collectJestEntries(packageJson.jest, rootDirectory, entries); + collectTypeTestFixtureEntries(packageJson, rootDirectory, entries); + collectComponentCompositionRegistryEntries(packageJson, rootDirectory, entries); } catch {} return entries; diff --git a/packages/core/src/project-analysis/collect/parse.ts b/packages/core/src/project-analysis/collect/parse.ts new file mode 100644 index 0000000000..3663215e11 --- /dev/null +++ b/packages/core/src/project-analysis/collect/parse.ts @@ -0,0 +1,2309 @@ +import { parse as parseAstro } from "@astrojs/compiler/sync"; +import type { Node as AstroNode } from "@astrojs/compiler/types"; +import { parseSync } from "oxc-parser"; +import { readFileSync, statSync } from "node:fs"; +import { parseFragment, type DefaultTreeAdapterMap } from "parse5"; +import ts from "typescript"; +import { + BINARY_DETECTION_NULL_BYTE_THRESHOLD, + BINARY_DETECTION_SAMPLE_BYTES, + MAX_PARSE_FILE_SIZE_BYTES, + MINIFIED_DETECTION_MEDIAN_LINE_LENGTH_THRESHOLD, + MINIFIED_DETECTION_MIN_BYTES, +} from "../constants.js"; +import { + type ProjectAnalysisError, + FileReadError, + ParseError, + describeUnknownError, +} from "../errors.js"; +import type { + Statement, + ImportDeclaration, + ExportNamedDeclaration, + ExportDefaultDeclaration, + ExportAllDeclaration, + Declaration, + VariableDeclaration, + BindingPattern, + ModuleExportName, + ModuleDeclaration, +} from "oxc-parser"; +import type { + ImportReference, + ExportReference, + ImportBinding, + MemberAccess, + SourceModuleAnalysis, +} from "../types.js"; +import { getLineFromOffset, getColumnFromOffset } from "../utils/line-column.js"; +import { extractDefaultExportLocalName } from "../utils/extract-default-export-local-name.js"; +import { getIdentifierName, isOxcAstNode } from "../utils/oxc-ast-node.js"; +import { visitOxcAstWithBindings } from "../utils/visit-oxc-ast-with-bindings.js"; +import { isGeneratedSource } from "../utils/is-generated-source.js"; +import { collectStylesheetImportSpecifiers } from "../utils/collect-stylesheet-import-specifiers.js"; +import { extractJitiLoadReferences } from "../utils/extract-jiti-load-references.js"; +import { extractMarkdownModuleStatements } from "../utils/extract-markdown-module-statements.js"; + +export interface ParsedSource extends SourceModuleAnalysis { + errors: ProjectAnalysisError[]; + isGenerated: boolean; +} + +const extractRecoveryImports = (filePath: string, sourceText: string): ImportReference[] => { + const sourceFile = ts.createSourceFile( + filePath, + sourceText, + ts.ScriptTarget.Latest, + true, + filePath.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const imports: ImportReference[] = []; + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + const importedNames: ImportBinding[] = []; + const importClause = statement.importClause; + if (importClause?.name) { + importedNames.push({ + name: "default", + alias: importClause.name.text, + isNamespace: false, + isDefault: true, + isTypeOnly: importClause.isTypeOnly, + }); + } + if (importClause?.namedBindings && ts.isNamespaceImport(importClause.namedBindings)) { + importedNames.push({ + name: "*", + alias: importClause.namedBindings.name.text, + isNamespace: true, + isDefault: false, + isTypeOnly: importClause.isTypeOnly, + }); + } + if (importClause?.namedBindings && ts.isNamedImports(importClause.namedBindings)) { + for (const element of importClause.namedBindings.elements) { + importedNames.push({ + name: element.propertyName?.text ?? element.name.text, + alias: element.name.text, + isNamespace: false, + isDefault: false, + isTypeOnly: importClause.isTypeOnly || element.isTypeOnly, + }); + } + } + const offset = statement.getStart(sourceFile); + imports.push({ + specifier: statement.moduleSpecifier.text, + importedNames, + isTypeOnly: importClause?.isTypeOnly ?? false, + isDynamic: false, + isSideEffect: importClause === undefined, + line: getLineFromOffset(sourceText, offset), + column: getColumnFromOffset(sourceText, offset), + }); + } + return imports; +}; + +const createWhitespaceMask = (sourceText: string): string[] => + sourceText + .split("") + .map((character) => (character === "\n" || character === "\r" ? character : " ")); + +const restoreMaskedSourceRange = ( + maskedSource: string[], + sourceSection: string, + startOffset: number, +): void => { + for (let characterIndex = 0; characterIndex < sourceSection.length; characterIndex++) { + maskedSource[startOffset + characterIndex] = sourceSection[characterIndex]; + } +}; + +const maskSelectedExportKeywords = ( + sourceText: string, + shouldMaskStatement: (statement: ts.Statement) => boolean, +): string => { + const sourceFile = ts.createSourceFile( + "embedded-component.tsx", + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + const maskedSource = sourceText.split(""); + for (const statement of sourceFile.statements) { + if (!shouldMaskStatement(statement) || !ts.canHaveModifiers(statement)) continue; + const exportModifier = ts + .getModifiers(statement) + ?.find((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); + if (!exportModifier) continue; + for ( + let characterIndex = exportModifier.getStart(sourceFile); + characterIndex < exportModifier.end; + characterIndex++ + ) { + maskedSource[characterIndex] = " "; + } + } + return maskedSource.join(""); +}; + +const maskAstroPropsExports = (sourceText: string): string => + maskSelectedExportKeywords( + sourceText, + (statement) => + (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) && + statement.name.text === "Props", + ); + +const maskSvelteInstancePropExports = (sourceText: string): string => + maskSelectedExportKeywords( + sourceText, + (statement) => + ts.isVariableStatement(statement) && + (statement.declarationList.flags & ts.NodeFlags.Let) !== 0, + ); + +const extractAstroSources = (sourceText: string): string => { + const maskedSource = createWhitespaceMask(sourceText); + let astroRoot: ReturnType["ast"]; + try { + astroRoot = parseAstro(sourceText, { position: true }).ast; + } catch { + return maskedSource.join(""); + } + + const sourceOffsetByByteOffset = new Map(); + let byteOffset = 0; + let sourceOffset = 0; + for (const character of sourceText) { + sourceOffsetByByteOffset.set(byteOffset, sourceOffset); + byteOffset += Buffer.byteLength(character); + sourceOffset += character.length; + } + sourceOffsetByByteOffset.set(byteOffset, sourceOffset); + + const getSourceOffset = (node: AstroNode): number | undefined => { + const nodeByteOffset = node.position?.start.offset; + return nodeByteOffset === undefined ? undefined : sourceOffsetByByteOffset.get(nodeByteOffset); + }; + const restoreNodeValue = ( + node: AstroNode, + sourceValue: string, + maskedValue = sourceValue, + ): void => { + const nodeStartOffset = getSourceOffset(node); + if (nodeStartOffset === undefined) return; + const valueStartOffset = sourceText.indexOf(sourceValue, nodeStartOffset); + if (valueStartOffset === -1) return; + restoreMaskedSourceRange(maskedSource, maskedValue, valueStartOffset); + }; + const visitNode = (node: AstroNode): void => { + if (node.type === "frontmatter") { + restoreNodeValue(node, node.value, maskAstroPropsExports(node.value)); + return; + } + if (node.type === "element" && node.name.toLowerCase() === "script") { + const sourceAttribute = node.attributes.find( + (attribute) => attribute.name.toLowerCase() === "src" && attribute.kind === "quoted", + ); + const nodeStartOffset = getSourceOffset(node); + if (sourceAttribute && nodeStartOffset !== undefined) { + restoreMaskedSourceRange( + maskedSource, + `import ${JSON.stringify(sourceAttribute.value)};`, + nodeStartOffset, + ); + } + for (const childNode of node.children) { + if (childNode.type === "text") restoreNodeValue(childNode, childNode.value); + } + } + if ("children" in node) { + for (const childNode of node.children) visitNode(childNode); + } + }; + visitNode(astroRoot); + return maskedSource.join(""); +}; + +const extractHtmlLikeTopLevelScriptContent = ( + sourceText: string, + transformScriptBody: ( + scriptBody: string, + scriptElement: DefaultTreeAdapterMap["element"], + ) => string, +): string => { + const maskedSource = createWhitespaceMask(sourceText); + const documentFragment = parseFragment(sourceText, { sourceCodeLocationInfo: true }); + const visitNode = (node: DefaultTreeAdapterMap["node"], isLexicallyTopLevel: boolean): void => { + if (!("tagName" in node)) return; + if (isLexicallyTopLevel && node.tagName.toLowerCase() === "script") { + const bodyStartOffset = node.sourceCodeLocation?.startTag?.endOffset; + const bodyEndOffset = node.sourceCodeLocation?.endTag?.startOffset; + if (bodyStartOffset !== undefined && bodyEndOffset !== undefined) { + const scriptBody = sourceText.slice(bodyStartOffset, bodyEndOffset); + restoreMaskedSourceRange( + maskedSource, + transformScriptBody(scriptBody, node), + bodyStartOffset, + ); + } + return; + } + const startTagLocation = node.sourceCodeLocation?.startTag; + const hasSelfClosingStartTag = + startTagLocation !== undefined && + sourceText + .slice(startTagLocation.startOffset, startTagLocation.endOffset) + .trimEnd() + .endsWith("/>"); + for (const childNode of node.childNodes) { + visitNode(childNode, isLexicallyTopLevel && hasSelfClosingStartTag); + } + }; + for (const childNode of documentFragment.childNodes) visitNode(childNode, true); + return maskedSource.join(""); +}; + +const extractVueScriptContent = (sourceText: string): string => + extractHtmlLikeTopLevelScriptContent(sourceText, (scriptBody) => scriptBody); + +const extractSvelteScriptContent = (sourceText: string): string => + extractHtmlLikeTopLevelScriptContent(sourceText, (scriptBody, scriptElement) => { + const isModuleScript = scriptElement.attrs.some( + (attribute) => + attribute.name === "module" || + (attribute.name === "context" && attribute.value === "module"), + ); + return isModuleScript ? scriptBody : maskSvelteInstancePropExports(scriptBody); + }); + +const getModuleExportNameValue = (exportName: ModuleExportName): string => { + if (exportName.type === "Identifier") return exportName.name; + if (exportName.type === "Literal") return exportName.value; + return "default"; +}; + +const CSS_EXTENSIONS = [".css", ".scss", ".less", ".sass"]; + +const parseCssImports = (filePath: string): ParsedSource => { + const sourceText = readFileSync(filePath, "utf-8"); + const imports: ImportReference[] = []; + + for (const { specifier, index } of collectStylesheetImportSpecifiers(sourceText)) { + if (!specifier.startsWith("http")) { + imports.push({ + specifier, + importedNames: [], + isTypeOnly: false, + isDynamic: false, + isSideEffect: true, + line: sourceText.substring(0, index).split("\n").length, + column: 0, + }); + } + } + + return { + imports, + exports: [], + memberAccesses: [], + wholeObjectUses: [], + localIdentifierReferences: [], + topLevelImportReferences: [], + referencedFilenames: [], + hasUnknownDynamicModuleLoad: false, + errors: [], + isGenerated: false, + }; +}; + +const NON_JS_EXTENSIONS = [".graphql", ".gql"]; + +const collectLocalIdentifierReferences = (statements: Statement[]): string[] => { + const references: string[] = []; + const seenNames = new Set(); + + const visitNode = (node: unknown): void => { + if (!node || typeof node !== "object") return; + + const record = node as Record; + if (record.type === "Identifier" && typeof record.name === "string") { + if (!seenNames.has(record.name)) { + seenNames.add(record.name); + references.push(record.name); + } + return; + } + + for (const value of Object.values(record)) { + if (Array.isArray(value)) { + for (const innerValue of value) visitNode(innerValue); + } else if (value && typeof value === "object") { + visitNode(value); + } + } + }; + + const visitExportedDeclarationValues = (declaration: unknown): void => { + if (!declaration || typeof declaration !== "object") return; + const record = declaration as Record; + if (typeof record.type === "string" && TS_VALUE_WRAPPER_NODE_TYPES.has(record.type)) { + visitNode(record.expression); + return; + } + if (record.type === "VariableDeclaration" && Array.isArray(record.declarations)) { + for (const declarator of record.declarations) { + if (declarator && typeof declarator === "object") { + visitNode((declarator as Record).init); + } + } + return; + } + if (record.type === "FunctionDeclaration" || record.type === "ClassDeclaration") { + visitNode(record.params); + visitNode(record.superClass); + visitNode(record.body); + return; + } + if (record.type === "TSTypeAliasDeclaration") { + visitNode(record.typeParameters); + visitNode(record.typeAnnotation); + return; + } + if (record.type === "TSInterfaceDeclaration") { + visitNode(record.typeParameters); + visitNode(record.extends); + visitNode(record.body); + return; + } + if (record.type === "TSEnumDeclaration" && Array.isArray(record.members)) { + for (const member of record.members) { + if (member && typeof member === "object") { + visitNode((member as Record).initializer); + } + } + return; + } + if (typeof record.type === "string" && !record.type.startsWith("TS")) { + visitNode(declaration); + } + }; + + for (const statement of statements) { + if (statement.type === "ImportDeclaration" || statement.type === "ExportAllDeclaration") { + continue; + } + if (statement.type === "ExportNamedDeclaration") { + visitExportedDeclarationValues((statement as { declaration?: unknown }).declaration); + continue; + } + if (statement.type === "ExportDefaultDeclaration") { + visitExportedDeclarationValues((statement as { declaration?: unknown }).declaration); + continue; + } + visitNode(statement); + } + + return references; +}; + +const TS_VALUE_WRAPPER_NODE_TYPES = new Set([ + "TSAsExpression", + "TSSatisfiesExpression", + "TSNonNullExpression", + "TSInstantiationExpression", + "TSTypeAssertion", +]); + +const TS_RUNTIME_DECLARATION_NODE_TYPES = new Set([ + "TSEnumDeclaration", + "TSModuleDeclaration", + "TSExportAssignment", +]); + +const FUNCTION_NODE_TYPES = new Set([ + "FunctionDeclaration", + "FunctionExpression", + "ArrowFunctionExpression", +]); + +const collectStaticImportLocalNames = (imports: ImportReference[]): Set => { + const localNames = new Set(); + for (const importInfo of imports) { + if (importInfo.isDynamic || importInfo.isTypeOnly) continue; + for (const binding of importInfo.importedNames) { + if (binding.isTypeOnly) continue; + const localName = binding.alias ?? binding.name; + if (localName && localName !== "*") localNames.add(localName); + } + } + return localNames; +}; + +// Records which static import bindings are dereferenced in code that runs at +// MODULE INIT time: top-level statements, IIFE bodies, class `extends` / +// decorators / static members — but not function bodies, method bodies, or +// erased TS type positions, all of which run (or vanish) after every module +// in a cycle has finished initializing. Cycle detection uses this to keep the +// documented initialization-order hazard firing while suppressing cycles whose +// back edges are only touched lazily. +const collectTopLevelImportReferences = ( + bodyNodes: Array, + importLocalNames: Set, +): string[] => { + const referencedNames = new Set(); + if (importLocalNames.size === 0) return []; + + const addBindingNames = (pattern: unknown, names: Set): void => { + if (!isWalkableNode(pattern)) return; + if (pattern.type === "Identifier") { + if (typeof pattern.name === "string") names.add(pattern.name); + return; + } + if (pattern.type === "RestElement") { + addBindingNames(pattern.argument, names); + return; + } + if (pattern.type === "AssignmentPattern") { + addBindingNames(pattern.left, names); + return; + } + if (pattern.type === "ObjectPattern" && Array.isArray(pattern.properties)) { + for (const property of pattern.properties) { + if (!isWalkableNode(property)) continue; + addBindingNames( + property.type === "RestElement" ? property.argument : property.value, + names, + ); + } + return; + } + if (pattern.type === "ArrayPattern" && Array.isArray(pattern.elements)) { + for (const element of pattern.elements) addBindingNames(element, names); + } + }; + + const collectDirectBlockBindings = (body: unknown): Set => { + const names = new Set(); + if (!Array.isArray(body)) return names; + for (const statement of body) { + if (!isWalkableNode(statement)) continue; + if (statement.type === "VariableDeclaration" && statement.kind !== "var") { + for (const declaration of Array.isArray(statement.declarations) + ? statement.declarations + : []) { + if (isWalkableNode(declaration)) addBindingNames(declaration.id, names); + } + } + if (statement.type === "FunctionDeclaration" || statement.type === "ClassDeclaration") { + addBindingNames(statement.id, names); + } + } + return names; + }; + + const collectFunctionBindings = (functionNode: WalkableNode): Set => { + const names = new Set(); + addBindingNames(functionNode.id, names); + for (const parameter of Array.isArray(functionNode.params) ? functionNode.params : []) { + addBindingNames(parameter, names); + } + const visitForVarBindings = (node: unknown): void => { + if (Array.isArray(node)) { + for (const element of node) visitForVarBindings(element); + return; + } + if (!isWalkableNode(node)) return; + if (node !== functionNode && FUNCTION_NODE_TYPES.has(node.type)) return; + if (node.type === "VariableDeclaration" && node.kind === "var") { + for (const declaration of Array.isArray(node.declarations) ? node.declarations : []) { + if (isWalkableNode(declaration)) addBindingNames(declaration.id, names); + } + } + for (const value of Object.values(node)) visitForVarBindings(value); + }; + visitForVarBindings(functionNode.body); + return names; + }; + + const unwrapFunctionExpression = (node: unknown): WalkableNode | undefined => { + let currentNode = isWalkableNode(node) ? node : undefined; + while ( + currentNode && + (currentNode.type === "ParenthesizedExpression" || + TS_VALUE_WRAPPER_NODE_TYPES.has(currentNode.type)) + ) { + currentNode = isWalkableNode(currentNode.expression) ? currentNode.expression : undefined; + } + return currentNode && FUNCTION_NODE_TYPES.has(currentNode.type) ? currentNode : undefined; + }; + + const mergeShadowedNames = ( + shadowedNames: ReadonlySet, + newNames: ReadonlySet, + ): ReadonlySet => + newNames.size === 0 ? shadowedNames : new Set([...shadowedNames, ...newNames]); + + const visitClassBody = (classBody: WalkableNode, shadowedNames: ReadonlySet): void => { + const bodyElements = Array.isArray(classBody.body) ? classBody.body.filter(isWalkableNode) : []; + for (const element of bodyElements) { + if (element.type === "StaticBlock") { + visitValueNode(element, shadowedNames); + continue; + } + const isComputedKey = Boolean(element.computed); + if (isComputedKey) visitValueNode(element.key, shadowedNames); + const isStatic = Boolean(element.static); + if (element.type === "PropertyDefinition" && isStatic) { + visitValueNode(element.value, shadowedNames); + } + visitValueNode(element.decorators, shadowedNames); + } + }; + + const visitValueNode = (node: unknown, shadowedNames: ReadonlySet): void => { + if (Array.isArray(node)) { + for (const element of node) visitValueNode(element, shadowedNames); + return; + } + if (!isWalkableNode(node)) return; + + if (node.type === "Identifier" || node.type === "JSXIdentifier") { + if ( + typeof node.name === "string" && + importLocalNames.has(node.name) && + !shadowedNames.has(node.name) + ) { + const identifierName = node.name; + referencedNames.add(identifierName); + } + return; + } + + if (node.type.startsWith("TS")) { + if (TS_VALUE_WRAPPER_NODE_TYPES.has(node.type)) { + visitValueNode(node.expression, shadowedNames); + return; + } + if (!TS_RUNTIME_DECLARATION_NODE_TYPES.has(node.type)) return; + } + + if (FUNCTION_NODE_TYPES.has(node.type)) return; + + if (node.type === "BlockStatement" || node.type === "StaticBlock") { + const blockShadowedNames = mergeShadowedNames( + shadowedNames, + collectDirectBlockBindings(node.body), + ); + visitValueNode(node.body, blockShadowedNames); + return; + } + + if (node.type === "VariableDeclarator") { + visitValueNode(node.init, shadowedNames); + return; + } + + if (node.type === "CatchClause") { + const catchBindingNames = new Set(); + addBindingNames(node.param, catchBindingNames); + const catchShadowedNames = mergeShadowedNames(shadowedNames, catchBindingNames); + visitValueNode(node.body, catchShadowedNames); + return; + } + + if (node.type === "ClassDeclaration" || node.type === "ClassExpression") { + visitValueNode(node.superClass, shadowedNames); + visitValueNode(node.decorators, shadowedNames); + if (isWalkableNode(node.body)) visitClassBody(node.body, shadowedNames); + return; + } + + if (node.type === "CallExpression" || node.type === "NewExpression") { + const calledFunction = unwrapFunctionExpression(node.callee); + if (calledFunction) { + const functionShadowedNames = mergeShadowedNames( + shadowedNames, + collectFunctionBindings(calledFunction), + ); + visitValueNode(calledFunction.body, functionShadowedNames); + visitValueNode(node.arguments, shadowedNames); + return; + } + } + + if (node.type === "MemberExpression" || node.type === "JSXMemberExpression") { + visitValueNode(node.object, shadowedNames); + if (node.computed) { + visitValueNode(node.property, shadowedNames); + } + return; + } + + if (node.type === "Property") { + if (node.computed) { + visitValueNode(node.key, shadowedNames); + } + visitValueNode(node.value, shadowedNames); + return; + } + + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const element of value) visitValueNode(element, shadowedNames); + } else if (value && typeof value === "object") { + visitValueNode(value, shadowedNames); + } + } + }; + + for (const statement of bodyNodes) { + if (statement.type === "ImportDeclaration" || statement.type === "ExportAllDeclaration") { + continue; + } + if ( + statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ) { + visitValueNode((statement as { declaration?: unknown }).declaration, new Set()); + continue; + } + visitValueNode(statement, new Set()); + } + + return [...referencedNames]; +}; + +const createEmptyParsedSource = (): ParsedSource => ({ + imports: [], + exports: [], + memberAccesses: [], + wholeObjectUses: [], + localIdentifierReferences: [], + topLevelImportReferences: [], + referencedFilenames: [], + hasUnknownDynamicModuleLoad: false, + errors: [], + isGenerated: false, +}); + +const stripByteOrderMark = (sourceText: string): string => { + if (sourceText.charCodeAt(0) === 0xfeff) return sourceText.slice(1); + return sourceText; +}; + +const looksLikeBinaryContent = (sourceText: string): boolean => { + const sampleLength = Math.min(sourceText.length, BINARY_DETECTION_SAMPLE_BYTES); + let nullByteCount = 0; + for (let scanIndex = 0; scanIndex < sampleLength; scanIndex++) { + if (sourceText.charCodeAt(scanIndex) === 0) nullByteCount++; + if (nullByteCount > BINARY_DETECTION_NULL_BYTE_THRESHOLD) return true; + } + return false; +}; + +const looksLikeMinifiedSource = (sourceText: string): boolean => { + if (sourceText.length < MINIFIED_DETECTION_MIN_BYTES) return false; + const lineLengths = sourceText + .split("\n") + .map((sourceLine) => sourceLine.length) + .sort((leftLength, rightLength) => leftLength - rightLength); + const medianLineLength = lineLengths[Math.floor(lineLengths.length / 2)]; + return medianLineLength > MINIFIED_DETECTION_MEDIAN_LINE_LENGTH_THRESHOLD; +}; + +const safeReadSourceFile = ( + filePath: string, + errors: ProjectAnalysisError[], +): string | undefined => { + try { + const stats = statSync(filePath); + if (stats.size === 0) { + errors.push( + new FileReadError({ + code: "file-empty", + severity: "info", + message: "file is empty — nothing to analyze", + path: filePath, + }), + ); + return undefined; + } + if (stats.size > MAX_PARSE_FILE_SIZE_BYTES) { + errors.push( + new FileReadError({ + code: "file-too-large", + message: `file size ${stats.size}B exceeds MAX_PARSE_FILE_SIZE_BYTES (${MAX_PARSE_FILE_SIZE_BYTES})`, + path: filePath, + }), + ); + return undefined; + } + } catch (statError) { + errors.push( + new FileReadError({ + code: "file-read-failed", + message: "could not stat source file", + path: filePath, + detail: describeUnknownError(statError), + }), + ); + return undefined; + } + try { + const rawSourceText = readFileSync(filePath, "utf-8"); + const sourceText = stripByteOrderMark(rawSourceText); + if (looksLikeBinaryContent(sourceText)) { + errors.push( + new FileReadError({ + code: "file-binary", + severity: "info", + message: "file appears to be binary — skipping", + path: filePath, + }), + ); + return undefined; + } + if (looksLikeMinifiedSource(sourceText)) { + errors.push( + new FileReadError({ + code: "file-minified", + severity: "info", + message: "file appears to be a minified/bundled artifact — skipping redundancy analysis", + path: filePath, + }), + ); + return undefined; + } + return sourceText; + } catch (readError) { + errors.push( + new FileReadError({ + code: "file-read-failed", + message: "could not read source file", + path: filePath, + detail: describeUnknownError(readError), + }), + ); + return undefined; + } +}; + +export const parseSourceFile = (filePath: string): ParsedSource => { + const shouldCollectPushReferences = !/(?:^|[\\/])app\.config\.[^\\/]+$/.test(filePath); + const isCss = CSS_EXTENSIONS.some((ext) => filePath.endsWith(ext)); + if (isCss) { + try { + return parseCssImports(filePath); + } catch (cssError) { + return { + ...createEmptyParsedSource(), + errors: [ + new ParseError({ + code: "parse-failed", + message: "CSS import parsing crashed", + path: filePath, + detail: describeUnknownError(cssError), + }), + ], + }; + } + } + + const isNonJsFile = NON_JS_EXTENSIONS.some((ext) => filePath.endsWith(ext)); + if (isNonJsFile) { + return createEmptyParsedSource(); + } + + const earlyErrors: ProjectAnalysisError[] = []; + const sourceText = safeReadSourceFile(filePath, earlyErrors); + if (sourceText === undefined) { + return { + ...createEmptyParsedSource(), + errors: earlyErrors, + isGenerated: isGeneratedSource(filePath, ""), + }; + } + const isGenerated = isGeneratedSource(filePath, sourceText); + const imports: ImportReference[] = []; + const exports: ExportReference[] = []; + + const isMdx = filePath.endsWith(".mdx") || filePath.endsWith(".md"); + const isAstro = filePath.endsWith(".astro"); + const isVue = filePath.endsWith(".vue"); + const isSvelte = filePath.endsWith(".svelte"); + const isPreprocessed = isMdx || isAstro || isVue || isSvelte; + const textToParse = isMdx + ? extractMarkdownModuleStatements(sourceText) + : isAstro + ? extractAstroSources(sourceText) + : isVue + ? extractVueScriptContent(sourceText) + : isSvelte + ? extractSvelteScriptContent(sourceText) + : sourceText; + const parseFileName = + isMdx || isAstro || isVue || isSvelte + ? filePath.replace(/\.(md|mdx|astro|vue|svelte)$/, ".tsx") + : filePath; + + let result: ReturnType; + try { + result = parseSync(parseFileName, textToParse); + } catch (parseError) { + return { + ...createEmptyParsedSource(), + isGenerated, + errors: [ + ...earlyErrors, + new ParseError({ + code: "parse-failed", + message: "oxc-parser threw during initial parse", + path: filePath, + detail: describeUnknownError(parseError), + }), + ], + }; + } + + const isPlainJsFile = + parseFileName.endsWith(".js") || + parseFileName.endsWith(".mjs") || + parseFileName.endsWith(".cjs"); + + if (isPlainJsFile && result.errors.length > 0) { + try { + const jsxFileName = parseFileName.replace(/\.(m?js|cjs)$/, ".jsx"); + const jsxResult = parseSync(jsxFileName, textToParse); + if (jsxResult.errors.length === 0) { + result = jsxResult; + } else { + const tsxFileName = parseFileName.replace(/\.(m?js|cjs)$/, ".tsx"); + const tsxResult = parseSync(tsxFileName, textToParse); + if (tsxResult.errors.length === 0) { + result = tsxResult; + } + } + } catch { + // fall through with the existing (error-laden) result + } + } + + if (result.errors.length > 0 && !isPreprocessed) { + imports.push(...extractRecoveryImports(filePath, sourceText)); + return { + ...createEmptyParsedSource(), + imports, + exports, + referencedFilenames: extractReferencedFilenames(sourceText, [], shouldCollectPushReferences), + isGenerated, + errors: [ + ...earlyErrors, + new ParseError({ + code: "parse-recovered", + severity: "info", + message: `oxc-parser reported ${result.errors.length} syntax issue(s); skipping deep analysis for this file`, + path: filePath, + }), + ], + }; + } + + if (result.errors.length > 0) { + earlyErrors.push( + new ParseError({ + code: "parse-recovered-partial", + severity: "info", + message: `oxc-parser reported ${result.errors.length} syntax issue(s) in extracted ${isAstro ? "Astro" : isVue ? "Vue" : isSvelte ? "Svelte" : "MDX"} sources; continuing with partial AST`, + path: filePath, + }), + ); + } + + const program = result.program; + if (!program?.body) { + return { + ...createEmptyParsedSource(), + imports, + exports, + referencedFilenames: extractReferencedFilenames(sourceText, [], shouldCollectPushReferences), + isGenerated, + errors: [ + ...earlyErrors, + new ParseError({ + code: "parse-failed", + message: "oxc-parser returned no program body", + path: filePath, + }), + ], + }; + } + + const detectorErrors: ProjectAnalysisError[] = []; + + const safeWalk = ( + walkerName: string, + walker: () => ResultType, + fallback: ResultType, + ): ResultType => { + try { + return walker(); + } catch (walkError) { + detectorErrors.push( + new ParseError({ + code: "ast-walk-failed", + message: `${walkerName} threw during AST traversal`, + path: filePath, + detail: describeUnknownError(walkError), + }), + ); + return fallback; + } + }; + + safeWalk( + "extractImportsAndExports", + () => { + for (const node of program.body) { + switch (node.type) { + case "ImportDeclaration": + extractImportDeclaration(node, sourceText, imports); + break; + case "ExportNamedDeclaration": + extractNamedExportDeclaration(node, sourceText, exports); + break; + case "ExportDefaultDeclaration": + extractDefaultExportDeclaration(node, sourceText, exports); + break; + case "ExportAllDeclaration": + extractExportAllDeclaration(node, sourceText, exports); + break; + } + } + return undefined; + }, + undefined, + ); + + const hasUnknownDynamicModuleLoad = safeWalk( + "collectDynamicImports", + () => collectDynamicImports(program.body, sourceText, imports), + true, + ); + + const namespaceLocalNames = collectNamespaceLocalNames(imports); + const memberAccesses: MemberAccess[] = []; + const wholeObjectUses: string[] = []; + if (namespaceLocalNames.size > 0) { + safeWalk( + "collectMemberAccesses", + () => { + collectMemberAccesses(program.body, namespaceLocalNames, memberAccesses, wholeObjectUses); + return undefined; + }, + undefined, + ); + } + + const localIdentifierReferences = safeWalk( + "collectLocalIdentifierReferences", + () => collectLocalIdentifierReferences(program.body), + [], + ); + + const topLevelImportReferences = safeWalk( + "collectTopLevelImportReferences", + () => collectTopLevelImportReferences(program.body, collectStaticImportLocalNames(imports)), + [], + ); + + const referencedFilenames = extractReferencedFilenames( + sourceText, + program.body, + shouldCollectPushReferences, + ); + + return { + imports, + exports, + memberAccesses, + wholeObjectUses, + localIdentifierReferences, + topLevelImportReferences, + referencedFilenames, + hasUnknownDynamicModuleLoad, + errors: [...earlyErrors, ...detectorErrors], + isGenerated, + }; +}; + +const REFERENCED_FILENAME_LITERAL_PATTERN = + /(? = [], + shouldCollectPushReferences = true, +): string[] => { + const captured = new Set(); + REFERENCED_FILENAME_LITERAL_PATTERN.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = REFERENCED_FILENAME_LITERAL_PATTERN.exec(sourceText)) !== null) { + captured.add(match[1]); + } + + const visitNode = (node: WalkableNode): void => { + if (node.type === "ImportExpression") { + const sourceExpression = node.source; + if (isWalkableNode(sourceExpression) && sourceExpression.type === "Literal") { + const literalValue = sourceExpression.value; + if (typeof literalValue === "string" && REFERENCED_MODULE_PATH_PATTERN.test(literalValue)) { + captured.add(literalValue); + } + } + } + + if (node.type === "CallExpression" || node.type === "NewExpression") { + const callArguments = node.arguments; + const isPushCall = + node.type === "CallExpression" && + isWalkableNode(node.callee) && + node.callee.type === "MemberExpression" && + getIdentifierName(node.callee.property) === "push"; + if (!isPushCall || shouldCollectPushReferences) { + for (const callArgument of Array.isArray(callArguments) ? callArguments : []) { + if (!isWalkableNode(callArgument) || callArgument.type !== "Literal") continue; + const literalValue = callArgument.value; + if ( + typeof literalValue === "string" && + (REFERENCED_MODULE_PATH_PATTERN.test(literalValue) || + REFERENCED_MODULE_STEM_PATTERN.test(literalValue)) + ) { + captured.add(literalValue); + } + } + } + } + + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const element of value) { + if (isWalkableNode(element)) visitNode(element); + } + } else if (isWalkableNode(value)) { + visitNode(value); + } + } + }; + + for (const bodyNode of bodyNodes) { + if (isWalkableNode(bodyNode)) visitNode(bodyNode); + } + return [...captured]; +}; + +const WHOLE_OBJECT_FUNCTION_NAMES = new Set([ + "keys", + "values", + "entries", + "assign", + "freeze", + "getOwnPropertyNames", + "getOwnPropertyDescriptors", +]); + +const collectNamespaceLocalNames = (imports: ImportReference[]): Set => { + const namespaceNames = new Set(); + for (const importInfo of imports) { + for (const importedName of importInfo.importedNames) { + if (importedName.isNamespace && importedName.alias) { + namespaceNames.add(importedName.alias); + } + } + } + return namespaceNames; +}; + +const collectMemberAccesses = ( + bodyNodes: Array, + namespaceLocalNames: Set, + memberAccesses: MemberAccess[], + wholeObjectUses: string[], +): void => { + const walkForMemberAccesses = (node: WalkableNode): void => { + if (node.type === "MemberExpression" && !node.computed) { + const objectName = getIdentifierName(node.object); + const memberName = getIdentifierName(node.property); + if (objectName && memberName && namespaceLocalNames.has(objectName)) { + memberAccesses.push({ objectName, memberName }); + } + } + + if (node.type === "MemberExpression" && Boolean(node.computed)) { + const objectName = getIdentifierName(node.object); + if (objectName && namespaceLocalNames.has(objectName)) { + const expressionNode = node.expression; + if ( + isWalkableNode(expressionNode) && + expressionNode.type === "Literal" && + typeof expressionNode.value === "string" + ) { + memberAccesses.push({ objectName, memberName: expressionNode.value }); + } else { + wholeObjectUses.push(objectName); + } + } + } + + // `` — a JSX element whose name is a member of a namespace + // import. The name node is a `JSXMemberExpression`, not a `MemberExpression`, + // so it would otherwise be missed and the export reported unused (#875). + if (node.type === "JSXMemberExpression") { + const objectNode = isWalkableNode(node.object) ? node.object : undefined; + const propertyNode = isWalkableNode(node.property) ? node.property : undefined; + if ( + objectNode?.type === "JSXIdentifier" && + typeof objectNode.name === "string" && + namespaceLocalNames.has(objectNode.name) && + typeof propertyNode?.name === "string" + ) { + memberAccesses.push({ + objectName: objectNode.name, + memberName: propertyNode.name, + }); + } + } + + if (node.type === "SpreadElement") { + const spreadArgumentName = getIdentifierName(node.argument); + if (spreadArgumentName && namespaceLocalNames.has(spreadArgumentName)) { + wholeObjectUses.push(spreadArgumentName); + } + } + + // `const { a, b } = ns` — destructuring a namespace import reads those + // members without a MemberExpression, so it would otherwise be invisible + // to the usage map and the destructured exports reported unused (#875). + if (node.type === "VariableDeclarator") { + const namespaceName = getIdentifierName(node.init); + if ( + namespaceName && + namespaceLocalNames.has(namespaceName) && + isWalkableNode(node.id) && + node.id.type === "ObjectPattern" && + Array.isArray(node.id.properties) + ) { + for (const property of node.id.properties.filter(isWalkableNode)) { + if (property.type === "RestElement") { + wholeObjectUses.push(namespaceName); + continue; + } + if (property.computed) { + wholeObjectUses.push(namespaceName); + } else if (isWalkableNode(property.key)) { + const propertyName = getIdentifierName(property.key); + if (propertyName) { + memberAccesses.push({ objectName: namespaceName, memberName: propertyName }); + } else if (property.key.type === "Literal" && typeof property.key.value === "string") { + memberAccesses.push({ objectName: namespaceName, memberName: property.key.value }); + } + } + } + } + } + + if (node.type === "ForInStatement") { + const rightName = getIdentifierName(node.right); + if (rightName && namespaceLocalNames.has(rightName)) { + wholeObjectUses.push(rightName); + } + } + + if (node.type === "CallExpression") { + const calleeMember = isWalkableNode(node.callee) ? node.callee : undefined; + if (calleeMember?.type === "MemberExpression" && !calleeMember.computed) { + const calleeObjectName = getIdentifierName(calleeMember.object); + const calleePropertyName = getIdentifierName(calleeMember.property); + if ( + calleeObjectName === "Object" && + calleePropertyName && + WHOLE_OBJECT_FUNCTION_NAMES.has(calleePropertyName) && + Array.isArray(node.arguments) + ) { + const firstArgumentName = getIdentifierName(node.arguments[0]); + if (firstArgumentName && namespaceLocalNames.has(firstArgumentName)) { + wholeObjectUses.push(firstArgumentName); + } + } + } + } + + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const element of value) { + if (isWalkableNode(element)) walkForMemberAccesses(element); + } + } else if (isWalkableNode(value)) { + walkForMemberAccesses(value); + } + } + }; + + for (const topLevelNode of bodyNodes) { + if (isWalkableNode(topLevelNode)) walkForMemberAccesses(topLevelNode); + } +}; + +const extractImportDeclaration = ( + node: ImportDeclaration, + sourceText: string, + imports: ImportReference[], +): void => { + const specifier = node.source.value; + if (!specifier) return; + + const isTypeOnly = node.importKind === "type"; + const importedNames: ImportBinding[] = []; + + for (const specifierNode of node.specifiers) { + switch (specifierNode.type) { + case "ImportDefaultSpecifier": { + importedNames.push({ + name: "default", + alias: specifierNode.local.name, + isNamespace: false, + isDefault: true, + isTypeOnly, + }); + break; + } + case "ImportNamespaceSpecifier": { + importedNames.push({ + name: "*", + alias: specifierNode.local.name, + isNamespace: true, + isDefault: false, + isTypeOnly, + }); + break; + } + case "ImportSpecifier": { + const importedName = getModuleExportNameValue(specifierNode.imported); + const localName = specifierNode.local.name; + const isSelfAlias = + localName === importedName && + specifierNode.imported.type === "Identifier" && + specifierNode.imported.start !== specifierNode.local.start; + + importedNames.push({ + name: importedName, + alias: localName !== importedName ? localName : undefined, + isNamespace: false, + isDefault: importedName === "default", + isTypeOnly: isTypeOnly || specifierNode.importKind === "type", + isRedundantAlias: isSelfAlias || undefined, + }); + break; + } + } + } + + const isSideEffectImport = importedNames.length === 0; + + if (isSideEffectImport) { + importedNames.push({ + name: "*", + alias: undefined, + isNamespace: false, + isDefault: false, + isTypeOnly: false, + }); + } + + imports.push({ + specifier, + importedNames, + isTypeOnly, + isDynamic: false, + isSideEffect: isSideEffectImport, + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), + }); +}; + +const extractNamedExportDeclaration = ( + node: ExportNamedDeclaration, + sourceText: string, + exports: ExportReference[], +): void => { + const isTypeOnly = node.exportKind === "type"; + const reExportSource = node.source?.value; + + if (node.declaration) { + extractDeclarationNames(node.declaration, isTypeOnly, sourceText, exports, node.start); + } + + for (const specifierNode of node.specifiers) { + const exportedName = getModuleExportNameValue(specifierNode.exported); + const localName = getModuleExportNameValue(specifierNode.local); + const isSelfAlias = + exportedName === localName && + specifierNode.exported.type === "Identifier" && + specifierNode.local.type === "Identifier" && + specifierNode.exported.start !== specifierNode.local.start; + + exports.push({ + name: exportedName, + isDefault: exportedName === "default", + isTypeOnly: isTypeOnly || specifierNode.exportKind === "type", + isReExport: reExportSource !== undefined, + isSynthetic: false, + reExportSource, + reExportOriginalName: reExportSource !== undefined ? localName : undefined, + isNamespaceReExport: false, + line: getLineFromOffset(sourceText, specifierNode.start ?? node.start), + column: getColumnFromOffset(sourceText, specifierNode.start ?? node.start), + isRedundantAlias: isSelfAlias || undefined, + }); + } +}; + +const extractDefaultExportDeclaration = ( + node: ExportDefaultDeclaration, + sourceText: string, + exports: ExportReference[], +): void => { + const defaultExportLocalName = extractDefaultExportLocalName(node.declaration); + + exports.push({ + name: "default", + isDefault: true, + isTypeOnly: false, + isReExport: false, + isSynthetic: false, + reExportSource: undefined, + reExportOriginalName: undefined, + isNamespaceReExport: false, + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), + defaultExportLocalName, + }); +}; + +const extractExportAllDeclaration = ( + node: ExportAllDeclaration, + sourceText: string, + exports: ExportReference[], +): void => { + const reExportSource = node.source.value; + if (!reExportSource) return; + + const exportedName = node.exported ? getModuleExportNameValue(node.exported) : undefined; + + exports.push({ + name: exportedName ?? "*", + isDefault: false, + isTypeOnly: node.exportKind === "type", + isReExport: true, + isSynthetic: false, + reExportSource, + reExportOriginalName: "*", + isNamespaceReExport: true, + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), + }); +}; + +const extractDeclarationNames = ( + declaration: Declaration, + isTypeOnly: boolean, + sourceText: string, + exports: ExportReference[], + fallbackStart: number, +): void => { + const declarationType = declaration.type; + + if ( + declarationType === "FunctionDeclaration" || + declarationType === "ClassDeclaration" || + declarationType === "TSEnumDeclaration" + ) { + const declarationWithId = declaration as { id: { name: string } | null; start: number }; + const declarationName = declarationWithId.id?.name; + if (declarationName) { + exports.push({ + name: declarationName, + isDefault: false, + isTypeOnly, + isReExport: false, + isSynthetic: false, + reExportSource: undefined, + reExportOriginalName: undefined, + isNamespaceReExport: false, + line: getLineFromOffset(sourceText, declaration.start ?? fallbackStart), + column: getColumnFromOffset(sourceText, declaration.start ?? fallbackStart), + }); + } + return; + } + + if ( + declarationType === "TSTypeAliasDeclaration" || + declarationType === "TSInterfaceDeclaration" + ) { + const typeDeclaration = declaration as { id: { name: string }; start: number }; + const declarationName = typeDeclaration.id.name; + if (declarationName) { + exports.push({ + name: declarationName, + isDefault: false, + isTypeOnly: true, + isReExport: false, + isSynthetic: false, + reExportSource: undefined, + reExportOriginalName: undefined, + isNamespaceReExport: false, + line: getLineFromOffset(sourceText, declaration.start ?? fallbackStart), + column: getColumnFromOffset(sourceText, declaration.start ?? fallbackStart), + }); + } + return; + } + + if (declarationType === "VariableDeclaration") { + const variableDeclaration = declaration as VariableDeclaration; + for (const declarator of variableDeclaration.declarations) { + const bindingNames = extractBindingPatternNames(declarator.id); + for (const bindingName of bindingNames) { + exports.push({ + name: bindingName, + isDefault: false, + isTypeOnly, + isReExport: false, + isSynthetic: false, + reExportSource: undefined, + reExportOriginalName: undefined, + isNamespaceReExport: false, + line: getLineFromOffset(sourceText, declarator.start ?? fallbackStart), + column: getColumnFromOffset(sourceText, declarator.start ?? fallbackStart), + }); + } + } + } +}; + +const extractBindingPatternNames = (pattern: BindingPattern): string[] => { + if (!pattern) return []; + + if (pattern.type === "Identifier") { + return pattern.name ? [pattern.name] : []; + } + + if (pattern.type === "ObjectPattern") { + const names: string[] = []; + for (const property of pattern.properties) { + if (property.type === "RestElement") { + names.push(...extractBindingPatternNames(property.argument)); + } else { + names.push(...extractBindingPatternNames(property.value)); + } + } + return names; + } + + if (pattern.type === "ArrayPattern") { + const names: string[] = []; + for (const element of pattern.elements) { + if (!element) continue; + if (element.type === "RestElement") { + names.push(...extractBindingPatternNames(element.argument)); + } else { + names.push(...extractBindingPatternNames(element)); + } + } + return names; + } + + if (pattern.type === "AssignmentPattern") { + return extractBindingPatternNames(pattern.left); + } + + return []; +}; + +const createNamespaceImportBinding = (): ImportBinding => ({ + name: "*", + alias: undefined, + isNamespace: true, + isDefault: false, + isTypeOnly: false, +}); + +const createTypeImportBinding = (qualifier: unknown): ImportBinding => { + let importedName: string | undefined; + let currentQualifier = isWalkableNode(qualifier) ? qualifier : undefined; + while (currentQualifier?.type === "TSQualifiedName") { + currentQualifier = isWalkableNode(currentQualifier.left) ? currentQualifier.left : undefined; + } + if (currentQualifier?.type === "Identifier" && typeof currentQualifier.name === "string") { + importedName = currentQualifier.name; + } + return importedName + ? { + name: importedName, + alias: importedName, + isNamespace: false, + isDefault: false, + isTypeOnly: true, + } + : { + ...createNamespaceImportBinding(), + isTypeOnly: true, + }; +}; + +interface WalkableNode { + type: string; + start: number; + end: number; + [key: string]: unknown; +} + +const isObjectRecord = (value: unknown): value is Record => + value !== null && typeof value === "object"; + +const isWalkableNode = (value: unknown): value is WalkableNode => + isObjectRecord(value) && typeof value.type === "string"; + +const isImportMeta = (value: unknown): boolean => + isWalkableNode(value) && + value.type === "MetaProperty" && + getIdentifierName(value.meta) === "import" && + getIdentifierName(value.property) === "meta"; + +const getTemplateCookedValues = (expression: WalkableNode): string[] | undefined => { + if (!Array.isArray(expression.quasis)) return undefined; + const cookedValues: string[] = []; + for (const quasi of expression.quasis) { + if ( + !isObjectRecord(quasi) || + !isObjectRecord(quasi.value) || + typeof quasi.value.cooked !== "string" + ) { + return undefined; + } + cookedValues.push(quasi.value.cooked); + } + return cookedValues; +}; + +const extractStringLiteralFromArgument = (callArguments: unknown): string | undefined => { + if (!Array.isArray(callArguments)) return undefined; + const firstArgument = callArguments[0]; + if (!isWalkableNode(firstArgument)) return undefined; + if (firstArgument.type === "SpreadElement") return undefined; + if (firstArgument.type !== "Literal") return undefined; + const literalValue = firstArgument.value; + return typeof literalValue === "string" ? literalValue : undefined; +}; + +const extractGlobPatterns = (callArguments: unknown): string[] => { + if (!Array.isArray(callArguments)) return []; + const firstArgument = callArguments[0]; + if (!isWalkableNode(firstArgument) || firstArgument.type === "SpreadElement") return []; + + if (firstArgument.type === "Literal") { + const literalValue = firstArgument.value; + if ( + typeof literalValue === "string" && + (literalValue.startsWith("./") || + literalValue.startsWith("../") || + literalValue.startsWith("/")) + ) { + return [literalValue]; + } + return []; + } + + if (firstArgument.type === "TemplateLiteral") { + const cookedValues = getTemplateCookedValues(firstArgument); + if ( + cookedValues?.length === 1 && + (cookedValues[0].startsWith("./") || + cookedValues[0].startsWith("../") || + cookedValues[0].startsWith("/")) + ) { + return [cookedValues[0]]; + } + return []; + } + + if (firstArgument.type === "ArrayExpression") { + if (!Array.isArray(firstArgument.elements)) return []; + return firstArgument.elements.flatMap((element) => { + if ( + !isWalkableNode(element) || + element.type !== "Literal" || + typeof element.value !== "string" || + (!element.value.startsWith("./") && + !element.value.startsWith("../") && + !element.value.startsWith("/")) + ) { + return []; + } + return [element.value]; + }); + } + + return []; +}; + +interface WebpackContextMetadata { + specifier: string; + globBaseDirectory: string; + globFilterPattern: string | undefined; + globFilterFlags: string | undefined; +} + +const extractRequireContextMetadata = ( + callArguments: unknown, +): WebpackContextMetadata | undefined => { + if (!Array.isArray(callArguments)) return undefined; + const directoryArgument = callArguments[0]; + const recursiveArgument = callArguments[1]; + const regularExpressionArgument = callArguments[2]; + if ( + !isWalkableNode(directoryArgument) || + directoryArgument.type !== "Literal" || + typeof directoryArgument.value !== "string" || + (!directoryArgument.value.startsWith("./") && + !directoryArgument.value.startsWith("../") && + directoryArgument.value !== "." && + directoryArgument.value !== "..") + ) { + return undefined; + } + + let isRecursive = true; + if (recursiveArgument !== undefined) { + if ( + !isWalkableNode(recursiveArgument) || + recursiveArgument.type !== "Literal" || + typeof recursiveArgument.value !== "boolean" + ) { + return undefined; + } + isRecursive = recursiveArgument.value; + } + + let globFilterPattern: string | undefined; + let globFilterFlags: string | undefined; + if (regularExpressionArgument !== undefined) { + if ( + !isWalkableNode(regularExpressionArgument) || + regularExpressionArgument.type !== "Literal" || + !isObjectRecord(regularExpressionArgument.regex) || + typeof regularExpressionArgument.regex.pattern !== "string" + ) { + return undefined; + } + globFilterPattern = regularExpressionArgument.regex.pattern; + globFilterFlags = + typeof regularExpressionArgument.regex.flags === "string" + ? regularExpressionArgument.regex.flags + : undefined; + } + + const directory = directoryArgument.value.replace(/\/$/, ""); + return { + specifier: `${directory}/${isRecursive ? "**/*" : "*"}`, + globBaseDirectory: directoryArgument.value, + globFilterPattern, + globFilterFlags, + }; +}; + +const hasMockFactoryArgument = (callArguments: unknown): boolean => { + if (!Array.isArray(callArguments)) return false; + const secondArgument = callArguments[1]; + if (!isWalkableNode(secondArgument)) return false; + if (secondArgument.type === "SpreadElement") return false; + return ( + secondArgument.type === "ArrowFunctionExpression" || + secondArgument.type === "FunctionExpression" + ); +}; + +const synthesizeAutoMockSibling = (mockSource: string): string | undefined => { + if ( + !mockSource || + mockSource.includes("://") || + mockSource.startsWith("data:") || + mockSource.split("/").some((segment) => segment === "__mocks__") + ) { + return undefined; + } + const lastSlashIndex = mockSource.lastIndexOf("/"); + if (lastSlashIndex === -1) return undefined; + const directory = mockSource.slice(0, lastSlashIndex); + const fileName = mockSource.slice(lastSlashIndex + 1); + if (!fileName) return undefined; + return `${directory}/__mocks__/${fileName}`; +}; + +const collectDynamicImports = ( + bodyNodes: Array, + sourceText: string, + imports: ImportReference[], +): boolean => { + const trustedTestApiBindingNames = new Set(); + const trustedCreateRequireFactoryNames = new Set(); + const trustedModuleNamespaceNames = new Set(); + for (const statement of bodyNodes) { + if (statement.type !== "ImportDeclaration") continue; + const moduleName = + isWalkableNode(statement.source) && typeof statement.source.value === "string" + ? statement.source.value + : undefined; + const isNodeModule = moduleName === "module" || moduleName === "node:module"; + const trustedImportName = + moduleName === "vitest" ? "vi" : moduleName === "@jest/globals" ? "jest" : undefined; + if (!Array.isArray(statement.specifiers)) continue; + for (const specifier of statement.specifiers) { + if (!isWalkableNode(specifier)) continue; + const localName = getIdentifierName(specifier.local); + if (!localName) continue; + if ( + trustedImportName && + specifier.type === "ImportSpecifier" && + getIdentifierName(specifier.imported) === trustedImportName + ) + trustedTestApiBindingNames.add(localName); + if (!isNodeModule) continue; + if ( + specifier.type === "ImportSpecifier" && + getIdentifierName(specifier.imported) === "createRequire" + ) + trustedCreateRequireFactoryNames.add(localName); + if (specifier.type === "ImportNamespaceSpecifier") { + trustedModuleNamespaceNames.add(localName); + } + } + } + let isGlobalRequireAvailable = true; + visitOxcAstWithBindings( + { type: "Program", start: 0, end: sourceText.length, body: bodyNodes }, + (_node, bindingNames) => { + isGlobalRequireAvailable = !bindingNames.has("require"); + return false; + }, + ); + const unwrapExpression = (value: unknown): WalkableNode | undefined => { + let expression = isWalkableNode(value) ? value : undefined; + while ( + expression && + (expression.type === "ParenthesizedExpression" || + expression.type === "TSAsExpression" || + expression.type === "TSTypeAssertion" || + expression.type === "TSNonNullExpression" || + expression.type === "ChainExpression") + ) { + expression = isWalkableNode(expression.expression) ? expression.expression : undefined; + } + return expression; + }; + const isTrustedNodeModuleRequireCall = (value: unknown): boolean => { + const expression = unwrapExpression(value); + if (!isGlobalRequireAvailable || expression?.type !== "CallExpression") return false; + if (getIdentifierName(unwrapExpression(expression.callee)) !== "require") return false; + const moduleName = extractStringLiteralFromArgument(expression.arguments); + return moduleName === "module" || moduleName === "node:module"; + }; + for (const statement of bodyNodes) { + if (statement.type !== "VariableDeclaration" || statement.kind !== "const") continue; + const declarations = Array.isArray(statement.declarations) ? statement.declarations : []; + for (const declaration of declarations) { + if (!isWalkableNode(declaration)) continue; + const initializer = unwrapExpression(declaration.init); + if (isTrustedNodeModuleRequireCall(initializer)) { + const namespaceName = getIdentifierName(declaration.id); + if (namespaceName) trustedModuleNamespaceNames.add(namespaceName); + if (isWalkableNode(declaration.id) && declaration.id.type === "ObjectPattern") { + const properties = Array.isArray(declaration.id.properties) + ? declaration.id.properties + : []; + for (const property of properties) { + if ( + isWalkableNode(property) && + property.type === "Property" && + !property.computed && + getIdentifierName(property.key) === "createRequire" + ) { + const factoryName = getIdentifierName(property.value); + if (factoryName) trustedCreateRequireFactoryNames.add(factoryName); + } + } + } + } + if ( + initializer?.type === "MemberExpression" && + !initializer.computed && + getIdentifierName(initializer.property) === "createRequire" && + isTrustedNodeModuleRequireCall(initializer.object) + ) { + const factoryName = getIdentifierName(declaration.id); + if (factoryName) trustedCreateRequireFactoryNames.add(factoryName); + } + } + } + const isTrustedCreateRequireCall = (value: unknown): boolean => { + const expression = unwrapExpression(value); + if (expression?.type !== "CallExpression") return false; + const callee = unwrapExpression(expression.callee); + const directCalleeName = getIdentifierName(callee); + if (directCalleeName && trustedCreateRequireFactoryNames.has(directCalleeName)) return true; + if (callee?.type !== "MemberExpression" || callee.computed) return false; + if (getIdentifierName(callee.property) !== "createRequire") return false; + const namespaceName = getIdentifierName(unwrapExpression(callee.object)); + if (namespaceName && trustedModuleNamespaceNames.has(namespaceName)) return true; + return isTrustedNodeModuleRequireCall(callee.object); + }; + const trustedRequireBindingNames = new Set(); + for (const statement of bodyNodes) { + if (statement.type !== "VariableDeclaration" || statement.kind !== "const") continue; + const declarations = Array.isArray(statement.declarations) ? statement.declarations : []; + for (const declaration of declarations) { + if (!isWalkableNode(declaration) || !isTrustedCreateRequireCall(declaration.init)) continue; + const localName = getIdentifierName(declaration.id); + if (localName) trustedRequireBindingNames.add(localName); + } + } + const jitiLoadReferences = extractJitiLoadReferences(sourceText); + let hasUnknownDynamicModuleLoad = jitiLoadReferences.some( + (jitiLoadReference) => jitiLoadReference.path === undefined, + ); + for (const jitiLoadReference of jitiLoadReferences) { + if (!jitiLoadReference.path) continue; + imports.push({ + specifier: jitiLoadReference.path, + importedNames: [createNamespaceImportBinding()], + isTypeOnly: false, + isDynamic: true, + isSideEffect: false, + line: jitiLoadReference.line, + column: jitiLoadReference.column, + }); + } + const walkNode = ( + node: WalkableNode, + bindingNames: ReadonlySet, + parentNode: WalkableNode | undefined, + nestedBindingNames: ReadonlySet, + ): boolean | void => { + const isGlobalRequire = !bindingNames.has("require"); + if (node.type === "TSImportType") { + const sourceExpression = isWalkableNode(node.source) ? node.source : undefined; + if ( + sourceExpression?.type === "Literal" && + typeof sourceExpression.value === "string" && + sourceExpression.value + ) { + imports.push({ + specifier: sourceExpression.value, + importedNames: [createTypeImportBinding(node.qualifier)], + isTypeOnly: true, + isDynamic: false, + isSideEffect: false, + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), + }); + } + return; + } + + if (node.type === "ImportExpression") { + const sourceExpression = isWalkableNode(node.source) ? node.source : undefined; + if (!sourceExpression) { + hasUnknownDynamicModuleLoad = true; + return; + } + if (sourceExpression.type === "Literal") { + if (typeof sourceExpression.value === "string" && sourceExpression.value) { + imports.push({ + specifier: sourceExpression.value, + importedNames: [createNamespaceImportBinding()], + isTypeOnly: false, + isDynamic: true, + isSideEffect: false, + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), + }); + } + } else if (sourceExpression.type === "TemplateLiteral") { + const cookedValues = getTemplateCookedValues(sourceExpression); + if (cookedValues && cookedValues.length >= 2) { + const globPattern = cookedValues.join("*"); + if (globPattern.startsWith("./") || globPattern.startsWith("../")) { + imports.push({ + specifier: globPattern, + importedNames: [createNamespaceImportBinding()], + isTypeOnly: false, + isDynamic: true, + isSideEffect: false, + isGlob: true, + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), + }); + } else { + hasUnknownDynamicModuleLoad = true; + } + } else { + hasUnknownDynamicModuleLoad = true; + } + } else { + hasUnknownDynamicModuleLoad = true; + } + return; + } + + if (node.type === "CallExpression") { + const callee = isWalkableNode(node.callee) ? node.callee : undefined; + const directCalleeName = getIdentifierName(callee); + const memberCalleeName = + callee?.type === "MemberExpression" && !callee.computed + ? getIdentifierName(callee.property) + : undefined; + if ( + directCalleeName === "readdir" || + directCalleeName === "readdirSync" || + memberCalleeName === "readdir" || + memberCalleeName === "readdirSync" + ) { + hasUnknownDynamicModuleLoad = true; + } + const isTrustedDirectRequire = + (directCalleeName === "require" && (isGlobalRequire || bindingNames.size === 0)) || + (directCalleeName !== undefined && + trustedRequireBindingNames.has(directCalleeName) && + !nestedBindingNames.has(directCalleeName)); + if (isTrustedDirectRequire) { + const requireSpecifier = extractStringLiteralFromArgument(node.arguments); + if (requireSpecifier) { + const parentMemberExpression = + parentNode?.type === "MemberExpression" && parentNode.object === node + ? parentNode + : undefined; + const importedMemberName = parentMemberExpression + ? parentMemberExpression.computed + ? isWalkableNode(parentMemberExpression.property) && + parentMemberExpression.property.type === "Literal" && + typeof parentMemberExpression.property.value === "string" + ? parentMemberExpression.property.value + : undefined + : getIdentifierName(parentMemberExpression.property) + : undefined; + imports.push({ + specifier: requireSpecifier, + importedNames: importedMemberName + ? [ + { + name: importedMemberName, + alias: undefined, + isNamespace: false, + isDefault: importedMemberName === "default", + isTypeOnly: false, + }, + ] + : [createNamespaceImportBinding()], + isTypeOnly: false, + isDynamic: true, + isSideEffect: false, + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), + }); + } else { + hasUnknownDynamicModuleLoad = true; + } + } + + if (callee?.type === "MemberExpression" && !callee.computed) { + const objectName = getIdentifierName(callee.object); + const propertyName = getIdentifierName(callee.property); + const isTrustedRequireObject = + (objectName === "require" && isGlobalRequire) || + (objectName !== undefined && + trustedRequireBindingNames.has(objectName) && + !nestedBindingNames.has(objectName)); + + if (objectName === "require" && propertyName === "context" && isGlobalRequire) { + const contextMetadata = extractRequireContextMetadata(node.arguments); + if (contextMetadata) { + imports.push({ + ...contextMetadata, + importedNames: [createNamespaceImportBinding()], + isTypeOnly: false, + isDynamic: true, + isSideEffect: false, + isGlob: true, + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), + }); + } else { + hasUnknownDynamicModuleLoad = true; + } + } + + if (propertyName === "resolve" && isTrustedRequireObject) { + const resolveSpecifier = extractStringLiteralFromArgument(node.arguments); + if (resolveSpecifier) { + imports.push({ + specifier: resolveSpecifier, + importedNames: [createNamespaceImportBinding()], + isTypeOnly: false, + isDynamic: true, + isSideEffect: false, + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), + }); + } else { + hasUnknownDynamicModuleLoad = true; + } + } + + const isUnshadowedTestApi = + objectName !== undefined && + (trustedTestApiBindingNames.has(objectName) + ? !nestedBindingNames.has(objectName) + : (objectName === "vi" || objectName === "jest") && !bindingNames.has(objectName)); + if (isUnshadowedTestApi && propertyName === "mock") { + const mockSpecifier = extractStringLiteralFromArgument(node.arguments); + if (mockSpecifier) { + imports.push({ + specifier: mockSpecifier, + importedNames: [createNamespaceImportBinding()], + isTypeOnly: false, + isDynamic: true, + isSideEffect: true, + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), + }); + + const hasFactoryArgument = hasMockFactoryArgument(node.arguments); + const autoMockSibling = synthesizeAutoMockSibling(mockSpecifier); + if (!hasFactoryArgument && autoMockSibling) { + imports.push({ + specifier: autoMockSibling, + importedNames: [createNamespaceImportBinding()], + isTypeOnly: false, + isDynamic: true, + isSideEffect: true, + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), + }); + } + } + } + if (isImportMeta(callee.object) && propertyName === "glob") { + const globPatterns = extractGlobPatterns(node.arguments); + if (globPatterns.length === 0) hasUnknownDynamicModuleLoad = true; + for (const globPattern of globPatterns) { + imports.push({ + specifier: globPattern, + importedNames: [createNamespaceImportBinding()], + isTypeOnly: false, + isDynamic: true, + isSideEffect: false, + isGlob: true, + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), + }); + } + } + } + } + + if (node.type === "NewExpression") { + const calleeName = getIdentifierName(node.callee); + if (calleeName === "URL" && Array.isArray(node.arguments) && node.arguments.length >= 2) { + const secondArgument = isWalkableNode(node.arguments[1]) ? node.arguments[1] : undefined; + const isImportMetaUrl = + secondArgument?.type === "MemberExpression" && + isImportMeta(secondArgument.object) && + getIdentifierName(secondArgument.property) === "url"; + if (isImportMetaUrl) { + const urlSpecifier = extractStringLiteralFromArgument(node.arguments); + if (urlSpecifier) { + imports.push({ + specifier: urlSpecifier, + importedNames: [createNamespaceImportBinding()], + isTypeOnly: false, + isDynamic: true, + isSideEffect: true, + line: getLineFromOffset(sourceText, node.start), + column: getColumnFromOffset(sourceText, node.start), + }); + } else { + hasUnknownDynamicModuleLoad = true; + } + } + } + } + + if (node.type === "Decorator") { + const expression = isWalkableNode(node.expression) ? node.expression : undefined; + if ( + expression?.type === "CallExpression" && + getIdentifierName(expression.callee) === "Component" + ) { + const objectArgument = Array.isArray(expression.arguments) + ? expression.arguments[0] + : undefined; + if (isWalkableNode(objectArgument) && objectArgument.type === "ObjectExpression") { + const objectProperties = Array.isArray(objectArgument.properties) + ? objectArgument.properties.filter(isWalkableNode) + : []; + for (const property of objectProperties) { + if (property.type !== "ObjectProperty" && property.type !== "Property") continue; + const propertyKey = isWalkableNode(property.key) ? property.key : undefined; + const propertyName = getIdentifierName(propertyKey) ?? propertyKey?.value; + const propertyValue = isWalkableNode(property.value) ? property.value : undefined; + if ( + propertyName === "templateUrl" && + propertyValue?.type === "Literal" && + typeof propertyValue.value === "string" && + propertyValue.value + ) { + const templatePath = propertyValue.value; + imports.push({ + specifier: templatePath.startsWith(".") ? templatePath : `./${templatePath}`, + importedNames: [], + isTypeOnly: false, + isDynamic: false, + isSideEffect: true, + line: getLineFromOffset(sourceText, property.start), + column: getColumnFromOffset(sourceText, property.start), + }); + } + if ((propertyName === "styleUrl" || propertyName === "styleUrls") && propertyValue) { + const styleUrlValues: string[] = []; + if (propertyValue.type === "Literal" && typeof propertyValue.value === "string") { + styleUrlValues.push(propertyValue.value); + } else if ( + propertyValue.type === "ArrayExpression" && + Array.isArray(propertyValue.elements) + ) { + for (const element of propertyValue.elements) { + if ( + isWalkableNode(element) && + element.type === "Literal" && + typeof element.value === "string" + ) { + styleUrlValues.push(element.value); + } + } + } + for (const styleUrl of styleUrlValues) { + imports.push({ + specifier: styleUrl.startsWith(".") ? styleUrl : `./${styleUrl}`, + importedNames: [], + isTypeOnly: false, + isDynamic: false, + isSideEffect: true, + line: getLineFromOffset(sourceText, property.start), + column: getColumnFromOffset(sourceText, property.start), + }); + } + } + } + } + } + } + + return true; + }; + + visitOxcAstWithBindings(bodyNodes, (node, bindingNames, parentNode, nestedBindingNames) => { + if (!isWalkableNode(node)) return; + return walkNode( + node, + bindingNames, + parentNode && isOxcAstNode(parentNode) && isWalkableNode(parentNode) ? parentNode : undefined, + nestedBindingNames, + ); + }); + return hasUnknownDynamicModuleLoad; +}; + +const ROUTE_CALL_FILE_ARG_INDEX: Record = { + route: 1, + layout: 0, + index: 0, +}; + +const extractStringFromExpression = (expression: WalkableNode): string | undefined => { + if (expression.type === "Literal") { + const literalValue = expression.value; + return typeof literalValue === "string" ? literalValue : undefined; + } + if (expression.type === "TemplateLiteral") { + const cookedValues = getTemplateCookedValues(expression); + if (Array.isArray(expression.expressions) && expression.expressions.length === 0) { + return cookedValues?.length === 1 ? cookedValues[0] : undefined; + } + } + return undefined; +}; + +export const extractReactRouterRouteModuleEntries = (routesFilePath: string): string[] => { + const sourceText = readFileSync(routesFilePath, "utf-8"); + const result = parseSync(routesFilePath, sourceText); + + if (result.errors.length > 0 || !result.program?.body) { + return []; + } + + const modulePaths: string[] = []; + + const walkForRouteCalls = (node: WalkableNode): void => { + if (node.type === "CallExpression") { + const calleeName = getIdentifierName(node.callee); + if (calleeName) { + const fileArgumentIndex = ROUTE_CALL_FILE_ARG_INDEX[calleeName]; + + if (fileArgumentIndex !== undefined && Array.isArray(node.arguments)) { + const fileArgument = node.arguments[fileArgumentIndex]; + if (isWalkableNode(fileArgument) && fileArgument.type !== "SpreadElement") { + const filePath = extractStringFromExpression(fileArgument); + if (filePath) { + modulePaths.push(filePath); + } + } + } + } + } + + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const element of value) { + if (isWalkableNode(element)) walkForRouteCalls(element); + } + } else if (isWalkableNode(value)) { + walkForRouteCalls(value); + } + } + }; + + for (const topLevelNode of result.program.body) { + if (isWalkableNode(topLevelNode)) walkForRouteCalls(topLevelNode); + } + + return modulePaths; +}; diff --git a/packages/core/src/project-analysis/collect/react-email-template-entries.ts b/packages/core/src/project-analysis/collect/react-email-template-entries.ts new file mode 100644 index 0000000000..367535630b --- /dev/null +++ b/packages/core/src/project-analysis/collect/react-email-template-entries.ts @@ -0,0 +1,40 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import fg from "fast-glob"; +import { extractReactEmailTemplateDirectories } from "../utils/extract-react-email-template-directories.js"; +import { parseSourceFile } from "./parse.js"; + +export const extractReactEmailTemplateEntries = (directory: string): string[] => { + const packageJsonPath = resolve(directory, "package.json"); + if (!existsSync(packageJsonPath)) return []; + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + const dependencies = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.optionalDependencies, + }; + if (!("react-email" in dependencies) && !("@react-email/preview-server" in dependencies)) { + return []; + } + const scripts = Object.values(packageJson.scripts ?? {}).filter( + (script): script is string => typeof script === "string", + ); + return extractReactEmailTemplateDirectories(scripts).flatMap((templateDirectory) => + fg + .sync("**/*.{js,jsx,tsx}", { + cwd: resolve(directory, templateDirectory), + absolute: true, + onlyFiles: true, + ignore: ["**/_*/**", "**/_*.*"], + }) + .filter((templatePath) => + parseSourceFile(templatePath).exports.some( + (exportReference) => exportReference.isDefault && !exportReference.isTypeOnly, + ), + ), + ); + } catch { + return []; + } +}; diff --git a/packages/core/src/project-analysis/collect/runtime-consumed-directory-files.ts b/packages/core/src/project-analysis/collect/runtime-consumed-directory-files.ts new file mode 100644 index 0000000000..ec83cfafc5 --- /dev/null +++ b/packages/core/src/project-analysis/collect/runtime-consumed-directory-files.ts @@ -0,0 +1,301 @@ +import { readFileSync, statSync } from "node:fs"; +import { dirname, isAbsolute, resolve } from "node:path"; +import fg from "fast-glob"; +import { parseSync } from "oxc-parser"; +import { MAX_PARSE_FILE_SIZE_BYTES } from "../constants.js"; +import { getIdentifierName, isOxcAstNode, type OxcAstNode } from "../utils/oxc-ast-node.js"; + +const SOURCE_FILE_GLOB = "**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs,es6}"; +const DIRECTORY_ROOT_NAME_PATTERN = /(?:root|resource|project|cwd)/i; +const DIRECTORY_CONSUMER_NAMES = new Set(["copySync", "listSync", "readdir", "readdirSync"]); +const FILESYSTEM_MODULE_NAMES = new Set(["fs", "node:fs", "fs/promises", "node:fs/promises"]); +const FILESYSTEM_BASE_MODULE_NAMES = new Set(["fs", "node:fs"]); +const PATH_MODULE_NAMES = new Set(["path", "node:path", "path/posix", "node:path/posix"]); +const TRANSPARENT_EXPRESSION_TYPES = new Set([ + "ChainExpression", + "ParenthesizedExpression", + "TSAsExpression", + "TSInstantiationExpression", + "TSNonNullExpression", + "TSSatisfiesExpression", + "TSTypeAssertion", +]); + +const getLiteralString = (node: unknown): string | undefined => { + if (!isOxcAstNode(node)) return undefined; + if (node.type === "Literal" && typeof node.value === "string") return node.value; + if ( + node.type === "TemplateLiteral" && + Array.isArray(node.expressions) && + node.expressions.length === 0 && + Array.isArray(node.quasis) && + isOxcAstNode(node.quasis[0]) && + node.quasis[0].value && + typeof node.quasis[0].value === "object" && + "cooked" in node.quasis[0].value && + typeof node.quasis[0].value.cooked === "string" + ) { + return node.quasis[0].value.cooked; + } + return undefined; +}; + +const extendScope = ( + statements: unknown[], + parentScope: ReadonlyMap, +): Map => { + const scope = new Map(parentScope); + for (const statement of statements) { + if (!isOxcAstNode(statement)) continue; + if ( + (statement.type === "FunctionDeclaration" || statement.type === "ClassDeclaration") && + getIdentifierName(statement.id) + ) { + const declarationName = getIdentifierName(statement.id); + if (declarationName) scope.set(declarationName, null); + } + if (statement.type !== "VariableDeclaration") continue; + const declarations = Array.isArray(statement.declarations) ? statement.declarations : []; + for (const declaration of declarations) { + if (!isOxcAstNode(declaration)) continue; + const variableName = getIdentifierName(declaration.id); + if (!variableName) continue; + scope.set( + variableName, + statement.kind === "const" && isOxcAstNode(declaration.init) ? declaration.init : null, + ); + } + } + return scope; +}; + +const getMemberPropertyName = (node: OxcAstNode): string | undefined => + node.type === "MemberExpression" && node.computed !== true + ? getIdentifierName(node.property) + : undefined; + +const isRequiredModule = ( + initializer: OxcAstNode | null | undefined, + moduleNames: ReadonlySet, +): boolean => { + if ( + !initializer || + initializer.type !== "CallExpression" || + getIdentifierName(initializer.callee) !== "require" || + !Array.isArray(initializer.arguments) + ) { + return false; + } + const moduleName = getLiteralString(initializer.arguments[0]); + return moduleName !== undefined && moduleNames.has(moduleName); +}; + +const evaluateStaticPath = ( + expression: unknown, + sourcePath: string, + projectDirectory: string, + initializers: ReadonlyMap, + visitedIdentifiers = new Set(), +): string | undefined => { + if (!isOxcAstNode(expression)) return undefined; + if (TRANSPARENT_EXPRESSION_TYPES.has(expression.type)) { + return evaluateStaticPath( + expression.expression, + sourcePath, + projectDirectory, + initializers, + visitedIdentifiers, + ); + } + const literalValue = getLiteralString(expression); + if (literalValue !== undefined) return literalValue; + const identifierName = getIdentifierName(expression); + if (identifierName === "__dirname") return dirname(sourcePath); + if (identifierName) { + if (visitedIdentifiers.has(identifierName)) return undefined; + if (initializers.has(identifierName)) { + const initializer = initializers.get(identifierName); + if (!initializer) return undefined; + return evaluateStaticPath( + initializer, + sourcePath, + projectDirectory, + initializers, + new Set(visitedIdentifiers).add(identifierName), + ); + } + return DIRECTORY_ROOT_NAME_PATTERN.test(identifierName) ? projectDirectory : undefined; + } + if (expression.type === "MemberExpression") { + const propertyName = getMemberPropertyName(expression); + return propertyName && DIRECTORY_ROOT_NAME_PATTERN.test(propertyName) + ? projectDirectory + : undefined; + } + if (expression.type !== "CallExpression" || !isOxcAstNode(expression.callee)) return undefined; + const argumentsList = Array.isArray(expression.arguments) ? expression.arguments : []; + const memberPropertyName = getMemberPropertyName(expression.callee); + if ( + memberPropertyName === "cwd" && + isOxcAstNode(expression.callee.object) && + getIdentifierName(expression.callee.object) === "process" && + argumentsList.length === 0 + ) { + return projectDirectory; + } + const isPathCall = + memberPropertyName !== undefined && + ["join", "resolve"].includes(memberPropertyName) && + isOxcAstNode(expression.callee.object) && + (() => { + const pathObjectName = getIdentifierName(expression.callee.object); + if (!pathObjectName) return false; + return ( + (!initializers.has(pathObjectName) && pathObjectName === "path") || + isRequiredModule(initializers.get(pathObjectName), PATH_MODULE_NAMES) + ); + })(); + if (!isPathCall || argumentsList.length < 2) return undefined; + const pathSegments = argumentsList.map((argument) => + evaluateStaticPath(argument, sourcePath, projectDirectory, initializers, visitedIdentifiers), + ); + return pathSegments.every((pathSegment): pathSegment is string => pathSegment !== undefined) + ? resolve(...pathSegments) + : undefined; +}; + +const getDirectoryConsumerName = ( + callee: OxcAstNode, + scope: ReadonlyMap, +): string | undefined => { + const directName = getIdentifierName(callee); + if (directName && DIRECTORY_CONSUMER_NAMES.has(directName) && !scope.has(directName)) { + return directName; + } + const memberName = getMemberPropertyName(callee); + if (!memberName || !DIRECTORY_CONSUMER_NAMES.has(memberName)) return undefined; + if (!isOxcAstNode(callee.object)) return undefined; + const objectName = getIdentifierName(callee.object); + if ( + objectName && + ((!scope.has(objectName) && ["fs", "fsp", "promises"].includes(objectName)) || + isRequiredModule(scope.get(objectName), FILESYSTEM_MODULE_NAMES)) + ) { + return memberName; + } + if ( + callee.object.type === "MemberExpression" && + (() => { + const fsObjectName = getIdentifierName(callee.object.object); + if (!fsObjectName) return false; + return ( + (!scope.has(fsObjectName) && fsObjectName === "fs") || + isRequiredModule(scope.get(fsObjectName), FILESYSTEM_BASE_MODULE_NAMES) + ); + })() && + getMemberPropertyName(callee.object) === "promises" + ) { + return memberName; + } + return undefined; +}; + +const collectConsumedDirectories = ( + program: unknown, + sourcePath: string, + projectDirectory: string, +): string[] => { + const consumedDirectories = new Set(); + const visitNode = ( + node: unknown, + inheritedScope: ReadonlyMap, + ): void => { + if (Array.isArray(node)) { + for (const child of node) visitNode(child, inheritedScope); + return; + } + if (!isOxcAstNode(node)) return; + let scope = inheritedScope; + if ((node.type === "Program" || node.type === "BlockStatement") && Array.isArray(node.body)) { + scope = extendScope(node.body, inheritedScope); + } + if ( + (node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression") && + Array.isArray(node.params) + ) { + const functionScope = new Map(scope); + for (const parameter of node.params) { + const parameterName = getIdentifierName(parameter); + if (parameterName) functionScope.set(parameterName, null); + } + scope = functionScope; + } + if ( + node.type === "CallExpression" && + isOxcAstNode(node.callee) && + getDirectoryConsumerName(node.callee, scope) && + Array.isArray(node.arguments) + ) { + const consumedDirectory = evaluateStaticPath( + node.arguments[0], + sourcePath, + projectDirectory, + scope, + ); + if (consumedDirectory) consumedDirectories.add(consumedDirectory); + } + for (const child of Object.values(node)) visitNode(child, scope); + }; + visitNode(program, new Map()); + return [...consumedDirectories]; +}; + +export const extractRuntimeConsumedDirectoryFiles = (directory: string): string[] => { + const consumedFiles = new Set(); + const sourcePaths = fg.sync(SOURCE_FILE_GLOB, { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], + }); + for (const sourcePath of sourcePaths) { + let source: string; + try { + if (statSync(sourcePath).size > MAX_PARSE_FILE_SIZE_BYTES) continue; + source = readFileSync(sourcePath, "utf-8"); + } catch { + continue; + } + let parsedModule: ReturnType; + try { + parsedModule = parseSync(sourcePath, source, { sourceType: "unambiguous" }); + } catch { + continue; + } + if (parsedModule.errors.some((error) => error.severity === "Error")) continue; + for (const consumedDirectory of collectConsumedDirectories( + parsedModule.program, + sourcePath, + directory, + )) { + const resolvedConsumedDirectory = isAbsolute(consumedDirectory) + ? consumedDirectory + : resolve(directory, consumedDirectory); + try { + if (!statSync(resolvedConsumedDirectory).isDirectory()) continue; + } catch { + continue; + } + for (const consumedFile of fg.sync(SOURCE_FILE_GLOB, { + cwd: resolvedConsumedDirectory, + absolute: true, + onlyFiles: true, + })) { + consumedFiles.add(consumedFile); + } + } + } + return [...consumedFiles]; +}; diff --git a/packages/deslop-js/src/collect/sections-module-entries.ts b/packages/core/src/project-analysis/collect/sections-module-entries.ts similarity index 100% rename from packages/deslop-js/src/collect/sections-module-entries.ts rename to packages/core/src/project-analysis/collect/sections-module-entries.ts diff --git a/packages/core/src/project-analysis/collect/sibling-workspace-import-entries.ts b/packages/core/src/project-analysis/collect/sibling-workspace-import-entries.ts new file mode 100644 index 0000000000..2dc05f913b --- /dev/null +++ b/packages/core/src/project-analysis/collect/sibling-workspace-import-entries.ts @@ -0,0 +1,237 @@ +import fg from "fast-glob"; +import { join, resolve } from "node:path"; +import { readFileSync } from "node:fs"; +import { parseSync } from "oxc-parser"; +import ts from "typescript"; +import { findMonorepoRoot } from "../utils/find-monorepo-root.js"; +import { getIdentifierName, isOxcAstNode } from "../utils/oxc-ast-node.js"; +import { resolveWorkspaces } from "./workspaces.js"; +import { resolveWorkspaceSubpath, trySourceFallback } from "../resolver/resolve.js"; + +const SIBLING_SOURCE_GLOB = "**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}"; + +const SIBLING_STYLELINT_CONFIG_GLOBS = [ + "**/.stylelintrc.{js,cjs,mjs,ts,mts,cts}", + "**/stylelint.config.{js,cjs,mjs,ts,mts,cts}", +]; + +const SIBLING_IGNORE_PATTERNS = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.git/**"]; + +const readPackageName = (directory: string): string | undefined => { + try { + const content = readFileSync(join(directory, "package.json"), "utf-8"); + const packageJson = JSON.parse(content); + return typeof packageJson.name === "string" ? packageJson.name : undefined; + } catch { + return undefined; + } +}; + +const extractImportSpecifiers = (sourceText: string): string[] => { + let parsedModule: ReturnType; + try { + parsedModule = parseSync("sibling-source.tsx", sourceText, { sourceType: "unambiguous" }); + } catch { + return []; + } + if (parsedModule.errors.some((error) => error.severity === "Error")) return []; + + const specifiers = new Set(); + const getStaticSpecifier = (node: unknown): string | undefined => { + if (!isOxcAstNode(node)) return undefined; + if (node.type === "Literal" && typeof node.value === "string") return node.value; + if ( + node.type === "TemplateLiteral" && + Array.isArray(node.expressions) && + node.expressions.length === 0 && + Array.isArray(node.quasis) && + isOxcAstNode(node.quasis[0]) && + node.quasis[0].value && + typeof node.quasis[0].value === "object" && + "cooked" in node.quasis[0].value && + typeof node.quasis[0].value.cooked === "string" + ) { + return node.quasis[0].value.cooked; + } + return undefined; + }; + const statementsBindRequire = (statements: unknown[]): boolean => + statements.some((statement) => { + if (!isOxcAstNode(statement)) return false; + if ( + (statement.type === "FunctionDeclaration" || statement.type === "ClassDeclaration") && + getIdentifierName(statement.id) === "require" + ) { + return true; + } + if (statement.type === "VariableDeclaration" && Array.isArray(statement.declarations)) { + return statement.declarations.some( + (declaration) => + isOxcAstNode(declaration) && getIdentifierName(declaration.id) === "require", + ); + } + if (statement.type === "ImportDeclaration" && Array.isArray(statement.specifiers)) { + return statement.specifiers.some( + (specifier) => + isOxcAstNode(specifier) && getIdentifierName(specifier.local) === "require", + ); + } + return false; + }); + const addSpecifier = (node: unknown): void => { + const specifier = getStaticSpecifier(node); + if (specifier) specifiers.add(specifier); + }; + const visitNode = (node: unknown, isRequireShadowed: boolean): void => { + if (Array.isArray(node)) { + for (const child of node) visitNode(child, isRequireShadowed); + return; + } + if (!isOxcAstNode(node)) return; + let isRequireShadowedInNode = isRequireShadowed; + if ((node.type === "Program" || node.type === "BlockStatement") && Array.isArray(node.body)) { + isRequireShadowedInNode ||= statementsBindRequire(node.body); + } + if ( + (node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression") && + Array.isArray(node.params) + ) { + isRequireShadowedInNode ||= node.params.some( + (parameter) => getIdentifierName(parameter) === "require", + ); + } + if (node.type === "CatchClause" && getIdentifierName(node.param) === "require") { + isRequireShadowedInNode = true; + } + if ( + node.type === "ImportDeclaration" || + node.type === "ExportNamedDeclaration" || + node.type === "ExportAllDeclaration" + ) { + addSpecifier(node.source); + } + if (node.type === "ImportExpression" || node.type === "TSImportType") { + addSpecifier(node.source); + } + if (node.type === "CallExpression" && !isRequireShadowedInNode && isOxcAstNode(node.callee)) { + const argumentsList = Array.isArray(node.arguments) ? node.arguments : []; + const isDirectRequire = getIdentifierName(node.callee) === "require"; + const isRequireResolve = + node.callee.type === "MemberExpression" && + node.callee.computed !== true && + getIdentifierName(node.callee.object) === "require" && + getIdentifierName(node.callee.property) === "resolve"; + if (isDirectRequire || isRequireResolve) addSpecifier(argumentsList[0]); + } + for (const child of Object.values(node)) visitNode(child, isRequireShadowedInNode); + }; + visitNode(parsedModule.program, false); + return [...specifiers]; +}; + +const extractStylelintPluginSpecifiers = (sourceText: string): string[] => { + const specifiers: string[] = []; + const sourceFile = ts.createSourceFile( + "stylelint.config.mjs", + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const collectStringLiterals = (node: ts.Node): void => { + if (ts.isStringLiteralLike(node)) { + specifiers.push(node.text); + return; + } + ts.forEachChild(node, collectStringLiterals); + }; + const visitNode = (node: ts.Node): void => { + if ( + ts.isPropertyAssignment(node) && + (ts.isIdentifier(node.name) || ts.isStringLiteralLike(node.name)) && + node.name.text === "plugins" + ) { + collectStringLiterals(node.initializer); + return; + } + ts.forEachChild(node, visitNode); + }; + visitNode(sourceFile); + return specifiers; +}; + +export const extractSiblingWorkspaceImportEntries = (absoluteRoot: string): string[] => { + const monorepoRoot = findMonorepoRoot(absoluteRoot); + if (!monorepoRoot || monorepoRoot === absoluteRoot) return []; + + const packageName = readPackageName(absoluteRoot); + if (!packageName) return []; + + const siblingDirectories = resolveWorkspaces(monorepoRoot) + .packages.map((workspacePackage) => workspacePackage.directory) + .filter( + (workspaceDirectory) => + workspaceDirectory !== absoluteRoot && + !workspaceDirectory.startsWith(`${absoluteRoot}/`) && + !absoluteRoot.startsWith(`${workspaceDirectory}/`), + ); + if (siblingDirectories.length === 0) return []; + + const importedEntries: string[] = []; + const addResolvedSpecifier = (specifier: string): void => { + if (specifier !== packageName && !specifier.startsWith(`${packageName}/`)) return; + const subpath = specifier.slice(packageName.length + 1); + if (!subpath) return; + const resolvedEntry = resolveWorkspaceSubpath(absoluteRoot, subpath); + const sourceFallback = trySourceFallback(resolve(absoluteRoot, subpath)); + const importedEntry = resolvedEntry ?? sourceFallback; + if (importedEntry) importedEntries.push(importedEntry); + }; + + for (const siblingDirectory of siblingDirectories) { + const siblingSourceFiles = fg.sync(SIBLING_SOURCE_GLOB, { + cwd: siblingDirectory, + absolute: true, + onlyFiles: true, + ignore: SIBLING_IGNORE_PATTERNS, + }); + + for (const siblingSourceFile of siblingSourceFiles) { + let sourceText: string; + try { + sourceText = readFileSync(siblingSourceFile, "utf-8"); + } catch { + continue; + } + if (!sourceText.includes(packageName)) continue; + + for (const importSpecifier of extractImportSpecifiers(sourceText)) { + addResolvedSpecifier(importSpecifier); + } + } + + const siblingStylelintConfigFiles = fg.sync(SIBLING_STYLELINT_CONFIG_GLOBS, { + cwd: siblingDirectory, + absolute: true, + onlyFiles: true, + dot: true, + ignore: SIBLING_IGNORE_PATTERNS, + }); + for (const siblingStylelintConfigFile of siblingStylelintConfigFiles) { + let sourceText: string; + try { + sourceText = readFileSync(siblingStylelintConfigFile, "utf-8"); + } catch { + continue; + } + if (!sourceText.includes(packageName)) continue; + for (const configSpecifier of extractStylelintPluginSpecifiers(sourceText)) { + addResolvedSpecifier(configSpecifier); + } + } + } + + return [...new Set(importedEntries)]; +}; diff --git a/packages/core/src/project-analysis/collect/static-globby-entries.ts b/packages/core/src/project-analysis/collect/static-globby-entries.ts new file mode 100644 index 0000000000..1d067f991a --- /dev/null +++ b/packages/core/src/project-analysis/collect/static-globby-entries.ts @@ -0,0 +1,240 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import fg from "fast-glob"; +import ts from "typescript"; +import { unwrapTypescriptExpression as unwrapExpression } from "../../utils/unwrap-typescript-expression.js"; +import { DEFAULT_EXTENSIONS } from "../constants.js"; +import { isPathInsideDirectoryOrEqual } from "../utils/is-path-inside-directory-or-equal.js"; + +interface GlobbyAnalysisContext { + entryFilePath: string; + projectRoot: string; + sourceFile: ts.SourceFile; + variableDeclarations: ReadonlyMap; +} + +const getCalledName = (expression: ts.LeftHandSideExpression): string | undefined => { + const unwrappedExpression = unwrapExpression(expression); + if (ts.isIdentifier(unwrappedExpression)) return unwrappedExpression.text; + if (ts.isPropertyAccessExpression(unwrappedExpression)) return unwrappedExpression.name.text; + return undefined; +}; + +const isImportMetaUrl = (expression: ts.Expression): boolean => { + const unwrappedExpression = unwrapExpression(expression); + return ( + ts.isPropertyAccessExpression(unwrappedExpression) && + unwrappedExpression.name.text === "url" && + ts.isMetaProperty(unwrappedExpression.expression) && + unwrappedExpression.expression.keywordToken === ts.SyntaxKind.ImportKeyword + ); +}; + +const evaluateDirectoryExpression = ( + expression: ts.Expression, + context: GlobbyAnalysisContext, + seenIdentifiers = new Set(), +): string | undefined => { + const unwrappedExpression = unwrapExpression(expression); + if (ts.isStringLiteralLike(unwrappedExpression)) { + return resolve(dirname(context.entryFilePath), unwrappedExpression.text); + } + if (ts.isIdentifier(unwrappedExpression)) { + if (unwrappedExpression.text === "__dirname") return dirname(context.entryFilePath); + if (seenIdentifiers.has(unwrappedExpression.text)) return undefined; + const initializer = context.variableDeclarations.get(unwrappedExpression.text); + if (!initializer) return undefined; + const nextSeenIdentifiers = new Set(seenIdentifiers); + nextSeenIdentifiers.add(unwrappedExpression.text); + return evaluateDirectoryExpression(initializer, context, nextSeenIdentifiers); + } + if ( + ts.isPropertyAccessExpression(unwrappedExpression) && + unwrappedExpression.name.text === "dirname" && + ts.isMetaProperty(unwrappedExpression.expression) && + unwrappedExpression.expression.keywordToken === ts.SyntaxKind.ImportKeyword + ) { + return dirname(context.entryFilePath); + } + if (!ts.isCallExpression(unwrappedExpression)) return undefined; + + const calledName = getCalledName(unwrappedExpression.expression); + if ( + calledName === "cwd" && + ts.isPropertyAccessExpression(unwrappedExpression.expression) && + ts.isIdentifier(unwrappedExpression.expression.expression) && + unwrappedExpression.expression.expression.text === "process" + ) { + return context.projectRoot; + } + if ( + calledName === "fileURLToPath" && + unwrappedExpression.arguments[0] && + isImportMetaUrl(unwrappedExpression.arguments[0]) + ) { + return context.entryFilePath; + } + if (calledName === "dirname" && unwrappedExpression.arguments[0]) { + const evaluatedPath = evaluateDirectoryExpression( + unwrappedExpression.arguments[0], + context, + seenIdentifiers, + ); + return evaluatedPath ? dirname(evaluatedPath) : undefined; + } + if (calledName !== "join" && calledName !== "resolve") return undefined; + + const [baseExpression, ...segmentExpressions] = unwrappedExpression.arguments; + if (!baseExpression) return undefined; + const baseDirectory = evaluateDirectoryExpression(baseExpression, context, seenIdentifiers); + if (!baseDirectory) return undefined; + const pathSegments: string[] = []; + for (const segmentExpression of segmentExpressions) { + const unwrappedSegment = unwrapExpression(segmentExpression); + if (!ts.isStringLiteralLike(unwrappedSegment)) return undefined; + pathSegments.push(unwrappedSegment.text); + } + return calledName === "join" + ? join(baseDirectory, ...pathSegments) + : resolve(baseDirectory, ...pathSegments); +}; + +const collectStaticPatterns = (expression: ts.Expression): string[] => { + const unwrappedExpression = unwrapExpression(expression); + if (ts.isStringLiteralLike(unwrappedExpression)) return [unwrappedExpression.text]; + if (!ts.isArrayLiteralExpression(unwrappedExpression)) return []; + const patterns: string[] = []; + for (const element of unwrappedExpression.elements) { + const unwrappedElement = unwrapExpression(element); + if (!ts.isStringLiteralLike(unwrappedElement)) return []; + patterns.push(unwrappedElement.text); + } + return patterns; +}; + +const findCwdExpression = (expression: ts.Expression | undefined): ts.Expression | undefined => { + if (!expression) return undefined; + const unwrappedExpression = unwrapExpression(expression); + if (!ts.isObjectLiteralExpression(unwrappedExpression)) return undefined; + for (const property of unwrappedExpression.properties) { + if (!ts.isPropertyAssignment(property)) continue; + const propertyName = + ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name) + ? property.name.text + : undefined; + if (propertyName === "cwd") return property.initializer; + } + return undefined; +}; + +const collectGlobbyLocalNames = (sourceFile: ts.SourceFile): Set => { + const localNames = new Set(); + for (const statement of sourceFile.statements) { + if ( + !ts.isImportDeclaration(statement) || + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== "globby" || + !statement.importClause?.namedBindings || + !ts.isNamedImports(statement.importClause.namedBindings) + ) { + continue; + } + for (const element of statement.importClause.namedBindings.elements) { + const importedName = element.propertyName?.text ?? element.name.text; + if (importedName === "globby" || importedName === "globbySync") { + localNames.add(element.name.text); + } + } + } + return localNames; +}; + +const collectVariableDeclarations = (sourceFile: ts.SourceFile): Map => { + const declarations = new Map(); + const visitNode = (node: ts.Node): void => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) { + declarations.set(node.name.text, node.initializer); + } + ts.forEachChild(node, visitNode); + }; + visitNode(sourceFile); + return declarations; +}; + +const extractGlobbyEntriesFromFile = (entryFilePath: string, projectRoot: string): string[] => { + if (!existsSync(entryFilePath)) return []; + let sourceText: string; + try { + sourceText = readFileSync(entryFilePath, "utf8"); + } catch { + return []; + } + if (!sourceText.includes("globby")) return []; + + const sourceFile = ts.createSourceFile( + entryFilePath, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + const globbyLocalNames = collectGlobbyLocalNames(sourceFile); + if (globbyLocalNames.size === 0) return []; + const context: GlobbyAnalysisContext = { + entryFilePath, + projectRoot, + sourceFile, + variableDeclarations: collectVariableDeclarations(sourceFile), + }; + const entries = new Set(); + + const visitNode = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(unwrapExpression(node.expression)) && + globbyLocalNames.has(unwrapExpression(node.expression).getText()) && + node.arguments[0] + ) { + const patterns = collectStaticPatterns(node.arguments[0]); + const cwdExpression = findCwdExpression(node.arguments[1]); + const workingDirectory = cwdExpression + ? evaluateDirectoryExpression(cwdExpression, context) + : dirname(entryFilePath); + if ( + patterns.length > 0 && + workingDirectory && + isPathInsideDirectoryOrEqual(workingDirectory, projectRoot) + ) { + for (const filePath of fg.sync(patterns, { + cwd: workingDirectory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + })) { + if ( + isPathInsideDirectoryOrEqual(filePath, projectRoot) && + DEFAULT_EXTENSIONS.some((extension) => filePath.endsWith(extension)) + ) { + entries.add(filePath); + } + } + } + } + ts.forEachChild(node, visitNode); + }; + visitNode(sourceFile); + return [...entries]; +}; + +export const extractStaticGlobbyEntries = ( + entryFilePaths: ReadonlyArray, + projectRoot: string, +): string[] => { + const entries = new Set(); + for (const entryFilePath of entryFilePaths) { + for (const globbyEntry of extractGlobbyEntriesFromFile(entryFilePath, projectRoot)) { + entries.add(globbyEntry); + } + } + return [...entries]; +}; diff --git a/packages/core/src/project-analysis/collect/supabase-function-entries.ts b/packages/core/src/project-analysis/collect/supabase-function-entries.ts new file mode 100644 index 0000000000..1e90f2fc6c --- /dev/null +++ b/packages/core/src/project-analysis/collect/supabase-function-entries.ts @@ -0,0 +1,8 @@ +import fg from "fast-glob"; + +export const extractSupabaseFunctionEntries = (rootDirectory: string): string[] => + fg.sync("supabase/functions/*/index.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", { + cwd: rootDirectory, + absolute: true, + onlyFiles: true, + }); diff --git a/packages/core/src/project-analysis/collect/taro-page-entries.ts b/packages/core/src/project-analysis/collect/taro-page-entries.ts new file mode 100644 index 0000000000..190b8b22c5 --- /dev/null +++ b/packages/core/src/project-analysis/collect/taro-page-entries.ts @@ -0,0 +1,329 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import fg from "fast-glob"; +import ts from "typescript"; +import { resolveEntryWithExtensions } from "../utils/resolve-entry-with-extensions.js"; + +const TARO_APP_CONFIG_GLOB = "src/app.config.{ts,tsx,js,jsx,mts,mjs,cts,cjs}"; +const TARO_PACKAGE_NAMES = ["@tarojs/cli", "@tarojs/react", "@tarojs/runtime"]; +const TARO_ARRAY_SELECTION_METHODS = new Set(["filter", "slice", "splice"]); + +const getPropertyName = (propertyName: ts.PropertyName): string | undefined => { + if ( + ts.isIdentifier(propertyName) || + ts.isStringLiteral(propertyName) || + ts.isNoSubstitutionTemplateLiteral(propertyName) + ) { + return propertyName.text; + } + return undefined; +}; + +const unwrapConfigExpression = (expression: ts.Expression): ts.Expression => { + if ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) + ) { + return unwrapConfigExpression(expression.expression); + } + if (ts.isCallExpression(expression)) { + const [configArgument] = expression.arguments; + return configArgument ? unwrapConfigExpression(configArgument) : expression; + } + return expression; +}; + +const unwrapArrayExpression = (expression: ts.Expression): ts.Expression => { + if ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) + ) { + return unwrapArrayExpression(expression.expression); + } + return expression; +}; + +const getPropertyInitializer = ( + objectLiteral: ts.ObjectLiteralExpression, + propertyNames: ReadonlySet, +): ts.Expression | undefined => { + for (const property of objectLiteral.properties) { + if (ts.isShorthandPropertyAssignment(property) && propertyNames.has(property.name.text)) { + return property.name; + } + if (!ts.isPropertyAssignment(property)) continue; + const propertyName = getPropertyName(property.name); + if (propertyName && propertyNames.has(propertyName)) return property.initializer; + } + return undefined; +}; + +const collectArrayElements = ( + expression: ts.Expression | undefined, + variableInitializers: ReadonlyMap, + pushedElements: ReadonlyMap>, + visitedIdentifiers = new Set(), +): ts.Expression[] => { + if (!expression) return []; + const unwrappedExpression = unwrapArrayExpression(expression); + if (ts.isArrayLiteralExpression(unwrappedExpression)) { + return [...unwrappedExpression.elements]; + } + if (ts.isConditionalExpression(unwrappedExpression)) { + return [ + ...collectArrayElements( + unwrappedExpression.whenTrue, + variableInitializers, + pushedElements, + visitedIdentifiers, + ), + ...collectArrayElements( + unwrappedExpression.whenFalse, + variableInitializers, + pushedElements, + visitedIdentifiers, + ), + ]; + } + if ( + ts.isCallExpression(unwrappedExpression) && + ts.isPropertyAccessExpression(unwrappedExpression.expression) && + TARO_ARRAY_SELECTION_METHODS.has(unwrappedExpression.expression.name.text) + ) { + return collectArrayElements( + unwrappedExpression.expression.expression, + variableInitializers, + pushedElements, + visitedIdentifiers, + ); + } + if (ts.isIdentifier(unwrappedExpression) && !visitedIdentifiers.has(unwrappedExpression.text)) { + const nextVisitedIdentifiers = new Set(visitedIdentifiers).add(unwrappedExpression.text); + return [ + ...collectArrayElements( + variableInitializers.get(unwrappedExpression.text), + variableInitializers, + pushedElements, + nextVisitedIdentifiers, + ), + ...(pushedElements.get(unwrappedExpression.text) ?? []), + ]; + } + return []; +}; + +const collectPagePaths = ( + expression: ts.Expression | undefined, + variableInitializers: ReadonlyMap, + pushedElements: ReadonlyMap>, +): string[] => + collectArrayElements(expression, variableInitializers, pushedElements).flatMap((element) => + ts.isStringLiteral(element) || ts.isNoSubstitutionTemplateLiteral(element) + ? [element.text] + : [], + ); + +const resolvePageEntries = (configDirectory: string, pagePaths: ReadonlyArray): string[] => + pagePaths.flatMap((pagePath) => { + const resolvedEntry = resolveEntryWithExtensions(resolve(configDirectory, pagePath)); + return resolvedEntry ? [resolvedEntry] : []; + }); + +const collectConfigPageEntries = ( + configDirectory: string, + configObject: ts.ObjectLiteralExpression, + variableInitializers: ReadonlyMap, + pushedElements: ReadonlyMap>, +): string[] => { + const entries = resolvePageEntries( + configDirectory, + collectPagePaths( + getPropertyInitializer(configObject, new Set(["pages"])), + variableInitializers, + pushedElements, + ), + ); + const subPackagesExpression = getPropertyInitializer( + configObject, + new Set(["subPackages", "subpackages"]), + ); + for (const subPackageElement of collectArrayElements( + subPackagesExpression, + variableInitializers, + pushedElements, + )) { + if (!ts.isObjectLiteralExpression(subPackageElement)) continue; + const rootExpression = getPropertyInitializer(subPackageElement, new Set(["root"])); + if ( + !rootExpression || + (!ts.isStringLiteral(rootExpression) && !ts.isNoSubstitutionTemplateLiteral(rootExpression)) + ) { + continue; + } + const subPackagePages = collectPagePaths( + getPropertyInitializer(subPackageElement, new Set(["pages"])), + variableInitializers, + pushedElements, + ).map((pagePath) => `${rootExpression.text}/${pagePath}`); + entries.push(...resolvePageEntries(configDirectory, subPackagePages)); + } + + return entries; +}; + +const collectVariableInitializers = (sourceFile: ts.SourceFile): Map => { + const variableInitializers = new Map(); + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name) && declaration.initializer) { + variableInitializers.set(declaration.name.text, declaration.initializer); + } + } + } + return variableInitializers; +}; + +const collectShadowedBindingNames = ( + bindingName: ts.BindingName, + topLevelVariableNames: ReadonlySet, +): string[] => { + if (ts.isIdentifier(bindingName)) { + return topLevelVariableNames.has(bindingName.text) ? [bindingName.text] : []; + } + return bindingName.elements.flatMap((bindingElement) => + ts.isBindingElement(bindingElement) + ? collectShadowedBindingNames(bindingElement.name, topLevelVariableNames) + : [], + ); +}; + +const collectPushedElements = ( + sourceFile: ts.SourceFile, + topLevelVariableNames: ReadonlySet, +): Map> => { + const pushedElements = new Map(); + const visitNode = (node: ts.Node, shadowedNames: ReadonlySet): void => { + if ( + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) || + ts.isMethodDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) || + ts.isConstructorDeclaration(node) + ) { + return; + } + let nextShadowedNames = shadowedNames; + const shadowBindingNames = (bindingNames: ReadonlyArray): void => { + const newlyShadowedNames = bindingNames.flatMap((bindingName) => + collectShadowedBindingNames(bindingName, topLevelVariableNames), + ); + if (newlyShadowedNames.length > 0) { + nextShadowedNames = new Set([...nextShadowedNames, ...newlyShadowedNames]); + } + }; + if (ts.isBlock(node)) { + shadowBindingNames( + node.statements.flatMap((statement) => { + if ( + !ts.isVariableStatement(statement) || + !(statement.declarationList.flags & ts.NodeFlags.BlockScoped) + ) { + return []; + } + return statement.declarationList.declarations.map((declaration) => declaration.name); + }), + ); + } + if (ts.isCatchClause(node) && node.variableDeclaration) { + shadowBindingNames([node.variableDeclaration.name]); + } + if (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) { + const initializer = node.initializer; + if ( + initializer && + ts.isVariableDeclarationList(initializer) && + initializer.flags & ts.NodeFlags.BlockScoped + ) { + shadowBindingNames(initializer.declarations.map((declaration) => declaration.name)); + } + } + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === "push" && + ts.isIdentifier(node.expression.expression) && + topLevelVariableNames.has(node.expression.expression.text) && + !nextShadowedNames.has(node.expression.expression.text) + ) { + const identifier = node.expression.expression.text; + const existingElements = pushedElements.get(identifier) ?? []; + existingElements.push(...node.arguments); + pushedElements.set(identifier, existingElements); + } + ts.forEachChild(node, (childNode) => visitNode(childNode, nextShadowedNames)); + }; + visitNode(sourceFile, new Set()); + return pushedElements; +}; + +const extractConfigObject = ( + sourceFile: ts.SourceFile, + variableInitializers: ReadonlyMap, +): ts.ObjectLiteralExpression | undefined => { + for (const statement of sourceFile.statements) { + if (!ts.isExportAssignment(statement) || statement.isExportEquals) continue; + let expression = unwrapConfigExpression(statement.expression); + const visitedIdentifiers = new Set(); + while (ts.isIdentifier(expression) && !visitedIdentifiers.has(expression.text)) { + visitedIdentifiers.add(expression.text); + const initializer = variableInitializers.get(expression.text); + if (!initializer) break; + expression = unwrapConfigExpression(initializer); + } + if (ts.isObjectLiteralExpression(expression)) return expression; + } + return undefined; +}; + +export const extractTaroPageEntries = ( + directory: string, + dependencies: Readonly>, +): string[] => { + if (!TARO_PACKAGE_NAMES.some((packageName) => packageName in dependencies)) return []; + + const entries = new Set(); + const configPaths = fg.sync(TARO_APP_CONFIG_GLOB, { + cwd: directory, + absolute: true, + onlyFiles: true, + }); + for (const configPath of configPaths) { + try { + const sourceText = readFileSync(configPath, "utf8"); + const sourceFile = ts.createSourceFile(configPath, sourceText, ts.ScriptTarget.Latest, true); + const variableInitializers = collectVariableInitializers(sourceFile); + const pushedElements = collectPushedElements( + sourceFile, + new Set(variableInitializers.keys()), + ); + const configObject = extractConfigObject(sourceFile, variableInitializers); + if (!configObject) continue; + for (const entry of collectConfigPageEntries( + dirname(configPath), + configObject, + variableInitializers, + pushedElements, + )) { + entries.add(entry); + } + } catch { + continue; + } + } + return [...entries]; +}; diff --git a/packages/core/src/project-analysis/collect/umi-dva-model-entries.ts b/packages/core/src/project-analysis/collect/umi-dva-model-entries.ts new file mode 100644 index 0000000000..98231159bc --- /dev/null +++ b/packages/core/src/project-analysis/collect/umi-dva-model-entries.ts @@ -0,0 +1,44 @@ +import { readFileSync } from "node:fs"; +import fg from "fast-glob"; +import { maskJavaScriptStringsAndComments } from "../utils/mask-javascript-strings-and-comments.js"; + +const UMI_DEPENDENCIES = ["umi", "@umijs/max"]; +const DVA_DEPENDENCIES = ["dva", "@umijs/plugin-dva"]; +const UMI_CONFIG_PATTERNS = [ + ".umirc.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", + "config/config.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", + "config/config.*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", +]; +const DVA_CONFIGURATION_PATTERN = /\bdva\s*:\s*(?:true|\{)/; + +export const extractUmiDvaModelEntries = ( + directory: string, + dependencies: Record, +): string[] => { + if (!UMI_DEPENDENCIES.some((dependencyName) => dependencyName in dependencies)) return []; + + let isDvaEnabled = DVA_DEPENDENCIES.some((dependencyName) => dependencyName in dependencies); + if (!isDvaEnabled) { + const configPaths = fg.sync(UMI_CONFIG_PATTERNS, { + cwd: directory, + absolute: true, + onlyFiles: true, + }); + isDvaEnabled = configPaths.some((configPath) => { + try { + const configContent = maskJavaScriptStringsAndComments(readFileSync(configPath, "utf-8")); + return DVA_CONFIGURATION_PATTERN.test(configContent); + } catch { + return false; + } + }); + } + if (!isDvaEnabled) return []; + + return fg.sync("src/models/**/*.{ts,tsx,js,jsx}", { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + }); +}; diff --git a/packages/core/src/project-analysis/collect/unplugin-auto-import-entries.ts b/packages/core/src/project-analysis/collect/unplugin-auto-import-entries.ts new file mode 100644 index 0000000000..0a0a1907ce --- /dev/null +++ b/packages/core/src/project-analysis/collect/unplugin-auto-import-entries.ts @@ -0,0 +1,243 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import fg from "fast-glob"; +import ts from "typescript"; +import type { ImportReference, SourceFile } from "../types.js"; +import { isPathInsideDirectoryOrEqual } from "../utils/is-path-inside-directory-or-equal.js"; +import { resolveEntryWithExtensions } from "../utils/resolve-entry-with-extensions.js"; +import { + collectUnpluginAutoImportGlobalScopes, + type UnpluginAutoImportGlobalScope, +} from "../../runners/oxlint/collect-unplugin-auto-import-global-scopes.js"; + +interface AutoImportSourceBinding { + globalName: string; + exportName: string; + scopeDirectory: string; + sourcePath: string; +} + +interface AutoImportScopeBindings { + bindingsByGlobalName: Map; + candidateGlobalNames: Set; +} + +const GENERATED_AUTO_IMPORT_MARKER = "Generated by unplugin-auto-import"; +const AUTO_IMPORT_SOURCE_BINDING_PATTERN = + /\bconst\s+([A-Za-z_$][\w$]*)\s*:\s*typeof\s+import\(["']([^"']+)["']\)\[["']([^"']+)["']\]/g; + +const isNonReferenceIdentifier = (identifier: ts.Identifier): boolean => { + const parent = identifier.parent; + return ( + ((ts.isVariableDeclaration(parent) || + ts.isParameter(parent) || + ts.isFunctionDeclaration(parent) || + ts.isFunctionExpression(parent) || + ts.isClassDeclaration(parent) || + ts.isClassExpression(parent) || + ts.isImportClause(parent) || + ts.isImportSpecifier(parent) || + ts.isNamespaceImport(parent) || + ts.isBindingElement(parent)) && + parent.name === identifier) || + (ts.isImportSpecifier(parent) && parent.propertyName === identifier) || + (ts.isBindingElement(parent) && parent.propertyName === identifier) || + (ts.isJsxAttribute(parent) && parent.name === identifier) || + ((ts.isPropertyAccessExpression(parent) || + ts.isPropertyAssignment(parent) || + ts.isPropertyDeclaration(parent) || + ts.isMethodDeclaration(parent) || + ts.isMethodSignature(parent) || + ts.isPropertySignature(parent)) && + parent.name === identifier) || + (ts.isQualifiedName(parent) && parent.right === identifier) + ); +}; + +const collectUnboundGlobalNames = ( + filePath: string, + candidateGlobalNames: ReadonlySet, +): Set => { + let source: string; + try { + source = readFileSync(filePath, "utf8"); + } catch { + return new Set(); + } + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true); + const compilerOptions: ts.CompilerOptions = { + allowJs: true, + jsx: ts.JsxEmit.Preserve, + noLib: true, + noResolve: true, + target: ts.ScriptTarget.Latest, + }; + const compilerHost = ts.createCompilerHost(compilerOptions); + compilerHost.fileExists = (candidatePath) => candidatePath === filePath; + compilerHost.getSourceFile = (candidatePath) => + candidatePath === filePath ? sourceFile : undefined; + compilerHost.readFile = (candidatePath) => (candidatePath === filePath ? source : undefined); + const program = ts.createProgram({ + rootNames: [filePath], + options: compilerOptions, + host: compilerHost, + }); + const typeChecker = program.getTypeChecker(); + const boundSourceFile = program.getSourceFile(filePath); + if (!boundSourceFile) return new Set(); + const unboundGlobalNames = new Set(); + const visitNode = (node: ts.Node): void => { + if ( + ts.isIdentifier(node) && + candidateGlobalNames.has(node.text) && + !isNonReferenceIdentifier(node) + ) { + const bindingSymbol = ts.isShorthandPropertyAssignment(node.parent) + ? typeChecker.getShorthandAssignmentValueSymbol(node.parent) + : typeChecker.getSymbolAtLocation(node); + if (!bindingSymbol) unboundGlobalNames.add(node.text); + } + ts.forEachChild(node, visitNode); + }; + visitNode(boundSourceFile); + return unboundGlobalNames; +}; + +const findOwningScope = ( + filePath: string, + scopes: ReadonlyArray, + rootDirectory: string, +): UnpluginAutoImportGlobalScope | undefined => + scopes + .filter((scope) => + isPathInsideDirectoryOrEqual(filePath, resolve(rootDirectory, scope.directory)), + ) + .toSorted( + (leftScope, rightScope) => rightScope.directory.length - leftScope.directory.length, + )[0]; + +const collectSourceBindings = ( + rootDirectory: string, + activeScopes: ReadonlyArray, +): AutoImportSourceBinding[] => { + const bindingsByScopeAndGlobalName = new Map(); + const declarationFilePaths = fg.sync("**/*auto-import*.d.ts", { + cwd: rootDirectory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], + }); + + for (const declarationFilePath of declarationFilePaths) { + const activeScope = findOwningScope(declarationFilePath, activeScopes, rootDirectory); + if (!activeScope) continue; + const activeGlobalNames = new Set(activeScope.names); + const absoluteScopeDirectory = resolve(rootDirectory, activeScope.directory); + let source: string; + try { + source = readFileSync(declarationFilePath, "utf8"); + } catch { + continue; + } + if (!source.includes(GENERATED_AUTO_IMPORT_MARKER)) continue; + let bindingMatch: RegExpExecArray | null; + AUTO_IMPORT_SOURCE_BINDING_PATTERN.lastIndex = 0; + while ((bindingMatch = AUTO_IMPORT_SOURCE_BINDING_PATTERN.exec(source)) !== null) { + if (!bindingMatch[2].startsWith(".")) continue; + if (!activeGlobalNames.has(bindingMatch[1])) continue; + const sourcePath = resolveEntryWithExtensions( + resolve(dirname(declarationFilePath), bindingMatch[2]), + ); + if ( + !sourcePath || + !existsSync(sourcePath) || + !isPathInsideDirectoryOrEqual(sourcePath, absoluteScopeDirectory) + ) { + continue; + } + const binding = { + globalName: bindingMatch[1], + exportName: bindingMatch[3], + scopeDirectory: activeScope.directory, + sourcePath, + }; + const bindingKey = `${binding.scopeDirectory}\0${binding.globalName}`; + const existingBinding = bindingsByScopeAndGlobalName.get(bindingKey); + if ( + bindingsByScopeAndGlobalName.has(bindingKey) && + (!existingBinding || + existingBinding.sourcePath !== binding.sourcePath || + existingBinding.exportName !== binding.exportName) + ) { + bindingsByScopeAndGlobalName.set(bindingKey, undefined); + } else if (!bindingsByScopeAndGlobalName.has(bindingKey)) { + bindingsByScopeAndGlobalName.set(bindingKey, binding); + } + } + } + + return [...bindingsByScopeAndGlobalName.values()].filter( + (binding): binding is AutoImportSourceBinding => binding !== undefined, + ); +}; + +export const collectUnpluginAutoImportReferences = ( + rootDirectory: string, + files: ReadonlyArray, +): ReadonlyMap> => { + const activeScopes = collectUnpluginAutoImportGlobalScopes({ + rootDirectory, + candidateFiles: files.map((file) => file.path), + }); + const sourceBindings = collectSourceBindings(rootDirectory, activeScopes); + if (sourceBindings.length === 0) return new Map(); + const scopeBindingsByDirectory = new Map(); + for (const sourceBinding of sourceBindings) { + const scopeBindings = scopeBindingsByDirectory.get(sourceBinding.scopeDirectory) ?? { + bindingsByGlobalName: new Map(), + candidateGlobalNames: new Set(), + }; + scopeBindings.bindingsByGlobalName.set(sourceBinding.globalName, sourceBinding); + scopeBindings.candidateGlobalNames.add(sourceBinding.globalName); + scopeBindingsByDirectory.set(sourceBinding.scopeDirectory, scopeBindings); + } + const referencesByModuleIndex = new Map(); + + for (let moduleIndex = 0; moduleIndex < files.length; moduleIndex++) { + const file = files[moduleIndex]; + const activeScope = findOwningScope(file.path, activeScopes, rootDirectory); + if (!activeScope) continue; + const scopeBindings = scopeBindingsByDirectory.get(activeScope.directory); + if (!scopeBindings) continue; + const unboundGlobalNames = collectUnboundGlobalNames( + file.path, + scopeBindings.candidateGlobalNames, + ); + for (const globalName of unboundGlobalNames) { + const sourceBinding = scopeBindings.bindingsByGlobalName.get(globalName); + if (!sourceBinding || sourceBinding.sourcePath === file.path) continue; + const importReference: ImportReference = { + specifier: sourceBinding.sourcePath, + importedNames: [ + { + name: sourceBinding.exportName, + alias: globalName, + isNamespace: false, + isDefault: sourceBinding.exportName === "default", + isTypeOnly: false, + }, + ], + isTypeOnly: false, + isDynamic: false, + isSideEffect: false, + line: 0, + column: 0, + }; + const references = referencesByModuleIndex.get(moduleIndex) ?? []; + references.push(importReference); + referencesByModuleIndex.set(moduleIndex, references); + } + } + + return referencesByModuleIndex; +}; diff --git a/packages/core/src/project-analysis/collect/wordpress-script-entries.ts b/packages/core/src/project-analysis/collect/wordpress-script-entries.ts new file mode 100644 index 0000000000..e664c2278e --- /dev/null +++ b/packages/core/src/project-analysis/collect/wordpress-script-entries.ts @@ -0,0 +1,37 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import fg from "fast-glob"; + +const WORDPRESS_ENQUEUE_CALL_PATTERN = /\bwp_enqueue_script\s*\([\s\S]*?\);/g; +const SCRIPT_LITERAL_PATTERN = /["']([^"']+\.(?:[cm]?[jt]sx?))["']/g; + +export const extractWordPressScriptEntries = (directory: string): string[] => { + const entries = new Set(); + const phpFilePaths = fg.sync(["*.php", "**/*.php"], { + cwd: directory, + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/vendor/**"], + }); + + for (const phpFilePath of phpFilePaths) { + let source: string; + try { + source = readFileSync(phpFilePath, "utf8"); + } catch { + continue; + } + let enqueueCallMatch: RegExpExecArray | null; + WORDPRESS_ENQUEUE_CALL_PATTERN.lastIndex = 0; + while ((enqueueCallMatch = WORDPRESS_ENQUEUE_CALL_PATTERN.exec(source)) !== null) { + let scriptLiteralMatch: RegExpExecArray | null; + SCRIPT_LITERAL_PATTERN.lastIndex = 0; + while ((scriptLiteralMatch = SCRIPT_LITERAL_PATTERN.exec(enqueueCallMatch[0])) !== null) { + const scriptPath = resolve(directory, scriptLiteralMatch[1].replace(/^\/+/, "")); + if (existsSync(scriptPath)) entries.add(scriptPath); + } + } + } + + return [...entries]; +}; diff --git a/packages/deslop-js/src/collect/workspaces.ts b/packages/core/src/project-analysis/collect/workspaces.ts similarity index 92% rename from packages/deslop-js/src/collect/workspaces.ts rename to packages/core/src/project-analysis/collect/workspaces.ts index bccc5d788b..85155b954a 100644 --- a/packages/deslop-js/src/collect/workspaces.ts +++ b/packages/core/src/project-analysis/collect/workspaces.ts @@ -1,7 +1,10 @@ import { resolve, join, relative, dirname } from "node:path"; import { readFileSync, existsSync, statSync } from "node:fs"; import fg from "fast-glob"; +import { parseYAML } from "confbox"; import { STANDALONE_PROJECT_LOCKFILES } from "../constants.js"; +import { evaluateStaticConfig } from "../utils/evaluate-static-config.js"; +import { toPosixPath } from "../utils/to-posix-path.js"; import { extractReactRouterRouteModuleEntries } from "./parse.js"; export interface WorkspacePackage { @@ -19,13 +22,14 @@ export interface WorkspaceDiscoveryResult { } export const resolveWorkspaces = (rootDir: string): WorkspaceDiscoveryResult => { - const rootPatterns = collectWorkspacePatterns(rootDir); + const rootDirectory = toPosixPath(resolve(rootDir)); + const rootPatterns = collectWorkspacePatterns(rootDirectory); const hasRootLevelWorkspacePatterns = rootPatterns.length > 0; let expandedDirectories = hasRootLevelWorkspacePatterns - ? expandWorkspaceGlobs(rootPatterns, rootDir) + ? expandWorkspaceGlobs(rootPatterns, rootDirectory) : []; - const implicitSubProjects = discoverImplicitSubProjects(rootDir, expandedDirectories); + const implicitSubProjects = discoverImplicitSubProjects(rootDirectory, expandedDirectories); if (expandedDirectories.length === 0 && implicitSubProjects.length > 0) { for (const subProjectDirectory of implicitSubProjects) { @@ -51,10 +55,10 @@ export const resolveWorkspaces = (rootDir: string): WorkspaceDiscoveryResult => try { const packageContent = readFileSync(packageJsonPath, "utf-8"); const packageJson = JSON.parse(packageContent); - const packageName = packageJson.name || relative(rootDir, directory); + const packageName = packageJson.name || relative(rootDirectory, directory); const entryFiles = extractWorkspaceEntries(packageJson, directory); - const relativePath = relative(rootDir, directory); + const relativePath = toPosixPath(relative(rootDirectory, directory)); const depthFromRoot = relativePath.split("/").filter(Boolean).length; workspacePackages.push({ name: packageName, @@ -78,7 +82,10 @@ const discoverImplicitSubProjects = ( rootDir: string, alreadyDiscoveredDirectories: string[], ): string[] => { - const knownDirectories = new Set(alreadyDiscoveredDirectories); + const normalizedRootDirectory = toPosixPath(resolve(rootDir)); + const knownDirectories = new Set( + alreadyDiscoveredDirectories.map((directory) => toPosixPath(resolve(directory))), + ); const hasDeclaredWorkspaces = alreadyDiscoveredDirectories.length > 0; const subProjectDirectories: string[] = []; @@ -91,8 +98,8 @@ const discoverImplicitSubProjects = ( }); for (const packageJsonPath of subPackageJsonPaths) { - const directory = packageJsonPath.replace(/\/package\.json$/, ""); - if (directory === rootDir) continue; + const directory = toPosixPath(resolve(dirname(packageJsonPath))); + if (directory === normalizedRootDirectory) continue; if (knownDirectories.has(directory)) continue; if (hasDeclaredWorkspaces && isStandaloneProject(directory)) continue; @@ -147,31 +154,21 @@ const collectWorkspacePatterns = (rootDir: string): string[] => { }; const extractPnpmWorkspacePackages = (yamlContent: string): string[] => { - const packages: string[] = []; - let inPackagesSection = false; - - for (const line of yamlContent.split("\n")) { - const trimmedLine = line.trim(); - if (trimmedLine === "packages:") { - inPackagesSection = true; - continue; - } - if (inPackagesSection) { - if (trimmedLine.startsWith("- ")) { - const pattern = trimmedLine - .slice(2) - .trim() - .replace(/^["']|["']$/g, ""); - if (pattern && !pattern.startsWith("!")) { - packages.push(pattern); - } - } else if (trimmedLine && !trimmedLine.startsWith("#")) { - break; - } - } + const workspaceConfig = parseYAML(yamlContent); + if ( + !workspaceConfig || + typeof workspaceConfig !== "object" || + Array.isArray(workspaceConfig) || + !("packages" in workspaceConfig) || + !Array.isArray(workspaceConfig.packages) + ) { + return []; } - return packages; + return workspaceConfig.packages.filter( + (packagePattern): packagePattern is string => + typeof packagePattern === "string" && !packagePattern.startsWith("!"), + ); }; const expandWorkspaceGlobs = (patterns: string[], rootDir: string): string[] => { @@ -189,13 +186,13 @@ const expandWorkspaceGlobs = (patterns: string[], rootDir: string): string[] => onlyFiles: true, }); for (const matchedPath of matchedFiles) { - directories.push(matchedPath.replace(/\/package\.json$/, "")); + directories.push(toPosixPath(resolve(dirname(matchedPath)))); } } catch {} } else { const absoluteDirectory = resolve(rootDir, pattern); if (existsSync(join(absoluteDirectory, "package.json"))) { - directories.push(absoluteDirectory); + directories.push(toPosixPath(absoluteDirectory)); } } } @@ -422,12 +419,16 @@ const readDependencies = (directory: string): Record => { const hasAnyEnabler = (dependencies: Record, enablers: string[]): boolean => enablers.some((enabler) => enabler in dependencies); -const extractReactRouterAppDirectory = (directory: string): string => { +const extractRouterAppDirectory = (directory: string): string => { const configCandidates = [ "react-router.config.ts", "react-router.config.js", "react-router.config.mjs", "react-router.config.cjs", + "remix.config.ts", + "remix.config.js", + "remix.config.mjs", + "remix.config.cjs", ]; for (const configFile of configCandidates) { @@ -436,12 +437,18 @@ const extractReactRouterAppDirectory = (directory: string): string => { try { const content = readFileSync(configPath, "utf-8"); - const appDirectoryMatch = content.match(/appDirectory\s*:\s*['"`]([^'"`]+)['"`]/); - if (appDirectoryMatch) { - return appDirectoryMatch[1].replace(/^\.\//, ""); + const config = evaluateStaticConfig(content, configPath); + if ( + config && + typeof config === "object" && + !Array.isArray(config) && + "appDirectory" in config && + typeof config.appDirectory === "string" + ) { + return config.appDirectory.replace(/^\.\//, ""); } } catch { - // fall through + continue; } } @@ -568,7 +575,7 @@ export const detectFrameworkEntries = (rootDir: string): string[] => { } if (isReactRouter || isRemix) { - const reactRouterAppDirectory = extractReactRouterAppDirectory(rootDir); + const reactRouterAppDirectory = extractRouterAppDirectory(rootDir); entryPoints.push( ...fg.sync( [ diff --git a/packages/core/src/project-analysis/config.ts b/packages/core/src/project-analysis/config.ts new file mode 100644 index 0000000000..2a537052bc --- /dev/null +++ b/packages/core/src/project-analysis/config.ts @@ -0,0 +1,25 @@ +import { resolve } from "node:path"; +import { DEFAULT_ENTRY_GLOBS, DEFAULT_EXTENSIONS } from "./constants.js"; +import type { ProjectAnalysisConfig } from "./types.js"; +import { toCanonicalPath } from "../utils/to-canonical-path.js"; + +export const defineProjectAnalysisConfig = ( + options: Partial & Pick, +): ProjectAnalysisConfig => { + const rootDir = toCanonicalPath(resolve(options.rootDir)); + return { + rootDir, + entryPatterns: options.entryPatterns ?? DEFAULT_ENTRY_GLOBS, + ignorePatterns: options.ignorePatterns ?? [], + includeExtensions: options.includeExtensions ?? DEFAULT_EXTENSIONS, + tsConfigPath: + options.tsConfigPath === undefined + ? undefined + : toCanonicalPath(resolve(rootDir, options.tsConfigPath)), + paths: options.paths, + reportTypes: options.reportTypes ?? true, + includeEntryExports: options.includeEntryExports ?? false, + hasExplicitEntryPatterns: + options.hasExplicitEntryPatterns ?? options.entryPatterns !== undefined, + }; +}; diff --git a/packages/core/src/project-analysis/constants.ts b/packages/core/src/project-analysis/constants.ts new file mode 100644 index 0000000000..42b5aaf94c --- /dev/null +++ b/packages/core/src/project-analysis/constants.ts @@ -0,0 +1,339 @@ +export const DEFAULT_EXTENSIONS = [ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mts", + ".mjs", + ".cts", + ".cjs", + ".es6", + ".mdx", + ".astro", + ".graphql", + ".gql", + ".css", + ".scss", + ".vue", + ".svelte", +]; + +export const LEGACY_GRAPH_ONLY_PATTERNS = ["**/*.es6"]; + +export const STANDALONE_PROJECT_LOCKFILES = [ + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "bun.lockb", +]; + +export const MONOREPO_ROOT_MARKERS = [ + "pnpm-workspace.yaml", + "pnpm-workspace.yml", + "lerna.json", + "nx.json", + "turbo.json", + "rush.json", +]; + +export const LOCKFILE_MARKERS = [ + "pnpm-lock.yaml", + "yarn.lock", + "package-lock.json", + "bun.lockb", + "bun.lock", +]; + +export const HIDDEN_DIRECTORY_ALLOWLIST = [ + ".storybook", + ".vitepress", + ".well-known", + ".changeset", + ".github", + ".client", + ".server", +]; + +export const OUTPUT_DIRECTORIES = ["dist", "build", "out", "esm", "cjs"]; +export const SOURCE_FALLBACK_OUTPUT_DIRECTORIES = [...OUTPUT_DIRECTORIES, "lib"]; + +export const SOURCE_EXTENSIONS = ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"]; + +export const DEFAULT_EXCLUSIONS = [ + "**/node_modules/**", + "**/.git/**", + "**/coverage/**", + "**/*.min.js", + "**/*.min.mjs", + "**/mockServiceWorker.js", +]; + +export const SCRIPT_FILE_PATTERN = + /(?:^|\s)(?:node|tsx|ts-node|tsc|npx|bun|esr|esno|jiti|babel-node|zx)\s+(?:\S+\s+)*?([\w./@-]+\.(?:ts|tsx|js|jsx|mts|mjs|cts|cjs))(?:\s|$)/; + +export const SCRIPT_EXTENSIONLESS_FILE_PATTERN = + /(?:^|\s)(?:node|tsx|ts-node|bun|esr|esno|jiti|babel-node|zx)\s+(?:\S+\s+)*?((?:[./]|[\w@][\w@-]*\/)[\w./@-]+)(?:\s|$)/; + +export const SCRIPT_CONFIG_FILE_PATTERN = + /--config\s+([\w./@-]+\.(?:ts|tsx|js|jsx|mts|mjs|cts|cjs))/; + +export const DEFAULT_ENTRY_GLOBS = [ + "src/index.{ts,tsx,js,jsx}", + "src/main.{ts,tsx,js,jsx}", + "index.{ts,tsx,js,jsx}", + "main.{ts,tsx,js,jsx}", +]; + +export const UNUSED_FILE_INCOMPLETE_CONTAINER_EXTENSIONS = [".astro", ".mdx", ".svelte", ".vue"]; + +export const UNUSED_FILE_UNSUPPORTED_FRAMEWORK_DEPENDENCIES = ["electron", "expo", "react-native"]; + +export const EXPO_CONFIG_SCAN_MAX_DEPTH = 6; + +export const GRAPHQL_CODEGEN_CONFIG_SCAN_MAX_DEPTH = 6; + +export const BUILD_SCRIPT_PACKAGE_SCAN_MAX_DEPTH = 6; + +export const BUILD_SCRIPT_DIRECTORY_SCAN_MAX_DEPTH = 8; + +export const GENERATED_SOURCE_HEADER_CHARACTERS = 4096; + +export const KNOWN_CONFIG_PREFIXES = [ + "babel.config.", + "rollup.config.", + "webpack.config.", + "postcss.config.", + "stencil.config.", + "remotion.config.", + "metro.config.", + "tsup.config.", + "tsdown.config.", + "unbuild.config.", + "esbuild.config.", + "swc.config.", + "turbo.", + "jest.config.", + "jest.setup.", + "vitest.config.", + "vitest.ci.config.", + "vitest.setup.", + "vitest.workspace.", + "playwright.config.", + "cypress.config.", + "karma.conf.", + "eslint.config.", + "prettier.config.", + "stylelint.config.", + "lint-staged.config.", + "commitlint.config.", + "next.config.", + "next-sitemap.config.", + "gatsby-config.", + "nuxt.config.", + "astro.config.", + "sanity.config.", + "vite.config.", + "tailwind.config.", + "drizzle.config.", + "knexfile.", + "sentry.client.config.", + "sentry.server.config.", + "sentry.edge.config.", + "react-router.config.", + "typedoc.", + "i18next-parser.config.", + "codegen.config.", + "codegen.", + "codegen-", + "graphql.config.", + "npmpackagejsonlint.config.", + "release-it.", + "release.config.", + "contentlayer.config.", + "rspack.config.", + "rsbuild.config.", + "module-federation.config.", + "vercel.", + "next-env.d.", + "env.d.", + "vite-env.d.", +]; + +export const IMPLICIT_DEPENDENCIES = new Set([ + "typescript", + "@types/node", + "@types/react", + "@types/react-dom", + "eslint", + "prettier", + "husky", + "lint-staged", + "tslib", + "@babel/core", + "@babel/runtime", + "babel-core", + "babel-jest", + "babel-loader", + "postcss", + "cross-env", + "node-sass", + "less", + "oxlint", + "biome", + "@biomejs/biome", + "patch-package", + "simple-git-hooks", + "lefthook", + "ts-node", + "ts-jest", + "tsx", + "jsdom", + "rimraf", + "concurrently", + "npm-run-all", + "npm-run-all2", + "dotenv-cli", + "webpack", + "rollup", + "terser", + "autoprefixer", + "tailwindcss", + "react-test-renderer", + "esbuild", + "typedoc", + "commitizen", + "cz-conventional-changelog", +]); + +export const BUILTIN_MODULES = new Set([ + "assert", + "async_hooks", + "buffer", + "child_process", + "cluster", + "console", + "constants", + "crypto", + "dgram", + "diagnostics_channel", + "dns", + "domain", + "events", + "fs", + "http", + "http2", + "https", + "inspector", + "module", + "net", + "os", + "path", + "perf_hooks", + "process", + "punycode", + "querystring", + "readline", + "repl", + "stream", + "string_decoder", + "sys", + "timers", + "tls", + "trace_events", + "tty", + "url", + "util", + "v8", + "vm", + "wasi", + "worker_threads", + "zlib", +]); + +export const PLATFORM_SUFFIXES = [ + ".web", + ".react-native", + ".native", + ".ios", + ".android", + ".desktop", + ".windows", + ".macos", + ".any", + ".react-server", + ".server", + ".client", +]; + +export const REACT_NATIVE_ADDITIONAL_PLATFORM_SUFFIXES = [".rn"]; + +export const TARO_PLATFORM_SUFFIXES = [ + ".rn", + ".h5", + ".weapp", + ".alipay", + ".swan", + ".tt", + ".qq", + ".jd", +]; + +export const REACT_NATIVE_PLATFORM_EXTENSIONS = [ + ".web.ts", + ".web.tsx", + ".web.js", + ".web.jsx", + ".native.ts", + ".native.tsx", + ".native.js", + ".native.jsx", + ".ios.ts", + ".ios.tsx", + ".ios.js", + ".ios.jsx", + ".android.ts", + ".android.tsx", + ".android.js", + ".android.jsx", +]; + +export const RESOLVER_EXTENSIONS = [ + ...DEFAULT_EXTENSIONS, + ".d.ts", + ".d.mts", + ".d.cts", + ".json", + ".node", + ".css", + ".scss", + ".less", + ".svg", + ".png", + ".jpg", + ".graphql", + ".gql", +]; + +export const SHALLOW_WORKSPACE_MAX_DEPTH = 2; + +export const TOOLING_SOURCE_MAX_DEPTH = 8; + +export const MAX_CYCLES_PER_SCC = 20; + +export const MAX_TOTAL_CYCLES = 200; + +export const MAX_SCC_SIZE_FOR_ENUMERATION = 50; + +export const MAX_PARSE_FILE_SIZE_BYTES = 2_000_000; + +export const MAX_ERROR_DETAIL_LENGTH = 1000; + +export const BINARY_DETECTION_SAMPLE_BYTES = 2048; + +export const BINARY_DETECTION_NULL_BYTE_THRESHOLD = 4; + +export const MINIFIED_DETECTION_MIN_BYTES = 5000; + +export const MINIFIED_DETECTION_MEDIAN_LINE_LENGTH_THRESHOLD = 500; + +export const GIT_CHECK_IGNORE_MAX_BUFFER_BYTES = 10 * 1024 * 1024; diff --git a/packages/core/src/project-analysis/errors.ts b/packages/core/src/project-analysis/errors.ts new file mode 100644 index 0000000000..4972c38125 --- /dev/null +++ b/packages/core/src/project-analysis/errors.ts @@ -0,0 +1,209 @@ +export type ProjectAnalysisErrorCode = + | "file-read-failed" + | "file-too-large" + | "file-empty" + | "file-binary" + | "file-minified" + | "parse-failed" + | "parse-recovered" + | "parse-recovered-partial" + | "ast-walk-failed" + | "ast-walk-depth-exceeded" + | "package-json-not-found" + | "package-json-parse-failed" + | "workspace-discovery-failed" + | "gitignore-check-failed" + | "resolver-init-failed" + | "monorepo-discovery-failed" + | "detector-failed" + | "config-invalid" + | "system-out-of-memory" + | "unknown"; + +export type ProjectAnalysisErrorModule = + | "collect" + | "parse" + | "linker" + | "resolver" + | "report" + | "config"; + +export type ProjectAnalysisErrorSeverity = "fatal" | "warning" | "info"; + +export interface ProjectAnalysisErrorInput { + code: ProjectAnalysisErrorCode; + module: ProjectAnalysisErrorModule; + message: string; + severity?: ProjectAnalysisErrorSeverity; + path?: string; + detail?: string; +} + +export interface ProjectAnalysisErrorFromCaughtInput extends Omit< + ProjectAnalysisErrorInput, + "detail" +> { + caught: unknown; +} + +export interface ProjectAnalysisErrorJson { + name: string; + code: ProjectAnalysisErrorCode; + module: ProjectAnalysisErrorModule; + severity: ProjectAnalysisErrorSeverity; + message: string; + path?: string; + detail?: string; +} + +import { MAX_ERROR_DETAIL_LENGTH } from "./constants.js"; + +const truncateDetail = (text: string): string => { + if (text.length <= MAX_ERROR_DETAIL_LENGTH) return text; + return `${text.slice(0, MAX_ERROR_DETAIL_LENGTH)}… [truncated ${text.length - MAX_ERROR_DETAIL_LENGTH} chars]`; +}; + +export const describeUnknownError = (caughtValue: unknown): string => { + let rawText: string; + if (caughtValue instanceof Error) { + rawText = caughtValue.message || caughtValue.name || "unknown error"; + } else if (typeof caughtValue === "string") { + rawText = caughtValue; + } else { + try { + rawText = JSON.stringify(caughtValue); + } catch { + rawText = String(caughtValue); + } + } + return truncateDetail(rawText ?? ""); +}; + +export class ProjectAnalysisError extends Error { + readonly code: ProjectAnalysisErrorCode; + readonly module: ProjectAnalysisErrorModule; + readonly severity: ProjectAnalysisErrorSeverity; + readonly path?: string; + readonly detail?: string; + + constructor(input: ProjectAnalysisErrorInput) { + super(input.message); + this.name = "ProjectAnalysisError"; + this.code = input.code; + this.module = input.module; + this.severity = input.severity ?? "warning"; + if (input.path !== undefined) this.path = input.path; + if (input.detail !== undefined) this.detail = input.detail; + } + + toJSON(): ProjectAnalysisErrorJson { + const payload: ProjectAnalysisErrorJson = { + name: this.name, + code: this.code, + module: this.module, + severity: this.severity, + message: this.message, + }; + if (this.path !== undefined) payload.path = this.path; + if (this.detail !== undefined) payload.detail = this.detail; + return payload; + } + + static fromCaught(input: ProjectAnalysisErrorFromCaughtInput): ProjectAnalysisError { + return new ProjectAnalysisError({ + code: input.code, + module: input.module, + severity: input.severity, + message: input.message, + path: input.path, + detail: describeUnknownError(input.caught), + }); + } +} + +export class ConfigError extends ProjectAnalysisError { + constructor( + input: Omit & { code?: "config-invalid" }, + ) { + super({ + ...input, + code: input.code ?? "config-invalid", + module: "config", + severity: input.severity ?? "fatal", + }); + this.name = "ConfigError"; + } +} + +export class FileReadError extends ProjectAnalysisError { + constructor( + input: Omit & { + code: "file-read-failed" | "file-too-large" | "file-empty" | "file-binary" | "file-minified"; + }, + ) { + super({ ...input, module: "parse" }); + this.name = "FileReadError"; + } +} + +export class ParseError extends ProjectAnalysisError { + constructor( + input: Omit & { + code: + | "parse-failed" + | "parse-recovered" + | "parse-recovered-partial" + | "ast-walk-failed" + | "ast-walk-depth-exceeded"; + }, + ) { + super({ ...input, module: "parse" }); + this.name = "ParseError"; + } +} + +export class WorkspaceError extends ProjectAnalysisError { + constructor( + input: Omit & { + code: + | "workspace-discovery-failed" + | "monorepo-discovery-failed" + | "package-json-not-found" + | "package-json-parse-failed" + | "gitignore-check-failed"; + }, + ) { + super({ ...input, module: "collect" }); + this.name = "WorkspaceError"; + } +} + +export class ResolverError extends ProjectAnalysisError { + constructor( + input: Omit & { code?: "resolver-init-failed" }, + ) { + super({ + ...input, + code: input.code ?? "resolver-init-failed", + module: "resolver", + severity: input.severity ?? "fatal", + }); + this.name = "ResolverError"; + } +} + +export class DetectorError extends ProjectAnalysisError { + constructor( + input: Omit & { + module?: ProjectAnalysisErrorModule; + code?: "detector-failed"; + }, + ) { + super({ + ...input, + code: input.code ?? "detector-failed", + module: input.module ?? "report", + }); + this.name = "DetectorError"; + } +} diff --git a/packages/core/src/project-analysis/linker/build-module-link-inputs.ts b/packages/core/src/project-analysis/linker/build-module-link-inputs.ts new file mode 100644 index 0000000000..60849a1d11 --- /dev/null +++ b/packages/core/src/project-analysis/linker/build-module-link-inputs.ts @@ -0,0 +1,399 @@ +import { dirname } from "node:path"; +import { existsSync } from "node:fs"; +import fg from "fast-glob"; +import type { + ImportReference, + ProjectAnalysisError, + ResolvedEntries, + SourceFile, +} from "../types.js"; +import { ResolverError, WorkspaceError, describeUnknownError } from "../errors.js"; +import { parseSourceFile, type ParsedSource } from "../collect/parse.js"; +import type { ResolvedImport } from "../resolver/resolve.js"; +import type { ModuleLinkInput } from "./build.js"; +import { isProjectAnalysisExcludedPath } from "../utils/is-project-analysis-excluded-path.js"; +import { normalizeProjectRootGlobSpecifier } from "../utils/normalize-project-root-glob-specifier.js"; +import { createImportGlobFilter } from "../utils/create-import-glob-filter.js"; + +interface BuildModuleLinkInputsOptions { + projectRootDirectories: ReadonlyArray; + files: SourceFile[]; + parsedModules: ParsedSource[]; + resolvedEntries: ResolvedEntries; + gitIgnoredFilePaths: ReadonlySet; + resolveModule: (specifier: string, fromFile: string) => ResolvedImport; +} + +interface ModuleLinkInputsResult { + graphInputs: ModuleLinkInput[]; + errors: ProjectAnalysisError[]; + resolvedLocalImportSpecifiersByFilePath: Map>; + unresolvedImportingFilePaths: Set; +} + +interface ModuleResolutionContext { + errors: ProjectAnalysisError[]; + resolveModule: (specifier: string, fromFile: string) => ResolvedImport; +} + +interface StyleDiscoveryContext extends ModuleResolutionContext { + discoveredFilePaths: Set; + pendingStyleFilePaths: Set; + styleFileQueue: string[]; +} + +const STYLE_EXTENSIONS = [".css", ".scss"]; + +const isStyleFile = (filePath: string): boolean => + STYLE_EXTENSIONS.some((extension) => filePath.endsWith(extension)); + +const unresolvedImport = (): ResolvedImport => ({ + resolvedPath: undefined, + isExternal: false, + packageName: undefined, +}); + +const resolveImport = ( + context: ModuleResolutionContext, + specifier: string, + fromFilePath: string, + failureMessage: string, +): ResolvedImport => { + try { + return context.resolveModule(specifier, fromFilePath); + } catch (resolveError) { + context.errors.push( + new ResolverError({ + severity: "warning", + message: failureMessage, + path: fromFilePath, + detail: describeUnknownError(resolveError), + }), + ); + return unresolvedImport(); + } +}; + +const expandImportGlob = ( + importReference: ImportReference, + fromFilePath: string, + errors: ProjectAnalysisError[], +): string[] => { + const specifier = importReference.specifier; + try { + const importGlobFilter = createImportGlobFilter(importReference, fromFilePath); + return fg + .sync(specifier, { + cwd: dirname(fromFilePath), + absolute: true, + onlyFiles: true, + ignore: ["**/node_modules/**"], + }) + .filter(importGlobFilter); + } catch (globError) { + errors.push( + new WorkspaceError({ + code: "workspace-discovery-failed", + message: `fast-glob threw on import glob "${specifier}"`, + path: fromFilePath, + detail: describeUnknownError(globError), + }), + ); + return []; + } +}; + +const collectSourceImports = ( + parsedModule: ParsedSource, + filePath: string, + context: ModuleResolutionContext, +): Map => { + const resolvedImports = new Map(); + for (const importInfo of parsedModule.imports) { + if (importInfo.isGlob) { + for (const expandedFilePath of expandImportGlob(importInfo, filePath, context.errors)) { + resolvedImports.set(expandedFilePath, { + resolvedPath: expandedFilePath, + isExternal: false, + packageName: undefined, + }); + } + resolvedImports.set(importInfo.specifier, unresolvedImport()); + continue; + } + resolvedImports.set( + importInfo.specifier, + resolveImport( + context, + importInfo.specifier, + filePath, + `moduleResolver.resolveModule threw on specifier "${importInfo.specifier}"`, + ), + ); + } + return resolvedImports; +}; + +const collectReExportImports = ( + parsedModule: ParsedSource, + filePath: string, + resolvedImports: Map, + context: ModuleResolutionContext, +): void => { + for (const exportInfo of parsedModule.exports) { + if ( + !exportInfo.isReExport || + !exportInfo.reExportSource || + resolvedImports.has(exportInfo.reExportSource) + ) { + continue; + } + resolvedImports.set( + exportInfo.reExportSource, + resolveImport( + context, + exportInfo.reExportSource, + filePath, + `moduleResolver.resolveModule threw on specifier "${exportInfo.reExportSource}"`, + ), + ); + } +}; + +const buildSourceModuleLinkInputs = ( + options: BuildModuleLinkInputsOptions, +): ModuleLinkInputsResult => { + const errors: ProjectAnalysisError[] = []; + const productionEntryPaths = new Set(options.resolvedEntries.productionEntries); + const authoritativeProductionEntryPaths = new Set( + options.resolvedEntries.authoritativeProductionEntries, + ); + const explicitProductionEntryPaths = new Set(options.resolvedEntries.explicitProductionEntries); + const testEntryPaths = new Set(options.resolvedEntries.testEntries); + const alwaysUsedFilePaths = new Set(options.resolvedEntries.alwaysUsedFiles); + const externallyConsumedFilePaths = new Set(options.resolvedEntries.externallyConsumedFiles); + const analysisExcludedFilePaths = new Set(options.resolvedEntries.analysisExcludedFiles); + const graphInputs: ModuleLinkInput[] = []; + const resolvedLocalImportSpecifiersByFilePath = new Map>(); + const unresolvedImportingFilePaths = new Set(); + const resolutionContext: ModuleResolutionContext = { + errors, + resolveModule: options.resolveModule, + }; + + for (let fileIndex = 0; fileIndex < options.files.length; fileIndex++) { + const file = options.files[fileIndex]; + const originalParsedModule = options.parsedModules[fileIndex]; + const parsedModule = { + ...originalParsedModule, + imports: originalParsedModule.imports.map((importInfo) => ({ + ...importInfo, + specifier: + importInfo.isGlob && importInfo.specifier.startsWith("/") + ? normalizeProjectRootGlobSpecifier( + importInfo.specifier, + file.path, + options.projectRootDirectories, + options.resolvedEntries.viteProjectScopes ?? [], + ) + : importInfo.specifier, + })), + }; + const resolvedImports = collectSourceImports(parsedModule, file.path, resolutionContext); + collectReExportImports(parsedModule, file.path, resolvedImports, resolutionContext); + const resolvedLocalImportSpecifiers = new Set( + [...resolvedImports] + .filter(([, resolvedImport]) => resolvedImport.resolvedPath && !resolvedImport.isExternal) + .map(([specifier]) => specifier), + ); + if (resolvedLocalImportSpecifiers.size > 0) { + resolvedLocalImportSpecifiersByFilePath.set(file.path, resolvedLocalImportSpecifiers); + } + if ( + [...resolvedImports.values()].some( + (resolvedImport) => !resolvedImport.resolvedPath && !resolvedImport.isExternal, + ) + ) { + unresolvedImportingFilePaths.add(file.path); + } + + graphInputs.push({ + fileId: file, + parsed: parsedModule, + resolvedImports, + isEntryPoint: + alwaysUsedFilePaths.has(file.path) || + productionEntryPaths.has(file.path) || + testEntryPaths.has(file.path), + isExternallyConsumed: externallyConsumedFilePaths.has(file.path), + isTestEntry: testEntryPaths.has(file.path), + isGitIgnored: options.gitIgnoredFilePaths.has(file.path), + isAnalysisExcluded: + analysisExcludedFilePaths.has(file.path) || + isProjectAnalysisExcludedPath(file.path, options.projectRootDirectories[0]), + isAuthoritativeEntryPoint: + authoritativeProductionEntryPaths.has(file.path) || alwaysUsedFilePaths.has(file.path), + isExplicitEntryPoint: explicitProductionEntryPaths.has(file.path), + }); + } + + return { + graphInputs, + errors, + resolvedLocalImportSpecifiersByFilePath, + unresolvedImportingFilePaths, + }; +}; + +const findUndiscoveredStyleFilePath = ( + resolvedImport: ResolvedImport, + discoveredFilePaths: ReadonlySet, +): string | undefined => { + const resolvedPath = resolvedImport.resolvedPath; + if ( + !resolvedPath || + discoveredFilePaths.has(resolvedPath) || + !isStyleFile(resolvedPath) || + !existsSync(resolvedPath) + ) { + return undefined; + } + return resolvedPath; +}; + +const collectPendingStyleFilePaths = ( + sourceGraphInputs: ModuleLinkInput[], + discoveredFilePaths: ReadonlySet, +): Set => { + const pendingStyleFilePaths = new Set(); + for (const graphInput of sourceGraphInputs) { + for (const resolvedImport of graphInput.resolvedImports.values()) { + if (resolvedImport.isExternal) continue; + const styleFilePath = findUndiscoveredStyleFilePath(resolvedImport, discoveredFilePaths); + if (styleFilePath) pendingStyleFilePaths.add(styleFilePath); + } + } + return pendingStyleFilePaths; +}; + +const collectStyleImports = ( + parsedStyleModule: ParsedSource, + styleFilePath: string, + context: StyleDiscoveryContext, +): Map => { + const resolvedStyleImports = new Map(); + for (const importInfo of parsedStyleModule.imports) { + const resolvedImport = resolveImport( + context, + importInfo.specifier, + styleFilePath, + `moduleResolver.resolveModule threw on style import "${importInfo.specifier}"`, + ); + resolvedStyleImports.set(importInfo.specifier, resolvedImport); + + const importedStyleFilePath = findUndiscoveredStyleFilePath( + resolvedImport, + context.discoveredFilePaths, + ); + if (!importedStyleFilePath || context.pendingStyleFilePaths.has(importedStyleFilePath)) { + continue; + } + context.pendingStyleFilePaths.add(importedStyleFilePath); + context.styleFileQueue.push(importedStyleFilePath); + } + return resolvedStyleImports; +}; + +const buildStyleModuleLinkInputs = ( + options: BuildModuleLinkInputsOptions, + sourceGraphInputs: ModuleLinkInput[], +): ModuleLinkInputsResult => { + const errors: ProjectAnalysisError[] = []; + const graphInputs: ModuleLinkInput[] = []; + const resolvedLocalImportSpecifiersByFilePath = new Map>(); + const unresolvedImportingFilePaths = new Set(); + const discoveredFilePaths = new Set(options.files.map((file) => file.path)); + const pendingStyleFilePaths = collectPendingStyleFilePaths( + sourceGraphInputs, + discoveredFilePaths, + ); + const styleFileQueue = [...pendingStyleFilePaths].sort(); + const discoveryContext: StyleDiscoveryContext = { + discoveredFilePaths, + errors, + pendingStyleFilePaths, + resolveModule: options.resolveModule, + styleFileQueue, + }; + let nextFileIndex = options.files.length; + for (let queueIndex = 0; queueIndex < styleFileQueue.length; queueIndex++) { + const styleFilePath = styleFileQueue[queueIndex]; + if (discoveredFilePaths.has(styleFilePath)) continue; + + const parsedStyleModule = parseSourceFile(styleFilePath); + const resolvedStyleImports = collectStyleImports( + parsedStyleModule, + styleFilePath, + discoveryContext, + ); + const resolvedLocalImportSpecifiers = new Set( + [...resolvedStyleImports] + .filter(([, resolvedImport]) => resolvedImport.resolvedPath && !resolvedImport.isExternal) + .map(([specifier]) => specifier), + ); + if (resolvedLocalImportSpecifiers.size > 0) { + resolvedLocalImportSpecifiersByFilePath.set(styleFilePath, resolvedLocalImportSpecifiers); + } + if ( + [...resolvedStyleImports.values()].some( + (resolvedImport) => !resolvedImport.resolvedPath && !resolvedImport.isExternal, + ) + ) { + unresolvedImportingFilePaths.add(styleFilePath); + } + + graphInputs.push({ + fileId: { index: nextFileIndex, path: styleFilePath }, + parsed: parsedStyleModule, + resolvedImports: resolvedStyleImports, + isEntryPoint: false, + isExternallyConsumed: false, + isTestEntry: false, + isGitIgnored: options.gitIgnoredFilePaths.has(styleFilePath), + isAnalysisExcluded: isProjectAnalysisExcludedPath( + styleFilePath, + options.projectRootDirectories[0], + ), + isAuthoritativeEntryPoint: false, + isExplicitEntryPoint: false, + }); + discoveredFilePaths.add(styleFilePath); + nextFileIndex++; + } + + return { + graphInputs, + errors, + resolvedLocalImportSpecifiersByFilePath, + unresolvedImportingFilePaths, + }; +}; + +export const buildModuleLinkInputs = ( + options: BuildModuleLinkInputsOptions, +): ModuleLinkInputsResult => { + const sourceResult = buildSourceModuleLinkInputs(options); + const styleResult = buildStyleModuleLinkInputs(options, sourceResult.graphInputs); + return { + graphInputs: [...sourceResult.graphInputs, ...styleResult.graphInputs], + errors: [...sourceResult.errors, ...styleResult.errors], + resolvedLocalImportSpecifiersByFilePath: new Map([ + ...sourceResult.resolvedLocalImportSpecifiersByFilePath, + ...styleResult.resolvedLocalImportSpecifiersByFilePath, + ]), + unresolvedImportingFilePaths: new Set([ + ...sourceResult.unresolvedImportingFilePaths, + ...styleResult.unresolvedImportingFilePaths, + ]), + }; +}; diff --git a/packages/core/src/project-analysis/linker/build.ts b/packages/core/src/project-analysis/linker/build.ts new file mode 100644 index 0000000000..9aabac1459 --- /dev/null +++ b/packages/core/src/project-analysis/linker/build.ts @@ -0,0 +1,206 @@ +import path from "node:path"; +import type { + SourceFile, + DependencyGraph, + SourceModule, + Edge, + LinkedSymbol, + ReExportMapping, +} from "../types.js"; +import type { ParsedSource } from "../collect/parse.js"; +import type { ResolvedImport } from "../resolver/resolve.js"; +import { isConfigFile } from "../utils/is-config-file.js"; +import { toPosixPath } from "../utils/to-posix-path.js"; +import { compileGlobPattern } from "../../utils/match-glob-pattern.js"; +import { createImportGlobFilter } from "../utils/create-import-glob-filter.js"; + +export interface ModuleLinkInput { + fileId: SourceFile; + parsed: ParsedSource; + resolvedImports: Map; + isEntryPoint: boolean; + isExternallyConsumed: boolean; + isTestEntry: boolean; + isGitIgnored: boolean; + isAnalysisExcluded: boolean; + isAuthoritativeEntryPoint: boolean; + isExplicitEntryPoint: boolean; +} + +export const buildDependencyGraph = (inputs: ModuleLinkInput[]): DependencyGraph => { + const normalizedInputs = inputs.map((input) => ({ + ...input, + fileId: { + ...input.fileId, + path: toPosixPath(input.fileId.path), + }, + })); + + const fileIdMap = new Map(); + for (const input of normalizedInputs) { + fileIdMap.set(input.fileId.path, input.fileId.index); + } + + const modules: SourceModule[] = normalizedInputs.map((input) => ({ + fileId: input.fileId, + imports: input.parsed.imports, + exports: input.parsed.exports, + memberAccesses: input.parsed.memberAccesses, + wholeObjectUses: input.parsed.wholeObjectUses, + localIdentifierReferences: input.parsed.localIdentifierReferences, + topLevelImportReferences: input.parsed.topLevelImportReferences, + referencedFilenames: input.parsed.referencedFilenames, + hasUnknownDynamicModuleLoad: input.parsed.hasUnknownDynamicModuleLoad, + parseErrors: input.parsed.errors, + isEntryPoint: input.isEntryPoint, + isExternallyConsumed: input.isExternallyConsumed, + isTestEntry: input.isTestEntry, + isReachable: false, + isDeclarationFile: + input.fileId.path.endsWith(".d.ts") || + input.fileId.path.endsWith(".d.mts") || + input.fileId.path.endsWith(".d.cts"), + isConfigFile: isConfigFile(input.fileId.path), + isGitIgnored: input.isGitIgnored, + isAnalysisExcluded: input.isAnalysisExcluded || input.parsed.isGenerated, + isAuthoritativeEntryPoint: input.isAuthoritativeEntryPoint, + isExplicitEntryPoint: input.isExplicitEntryPoint, + isPackageGraphComplete: false, + hasPackageDynamicLoaderUncertainty: false, + })); + + const edges: Edge[] = []; + const reverseEdges = new Map(); + + const addEdge = ( + sourceIndex: number, + targetIndex: number, + symbols: LinkedSymbol[], + isReExportEdge: boolean = false, + reExportedNames: string[] = [], + reExportMappings: ReExportMapping[] = [], + isDynamic: boolean = false, + isSideEffect: boolean = false, + isTypeOnly: boolean = false, + ): void => { + edges.push({ + source: sourceIndex, + target: targetIndex, + importedSymbols: symbols, + isReExportEdge, + isDynamic, + isSideEffect, + isTypeOnly, + reExportedNames, + reExportMappings, + }); + + const existingReverseEdges = reverseEdges.get(targetIndex); + if (existingReverseEdges) { + if (!existingReverseEdges.includes(sourceIndex)) { + existingReverseEdges.push(sourceIndex); + } + } else { + reverseEdges.set(targetIndex, [sourceIndex]); + } + }; + + for (const input of normalizedInputs) { + const sourceIndex = input.fileId.index; + + for (const importInfo of input.parsed.imports) { + if (importInfo.isGlob) { + const sourceDir = path.dirname(input.fileId.path); + const globPattern = importInfo.specifier; + const globExpression = compileGlobPattern(globPattern); + const importGlobFilter = createImportGlobFilter(importInfo, input.fileId.path); + for (const [filePath] of fileIdMap) { + const relativePath = toPosixPath(path.relative(sourceDir, filePath)); + if (globExpression.test(relativePath) && importGlobFilter(filePath)) { + const targetIndex = fileIdMap.get(filePath); + if (targetIndex !== undefined) { + addEdge(sourceIndex, targetIndex, [], false, [], [], true); + } + } + } + continue; + } + + const resolved = input.resolvedImports.get(importInfo.specifier); + if (!resolved?.resolvedPath) continue; + + const targetIndex = fileIdMap.get(toPosixPath(resolved.resolvedPath)); + if (targetIndex === undefined) continue; + + const importedSymbols: LinkedSymbol[] = importInfo.importedNames.map((importedName) => ({ + importedName: importedName.name, + localName: importedName.alias ?? importedName.name, + isTypeOnly: importedName.isTypeOnly, + isNamespace: importedName.isNamespace, + isDefault: importedName.isDefault, + })); + + addEdge( + sourceIndex, + targetIndex, + importedSymbols, + false, + [], + [], + importInfo.isDynamic, + importInfo.isSideEffect, + ); + } + + const reExportsByTarget = new Map< + number, + { names: string[]; mappings: ReExportMapping[]; isTypeOnly: boolean } + >(); + for (const exportInfo of input.parsed.exports) { + if (!exportInfo.isReExport || !exportInfo.reExportSource) continue; + + const resolved = input.resolvedImports.get(exportInfo.reExportSource); + if (!resolved?.resolvedPath) continue; + + const targetIndex = fileIdMap.get(toPosixPath(resolved.resolvedPath)); + if (targetIndex === undefined) continue; + + const exportedName = exportInfo.name; + const originalName = exportInfo.isNamespaceReExport + ? "*" + : (exportInfo.reExportOriginalName ?? exportInfo.name); + + const existing = reExportsByTarget.get(targetIndex); + if (existing) { + existing.names.push(exportedName); + existing.mappings.push({ exportedName, originalName }); + existing.isTypeOnly = existing.isTypeOnly && exportInfo.isTypeOnly; + } else { + reExportsByTarget.set(targetIndex, { + names: [exportedName], + mappings: [{ exportedName, originalName }], + isTypeOnly: exportInfo.isTypeOnly, + }); + } + } + + for (const [ + targetIndex, + { names: reExportedNames, mappings: reExportMappings, isTypeOnly }, + ] of reExportsByTarget) { + addEdge( + sourceIndex, + targetIndex, + [], + true, + reExportedNames, + reExportMappings, + false, + false, + isTypeOnly, + ); + } + } + + return { modules, edges, reverseEdges, fileIdMap }; +}; diff --git a/packages/deslop-js/src/linker/mark-filename-registry-entries.ts b/packages/core/src/project-analysis/linker/mark-filename-registry-entries.ts similarity index 94% rename from packages/deslop-js/src/linker/mark-filename-registry-entries.ts rename to packages/core/src/project-analysis/linker/mark-filename-registry-entries.ts index c9c110fea9..04dee433e7 100644 --- a/packages/deslop-js/src/linker/mark-filename-registry-entries.ts +++ b/packages/core/src/project-analysis/linker/mark-filename-registry-entries.ts @@ -43,6 +43,11 @@ const buildRegistryModuleLookup = (moduleGraph: DependencyGraph): RegistryModule basenameFromPath(module.fileId.path), module.fileId.index, ); + recordUniqueModuleIndex( + basenameToModuleIndex, + normalizeRegistryModulePath(basenameFromPath(module.fileId.path)), + module.fileId.index, + ); const extensionlessPath = normalizeRegistryModulePath(module.fileId.path); let slashIndex = extensionlessPath.indexOf("/"); diff --git a/packages/deslop-js/src/linker/re-exports.ts b/packages/core/src/project-analysis/linker/re-exports.ts similarity index 96% rename from packages/deslop-js/src/linker/re-exports.ts rename to packages/core/src/project-analysis/linker/re-exports.ts index eac74978a6..74505e308d 100644 --- a/packages/deslop-js/src/linker/re-exports.ts +++ b/packages/core/src/project-analysis/linker/re-exports.ts @@ -15,7 +15,8 @@ export const resolveReExportChains = (graph: DependencyGraph): void => { (exportInfo) => exportInfo.isReExport && exportInfo.reExportSource !== undefined && - exportInfo.isNamespaceReExport, + exportInfo.isNamespaceReExport && + exportInfo.name === "*", ); if (!namespaceReExport) continue; diff --git a/packages/core/src/project-analysis/linker/reachability.ts b/packages/core/src/project-analysis/linker/reachability.ts new file mode 100644 index 0000000000..3ee53bbb4a --- /dev/null +++ b/packages/core/src/project-analysis/linker/reachability.ts @@ -0,0 +1,143 @@ +import type { DependencyGraph, Edge } from "../types.js"; + +interface ReachabilityQueueItem { + moduleIndex: number; + demandedSymbols: Set | "all"; +} + +export const traceReachability = ( + graph: DependencyGraph, + platformSiblingIndex: ReadonlyMap> = new Map(), +): void => { + const totalModules = graph.modules.length; + const visited = new Uint8Array(totalModules); + const consumedExportsPerModule = new Map>(); + const queue: ReachabilityQueueItem[] = []; + + const outgoingEdgesMap = new Map(); + for (const edge of graph.edges) { + const existing = outgoingEdgesMap.get(edge.source); + if (existing) { + existing.push(edge); + } else { + outgoingEdgesMap.set(edge.source, [edge]); + } + } + + for (const module of graph.modules) { + if (module.isEntryPoint) { + const moduleIndex = module.fileId.index; + if (moduleIndex < totalModules) { + visited[moduleIndex] = 1; + queue.push({ moduleIndex, demandedSymbols: "all" }); + } + } + } + + const markConsumedExports = (targetModuleIndex: number, symbols: Set | "all"): void => { + if (symbols === "all") { + consumedExportsPerModule.set(targetModuleIndex, new Set(["*"])); + return; + } + const existing = consumedExportsPerModule.get(targetModuleIndex); + if (existing && existing.has("*")) return; + if (existing) { + for (const symbol of symbols) { + existing.add(symbol); + } + } else { + consumedExportsPerModule.set(targetModuleIndex, new Set(symbols)); + } + }; + + let headPointer = 0; + while (headPointer < queue.length) { + const { moduleIndex: currentIndex } = queue[headPointer++]; + const outgoingEdges = outgoingEdgesMap.get(currentIndex); + if (!outgoingEdges) continue; + + for (const edge of outgoingEdges) { + const targetIndex = edge.target; + if (targetIndex >= totalModules) continue; + + if (edge.isReExportEdge) { + if (!visited[targetIndex]) { + visited[targetIndex] = 1; + markConsumedExports(targetIndex, "all"); + queue.push({ moduleIndex: targetIndex, demandedSymbols: "all" }); + } + } else { + const importSymbolNames = new Set(); + let isNamespaceOrSideEffect = edge.importedSymbols.length === 0; + + for (const symbol of edge.importedSymbols) { + if (symbol.isNamespace) { + isNamespaceOrSideEffect = true; + break; + } + importSymbolNames.add(symbol.importedName); + if (symbol.isDefault) { + importSymbolNames.add("default"); + } + } + + const symbolDemand: Set | "all" = isNamespaceOrSideEffect + ? "all" + : importSymbolNames; + + if (!visited[targetIndex]) { + visited[targetIndex] = 1; + markConsumedExports(targetIndex, symbolDemand); + queue.push({ moduleIndex: targetIndex, demandedSymbols: symbolDemand }); + } else { + const existingConsumed = consumedExportsPerModule.get(targetIndex); + if (symbolDemand !== "all" && existingConsumed && !existingConsumed.has("*")) { + let hasNewSymbols = false; + for (const symbol of symbolDemand) { + if (!existingConsumed.has(symbol)) { + hasNewSymbols = true; + break; + } + } + if (hasNewSymbols) { + markConsumedExports(targetIndex, symbolDemand); + queue.push({ moduleIndex: targetIndex, demandedSymbols: symbolDemand }); + } + } else if (symbolDemand === "all" && (!existingConsumed || !existingConsumed.has("*"))) { + markConsumedExports(targetIndex, "all"); + queue.push({ moduleIndex: targetIndex, demandedSymbols: "all" }); + } + } + } + } + } + + const platformQueue: ReachabilityQueueItem[] = []; + for (let moduleIndex = 0; moduleIndex < totalModules; moduleIndex++) { + if (!visited[moduleIndex]) continue; + for (const siblingIndex of platformSiblingIndex.get(moduleIndex) ?? []) { + if (!visited[siblingIndex]) { + visited[siblingIndex] = 1; + platformQueue.push({ moduleIndex: siblingIndex, demandedSymbols: "all" }); + } + } + } + + let platformHeadPointer = 0; + while (platformHeadPointer < platformQueue.length) { + const { moduleIndex: currentIndex } = platformQueue[platformHeadPointer++]; + const outgoingEdges = outgoingEdgesMap.get(currentIndex); + if (!outgoingEdges) continue; + + for (const edge of outgoingEdges) { + if (edge.target < totalModules && !visited[edge.target]) { + visited[edge.target] = 1; + platformQueue.push({ moduleIndex: edge.target, demandedSymbols: "all" }); + } + } + } + + for (let moduleIndex = 0; moduleIndex < totalModules; moduleIndex++) { + graph.modules[moduleIndex].isReachable = Boolean(visited[moduleIndex]); + } +}; diff --git a/packages/core/src/project-analysis/project-analysis-worker-slots.ts b/packages/core/src/project-analysis/project-analysis-worker-slots.ts new file mode 100644 index 0000000000..0c9de84e5f --- /dev/null +++ b/packages/core/src/project-analysis/project-analysis-worker-slots.ts @@ -0,0 +1,16 @@ +import { createWorkerSlots } from "../utils/create-worker-slots.js"; +import type { WorkerSlots } from "../utils/create-worker-slots.js"; +import { resolveProjectAnalysisConcurrency } from "../utils/resolve-project-analysis-concurrency.js"; + +let projectAnalysisWorkerSlots: WorkerSlots | null = null; + +export const withProjectAnalysisWorkerSlot = async ( + task: () => Promise, + abortSignal?: AbortSignal, +): Promise => { + projectAnalysisWorkerSlots ??= createWorkerSlots({ + slotCount: resolveProjectAnalysisConcurrency(), + createAbortError: () => new Error("Project analysis was cancelled."), + }); + return projectAnalysisWorkerSlots.run(task, abortSignal); +}; diff --git a/packages/deslop-js/src/report/cycles.ts b/packages/core/src/project-analysis/report/cycles.ts similarity index 92% rename from packages/deslop-js/src/report/cycles.ts rename to packages/core/src/project-analysis/report/cycles.ts index c70e9db10f..bd8e6bcb77 100644 --- a/packages/deslop-js/src/report/cycles.ts +++ b/packages/core/src/project-analysis/report/cycles.ts @@ -34,13 +34,24 @@ const buildAdjacencyList = (graph: DependencyGraph): number[][] => { const targetSets: Set[] = Array.from({ length: graph.modules.length }, () => new Set()); for (const edge of graph.edges) { + const sourceModule = graph.modules[edge.source]; + const targetModule = graph.modules[edge.target]; + if (sourceModule?.isAnalysisExcluded || targetModule?.isAnalysisExcluded) { + continue; + } + // A lazy `import()` / `require()` edge only evaluates at call time, after // module init, so it cannot close an initialization-order cycle. if (edge.isDynamic) { continue; } - const isTypeOnlyEdge = edge.importedSymbols.every((symbol) => symbol.isTypeOnly); + if (edge.isTypeOnly) { + continue; + } + + const isTypeOnlyEdge = + edge.importedSymbols.length > 0 && edge.importedSymbols.every((symbol) => symbol.isTypeOnly); if (isTypeOnlyEdge) { continue; } @@ -65,7 +76,11 @@ const buildAdjacencyList = (graph: DependencyGraph): number[][] => { const buildModuleInitAccessEdgeSet = (graph: DependencyGraph): Set => { const initAccessEdges = new Set(); for (const edge of graph.edges) { - if (edge.isDynamic || edge.isReExportEdge) continue; + if (edge.isDynamic || edge.isReExportEdge || edge.isTypeOnly) continue; + if (edge.isSideEffect || edge.importedSymbols.length === 0) { + initAccessEdges.add(`${edge.source}:${edge.target}`); + continue; + } const sourceModule = graph.modules[edge.source]; const topLevelReferences = sourceModule?.topLevelImportReferences; if (!topLevelReferences || topLevelReferences.length === 0) continue; diff --git a/packages/core/src/project-analysis/report/exports.ts b/packages/core/src/project-analysis/report/exports.ts new file mode 100644 index 0000000000..c6f6969a63 --- /dev/null +++ b/packages/core/src/project-analysis/report/exports.ts @@ -0,0 +1,412 @@ +import type { + DependencyGraph, + Edge, + SourceModule, + ExportReference, + UnusedExport, + ProjectAnalysisConfig, + MemberAccess, +} from "../types.js"; +import { collectConventionConsumedExportKeys } from "../utils/collect-convention-consumed-export-keys.js"; +import { buildExportKey } from "../utils/build-export-key.js"; + +interface ReExportTarget { + readonly targetIndex: number; + readonly mappings: Edge["reExportMappings"]; +} + +export const detectDeadExports = ( + graph: DependencyGraph, + config: ProjectAnalysisConfig, + platformSiblingIndex: ReadonlyMap> = new Map(), +): UnusedExport[] => { + const usageMap = buildUsageMap(graph, platformSiblingIndex); + const conventionConsumedExportKeys = collectConventionConsumedExportKeys(graph); + const unusedExports: UnusedExport[] = []; + + for (const module of graph.modules) { + if (module.hasPackageDynamicLoaderUncertainty) continue; + if (!module.isReachable && !module.isExternallyConsumed) continue; + if (module.isDeclarationFile) continue; + if (module.isGitIgnored) continue; + if (module.isAnalysisExcluded) continue; + if (module.isEntryPoint && !config.includeEntryExports) continue; + + const defaultExportLinkedNames = new Set(); + for (const exportInfo of module.exports) { + if ( + exportInfo.isDefault && + exportInfo.defaultExportLocalName && + usageMap.has(`${module.fileId.path}::default`) + ) { + defaultExportLinkedNames.add(exportInfo.defaultExportLocalName); + } + } + + for (const exportInfo of module.exports) { + if (exportInfo.name === "*" && exportInfo.isNamespaceReExport) continue; + if (exportInfo.isReExport && exportInfo.reExportOriginalName) continue; + if (!config.reportTypes && exportInfo.isTypeOnly) continue; + + const usageKey = `${module.fileId.path}::${exportInfo.name}`; + if (usageMap.has(usageKey)) continue; + if (conventionConsumedExportKeys.has(buildExportKey(module.fileId.path, exportInfo.name))) { + continue; + } + + if (module.localIdentifierReferences.includes(exportInfo.name)) continue; + + if (!exportInfo.isDefault && defaultExportLinkedNames.has(exportInfo.name)) { + continue; + } + + // `export default Page` aliasing a named export that IS consumed: + // deleting the default would be busywork the named usage disproves. + if ( + exportInfo.isDefault && + exportInfo.defaultExportLocalName && + usageMap.has(`${module.fileId.path}::${exportInfo.defaultExportLocalName}`) + ) { + continue; + } + + unusedExports.push({ + path: module.fileId.path, + name: exportInfo.name, + line: exportInfo.line, + column: exportInfo.column, + isTypeOnly: exportInfo.isTypeOnly, + }); + } + } + + return unusedExports; +}; + +const buildUsageMap = ( + graph: DependencyGraph, + platformSiblingIndex: ReadonlyMap>, +): Set => { + const usedExportKeys = new Set(); + const sourceToTargetMap = buildSourceToTargetsMap(graph, platformSiblingIndex); + + // Indexed by source so the entry-point pass is O(edges), not + // O(entry points × edges) — on a large repo with thousands of entry + // modules the unindexed scan dominated this detector. + const reExportEdgesBySource = new Map(); + for (const edge of graph.edges) { + if (!edge.isReExportEdge) continue; + const existingEdges = reExportEdgesBySource.get(edge.source); + if (existingEdges) { + existingEdges.push(edge); + } else { + reExportEdgesBySource.set(edge.source, [edge]); + } + } + + for (const module of graph.modules) { + if (!module.isEntryPoint) continue; + + for (const edge of reExportEdgesBySource.get(module.fileId.index) ?? []) { + const isWildcardReExport = edge.reExportedNames.includes("*"); + for (const targetIndex of platformSiblingIndex.get(edge.target) ?? [edge.target]) { + const targetModule = graph.modules[targetIndex]; + if (!targetModule) continue; + + if (isWildcardReExport) { + markAllExportsUsedRecursive( + targetModule, + graph, + sourceToTargetMap, + usedExportKeys, + new Set(), + ); + } else { + for (const mapping of edge.reExportMappings) { + if (mapping.originalName === "*") { + markAllExportsUsedRecursive( + targetModule, + graph, + sourceToTargetMap, + usedExportKeys, + new Set(), + ); + } else { + markExportUsedRecursive( + targetModule.fileId.path, + mapping.originalName, + graph, + sourceToTargetMap, + usedExportKeys, + new Set(), + ); + } + } + } + } + } + } + + for (const edge of graph.edges) { + const sourceModule = graph.modules[edge.source]; + for (const targetIndex of platformSiblingIndex.get(edge.target) ?? [edge.target]) { + const targetModule = graph.modules[targetIndex]; + if (!targetModule) continue; + + // `import()` consumers are opaque: `lazy(() => import("./page"))` takes + // the default, `.then((m) => m.X)` takes named members, and neither shows + // up as an imported symbol. Treat every export of a dynamically imported + // module as used rather than flag exports we cannot trace. + if (edge.isDynamic && edge.importedSymbols.length === 0) { + markAllExportsUsedRecursive( + targetModule, + graph, + sourceToTargetMap, + usedExportKeys, + new Set(), + ); + continue; + } + + for (const symbol of edge.importedSymbols) { + if (symbol.isNamespace) { + handleNamespaceImport( + sourceModule, + targetModule, + symbol.localName, + graph, + sourceToTargetMap, + usedExportKeys, + ); + } else { + const importName = symbol.isDefault ? "default" : symbol.importedName; + markExportUsedRecursive( + targetModule.fileId.path, + importName, + graph, + sourceToTargetMap, + usedExportKeys, + new Set(), + ); + + if (symbol.isDefault) { + const hasDefaultExport = targetModule.exports.some( + (exportInfo) => exportInfo.isDefault, + ); + if (!hasDefaultExport && symbol.localName !== "default") { + const matchingNamedExport = targetModule.exports.find( + (exportInfo) => exportInfo.name === symbol.localName, + ); + if (matchingNamedExport) { + markExportUsedRecursive( + targetModule.fileId.path, + symbol.localName, + graph, + sourceToTargetMap, + usedExportKeys, + new Set(), + ); + } + } + } + } + } + } + } + + return usedExportKeys; +}; + +const handleNamespaceImport = ( + sourceModule: SourceModule | undefined, + targetModule: SourceModule, + namespaceLocalName: string, + graph: DependencyGraph, + sourceToTargets: Map, + usedKeys: Set, +): void => { + if (!sourceModule) { + markAllExportsUsedRecursive(targetModule, graph, sourceToTargets, usedKeys, new Set()); + return; + } + + const isWholeObjectUse = sourceModule.wholeObjectUses.includes(namespaceLocalName); + if (isWholeObjectUse) { + markAllExportsUsedRecursive(targetModule, graph, sourceToTargets, usedKeys, new Set()); + return; + } + + const accessedMemberNames = extractAccessedMemberNames( + sourceModule.memberAccesses, + namespaceLocalName, + ); + + const isNamespaceReExported = sourceModule.exports.some( + (exportInfo) => + exportInfo.reExportOriginalName === namespaceLocalName || + (!exportInfo.isReExport && exportInfo.name === namespaceLocalName), + ); + + if (accessedMemberNames.length === 0 && !isNamespaceReExported) { + markAllExportsUsedRecursive(targetModule, graph, sourceToTargets, usedKeys, new Set()); + return; + } + + if (isNamespaceReExported) { + markAllExportsUsedRecursive(targetModule, graph, sourceToTargets, usedKeys, new Set()); + return; + } + + for (const memberName of accessedMemberNames) { + markExportUsedRecursive( + targetModule.fileId.path, + memberName, + graph, + sourceToTargets, + usedKeys, + new Set(), + ); + } +}; + +const extractAccessedMemberNames = ( + memberAccesses: MemberAccess[], + objectName: string, +): string[] => { + const memberNames: string[] = []; + const seenNames = new Set(); + for (const access of memberAccesses) { + if (access.objectName === objectName && !seenNames.has(access.memberName)) { + seenNames.add(access.memberName); + memberNames.push(access.memberName); + } + } + return memberNames; +}; + +const buildSourceToTargetsMap = ( + graph: DependencyGraph, + platformSiblingIndex: ReadonlyMap>, +): Map => { + const sourceToTargets = new Map(); + + for (const edge of graph.edges) { + if (!edge.isReExportEdge) continue; + for (const targetIndex of platformSiblingIndex.get(edge.target) ?? [edge.target]) { + const existing = sourceToTargets.get(edge.source); + if (existing) { + existing.push({ targetIndex, mappings: edge.reExportMappings }); + } else { + sourceToTargets.set(edge.source, [{ targetIndex, mappings: edge.reExportMappings }]); + } + } + } + + return sourceToTargets; +}; + +const markAllExportsUsedRecursive = ( + module: SourceModule, + graph: DependencyGraph, + sourceToTargets: Map, + usedKeys: Set, + visited: Set, +): void => { + const visitKey = `all::${module.fileId.path}`; + if (visited.has(visitKey)) return; + visited.add(visitKey); + + for (const exportInfo of module.exports) { + if (exportInfo.name === "*" && exportInfo.isNamespaceReExport) continue; + + const usageKey = `${module.fileId.path}::${exportInfo.name}`; + usedKeys.add(usageKey); + + if (exportInfo.isReExport && exportInfo.reExportSource) { + followReExportChain( + module.fileId.index, + exportInfo, + graph, + sourceToTargets, + usedKeys, + visited, + ); + } + } +}; + +const markExportUsedRecursive = ( + filePath: string, + exportName: string, + graph: DependencyGraph, + sourceToTargets: Map, + usedKeys: Set, + visited: Set, +): void => { + const visitKey = `${filePath}::${exportName}`; + if (visited.has(visitKey)) return; + visited.add(visitKey); + + usedKeys.add(visitKey); + + const moduleIndex = graph.fileIdMap.get(filePath); + if (moduleIndex === undefined) return; + + const module = graph.modules[moduleIndex]; + if (!module) return; + + for (const exportInfo of module.exports) { + if (exportInfo.name !== exportName) continue; + + if (exportInfo.isReExport && exportInfo.reExportSource) { + followReExportChain(moduleIndex, exportInfo, graph, sourceToTargets, usedKeys, visited); + } + } +}; + +const followReExportChain = ( + reExporterModuleIndex: number, + exportInfo: ExportReference, + graph: DependencyGraph, + sourceToTargets: Map, + usedKeys: Set, + visited: Set, +): void => { + const targetIndices = sourceToTargets.get(reExporterModuleIndex); + if (!targetIndices) return; + + const originalName = exportInfo.reExportOriginalName ?? exportInfo.name; + + for (const target of targetIndices) { + const hasMatchingMapping = target.mappings.some( + (mapping) => + (mapping.exportedName === exportInfo.name && mapping.originalName === originalName) || + (exportInfo.isSynthetic && mapping.exportedName === "*" && mapping.originalName === "*"), + ); + if (!hasMatchingMapping) continue; + + const targetModule = graph.modules[target.targetIndex]; + if (!targetModule) continue; + + if (originalName === "*" || exportInfo.isNamespaceReExport) { + markAllExportsUsedRecursive(targetModule, graph, sourceToTargets, usedKeys, visited); + } else { + const targetHasExport = targetModule.exports.some( + (targetExport) => + targetExport.name === originalName || + (targetExport.isNamespaceReExport && targetExport.name === "*"), + ); + + if (targetHasExport) { + markExportUsedRecursive( + targetModule.fileId.path, + originalName, + graph, + sourceToTargets, + usedKeys, + visited, + ); + } + } + } +}; diff --git a/packages/deslop-js/src/report/files.ts b/packages/core/src/project-analysis/report/files.ts similarity index 81% rename from packages/deslop-js/src/report/files.ts rename to packages/core/src/project-analysis/report/files.ts index 5f453c46d1..d2609d7fdf 100644 --- a/packages/deslop-js/src/report/files.ts +++ b/packages/core/src/project-analysis/report/files.ts @@ -1,5 +1,9 @@ import type { DependencyGraph, UnusedFile, SourceModule } from "../types.js"; +interface DetectOrphanFilesOptions { + requireCompletePackageGraph?: boolean; +} + const EXCLUDED_EXTENSIONS = new Set([ ".html", ".mdx", @@ -15,14 +19,13 @@ const EXCLUDED_EXTENSIONS = new Set([ // `.test-d.` files are consumed by vitest's typecheck glob, never imported. const TEST_FILE_PATTERN = /(?:\.(?:test|spec|stories|story|cy|test-d)\.|(?:^|\/)__tests__\/)/; -// `public` files are served / `` }, + { "react-grab": "1.0.0", "unused-package": "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it("credits package imports in tracked dot-directory source files", () => { + const rootDirectory = createProject( + { ".vn/tests/auth.test.ts": `import request from "supertest";` }, + { supertest: "1.0.0", "unused-package": "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it("credits explicit node_modules references in hook scripts", () => { + const rootDirectory = createProject( + { ".husky/commit-msg": `node_modules/@evilmartians/lefthook/bin/lefthook run` }, + { "@evilmartians/lefthook": "1.0.0", "unused-package": "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it("credits package-runner commands in hook scripts", () => { + const rootDirectory = createProject( + { ".husky/pre-commit": `npx pretty-quick --staged` }, + { "pretty-quick": "1.0.0", "unused-package": "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it("credits TypeScript type reference directives", () => { + const rootDirectory = createProject( + { + "src/index.ts": `/// \nexport const value = true;`, + }, + { "@example/runtime-types": "1.0.0", "unused-package": "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it("credits explicit node_modules references in TypeScript configuration", () => { + const rootDirectory = createProject( + { + "tsconfig.json": JSON.stringify({ + include: ["./node_modules/@sanity/base/types/**/*.ts", "./src/**/*.ts"], + }), + }, + { "@sanity/base": "1.0.0", "unused-package": "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it("credits packages in JSONC TypeScript configuration containing URL strings", () => { + const rootDirectory = createProject( + { + "tsconfig.json": `{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "plugins": [{ "name": "typescript-plugin-css-modules" }], + }, + }`, + }, + { "typescript-plugin-css-modules": "1.0.0", "unused-package": "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it("does not credit package names in TypeScript configuration without a node_modules path", () => { + const rootDirectory = createProject( + { "tsconfig.json": JSON.stringify({ exclude: ["examples/unused-package"] }) }, + { "unused-package": "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it("does not credit packages excluded through a node_modules path", () => { + const rootDirectory = createProject( + { "tsconfig.json": JSON.stringify({ exclude: ["./node_modules/unused-package/**"] }) }, + { "unused-package": "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it.each(["./node_modules/package-directory", "./node_modules/@scope/package-directory"])( + "credits terminal node_modules directory references in TypeScript configuration: %s", + (packagePath) => { + const packageName = packagePath.slice("./node_modules/".length); + const rootDirectory = createProject( + { "tsconfig.json": JSON.stringify({ include: [packagePath] }) }, + { [packageName]: "1.0.0", "unused-package": "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }, + ); + + it.each([ + ["font-awesome", "../node_modules/font-awesome/css/font-awesome.min.css"], + ["@scope/styles", "../node_modules/@scope/styles/index.css"], + ])("credits explicit node_modules source imports for %s", (packageName, specifier) => { + const rootDirectory = createProject( + { "src/index.js": `import ${JSON.stringify(specifier)};` }, + { [packageName]: "1.0.0", "unused-package": "1.0.0" }, + ); + const graph = graphWithReachableImport(path.join(rootDirectory, "src/index.js"), specifier); + + expect( + detectStalePackages( + graph, + defineProjectAnalysisConfig({ rootDir: rootDirectory }), + ).unusedDependencies.map((dependency) => dependency.name), + ).toEqual(["unused-package"]); + }); + + it("credits Sanity v2 plugins, core runtime, and required peers", () => { + const dependencies = { + "@sanity/base": "2.34.0", + "@sanity/core": "2.34.0", + "@sanity/default-layout": "2.34.0", + "@sanity/vision": "2.34.0", + "prop-types": "15.8.1", + react: "17.0.2", + "react-dom": "17.0.2", + "styled-components": "5.3.11", + "unused-package": "1.0.0", + }; + const rootDirectory = createProject( + { + "package-lock.json": JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { dependencies }, + "node_modules/@sanity/base": { + version: "2.34.0", + peerDependencies: { + "prop-types": "^15.6", + react: "^17", + "react-dom": "^17", + "styled-components": "^5", + }, + }, + "node_modules/@sanity/core": { version: "2.34.0" }, + "node_modules/@sanity/default-layout": { version: "2.34.0" }, + "node_modules/@sanity/vision": { version: "2.34.0" }, + "node_modules/prop-types": { version: "15.8.1" }, + "node_modules/react": { version: "17.0.2" }, + "node_modules/react-dom": { version: "17.0.2" }, + "node_modules/styled-components": { version: "5.3.11" }, + "node_modules/unused-package": { version: "1.0.0" }, + }, + }), + "sanity.json": JSON.stringify({ + root: true, + plugins: ["@sanity/base", "@sanity/default-layout"], + env: { development: { plugins: ["@sanity/vision"] } }, + }), + }, + dependencies, + { start: "sanity start" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it("does not credit nested Sanity manifests or core without a Sanity script", () => { + const rootDirectory = createProject( + { + "examples/sanity.json": JSON.stringify({ root: true, plugins: ["@sanity/base"] }), + "sanity.json": JSON.stringify({ + root: true, + project: { name: "unused-package" }, + plugins: [], + parts: [{ name: "part:@sanity/base/schema", path: "./schemas/schema" }], + }), + }, + { + "@sanity/base": "2.34.0", + "@sanity/core": "2.34.0", + "unused-package": "1.0.0", + }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["@sanity/core", "unused-package"]); + }); + + it("credits commands nested in concurrently scripts", () => { + const rootDirectory = createProject( + {}, + { concurrently: "1.0.0", "wait-on": "1.0.0", "unused-package": "1.0.0" }, + { + dev: `concurrently "BROWSER=none npm start" "wait-on http://127.0.0.1:3000 && npm run desktop"`, + }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it("skips concurrently option values while crediting positional commands", () => { + const rootDirectory = createProject( + {}, + { concurrently: "1.0.0", "wait-on": "1.0.0", "unused-package": "1.0.0" }, + { + dev: `concurrently --names wait-on,web --prefix name "npm start" "wait-on http://127.0.0.1:3000"`, + }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it("does not treat concurrently non-command option values as commands", () => { + const rootDirectory = createProject( + {}, + { concurrently: "1.0.0", "wait-on": "1.0.0" }, + { dev: `concurrently --hide wait-on --shell zsh "npm start"` }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["wait-on"]); + }); + + it("does not treat concurrently bundled short option values as commands", () => { + const rootDirectory = createProject( + {}, + { concurrently: "1.0.0", "wait-on": "1.0.0" }, + { dev: `concurrently -kn wait-on "npm start"` }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["wait-on"]); + }); + + it("credits concurrently teardown commands", () => { + const rootDirectory = createProject( + {}, + { concurrently: "1.0.0", "wait-on": "1.0.0" }, + { dev: `concurrently --teardown "wait-on http://127.0.0.1:3000" "npm start"` }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual([]); + }); + + it("does not treat quoted arguments of ordinary commands as nested commands", () => { + const rootDirectory = createProject( + {}, + { "wait-on": "1.0.0" }, + { dev: `echo "wait-on http://127.0.0.1:3000"` }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["wait-on"]); + }); + + it.each([ + ["android/build.gradle", `apply from: "../node_modules/native-plugin/plugin.gradle"`], + ["src/styles.scss", `@import "../node_modules/style-package/index";`], + [ + "patches/runtime-package.patch", + `diff --git a/node_modules/runtime-package/index.js b/node_modules/runtime-package/index.js`, + ], + ])("credits explicit node_modules references in %s", (relativePath, source) => { + const rootDirectory = createProject( + { [relativePath]: source }, + { "native-plugin": "1.0.0", "style-package": "1.0.0", "runtime-package": "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual( + ["native-plugin", "runtime-package", "style-package"].filter( + (packageName) => !source.includes(`node_modules/${packageName}`), + ), + ); + }); + + it("credits a package targeted by a patch-package filename", () => { + const rootDirectory = createProject( + { "patches/@scope+patched-package+1.2.3.patch": `--- a/index.js\n+++ b/index.js` }, + { "@scope/patched-package": "1.2.3", "unused-package": "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it.each([ + ["src/config.coffee", `Emitter = require("emissary")`], + ["contracts/token.sol", `import "@openzeppelin/contracts/token/ERC20/ERC20.sol";`], + ["types/plugin.d.ts", `export type Plugin = import("typed-package").Plugin;`], + ])("credits package imports in authored %s files", (relativePath, source) => { + const rootDirectory = createProject( + { [relativePath]: source }, + { + emissary: "1.0.0", + "@openzeppelin/contracts": "1.0.0", + "typed-package": "1.0.0", + }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual( + ["@openzeppelin/contracts", "emissary", "typed-package"].filter( + (packageName) => !source.includes(packageName), + ), + ); + }); + + it("does not credit documentation or arbitrary patch text", () => { + const rootDirectory = createProject( + { + "docs/example.md": `\`\`\`ts\nimport value from "documentation-only-package";\n\`\`\``, + "patches/example.patch": `+ console.log("node_modules/patch-text-only-package/index.js")`, + }, + { + "documentation-only-package": "1.0.0", + "patch-text-only-package": "1.0.0", + }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual([ + "documentation-only-package", + "patch-text-only-package", + ]); + }); + + it("credits imports in twoslash code fences without crediting ordinary examples", () => { + const rootDirectory = createProject( + { + "docs/examples.mdx": [ + "```ts twoslash", + 'import type { Node } from "@babel/types";', + 'import render from "estree-to-babel";', + "/**", + " * @import {File} from 'jsdoc-import-package'", + " */", + "```", + "", + "```ts", + 'import Example from "documentation-only-package";', + "```", + ].join("\n"), + }, + { + "@babel/types": "1.0.0", + "documentation-only-package": "1.0.0", + "estree-to-babel": "1.0.0", + "jsdoc-import-package": "1.0.0", + }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["documentation-only-package"]); + }); + + it.each(["md", "mdx"])( + "credits live imports in authored .%s documents without crediting examples or prose", + (extension) => { + const rootDirectory = createProject( + { + [`docs/player.${extension}`]: [ + "---", + "title: Player", + "---", + 'import ReactPlayer from "react-player";', + "", + "", + "```tsx", + 'import FencedPlayer from "fenced-player";', + "```", + ' import IndentedPlayer from "indented-player";', + '`import InlinePlayer from "inline-player";`', + "", + ].join("\n"), + }, + { + "@docusaurus/core": "1.0.0", + "commented-player": "1.0.0", + "fenced-player": "1.0.0", + "indented-player": "1.0.0", + "inline-player": "1.0.0", + "react-player": "1.0.0", + }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual([ + "commented-player", + "fenced-player", + "indented-player", + "inline-player", + ]); + }, + ); + + it("does not credit import examples or frontmatter in plain Markdown", () => { + const rootDirectory = createProject( + { + "README.md": [ + "---", + "description: |", + ' import FrontmatterExample from "frontmatter-package"', + "---", + 'import Example from "example-package";', + ].join("\n"), + }, + { + "example-package": "1.0.0", + "frontmatter-package": "1.0.0", + }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual([ + "example-package", + "frontmatter-package", + ]); + }); + + it("credits multiline module statements in authored Markdown", () => { + const rootDirectory = createProject( + { + "docs/modules.mdx": [ + "import {", + " NamedPlayer", + "}", + 'from "named-player"', + "", + "import DefaultPlayer", + 'from "default-player"', + "", + "export {", + " ExportedPlayer", + "}", + 'from "exported-player"', + ].join("\n"), + }, + { + "default-player": "1.0.0", + "exported-player": "1.0.0", + "named-player": "1.0.0", + "unused-package": "1.0.0", + }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it("normalizes Babel module-prefixed preset specifiers only in Babel config", () => { + const rootDirectory = createProject( + { + "babel.config.js": ` + module.exports = { + presets: [ + "module:metro-react-native-babel-preset", + ["module:real-plugin", { plugins: ["module:nested-option-package"] }], + ], + label: "module:not-a-babel-preset", + // "module:commented-preset" + }; + `, + "vite.config.js": `export default { label: "module:not-a-babel-preset" };`, + }, + { + "commented-preset": "1.0.0", + "metro-react-native-babel-preset": "1.0.0", + "nested-option-package": "1.0.0", + "not-a-babel-preset": "1.0.0", + "real-plugin": "1.0.0", + }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual([ + "commented-preset", + "nested-option-package", + "not-a-babel-preset", + ]); + }); + + it("credits Grunt plugins referenced by registered task names", () => { + const rootDirectory = createProject( + { "Gruntfile.js": `grunt.registerTask("test", ["karma"]);` }, + { karma: "1.0.0", "unused-package": "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it("credits workspace package binaries invoked by scripts", () => { + const dependencies = { "@sa/scripts": "workspace:*", "unused-package": "1.0.0" }; + const scripts = { cleanup: "sa cleanup" }; + const rootDirectory = createProject( + { + "package.json": JSON.stringify({ + private: true, + workspaces: ["packages/*"], + dependencies, + scripts, + }), + "packages/scripts/package.json": JSON.stringify({ + name: "@sa/scripts", + bin: { sa: "./bin.ts" }, + }), + }, + dependencies, + scripts, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it("credits exact package-lock binary and required peer metadata", () => { + const rootDirectory = createProject( + { + "package-lock.json": JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { + dependencies: { "tool-package": "2.0.0", "peer-package": "3.1.0" }, + }, + "node_modules/tool-package": { + version: "2.0.0", + bin: { tool: "bin.js" }, + peerDependencies: { "peer-package": "^3.0.0" }, + }, + "node_modules/peer-package": { version: "3.1.0" }, + }, + }), + "src/index.ts": `import "tool-package";`, + }, + { "tool-package": "2.0.0", "peer-package": "3.1.0" }, + { build: "tool build" }, + ); + const graph = graphWithReachableImport( + path.join(rootDirectory, "src/index.ts"), + "tool-package", + ); + + expect( + detectStalePackages(graph, defineProjectAnalysisConfig({ rootDir: rootDirectory })) + .unusedDependencies, + ).toEqual([]); + }); + + it("credits required peers of conventionally used config packages", () => { + const rootDirectory = createProject( + { + "package-lock.json": JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { + devDependencies: { + "babel-eslint": "10.1.0", + "eslint-config-example": "1.0.0", + }, + }, + "node_modules/babel-eslint": { version: "10.1.0" }, + "node_modules/eslint-config-example": { + version: "1.0.0", + peerDependencies: { "babel-eslint": "^10.0.0" }, + }, + }, + }), + }, + {}, + ); + fs.writeFileSync( + path.join(rootDirectory, "package.json"), + JSON.stringify({ + devDependencies: { + "babel-eslint": "10.1.0", + "eslint-config-example": "1.0.0", + }, + }), + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual([]); + }); + + it("credits Ajv as the implementation selected by the RJSF Ajv 8 validator", () => { + const rootDirectory = createProject( + { "src/index.ts": `import validator from "@rjsf/validator-ajv8"; console.log(validator);` }, + { "@rjsf/validator-ajv8": "1.0.0", ajv: "8.18.0", "unused-package": "1.0.0" }, + ); + + const graph = graphWithReachableImport( + path.join(rootDirectory, "src/index.ts"), + "@rjsf/validator-ajv8", + ); + + expect( + detectStalePackages(graph, defineProjectAnalysisConfig({ rootDir: rootDirectory })) + .unusedDependencies.map((dependency) => dependency.name) + .sort(), + ).toEqual(["unused-package"]); + }); + + it("does not credit Ajv without the RJSF Ajv 8 validator", () => { + const rootDirectory = createProject( + { "src/index.ts": `export const value = true;` }, + { ajv: "8.18.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["ajv"]); + }); + + it("does not credit optional or stale-version lockfile peers", () => { + const rootDirectory = createProject( + { + "package-lock.json": JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { + dependencies: { + "tool-package": "2.0.0", + "optional-peer": "1.0.0", + "stale-peer": "1.0.0", + }, + }, + "node_modules/old-tool-package": { + version: "1.0.0", + peerDependencies: { "stale-peer": "^1.0.0" }, + }, + "node_modules/tool-package": { + version: "2.0.0", + peerDependencies: { "optional-peer": "^1.0.0" }, + peerDependenciesMeta: { "optional-peer": { optional: true } }, + }, + "node_modules/parent-package/node_modules/tool-package": { + version: "1.0.0", + peerDependencies: { "stale-peer": "^1.0.0" }, + }, + }, + }), + "src/index.ts": `import "tool-package";`, + }, + { + "tool-package": "2.0.0", + "optional-peer": "1.0.0", + "stale-peer": "1.0.0", + }, + ); + const graph = graphWithReachableImport( + path.join(rootDirectory, "src/index.ts"), + "tool-package", + ); + + expect( + detectStalePackages( + graph, + defineProjectAnalysisConfig({ rootDir: rootDirectory }), + ).unusedDependencies.map((dependency) => dependency.name), + ).toEqual(["optional-peer", "stale-peer"]); + }); + + it("does not infer react-refresh from a static wrapper name", () => { + const rootDirectory = createProject( + { + ".apprc.js": ` + const ReactRefreshPlugin = require("@pmmmwh/react-refresh-webpack-plugin"); + export default ReactRefreshPlugin; + `, + }, + { + "@pmmmwh/react-refresh-webpack-plugin": "1.0.0", + "react-refresh": "1.0.0", + }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["react-refresh"]); + }); + + it("does not credit a binary from a nested stale version of the same package", () => { + const rootDirectory = createProject( + { + "package-lock.json": JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { dependencies: { "tool-package": "2.0.0" } }, + "node_modules/tool-package": { + version: "2.0.0", + bin: { currentTool: "current.js" }, + }, + "node_modules/parent-package/node_modules/tool-package": { + version: "1.0.0", + bin: { staleTool: "stale.js" }, + }, + }, + }), + }, + { "tool-package": "2.0.0" }, + { build: "staleTool build" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["tool-package"]); + }); + + it("does not credit a binary from a stale installed package version", () => { + const rootDirectory = createProject( + { + "yarn.lock": "# yarn lockfile v1\n", + "node_modules/tool-package/package.json": JSON.stringify({ + name: "tool-package", + version: "1.0.0", + bin: { staleTool: "stale.js" }, + }), + }, + { "tool-package": "2.0.0" }, + { build: "staleTool build" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["tool-package"]); + }); + + it("uses child-resolution lock metadata instead of a hoisted different version", () => { + const childDependencies = { + "consumer-package": "2.0.0", + "peer-from-root-version": "1.0.0", + "unused-package": "1.0.0", + }; + const rootDirectory = createProject( + { + "package.json": JSON.stringify({ private: true, workspaces: ["packages/*"] }), + "packages/app/package.json": JSON.stringify({ + name: "app", + dependencies: childDependencies, + }), + "packages/app/src/index.ts": `import "consumer-package";`, + "package-lock.json": JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { workspaces: ["packages/*"] }, + "packages/app": { dependencies: childDependencies }, + "node_modules/consumer-package": { + version: "1.0.0", + peerDependencies: { "peer-from-root-version": "^1.0.0" }, + }, + "packages/app/node_modules/consumer-package": { version: "2.0.0" }, + "node_modules/peer-from-root-version": { version: "1.0.0" }, + "node_modules/unused-package": { version: "1.0.0" }, + }, + }), + }, + {}, + ); + const childDirectory = path.join(rootDirectory, "packages/app"); + + expect(collectUnusedDependencyNames(childDirectory)).toEqual([ + "peer-from-root-version", + "unused-package", + ]); + }); + + it("credits package imports in statically linked build scripts", () => { + const rootDirectory = createProject( + { + "build/build.js": ` + require("direct-build-package"); + require("./webpack.prod.conf"); + `, + "build/webpack.prod.conf.js": ` + import plugin from "transitive-build-package"; + const baseConfig = { entry: { app: "./src/client" } }; + baseConfig.entry.app = ["./build/dev-client"].concat(baseConfig.entry.app); + export default plugin; + `, + "build/dev-client.js": `require("webpack-entry-package");`, + "src/client.js": `require("root-webpack-entry-package");`, + "build/dormant.js": `require("dormant-build-package");`, + }, + { + "direct-build-package": "1.0.0", + "transitive-build-package": "1.0.0", + "webpack-entry-package": "1.0.0", + "root-webpack-entry-package": "1.0.0", + "dormant-build-package": "1.0.0", + }, + { build: "node build/build.js" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["dormant-build-package"]); + }); + + it("credits package imports in config helper trees", () => { + const rootDirectory = createProject( + { + "vite.config.ts": `import { setupPlugins } from "./build/plugins"; export default setupPlugins();`, + "build/plugins/index.ts": `import { inspect } from "./inspect"; export const setupPlugins = () => inspect;`, + "build/plugins/inspect.ts": `import boxen from "boxen"; export const inspect = boxen;`, + "build/plugins/dormant.ts": `import chalk from "chalk"; export const dormant = chalk;`, + }, + { boxen: "1.0.0", chalk: "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["chalk"]); + }); + + it("does not follow dynamic build-script imports or package-name text", () => { + const rootDirectory = createProject( + { + "build/build.js": ` + const packageName = "text-only-package"; + const configName = process.env.CONFIG; + require("./" + configName); + const config = { template: "./production" }; + console.log(packageName, config); + `, + "build/production.js": `require("dynamic-build-package");`, + }, + { + "text-only-package": "1.0.0", + "dynamic-build-package": "1.0.0", + }, + { build: "node build/build.js" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual([ + "dynamic-build-package", + "text-only-package", + ]); + }); + + it("credits Sass compiled from programmatic Parcel HTML entries", () => { + const rootDirectory = createProject( + { + "src/build.js": ` + const Bundler = require("parcel-bundler"); + const entryFiles = path.join(__dirname, "./html/*.html"); + new Bundler(entryFiles, {}); + `, + "src/html/index.html": '', + "src/styles/app.scss": "$color: red;", + }, + { "parcel-bundler": "1.0.0", sass: "1.0.0" }, + { build: "node src/build.js" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual([]); + }); + + it("credits Sass from an unquoted Parcel HTML stylesheet link", () => { + const rootDirectory = createProject( + { + "src/build.js": ` + const Bundler = require("parcel-bundler"); + new Bundler(path.join(__dirname, "./html/index.html"), {}); + `, + "src/html/index.html": "", + "src/styles/app.scss": "$color: red;", + }, + { "parcel-bundler": "1.0.0", sass: "1.0.0" }, + { build: "node src/build.js" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual([]); + }); + + it("does not credit Sass from an unconsumed Parcel HTML path", () => { + const rootDirectory = createProject( + { + "src/build.js": ` + const Bundler = require("parcel-bundler"); + const dormantEntry = path.join(__dirname, "./html/*.html"); + console.log(Bundler, dormantEntry); + `, + "src/html/index.html": '', + "src/styles/app.scss": "$color: red;", + }, + { "parcel-bundler": "1.0.0", sass: "1.0.0" }, + { build: "node src/build.js" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["sass"]); + }); + + it("credits Stylus compiled by a used React Native transformer", () => { + const rootDirectory = createProject( + { + "metro.config.js": `module.exports = require("react-native-stylus-transformer");`, + "src/screen.tsx": `const styles = require("./screen.styl");`, + }, + { + "react-native-stylus-transformer": "1.0.0", + stylus: "1.0.0", + }, + ); + const graph = graphWithReachableImport( + path.join(rootDirectory, "src/screen.tsx"), + "./screen.styl", + ); + + expect( + detectStalePackages(graph, defineProjectAnalysisConfig({ rootDir: rootDirectory })) + .unusedDependencies, + ).toEqual([]); + }); + + it("does not credit a style compiler through an unused host declaration", () => { + const rootDirectory = createProject( + { "src/screen.tsx": `const styles = require("./screen.styl");` }, + { "stylus-loader": "1.0.0", stylus: "1.0.0" }, + ); + const graph = graphWithReachableImport( + path.join(rootDirectory, "src/screen.tsx"), + "./screen.styl", + ); + + expect( + detectStalePackages( + graph, + defineProjectAnalysisConfig({ rootDir: rootDirectory }), + ).unusedDependencies.map((dependency) => dependency.name), + ).toEqual(["stylus"]); + }); + + it("credits the Supabase CLI from its root project config", () => { + const rootDirectory = createProject( + { "supabase/config.toml": 'project_id = "example"' }, + { supabase: "1.0.0", "unused-package": "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it("does not credit the Supabase CLI from a nested config", () => { + const rootDirectory = createProject( + { "examples/app/supabase/config.toml": 'project_id = "example"' }, + { supabase: "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["supabase"]); + }); + + it.each(["js", "cjs", "mjs", "ts", "cts", "mts"])( + "credits declared package keys in react-native.config.%s", + (extension) => { + const rootDirectory = createProject( + { + [`react-native.config.${extension}`]: ` + module.exports = { + dependencies: { + expo: { platforms: { android: null, ios: null } }, + "@scope/native-tool": { platforms: { android: null } }, + undeclared: {}, + }, + }; + `, + }, + { + expo: "1.0.0", + "@scope/native-tool": "1.0.0", + "unused-package": "1.0.0", + }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }, + ); + + it("does not credit nested dependency-shaped objects in React Native config", () => { + const rootDirectory = createProject( + { + "react-native.config.js": ` + module.exports = { + project: { dependencies: { dormant: {} } }, + }; + `, + }, + { dormant: "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["dormant"]); + }); + + it("credits packages imported by installed agent skill code", () => { + const rootDirectory = createProject( + { + "skills-lock.json": JSON.stringify({ skills: { "media-skill": {} } }), + ".agents/skills/media-skill/SKILL.md": "Read the routed rule.", + ".agents/skills/media-skill/rules/audio.md": `Use this implementation: + +\`\`\`tsx +import { visualizeAudio } from "@example/media-utils"; +\`\`\``, + }, + { "@example/media-utils": "1.0.0", "unused-package": "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["unused-package"]); + }); + + it("does not credit code in an uninstalled agent skill", () => { + const rootDirectory = createProject( + { + "skills-lock.json": JSON.stringify({ skills: {} }), + ".agents/skills/media-skill/SKILL.md": `\`\`\`tsx +import { visualizeAudio } from "@example/media-utils"; +\`\`\``, + }, + { "@example/media-utils": "1.0.0" }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual(["@example/media-utils"]); + }); + + it("credits packages imported by installed agent skill source files", () => { + const rootDirectory = createProject( + { + "skills-lock.json": JSON.stringify({ skills: { "media-skill": {} } }), + ".agents/skills/media-skill/SKILL.md": "Read the implementation.", + ".agents/skills/media-skill/assets/audio.tsx": [ + 'require("@example/require-utils");', + 'import("@example/dynamic-utils");', + 'type Media = import("@example/type-utils").Media;', + 'import media = require("@example/import-equals-utils");', + "require(`@example/require-template-utils`);", + "import(`@example/dynamic-template-utils`);", + ].join("\n"), + }, + { + "@example/require-utils": "1.0.0", + "@example/dynamic-utils": "1.0.0", + "@example/type-utils": "1.0.0", + "@example/import-equals-utils": "1.0.0", + "@example/require-template-utils": "1.0.0", + "@example/dynamic-template-utils": "1.0.0", + }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual([]); + }); + + it("credits imports in installed agent skill code fragments", () => { + const rootDirectory = createProject( + { + "skills-lock.json": JSON.stringify({ skills: { "media-skill": {} } }), + ".agents/skills/media-skill/SKILL.md": `\`\`\`tsx +return ; +import { visualizeAudio } from "@example/media-utils"; +require(\`@example/require-template-utils\`); +import(\`@example/dynamic-template-utils\`); +\`\`\``, + }, + { + "@example/media-utils": "1.0.0", + "@example/require-template-utils": "1.0.0", + "@example/dynamic-template-utils": "1.0.0", + }, + ); + + expect(collectUnusedDependencyNames(rootDirectory)).toEqual([]); + }); + + it("credits a local file dependency mapped to its source by tsconfig paths", () => { + const rootDirectory = createProject( + { + "packages/example/package.json": JSON.stringify({ + dependencies: { "local-package": "file:.." }, + }), + "packages/example/tsconfig.json": JSON.stringify({ + compilerOptions: { paths: { "local-package": ["../src/index"] } }, + }), + "packages/src/index.ts": "export const value = 1;", + }, + {}, + ); + const exampleDirectory = path.join(rootDirectory, "packages/example"); + + expect(collectUnusedDependencyNames(exampleDirectory)).toEqual([]); + }); + + it("does not credit a local file dependency mapped outside its target", () => { + const rootDirectory = createProject( + { + "packages/example/package.json": JSON.stringify({ + dependencies: { "local-package": "file:../local-package" }, + }), + "packages/example/tsconfig.json": JSON.stringify({ + compilerOptions: { paths: { "local-package": ["../../unrelated/index"] } }, + }), + "packages/local-package/package.json": JSON.stringify({ name: "local-package" }), + "unrelated/index.ts": "export const value = 1;", + }, + {}, + ); + const exampleDirectory = path.join(rootDirectory, "packages/example"); + + expect(collectUnusedDependencyNames(exampleDirectory)).toEqual(["local-package"]); + }); + + it("resolves local file dependency wildcard paths from tsconfig baseUrl", () => { + const rootDirectory = createProject( + { + "packages/example/package.json": JSON.stringify({ + dependencies: { "local-package": "file:../local-package" }, + }), + "packages/example/tsconfig.json": JSON.stringify({ + compilerOptions: { + baseUrl: "../local-package", + paths: { "local-package/*": ["src/*"] }, + }, + }), + "packages/local-package/src/index.ts": "export const value = 1;", + }, + {}, + ); + const exampleDirectory = path.join(rootDirectory, "packages/example"); + + expect(collectUnusedDependencyNames(exampleDirectory)).toEqual([]); + }); + + it("resolves local file dependencies from a monorepo tsconfig", () => { + const rootDirectory = createProject( + { + "pnpm-workspace.yaml": "packages:\n - packages/*\n", + "tsconfig.json": JSON.stringify({ + compilerOptions: { paths: { "local-package": ["packages/local-package/src/index"] } }, + }), + "packages/example/package.json": JSON.stringify({ + dependencies: { "local-package": "file:../local-package" }, + }), + "packages/local-package/src/index.ts": "export const value = 1;", + }, + {}, + ); + const exampleDirectory = path.join(rootDirectory, "packages/example"); + + expect(collectUnusedDependencyNames(exampleDirectory)).toEqual([]); + }); +}); diff --git a/packages/core/tests/discover-project.test.ts b/packages/core/tests/discover-project.test.ts index cf05b8b2fc..26f770868c 100644 --- a/packages/core/tests/discover-project.test.ts +++ b/packages/core/tests/discover-project.test.ts @@ -2746,6 +2746,94 @@ describe("discoverProject", () => { }); describe("listWorkspacePackages", () => { + it("includes packages that declare supported framework and ecosystem dependencies", () => { + const rootDirectory = path.join(tempDirectory, "supported-dependency-workspace"); + const supportedDependencyNames = [ + "react-dom", + "expo", + "expo-router", + "gatsby", + "@remix-run/react", + "@tanstack/react-start", + "react-scripts", + "@astrojs/react", + "remotion", + "@react-three/rapier", + "@react-three/postprocessing", + "@react-three/xr", + "@react-three/cannon", + ]; + fs.mkdirSync(rootDirectory, { recursive: true }); + fs.writeFileSync( + path.join(rootDirectory, "package.json"), + JSON.stringify({ name: "workspace", workspaces: ["packages/*"] }), + ); + + for (const [packageIndex, dependencyName] of supportedDependencyNames.entries()) { + const packageDirectory = path.join(rootDirectory, "packages", `package-${packageIndex}`); + fs.mkdirSync(packageDirectory, { recursive: true }); + fs.writeFileSync( + path.join(packageDirectory, "package.json"), + JSON.stringify({ name: dependencyName, dependencies: { [dependencyName]: "1.0.0" } }), + ); + } + + expect( + listWorkspacePackages(rootDirectory) + .map((workspacePackage) => workspacePackage.name) + .toSorted(), + ).toEqual(supportedDependencyNames.toSorted()); + }); + + it("excludes dependencies without supported runtime capabilities", () => { + const rootDirectory = path.join(tempDirectory, "unsupported-dependency-workspace"); + const unsupportedDependencyNames = [ + "vite", + "astro", + "@types/three", + "threewright", + "three-tester", + "phaser", + "@babylonjs/core", + "pixi.js", + "playcanvas", + ]; + fs.mkdirSync(rootDirectory, { recursive: true }); + fs.writeFileSync( + path.join(rootDirectory, "package.json"), + JSON.stringify({ name: "workspace", workspaces: ["packages/*"] }), + ); + + for (const [packageIndex, dependencyName] of unsupportedDependencyNames.entries()) { + const packageDirectory = path.join(rootDirectory, "packages", `package-${packageIndex}`); + fs.mkdirSync(packageDirectory, { recursive: true }); + fs.writeFileSync( + path.join(packageDirectory, "package.json"), + JSON.stringify({ name: dependencyName, dependencies: { [dependencyName]: "1.0.0" } }), + ); + } + + expect(listWorkspacePackages(rootDirectory)).toEqual([]); + }); + + it("includes standalone Three.js workspace packages", () => { + const rootDirectory = path.join(tempDirectory, "three-workspace"); + const gameDirectory = path.join(rootDirectory, "games", "viewer"); + fs.mkdirSync(gameDirectory, { recursive: true }); + fs.writeFileSync( + path.join(rootDirectory, "package.json"), + JSON.stringify({ name: "workspace", workspaces: ["games/*"] }), + ); + fs.writeFileSync( + path.join(gameDirectory, "package.json"), + JSON.stringify({ name: "viewer", dependencies: { three: "^0.180.0" } }), + ); + + expect(listWorkspacePackages(rootDirectory)).toEqual([ + { name: "viewer", directory: gameDirectory }, + ]); + }); + it("resolves nested workspace patterns like apps/*/ClientApp", () => { const packages = listWorkspacePackages(path.join(FIXTURES_DIRECTORY, "nested-workspaces")); const packageNames = packages.map((workspacePackage) => workspacePackage.name); @@ -3242,7 +3330,61 @@ describe("discoverProject without a package.json", () => { }); }); +describe("supported ecosystem dependencies", () => { + it("derives project facts from framework and runtime packages", () => { + const rootDirectory = path.join(tempDirectory, "ecosystem-capabilities"); + const expoDirectory = path.join(rootDirectory, "expo"); + const astroDirectory = path.join(rootDirectory, "astro"); + const remotionDirectory = path.join(rootDirectory, "remotion"); + const reactThreeFiberDirectory = path.join(rootDirectory, "r3f"); + for (const directory of [ + expoDirectory, + astroDirectory, + remotionDirectory, + reactThreeFiberDirectory, + ]) { + fs.mkdirSync(directory, { recursive: true }); + } + fs.writeFileSync( + path.join(expoDirectory, "package.json"), + JSON.stringify({ dependencies: { "expo-router": "1.0.0" } }), + ); + fs.writeFileSync( + path.join(astroDirectory, "package.json"), + JSON.stringify({ dependencies: { "@astrojs/react": "1.0.0" } }), + ); + fs.writeFileSync( + path.join(remotionDirectory, "package.json"), + JSON.stringify({ dependencies: { remotion: "4.0.0" } }), + ); + fs.writeFileSync( + path.join(reactThreeFiberDirectory, "package.json"), + JSON.stringify({ dependencies: { "@react-three/rapier": "2.0.0" } }), + ); + + expect(discoverProject(expoDirectory).framework).toBe("expo"); + expect(discoverProject(astroDirectory).framework).toBe("astro"); + expect(discoverProject(remotionDirectory).hasRemotion).toBe(true); + expect(discoverProject(reactThreeFiberDirectory).hasReactThreeFiber).toBe(true); + }); +}); + describe("discoverReactSubprojects", () => { + it("includes nested standalone Three.js packages", () => { + const rootDirectory = path.join(tempDirectory, "three-wrapper"); + const gameDirectory = path.join(rootDirectory, "results", "viewer"); + fs.mkdirSync(gameDirectory, { recursive: true }); + fs.writeFileSync( + path.join(gameDirectory, "package.json"), + JSON.stringify({ name: "viewer", dependencies: { three: "^0.180.0" } }), + ); + + expect(discoverReactSubprojects(rootDirectory)).toContainEqual({ + name: "viewer", + directory: gameDirectory, + }); + }); + it("skips subdirectories where package.json is a directory (EISDIR)", () => { const rootDirectory = path.join(tempDirectory, "eisdir-package-json"); const subdirectory = path.join(rootDirectory, "broken-sub"); diff --git a/packages/core/tests/editor-scan.test.ts b/packages/core/tests/editor-scan.test.ts deleted file mode 100644 index cf2f719b77..0000000000 --- a/packages/core/tests/editor-scan.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { describe, expect, it } from "vite-plus/test"; -import { runEditorScan } from "@react-doctor/core"; - -describe("runEditorScan", () => { - it("resolves config rootDir inside the Effect-owned scan lifecycle", async () => { - const wrapperDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-editor-scan-")); - const projectDirectory = path.join(wrapperDirectory, "app"); - fs.mkdirSync(path.join(projectDirectory, "src"), { recursive: true }); - fs.writeFileSync( - path.join(wrapperDirectory, "doctor.config.json"), - JSON.stringify({ rootDir: "app", lint: false }), - ); - fs.writeFileSync( - path.join(projectDirectory, "package.json"), - JSON.stringify({ name: "editor-project", dependencies: { react: "^19.0.0" } }), - ); - fs.writeFileSync( - path.join(projectDirectory, "src", "index.tsx"), - "export const App = () => null;", - ); - - try { - const result = await runEditorScan({ directory: wrapperDirectory }); - - expect(result.ok).toBe(true); - expect(result.skipped).toBe(false); - expect(result.resolvedDirectory).toBe(projectDirectory); - expect(result.project?.projectName).toBe("editor-project"); - expect(result.diagnostics).toHaveLength(0); - } finally { - fs.rmSync(wrapperDirectory, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/core/tests/errors.test.ts b/packages/core/tests/errors.test.ts index f032d37613..490df436df 100644 --- a/packages/core/tests/errors.test.ts +++ b/packages/core/tests/errors.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { AmbiguousProject, ConfigParseFailed, - DeadCodeAnalysisFailed, + MaintainabilityAnalysisFailed, formatReactDoctorError, isReactDoctorError, isSplittableReactDoctorError, @@ -135,12 +135,12 @@ describe("ReactDoctorError leaves", () => { ); }); - it("DeadCodeAnalysisFailed wraps the cause", () => { + it("MaintainabilityAnalysisFailed wraps the cause", () => { const error = new ReactDoctorError({ - reason: new DeadCodeAnalysisFailed({ cause: "SIGABRT from native binding" }), + reason: new MaintainabilityAnalysisFailed({ cause: "unreadable source" }), }); - expect(formatReactDoctorError(error)).toContain("Dead-code analysis failed"); - expect(formatReactDoctorError(error)).toContain("SIGABRT from native binding"); + expect(formatReactDoctorError(error)).toContain("Maintainability analysis failed"); + expect(formatReactDoctorError(error)).toContain("unreadable source"); }); it("ScanDeadlineExceeded renders the elapsed detail and is not splittable", () => { @@ -192,7 +192,7 @@ describe("isSplittableReactDoctorError", () => { new NoReactDependency({ directory: "x" }), new AmbiguousProject({ directory: "x", candidates: [] }), new ProjectDiscoveryFailed({ directory: "x", cause: new Error("boom") }), - new DeadCodeAnalysisFailed({ cause: "x" }), + new MaintainabilityAnalysisFailed({ cause: "x" }), ] as const; for (const reason of cases) { const error = new ReactDoctorError({ reason }); diff --git a/packages/core/tests/evaluate-static-config.test.ts b/packages/core/tests/evaluate-static-config.test.ts new file mode 100644 index 0000000000..794eb26e76 --- /dev/null +++ b/packages/core/tests/evaluate-static-config.test.ts @@ -0,0 +1,130 @@ +import * as path from "node:path"; +import { describe, expect, it } from "vite-plus/test"; +import { evaluateStaticConfig } from "../src/project-analysis/utils/evaluate-static-config.js"; + +const CONFIG_PATH = "/workspace/vite.config.ts"; + +describe("evaluateStaticConfig helper provenance", () => { + it("evaluates approved ESM helper aliases and globals", () => { + const config = evaluateStaticConfig( + ` + import { defineConfig as configure } from "vite"; + import pathHelpers, { join as joinPath } from "node:path"; + import { fileURLToPath as fromFileUrl } from "node:url"; + export default configure(Object.freeze({ + root: pathHelpers.resolve(__dirname, "app"), + input: joinPath(__dirname, "src", "index.ts"), + currentFile: fromFileUrl(new URL("./vite.config.ts", import.meta.url)), + })); + `, + CONFIG_PATH, + ); + + expect(config).toEqual({ + root: path.resolve("/workspace", "app"), + input: path.resolve("/workspace", "src", "index.ts"), + currentFile: path.resolve("/workspace", "vite.config.ts"), + }); + }); + + it("evaluates approved namespace and CommonJS helpers", () => { + expect( + evaluateStaticConfig( + ` + import * as vite from "vite"; + import * as pathHelpers from "path"; + export default vite.defineConfig({ root: pathHelpers.join(__dirname, "app") }); + `, + CONFIG_PATH, + ), + ).toEqual({ root: path.resolve("/workspace", "app") }); + + expect( + evaluateStaticConfig( + ` + const { defineConfig: configure } = require("tsup"); + const { resolve: resolvePath } = require("node:path"); + module.exports = configure({ entry: resolvePath(__dirname, "src/index.ts") }); + `, + CONFIG_PATH, + ), + ).toEqual({ entry: path.resolve("/workspace", "src/index.ts") }); + }); + + it.each([ + [ + "a local config wrapper", + `const defineConfig = (value) => ({ root: "wrong" }); export default defineConfig({ root: "app" });`, + ], + [ + "a relative config-wrapper import", + `import { defineConfig } from "./helpers"; export default defineConfig({ root: "app" });`, + ], + [ + "an unrelated config-wrapper import", + `import { defineConfig } from "config-transformer"; export default defineConfig({ root: "app" });`, + ], + [ + "a local freeze function", + `const freeze = (value) => value; export default freeze({ root: "app" });`, + ], + [ + "an imported freeze function", + `import { freeze } from "object-tools"; export default freeze({ root: "app" });`, + ], + [ + "a shadowed Object global", + `const Object = { freeze: (value) => value }; export default Object.freeze({ root: "app" });`, + ], + ])("does not evaluate %s", (_, content) => { + expect(evaluateStaticConfig(content, CONFIG_PATH)).toBeUndefined(); + }); + + it.each([ + [ + "a local path function", + `const resolve = () => "/wrong"; export default { safe: true, root: resolve(__dirname, "app") };`, + ], + [ + "a relative path-function import", + `import { resolve } from "./helpers"; export default { safe: true, root: resolve(__dirname, "app") };`, + ], + [ + "an unrelated path namespace", + `import pathHelpers from "./helpers"; export default { safe: true, root: pathHelpers.resolve(__dirname, "app") };`, + ], + [ + "a local path namespace", + `const path = { resolve: () => "/wrong" }; export default { safe: true, root: path.resolve(__dirname, "app") };`, + ], + [ + "a callback parameter shadowing an approved import", + ` + import { defineConfig } from "vite"; + import { resolve } from "node:path"; + export default defineConfig((resolve) => ({ safe: true, root: resolve(__dirname, "app") })); + `, + ], + [ + "a callback-local binding shadowing an approved import", + ` + import { defineConfig } from "vite"; + import path from "node:path"; + export default defineConfig(() => { + const path = { resolve: () => "/wrong" }; + return { safe: true, root: path.resolve(__dirname, "app") }; + }); + `, + ], + [ + "a shadowed URL global", + ` + import { fileURLToPath } from "node:url"; + const URL = class {}; + export default { safe: true, file: fileURLToPath(new URL("./app", import.meta.url)) }; + `, + ], + ])("omits calls through %s", (_, content) => { + expect(evaluateStaticConfig(content, CONFIG_PATH)).toEqual({ safe: true }); + }); +}); diff --git a/packages/core/tests/extract-jiti-load-references.test.ts b/packages/core/tests/extract-jiti-load-references.test.ts new file mode 100644 index 0000000000..0f8921666a --- /dev/null +++ b/packages/core/tests/extract-jiti-load-references.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vite-plus/test"; +import { extractJitiLoadReferences } from "../src/project-analysis/utils/extract-jiti-load-references.js"; + +describe("extractJitiLoadReferences", () => { + it("extracts static ESM and CommonJS Jiti loads", () => { + const references = extractJitiLoadReferences(` + import { createJiti } from "jiti"; + const esmLoader = createJiti(import.meta.url); + esmLoader.import("./esm-import.ts"); + createJiti(import.meta.url)("./esm-inline.ts"); + const commonJsLoader = require("jiti")(__filename); + commonJsLoader("./commonjs-loader.ts"); + require("jiti")(__filename)("./commonjs-inline.ts"); + const { createJiti: createCommonJsJiti } = require("jiti"); + const commonJsV2Loader = createCommonJsJiti(__filename); + commonJsV2Loader.import("./commonjs-v2.ts"); + `); + + expect(references.map((reference) => reference.path)).toEqual([ + "./esm-import.ts", + "./esm-inline.ts", + "./commonjs-loader.ts", + "./commonjs-inline.ts", + "./commonjs-v2.ts", + ]); + }); + + it("uses binding identity and records only genuine computed Jiti loads", () => { + const references = extractJitiLoadReferences(` + import { createJiti } from "jiti"; + import { loadModule } from "./ordinary-helper"; + const runtimeLoader = createJiti(import.meta.url); + const ordinaryCall = (runtimeLoader) => runtimeLoader("./ordinary.ts"); + loadModule("./also-ordinary.ts"); + runtimeLoader.import(process.env.RUNTIME_MODULE); + ordinaryCall((source) => source); + `); + + expect(references).toHaveLength(1); + expect(references[0].path).toBeUndefined(); + }); +}); diff --git a/packages/core/tests/extract-markdown-module-statements.test.ts b/packages/core/tests/extract-markdown-module-statements.test.ts new file mode 100644 index 0000000000..d66f9410bf --- /dev/null +++ b/packages/core/tests/extract-markdown-module-statements.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vite-plus/test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseSync } from "oxc-parser"; +import { extractMarkdownModuleStatements } from "../src/project-analysis/utils/extract-markdown-module-statements.js"; +import { parseSourceFile } from "../src/project-analysis/collect/parse.js"; + +describe("extractMarkdownModuleStatements", () => { + it("extracts multiline imports and exports", () => { + const sourceText = [ + "import {", + " Alpha,", + " Beta,", + '} from "live-package"', + "", + "export {", + " Gamma,", + '} from "exported-package"', + ].join("\n"); + + const extractedStatements = extractMarkdownModuleStatements(sourceText); + expect(extractedStatements).toBe(sourceText); + + const parsedModule = parseSync("document.tsx", extractedStatements); + expect(parsedModule.errors).toEqual([]); + expect( + parsedModule.module.staticImports.map((moduleImport) => moduleImport.moduleRequest.value), + ).toEqual(["live-package"]); + expect( + parsedModule.module.staticExports.flatMap((moduleExport) => + moduleExport.entries.flatMap((entry) => entry.moduleRequest?.value ?? []), + ), + ).toEqual(["exported-package"]); + }); + + it("ignores fenced imports with CRLF line endings", () => { + const sourceText = [ + "```tsx", + 'import Fenced from "fenced-package"', + "```", + "", + 'import Live from "live-package"', + ].join("\r\n"); + + const extractedStatements = extractMarkdownModuleStatements(sourceText); + expect(extractedStatements).toContain('import Live from "live-package"'); + expect(extractedStatements).not.toContain("fenced-package"); + expect(extractedStatements).toHaveLength(sourceText.length); + }); + + it("ignores imports in frontmatter block scalars", () => { + const sourceText = [ + "---", + "description: |", + ' import Frontmatter from "frontmatter-package"', + "---", + "", + 'import Live from "live-package"', + ].join("\n"); + + const extractedStatements = extractMarkdownModuleStatements(sourceText); + expect(extractedStatements).toContain('import Live from "live-package"'); + expect(extractedStatements).not.toContain("frontmatter-package"); + }); + + it("ignores imports in HTML comments", () => { + const sourceText = [ + "", + 'import Live from "live-package"', + ].join("\n"); + + const extractedStatements = extractMarkdownModuleStatements(sourceText); + expect(extractedStatements).toContain('import Live from "live-package"'); + expect(extractedStatements).not.toContain("commented-package"); + }); + + it("ignores indented and inline import examples", () => { + const sourceText = [ + ' import Indented from "indented-package"', + "", + 'Use `import Inline from "inline-package"` in your application.', + "", + 'import Live from "live-package"', + ].join("\n"); + + const extractedStatements = extractMarkdownModuleStatements(sourceText); + expect(extractedStatements).toContain('import Live from "live-package"'); + expect(extractedStatements).not.toContain("indented-package"); + expect(extractedStatements).not.toContain("inline-package"); + }); + + it("fails closed for malformed live module syntax", () => { + expect(extractMarkdownModuleStatements('import { Broken from "broken-package"').trim()).toBe( + "", + ); + }); + + it("recovers live imports around standalone MDX JSX", () => { + const sourceText = [ + 'import First from "first-package"', + "", + "", + "", + 'import Second from "second-package"', + ].join("\n"); + + const extractedStatements = extractMarkdownModuleStatements(sourceText); + expect(extractedStatements).toContain('import First from "first-package"'); + expect(extractedStatements).toContain('import Second from "second-package"'); + expect(extractedStatements).not.toContain(""); + }); + + it("preserves JSX exports and exact source positions through Unicode and CRLF content", () => { + const sourceText = [ + "# 😀 Player", + "", + 'import { Broken from "broken-package"', + "", + 'import Player from "player-package"', + "", + "export const PlayerExample = () => ", + ].join("\r\n"); + const rootDirectory = mkdtempSync(join(tmpdir(), "react-doctor-mdx-positions-")); + const filePath = join(rootDirectory, "player.mdx"); + writeFileSync(filePath, sourceText); + + try { + const parsedSource = parseSourceFile(filePath); + expect(parsedSource.imports).toEqual([ + expect.objectContaining({ specifier: "player-package", line: 5, column: 0 }), + ]); + expect(parsedSource.exports).toEqual([ + expect.objectContaining({ name: "PlayerExample", line: 7, column: 13 }), + ]); + } finally { + rmSync(rootDirectory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/tests/extract-script-file-references.test.ts b/packages/core/tests/extract-script-file-references.test.ts new file mode 100644 index 0000000000..0b29265144 --- /dev/null +++ b/packages/core/tests/extract-script-file-references.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; +import { extractScriptFileReferences } from "../src/project-analysis/utils/extract-script-file-references.js"; + +describe("extractScriptFileReferences", () => { + it("extracts quoted and unquoted script paths across shell commands", () => { + expect( + extractScriptFileReferences( + 'tsx "scripts/build registry.ts" && node scripts/post-build.mjs | bun scripts/publish.cts', + ), + ).toEqual(["scripts/build registry.ts", "scripts/post-build.mjs", "scripts/publish.cts"]); + }); + + it("ignores source-looking text that is part of another token", () => { + expect(extractScriptFileReferences("echo source.ts.map --config=build.ts")).toEqual([]); + }); + + it("extracts compiler files from language mappings", () => { + expect(extractScriptFileReferences("mocha --compilers css:mocha-compiler.js")).toEqual([ + "mocha-compiler.js", + ]); + }); +}); diff --git a/packages/core/tests/has-published-fix-recipe.test.ts b/packages/core/tests/has-published-fix-recipe.test.ts index 8f83e1f7f3..fb221ff705 100644 --- a/packages/core/tests/has-published-fix-recipe.test.ts +++ b/packages/core/tests/has-published-fix-recipe.test.ts @@ -9,9 +9,10 @@ describe("hasPublishedFixRecipe", () => { expect(hasPublishedFixRecipe({ plugin: "react-doctor", rule: "no-derived-state" })).toBe(true); }); - it("is false for dead-code diagnostics (deslop has no recipes)", () => { - expect(hasPublishedFixRecipe({ plugin: "deslop", rule: "unused-file" })).toBe(false); - expect(hasPublishedFixRecipe({ plugin: "deslop", rule: "circular-dependency" })).toBe(false); + it("is true for registered project rules with a published recipe", () => { + expect(hasPublishedFixRecipe({ plugin: "react-doctor", rule: "duplicate-jsx-subtree" })).toBe( + true, + ); }); it("is false for react-doctor-namespaced synthetic environment checks", () => { diff --git a/packages/core/tests/is-analyzable-project.test.ts b/packages/core/tests/is-analyzable-project.test.ts index f2869fa395..048dad3f9a 100644 --- a/packages/core/tests/is-analyzable-project.test.ts +++ b/packages/core/tests/is-analyzable-project.test.ts @@ -39,10 +39,22 @@ describe("isAnalyzableProject", () => { expect(isAnalyzableProject({ ...baseProject, preactVersion: "^10.22.0" })).toBe(true); }); + it("is analyzable for a React framework with no direct react package", () => { + expect(isAnalyzableProject({ ...baseProject, framework: "expo" })).toBe(true); + }); + it("is analyzable for a plain TypeScript project with source files but no React", () => { expect(isAnalyzableProject({ ...baseProject, sourceFileCount: 12 })).toBe(true); }); + it("is analyzable for a standalone Three.js project with no source files", () => { + expect(isAnalyzableProject({ ...baseProject, hasThree: true })).toBe(true); + }); + + it("is analyzable for a Remotion project with no source files", () => { + expect(isAnalyzableProject({ ...baseProject, hasRemotion: true })).toBe(true); + }); + it("is not analyzable with no react, no preact, and no source files", () => { expect(isAnalyzableProject(baseProject)).toBe(false); }); diff --git a/packages/core/tests/is-lintable-source-file.test.ts b/packages/core/tests/is-lintable-source-file.test.ts index 3c6f7d305e..a01bb4e975 100644 --- a/packages/core/tests/is-lintable-source-file.test.ts +++ b/packages/core/tests/is-lintable-source-file.test.ts @@ -42,6 +42,22 @@ describe("isLintableSourceFile", () => { } }); + it("keeps ambiguous generated-looking source paths lintable", () => { + for (const filePath of [ + "src/generated/graphql.ts", + "src/graphql/types.generated.ts", + "src/protocol.gen.ts", + "src/protocol.h.ts", + "src/graphql/types.generated.tsx", + ]) { + expect(isLintableSourceFile(filePath), filePath).toBe(true); + } + }); + + it("rejects conventional __generated__ output", () => { + expect(isLintableSourceFile("src/__generated__/schema.ts")).toBe(false); + }); + it("does not over-match files that merely contain a bundle keyword in the name", () => { for (const filePath of [ "src/iife-helpers.ts", diff --git a/packages/core/tests/karma-config-dependencies.test.ts b/packages/core/tests/karma-config-dependencies.test.ts new file mode 100644 index 0000000000..636f0a515f --- /dev/null +++ b/packages/core/tests/karma-config-dependencies.test.ts @@ -0,0 +1,119 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { analyzeProject } from "../src/project-analysis/analyze-project.js"; +import { extractKarmaConfigPackageReferences } from "../src/project-analysis/utils/extract-karma-config-package-references.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +const createProject = (files: Readonly>): string => { + const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-karma-config-")); + temporaryDirectories.push(rootDirectory); + for (const [relativePath, source] of Object.entries(files)) { + const filePath = path.join(rootDirectory, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, source); + } + return fs.realpathSync(rootDirectory); +}; + +describe("Karma config dependencies", () => { + it("credits packages selected through Karma semantic tokens", () => { + const declaredPackageNames = new Set([ + "karma-babel-preprocessor", + "karma-chai", + "karma-chai-plugins", + "karma-chrome-launcher", + "karma-coverage", + "karma-coveralls", + "karma-firefox-launcher", + "karma-mocha", + "karma-mocha-reporter", + "karma-phantomjs-launcher", + "karma-sourcemap-loader", + "karma-webpack", + ]); + + const references = extractKarmaConfigPackageReferences( + ` + let reporters; + if (process.env.CI) reporters = ["coverage", "coveralls"]; + else reporters = ["mocha"]; + module.exports = config => config.set({ + frameworks: ["mocha", "chai", "sinon-chai"], + browsers: ["ChromeHeadless", "Firefox", "PhantomJS"], + reporters, + preprocessors: { "src/**/*.js": ["babel", "webpack", "sourcemap"] }, + }); + `, + declaredPackageNames, + ); + + expect(new Set(references)).toEqual(declaredPackageNames); + }); + + it("does not credit comments, unrelated strings, property keys, or undeclared packages", () => { + const references = extractKarmaConfigPackageReferences( + ` + // frameworks: ["mocha"] + const documentation = "ChromeHeadless"; + module.exports = config => config.set({ + files: ["coverage", "karma-webpack"], + preprocessors: { mocha: ["unknown"] }, + browsers: ["Safari"], + }); + `, + new Set(["karma-mocha", "karma-chrome-launcher", "karma-coverage", "karma-webpack"]), + ); + + expect(references).toEqual([]); + }); + + it("keeps semantically selected plugins out of unused dependency findings", async () => { + const devDependencies = { + karma: "1.0.0", + "karma-chrome-launcher": "1.0.0", + "karma-mocha": "1.0.0", + "karma-webpack": "1.0.0", + "unused-tool": "1.0.0", + }; + const rootDirectory = createProject({ + "package.json": JSON.stringify({ + scripts: { test: "karma start" }, + devDependencies, + }), + "package-lock.json": JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { devDependencies }, + ...Object.fromEntries( + Object.entries(devDependencies).map(([dependencyName, version]) => [ + `node_modules/${dependencyName}`, + { version }, + ]), + ), + }, + }), + "src/index.ts": "console.log('application');", + "karma.config.js": ` + module.exports = config => config.set({ + frameworks: ["mocha"], + browsers: ["ChromeHeadless"], + preprocessors: { "src/**/*.js": ["webpack"] }, + }); + `, + }); + + const result = await analyzeProject({ rootDirectory }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + expect(unusedPackageNames).toEqual(["unused-tool"]); + }); +}); diff --git a/packages/core/tests/matches-iconify-collection-reference.test.ts b/packages/core/tests/matches-iconify-collection-reference.test.ts new file mode 100644 index 0000000000..06d678ba0c --- /dev/null +++ b/packages/core/tests/matches-iconify-collection-reference.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; +import { matchesIconifyCollectionReference } from "../src/project-analysis/utils/matches-iconify-collection-reference.js"; + +describe("matchesIconifyCollectionReference", () => { + it.each([ + [``, "@iconify-json/lucide"], + [`const icon = 'fa6-solid:chart-gantt';`, "@iconify-json/fa6-solid"], + [`import icon from "~icons/mdi/account";`, "@iconify-json/mdi"], + ])("matches a collection selected by a virtual icon reference", (content, packageName) => { + expect(matchesIconifyCollectionReference(content, packageName)).toBe(true); + }); + + it.each([ + [`const protocol = "lucide:https";`, "@iconify-json/lucide"], + [`const icon = "lucide-react:check";`, "@iconify-json/lucide"], + [`const icon = "fa:check";`, "@iconify-json/fa6-solid"], + [`const icon = "lucide:check";`, "lucide"], + [`const namespace = "ri"; const name = "search-line";`, "@iconify-json/ri"], + ])("rejects unrelated colon strings and collection-name collisions", (content, packageName) => { + expect(matchesIconifyCollectionReference(content, packageName)).toBe(false); + }); +}); diff --git a/packages/core/tests/oxlint-config-settings.test.ts b/packages/core/tests/oxlint-config-settings.test.ts index 69bb7d665f..5fd1d36c10 100644 --- a/packages/core/tests/oxlint-config-settings.test.ts +++ b/packages/core/tests/oxlint-config-settings.test.ts @@ -359,6 +359,22 @@ describe("createOxlintConfig settings", () => { expect(config.rules).not.toHaveProperty("react-doctor/no-all-caps-body-text"); }); + it("keeps project rules out of the generated oxlint config", () => { + const config = createOxlintConfig({ + pluginPath: "/tmp/plugin.js", + project: viteWebProject, + severityControls: { + rules: { + "react-doctor/duplicate-jsx-subtree": "warn", + "react-doctor/unused-export": "error", + }, + }, + }); + + expect(config.rules).not.toHaveProperty("react-doctor/duplicate-jsx-subtree"); + expect(config.rules).not.toHaveProperty("react-doctor/unused-export"); + }); + it("runs only an explicitly included tag and activates that tag's opt-in rules", () => { const config = createOxlintConfig({ pluginPath: "/tmp/plugin.js", diff --git a/packages/core/tests/project-analysis-config-parsers.test.ts b/packages/core/tests/project-analysis-config-parsers.test.ts new file mode 100644 index 0000000000..a81489eb59 --- /dev/null +++ b/packages/core/tests/project-analysis-config-parsers.test.ts @@ -0,0 +1,142 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { analyzeProject } from "../src/project-analysis/analyze-project.js"; +import { resolveWorkspaces } from "../src/project-analysis/collect/workspaces.js"; +import { collectPnpmWorkspaceOverrideMappings } from "../src/project-analysis/utils/parse-pnpm-workspace-overrides.js"; + +const temporaryDirectories: string[] = []; + +const createDirectory = (): string => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-config-parsers-")); + temporaryDirectories.push(directory); + return fs.realpathSync(directory); +}; + +const writeFiles = (rootDirectory: string, files: Readonly>): void => { + for (const [relativePath, source] of Object.entries(files)) { + const filePath = path.join(rootDirectory, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, source); + } +}; + +const relativeUnusedPaths = ( + rootDirectory: string, + unusedFiles: ReadonlyArray<{ readonly path: string }>, +): string[] => + unusedFiles.map((unusedFile) => + path.relative(rootDirectory, unusedFile.path).replaceAll("\\", "/"), + ); + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("project analysis config parsers", () => { + it("discovers GitHub Actions scripts from folded and quoted YAML run scalars", async () => { + const rootDirectory = createDirectory(); + writeFiles(rootDirectory, { + "package.json": JSON.stringify({ name: "workflow-config" }), + "src/index.ts": "console.log('entry');", + "scripts/folded.ts": "console.log('folded');", + "scripts/quoted.ts": "console.log('quoted');", + "src/orphan.ts": "export const orphan = true;", + ".github/workflows/check.yml": `jobs: + check: + steps: + - run: >- + node scripts/folded.ts + --check + - run: "node scripts/quoted.ts --check" +`, + }); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it.each([ + ["block", 'packages:\n - "apps/*"\n'], + ["flow", 'packages: ["apps/*"]\n'], + ])("discovers declared pnpm workspaces from %s YAML sequences", (_, workspaceSource) => { + const rootDirectory = createDirectory(); + writeFiles(rootDirectory, { + "package.json": JSON.stringify({ name: "workspace-root", private: true }), + "pnpm-workspace.yaml": workspaceSource, + "apps/web/package.json": JSON.stringify({ name: "web" }), + }); + + const declaredWorkspaces = resolveWorkspaces(rootDirectory).packages.filter( + (workspacePackage) => workspacePackage.isDeclaredWorkspace, + ); + + expect(declaredWorkspaces).toEqual([ + expect.objectContaining({ + name: "web", + directory: path.join(rootDirectory, "apps/web").replaceAll("\\", "/"), + depthFromRoot: 2, + }), + ]); + }); + + it("parses pnpm override flow mappings, anchors, and comments", () => { + const rootDirectory = createDirectory(); + writeFiles(rootDirectory, { + "pnpm-workspace.yaml": `shared: &shared + source-package: npm:target-package@1.0.0 +overrides: { <<: *shared, parent-package: { nested-source: "npm:nested-target@2.0.0" } } # merged +`, + }); + + expect(collectPnpmWorkspaceOverrideMappings(rootDirectory)).toEqual([ + { fromPackage: "source-package", toPackage: "target-package" }, + { fromPackage: "nested-source", toPackage: "nested-target" }, + ]); + }); + + it("uses the parsed Netlify functions table for quoted TOML keys", async () => { + const rootDirectory = createDirectory(); + writeFiles(rootDirectory, { + "package.json": JSON.stringify({ name: "netlify-config" }), + "src/index.ts": "console.log('entry');", + "netlify.toml": '[functions]\n"directory" = "server/functions"\n', + "server/functions/notify.ts": "export default () => new Response();", + "netlify/functions/dormant.ts": "export default () => new Response();", + }); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "netlify/functions/dormant.ts", + ]); + }); + + it("uses parsed JSONC worker fields without matching commented decoys", async () => { + const rootDirectory = createDirectory(); + writeFiles(rootDirectory, { + "package.json": JSON.stringify({ name: "worker-config" }), + "wrangler.jsonc": `{ + // "main": "src/commented-decoy.ts", + "main": "src/worker.ts", + "services": [{ "entry_point": "src/service.ts" }], +} +`, + "src/worker.ts": "export default { fetch: () => new Response() };", + "src/service.ts": "export default { fetch: () => new Response() };", + "src/commented-decoy.ts": "export default {};", + "src/orphan.ts": "export const orphan = true;", + }); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/commented-decoy.ts", + "src/orphan.ts", + ]); + }); +}); diff --git a/packages/core/tests/project-analysis-entry-ast.test.ts b/packages/core/tests/project-analysis-entry-ast.test.ts new file mode 100644 index 0000000000..db51de3452 --- /dev/null +++ b/packages/core/tests/project-analysis-entry-ast.test.ts @@ -0,0 +1,198 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { analyzeProject } from "../src/project-analysis/analyze-project.js"; + +const temporaryDirectories: string[] = []; + +const createProject = (files: Readonly>, packageJson: object): string => { + const rootDirectory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-entry-ast-")), + ); + temporaryDirectories.push(rootDirectory); + for (const [relativePath, sourceText] of Object.entries({ + "package.json": JSON.stringify({ name: "entry-ast", ...packageJson }), + ...files, + })) { + const filePath = path.join(rootDirectory, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, sourceText); + } + return rootDirectory; +}; + +const getUnusedPaths = ( + rootDirectory: string, + unusedFiles: ReadonlyArray<{ readonly path: string }>, +): string[] => + unusedFiles.map((unusedFile) => + path.relative(rootDirectory, unusedFile.path).replaceAll("\\", "/"), + ); + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("project entry syntax analysis", () => { + it("collects only unshadowed extensionless script requires", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "export const application = true;", + "script/task": [ + "#!/usr/bin/env node", + 'require("./active");', + 'const text = `require("./string-decoy")`;', + '// require("./comment-decoy");', + '((require) => require("./shadowed"))(() => undefined);', + ].join("\n"), + "script/shadowed-task": [ + "#!/usr/bin/env node", + "const require = (specifier) => specifier;", + 'require("./top-level-shadowed");', + ].join("\n"), + "script/active.js": "module.exports = true;", + "script/string-decoy.js": "module.exports = true;", + "script/comment-decoy.js": "module.exports = true;", + "script/shadowed.js": "module.exports = true;", + "script/top-level-shadowed.js": "module.exports = true;", + }, + { scripts: { build: "script/task build", prepare: "script/shadowed-task" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(getUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "script/comment-decoy.js", + "script/shadowed.js", + "script/string-decoy.js", + "script/top-level-shadowed.js", + ]); + }); + + it("collects exported Webpack entries without string, comment, or shadowing decoys", async () => { + const rootDirectory = createProject( + { + "webpack.config.js": [ + 'import "./webpack-shadowed";', + 'const path = require("node:path");', + 'const directEntries = ["./src/direct", require.resolve("./src/resolved")];', + "const config = {", + " entry: {", + " application: directEntries,", + ' server: path.join(__dirname, "src", "joined"),', + " },", + "};", + 'const text = `entry: "./src/string-decoy"`;', + '// module.exports = { entry: "./src/comment-decoy" };', + "const buildFakeConfig = (require, path) => ({", + ' entry: require.resolve("./src/shadowed-require"),', + "});", + "module.exports = config;", + ].join("\n"), + "webpack-shadowed.js": [ + "const require = (specifier) => specifier;", + 'require("./src/top-level-shadowed");', + ].join("\n"), + "src/direct.ts": "export const direct = true;", + "src/resolved.ts": "export const resolved = true;", + "src/joined.ts": "export const joined = true;", + "src/string-decoy.ts": "export const value = true;", + "src/comment-decoy.ts": "export const value = true;", + "src/shadowed-require.ts": "export const value = true;", + "src/top-level-shadowed.ts": "export const value = true;", + }, + { devDependencies: { webpack: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(getUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/comment-decoy.ts", + "src/shadowed-require.ts", + "src/string-decoy.ts", + "src/top-level-shadowed.ts", + ]); + }); + + it("follows imported Next plugin bindings and ignores shadowed calls", async () => { + const rootDirectory = createProject( + { + "next.config.mjs": [ + 'import makeIntl from "next-intl/plugin";', + 'import { withPlaiceholder as wrapImages } from "@plaiceholder/next";', + 'const withIntl = makeIntl("./src/i18n/live");', + 'const withImages = wrapImages("./src/images/live");', + 'makeIntl("./src/i18n/dormant");', + 'const text = `makeIntl("./src/i18n/string-decoy")`;', + '// makeIntl("./src/i18n/comment-decoy");', + 'const buildFakeConfig = (makeIntl) => makeIntl("./src/i18n/shadowed");', + "export default withImages(withIntl({}));", + ].join("\n"), + "src/app/page.tsx": "export default () => null;", + "src/i18n/live.ts": "export const request = true;", + "src/images/live.ts": "export const image = true;", + "src/i18n/string-decoy.ts": "export const value = true;", + "src/i18n/comment-decoy.ts": "export const value = true;", + "src/i18n/shadowed.ts": "export const value = true;", + "src/i18n/dormant.ts": "export const value = true;", + }, + { + dependencies: { + "@plaiceholder/next": "1.0.0", + next: "1.0.0", + "next-intl": "1.0.0", + react: "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(getUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/i18n/comment-decoy.ts", + "src/i18n/dormant.ts", + "src/i18n/shadowed.ts", + "src/i18n/string-decoy.ts", + ]); + }); + + it("follows Node createRequire calls without trusting lookalikes", async () => { + const rootDirectory = createProject( + { + "src/index.ts": [ + 'import { createRequire as makeRequire } from "node:module";', + 'import * as nodeModule from "module";', + "const localRequire = makeRequire(import.meta.url);", + "const namespaceRequire = nodeModule.createRequire(import.meta.url);", + 'const commonjsRequire = require("node:module").createRequire(import.meta.url);', + 'const { createRequire: commonjsFactory } = require("module");', + "const destructuredRequire = commonjsFactory(import.meta.url);", + 'localRequire("./loaded");', + 'namespaceRequire.resolve("./resolved");', + 'commonjsRequire("./commonjs");', + 'destructuredRequire("./destructured");', + 'localRequire.context("./context", true, /\\.ts$/);', + 'const useShadow = (localRequire) => localRequire("./shadowed");', + "console.log(useShadow);", + ].join("\n"), + "src/loaded.ts": "export const value = true;", + "src/resolved.ts": "export const value = true;", + "src/commonjs.ts": "export const value = true;", + "src/destructured.ts": "export const value = true;", + "src/context/orphan.ts": "export const value = true;", + "src/shadowed.ts": "export const value = true;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(getUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/context/orphan.ts", + "src/shadowed.ts", + ]); + }); +}); diff --git a/packages/core/tests/project-analysis-namespace-exports.test.ts b/packages/core/tests/project-analysis-namespace-exports.test.ts new file mode 100644 index 0000000000..cd3d4318fd --- /dev/null +++ b/packages/core/tests/project-analysis-namespace-exports.test.ts @@ -0,0 +1,94 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { analyzeProject } from "../src/project-analysis/analyze-project.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +const createProject = (files: Readonly>): string => { + const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-namespace-exports-")); + temporaryDirectories.push(rootDirectory); + fs.writeFileSync(path.join(rootDirectory, "package.json"), "{}"); + for (const [relativePath, source] of Object.entries(files)) { + const filePath = path.join(rootDirectory, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, source); + } + return fs.realpathSync(rootDirectory); +}; + +describe("namespace export usage", () => { + it("preserves every member exposed through an entry-point namespace object", async () => { + const rootDirectory = createProject({ + "src/index.ts": ` + import { REASONS } from "./reasons"; + import * as internal from "./internal"; + console.log(REASONS.open, internal.used); + `, + "src/reasons.ts": ` + import * as REASONS from "./reason-parts"; + export { REASONS }; + `, + "src/reason-parts.ts": ` + export const open = "open"; + export const externallySelected = "externally-selected"; + `, + "src/internal.ts": ` + export const used = true; + export const stale = false; + `, + "src/public-api.ts": `export * as Library from "./library";`, + "src/library.ts": ` + export const firstPublicMember = true; + export const secondPublicMember = true; + `, + }); + + const result = await analyzeProject({ + rootDirectory, + entryPatterns: ["src/index.ts", "src/reasons.ts", "src/public-api.ts"], + }); + const unusedExports = result.unusedExports.map((unusedExport) => ({ + path: path.relative(rootDirectory, unusedExport.path).replaceAll("\\", "/"), + name: unusedExport.name, + })); + + expect(unusedExports).toEqual([{ path: "src/internal.ts", name: "stale" }]); + }); + + it("does not credit named re-exports from unrelated namespace targets", async () => { + const rootDirectory = createProject({ + "src/index.ts": ` + import { Library } from "./public-api"; + console.log(Library.firstPublicMember); + `, + "src/public-api.ts": ` + export * as Library from "./library"; + export { unrelatedPublicMember } from "./unrelated"; + `, + "src/library.ts": ` + export const firstPublicMember = true; + export const secondPublicMember = true; + `, + "src/unrelated.ts": `export const unrelatedPublicMember = true;`, + }); + + const result = await analyzeProject({ + rootDirectory, + entryPatterns: ["src/index.ts"], + }); + const unusedExports = result.unusedExports.map((unusedExport) => ({ + path: path.relative(rootDirectory, unusedExport.path).replaceAll("\\", "/"), + name: unusedExport.name, + })); + + expect(unusedExports).toEqual([{ path: "src/unrelated.ts", name: "unrelatedPublicMember" }]); + }); +}); diff --git a/packages/core/tests/project-analysis-oxc-runtime-parsers.test.ts b/packages/core/tests/project-analysis-oxc-runtime-parsers.test.ts new file mode 100644 index 0000000000..c593cc906a --- /dev/null +++ b/packages/core/tests/project-analysis-oxc-runtime-parsers.test.ts @@ -0,0 +1,463 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { analyzeProject } from "../src/project-analysis/analyze-project.js"; +import { parseSourceFile } from "../src/project-analysis/collect/parse.js"; +import { extractRuntimeConsumedDirectoryFiles } from "../src/project-analysis/collect/runtime-consumed-directory-files.js"; +import { MAX_PARSE_FILE_SIZE_BYTES } from "../src/project-analysis/constants.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +const createProject = ( + files: Readonly>, + packageJson: Readonly> = {}, +): string => { + const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxc-parsers-")); + temporaryDirectories.push(rootDirectory); + fs.writeFileSync(path.join(rootDirectory, "package.json"), JSON.stringify(packageJson)); + for (const [relativePath, source] of Object.entries(files)) { + const filePath = path.join(rootDirectory, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, source); + } + return fs.realpathSync(rootDirectory); +}; + +const relativeUnusedPaths = ( + rootDirectory: string, + unusedFiles: ReadonlyArray<{ readonly path: string }>, +): string[] => + unusedFiles.map((unusedFile) => + path.relative(rootDirectory, unusedFile.path).replaceAll("\\", "/"), + ); + +describe("Oxc-backed project configuration discovery", () => { + it("recognizes imported test APIs without trusting shadowed lookalikes", () => { + const rootDirectory = createProject({ + "src/mocks.ts": ` + import { vi as vitestApi } from "vitest"; + import { jest as jestApi } from "@jest/globals"; + vitestApi.mock("./from-vitest", () => ({})); + jestApi.mock("./from-jest", () => ({})); + { + const vitestApi = { mock: () => undefined }; + vitestApi.mock("./shadowed-vitest", () => ({})); + } + const vi = { mock: () => undefined }; + const jest = { mock: () => undefined }; + vi.mock("./local-vi", () => ({})); + jest.mock("./local-jest", () => ({})); + `, + }); + + const parsedSource = parseSourceFile(path.join(rootDirectory, "src/mocks.ts")); + const relativeDynamicSpecifiers = parsedSource.imports + .filter((importReference) => importReference.isDynamic) + .map((importReference) => importReference.specifier); + + expect(relativeDynamicSpecifiers).toEqual(["./from-vitest", "./from-jest"]); + }); + + it("hoists var bindings only to their containing function or program", () => { + const rootDirectory = createProject({ + "src/function-scope.cjs": ` + require("./global-runtime"); + function loadLocally() { + require("./shadowed-before-declaration"); + if (false) var require = localLoader; + } + console.log(loadLocally); + `, + "src/program-scope.cjs": ` + require("./shadowed-at-program-scope"); + if (false) var require = localLoader; + `, + }); + + const functionScopeSource = parseSourceFile(path.join(rootDirectory, "src/function-scope.cjs")); + const programScopeSource = parseSourceFile(path.join(rootDirectory, "src/program-scope.cjs")); + + expect( + functionScopeSource.imports + .filter((importReference) => importReference.isDynamic) + .map((importReference) => importReference.specifier), + ).toEqual(["./global-runtime"]); + expect(programScopeSource.imports).toEqual([]); + }); + + it("discovers statically bound directories read through fs.promises", async () => { + const rootDirectory = createProject({ + "src/index.ts": ` + import fs from "node:fs"; + import path from "node:path"; + const projectRoot = process.cwd(); + const featureDirectoryName = "features"; + const featureDirectory = path.join(projectRoot, "src", featureDirectoryName); + fs.promises.readdir(featureDirectory); + const filesystem = require("node:fs"); + const filePaths = require("node:path"); + const commonJsDirectory = filePaths.resolve(projectRoot, "src", "commonjs"); + filesystem.readdirSync(commonJsDirectory); + const dormantDirectory = path.join(projectRoot, "src", "dormant"); + database.readdir(dormantDirectory); + const commentedDirectory = path.join(projectRoot, "src", "commented"); + // fs.readdirSync(commentedDirectory); + console.log("fs.readdirSync(commentedDirectory)"); + { + const featureDirectory = path.join(projectRoot, "src", "shadowed"); + const fs = { promises: { readdir: () => [] } }; + fs.promises.readdir(featureDirectory); + } + `, + "src/features/runtime.ts": "export const runtime = true;", + "src/commonjs/runtime.ts": "export const commonJsRuntime = true;", + "src/dormant/runtime.ts": "export const dormant = true;", + "src/commented/runtime.ts": "export const commented = true;", + "src/shadowed/runtime.ts": "export const shadowed = true;", + }); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/commented/runtime.ts", + "src/dormant/runtime.ts", + "src/shadowed/runtime.ts", + ]); + }); + + it("ignores files passed to directory-copy APIs", async () => { + const rootDirectory = createProject({ + "rollup.config.mjs": ` + import fs from "fs-extra"; + fs.copySync("./README.md", "./dist/README.md"); + fs.copySync("./index.html", "./dist/index.html"); + fs.copySync("./templates", "./dist/templates"); + `, + "README.md": "# Example", + "index.html": "
Example
", + "templates/runtime.ts": "export const runtime = true;", + "src/index.ts": "export const entry = true;", + "src/orphan.ts": "export const orphan = true;", + }); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.analysisErrors).toEqual([]); + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("skips runtime directory discovery in oversized source files", () => { + const rootDirectory = createProject({ + "src/oversized-runtime.js": ` + fs.readdirSync(path.join(process.cwd(), "src", "runtime")); + /* ${"x".repeat(MAX_PARSE_FILE_SIZE_BYTES)} */ + `, + "src/runtime/discovered.ts": "export const discovered = true;", + }); + + expect(extractRuntimeConsumedDirectoryFiles(rootDirectory)).toEqual([]); + }); + + it("scans large scopes without copying bindings for every AST node", () => { + const largeScopeBindingCount = 20_000; + const largeScopeBindings = Array.from( + { length: largeScopeBindingCount }, + (_, bindingIndex) => `const binding${bindingIndex} = ${bindingIndex};`, + ).join("\n"); + const rootDirectory = createProject({ + "src/runtime-loader.js": ` + ${largeScopeBindings} + fs.readdirSync(path.join(process.cwd(), "src", "runtime")); + `, + "src/runtime/discovered.ts": "export const discovered = true;", + }); + + expect( + extractRuntimeConsumedDirectoryFiles(rootDirectory).map((filePath) => + path.relative(rootDirectory, filePath).replaceAll("\\", "/"), + ), + ).toEqual(["src/runtime/discovered.ts"]); + }); + + it("resolves React Router appDirectory through static shorthand bindings", async () => { + const rootDirectory = createProject( + { + "react-router.config.ts": ` + const appDirectory = "application"; + const config = { appDirectory }; + export default config; + `, + "application/root.tsx": "export default () => null;", + "application/routes/home.tsx": "export default () => null;", + "src/orphan.ts": "export const orphan = true;", + }, + { dependencies: { "@react-router/dev": "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + const unusedFilePaths = relativeUnusedPaths(rootDirectory, result.unusedFiles); + expect(unusedFilePaths).toContain("src/orphan.ts"); + expect(unusedFilePaths).not.toEqual( + expect.arrayContaining(["application/root.tsx", "application/routes/home.tsx"]), + ); + }); + + it("preserves sibling workspace subpaths loaded through require.resolve", async () => { + const monorepoDirectory = createProject( + { + "packages/library/package.json": JSON.stringify({ name: "@example/library" }), + "packages/library/src/index.ts": "export const root = true;", + "packages/library/src/runtime/plugin.ts": "export const plugin = true;", + "packages/library/src/runtime/commented.ts": "export const commented = true;", + "packages/library/src/runtime/shadowed.ts": "export const shadowed = true;", + "packages/app/package.json": JSON.stringify({ name: "@example/app" }), + "packages/app/src/index.ts": ` + const pluginPath = require.resolve("@example/library/src/runtime/plugin"); + const loadWithShadow = () => { + const require = { resolve: (specifier) => specifier }; + require.resolve("@example/library/src/runtime/shadowed"); + }; + // require.resolve("@example/library/src/runtime/commented"); + console.log(pluginPath, loadWithShadow); + `, + }, + { private: true, workspaces: ["packages/*"] }, + ); + const libraryDirectory = path.join(monorepoDirectory, "packages/library"); + + const result = await analyzeProject({ + rootDirectory: libraryDirectory, + entryPatterns: ["src/index.ts"], + }); + + expect(relativeUnusedPaths(libraryDirectory, result.unusedFiles)).toEqual([ + "src/runtime/commented.ts", + "src/runtime/shadowed.ts", + ]); + }); + + it("resolves Jest moduleNameMapper aliases through static shorthand bindings", async () => { + const rootDirectory = createProject({ + "jest.config.ts": ` + const moduleNameMapper = { "^@library/(.*)$": "/src/library/$1" }; + const config = { moduleNameMapper }; + export default config; + `, + "src/index.ts": `import { value } from "@library/value"; console.log(value);`, + "src/library/value.ts": "export const value = true; export const unused = false;", + }); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).not.toContain( + "src/library/value.ts", + ); + expect(result.unusedExports.map((unusedExport) => unusedExport.name)).toEqual(["unused"]); + }); +}); + +describe("embedded component source positions", () => { + it("finds top-level scripts after self-closing custom elements", () => { + const rootDirectory = createProject({ + "src/card.vue": [ + "", + "", + " ", + "", + "", + ].join("\n"), + }); + + const parsedSource = parseSourceFile(path.join(rootDirectory, "src/card.vue")); + + expect(parsedSource.imports).toEqual([ + expect.objectContaining({ specifier: "./actual", line: 6, column: 0 }), + ]); + expect(parsedSource.exports).toEqual([ + expect.objectContaining({ name: "stale", line: 7, column: 13 }), + ]); + }); + + it("preserves Astro frontmatter and later script positions", () => { + const rootDirectory = createProject({ + "src/card.astro": [ + "---", + 'import Frontmatter from "./frontmatter";', + "export interface Props { title: string }", + "export const staleFrontmatter = true;", + "---", + "
😀
", + '", + ].join("\r\n"), + }); + + const parsedSource = parseSourceFile(path.join(rootDirectory, "src/card.astro")); + + expect(parsedSource.imports).toEqual([ + expect.objectContaining({ specifier: "./frontmatter", line: 2, column: 0 }), + expect.objectContaining({ specifier: "./client.js", line: 7, column: 0 }), + expect.objectContaining({ specifier: "./later", line: 9, column: 0 }), + ]); + expect(parsedSource.exports).toEqual([ + expect.objectContaining({ name: "staleFrontmatter", line: 4, column: 13 }), + expect.objectContaining({ name: "staleLater", line: 10, column: 13 }), + ]); + }); + + it("uses Astro syntax nodes for frontmatter and script extraction", () => { + const rootDirectory = createProject({ + "src/card.astro": [ + "---", + 'import Frontmatter from "./frontmatter";', + 'const description = "export interface Props { ignored: true }";', + "export interface Props { title: string }", + "---", + '', + '", + ].join("\n"), + }); + + const parsedSource = parseSourceFile(path.join(rootDirectory, "src/card.astro")); + + expect(parsedSource.imports).toEqual([ + expect.objectContaining({ specifier: "./frontmatter", line: 2, column: 0 }), + expect.objectContaining({ specifier: "./actual", line: 8, column: 0 }), + ]); + expect(parsedSource.exports).toEqual([ + expect.objectContaining({ name: "stale", line: 9, column: 13 }), + ]); + }); + + it("follows Astro compiler semantics for frontmatter delimiter suffixes", () => { + const rootDirectory = createProject({ + "src/card.astro": [ + "---", + 'import BeforeSuffix from "./before-suffix";', + "---suffix", + 'import AfterSuffix from "./after-suffix";', + "---", + ].join("\n"), + }); + + const parsedSource = parseSourceFile(path.join(rootDirectory, "src/card.astro")); + + expect(parsedSource.imports).toEqual([ + expect.objectContaining({ specifier: "./before-suffix", line: 2, column: 0 }), + ]); + }); + + it("preserves positions across multiple Vue script blocks", () => { + const rootDirectory = createProject({ + "src/card.vue": [ + "", + "", + "", + '", + ].join("\n"), + }); + + const parsedSource = parseSourceFile(path.join(rootDirectory, "src/card.vue")); + + expect(parsedSource.imports).toEqual([ + expect.objectContaining({ specifier: "./first", line: 5, column: 0 }), + expect.objectContaining({ specifier: "./second", line: 9, column: 0 }), + ]); + expect(parsedSource.exports).toEqual([ + expect.objectContaining({ name: "stale", line: 10, column: 13 }), + ]); + }); + + it("ignores commented Vue scripts and quoted greater-than attributes", () => { + const rootDirectory = createProject({ + "src/card.vue": [ + '', + "", + '", + ].join("\n"), + }); + + const parsedSource = parseSourceFile(path.join(rootDirectory, "src/card.vue")); + + expect(parsedSource.imports).toEqual([ + expect.objectContaining({ specifier: "./actual", line: 6, column: 0 }), + ]); + expect(parsedSource.exports).toEqual([ + expect.objectContaining({ name: "stale", line: 7, column: 13 }), + ]); + }); + + it("preserves Svelte module and instance script positions", () => { + const rootDirectory = createProject({ + "src/card.svelte": [ + "
😀
", + '", + "
content
", + '", + ].join("\n"), + }); + + const parsedSource = parseSourceFile(path.join(rootDirectory, "src/card.svelte")); + + expect(parsedSource.exports).toEqual([ + expect.objectContaining({ name: "staleModule", line: 3, column: 13 }), + expect.objectContaining({ name: "staleInstance", line: 8, column: 13 }), + ]); + }); + + it("recognizes only real Svelte module attributes", () => { + const rootDirectory = createProject({ + "src/card.svelte": [ + "", + '", + '", + ].join("\n"), + }); + + const parsedSource = parseSourceFile(path.join(rootDirectory, "src/card.svelte")); + + expect(parsedSource.exports).toEqual([ + expect.objectContaining({ name: "staleInstance", line: 4, column: 13 }), + expect.objectContaining({ name: "moduleValue", line: 7, column: 11 }), + ]); + }); +}); diff --git a/packages/core/tests/project-analysis-path-utils.test.ts b/packages/core/tests/project-analysis-path-utils.test.ts new file mode 100644 index 0000000000..02344f3a50 --- /dev/null +++ b/packages/core/tests/project-analysis-path-utils.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vite-plus/test"; +import { buildExportKey } from "../src/project-analysis/utils/build-export-key.js"; +import { isPathInsideDirectoryOrEqual } from "../src/project-analysis/utils/is-path-inside-directory-or-equal.js"; + +describe("project-analysis path utilities", () => { + it("normalizes export identity path separators", () => { + expect(buildExportKey("C:\\project\\src\\page.tsx", "Page")).toBe( + "C:/project/src/page.tsx::Page", + ); + }); + + it("compares directory containment across path separators", () => { + expect(isPathInsideDirectoryOrEqual("C:/project/src/page.tsx", "C:\\project")).toBe(true); + expect(isPathInsideDirectoryOrEqual("C:/project-copy/page.tsx", "C:\\project")).toBe(false); + }); +}); diff --git a/packages/core/tests/project-analysis-platform-exports.test.ts b/packages/core/tests/project-analysis-platform-exports.test.ts new file mode 100644 index 0000000000..11027d3ed2 --- /dev/null +++ b/packages/core/tests/project-analysis-platform-exports.test.ts @@ -0,0 +1,84 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { analyzeProject } from "../src/project-analysis/analyze-project.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +const createProject = (files: Readonly>): string => { + const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-platform-exports-")); + temporaryDirectories.push(rootDirectory); + fs.writeFileSync( + path.join(rootDirectory, "package.json"), + JSON.stringify({ dependencies: { react: "1.0.0", "react-native": "1.0.0" } }), + ); + for (const [relativePath, source] of Object.entries(files)) { + const filePath = path.join(rootDirectory, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, source); + } + return fs.realpathSync(rootDirectory); +}; + +const platformSuffixes = ["", ".web", ".native", ".ios", ".android", ".rn"]; + +describe("platform sibling export usage", () => { + it("credits only imported exports across React Native platform variants", async () => { + const sourceFiles: Record = { + "src/index.ts": ` + import theme, { shared } from "./theme"; + import * as icons from "./icons"; + import { reExported } from "./public"; + console.log(theme, shared, icons.usedMember, reExported); + `, + "src/public.ts": `export { reExported } from "./re-exported";`, + }; + + for (const platformSuffix of platformSuffixes) { + const variantName = platformSuffix || ".base"; + sourceFiles[`src/theme${platformSuffix}.ts`] = ` + export default "${variantName}"; + export const shared = "${variantName}"; + export const themeOnly = "${variantName}"; + `; + sourceFiles[`src/icons${platformSuffix}.ts`] = ` + export const usedMember = "${variantName}"; + export const iconOnly = "${variantName}"; + `; + sourceFiles[`src/re-exported${platformSuffix}.ts`] = ` + export const reExported = "${variantName}"; + export const reExportOnly = "${variantName}"; + `; + } + + const rootDirectory = createProject(sourceFiles); + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const findings = result.unusedExports.map((finding) => ({ + path: path.relative(rootDirectory, finding.path).replaceAll("\\", "/"), + name: finding.name, + })); + + expect( + findings.filter((finding) => + ["default", "shared", "usedMember", "reExported"].includes(finding.name), + ), + ).toEqual([]); + expect(findings).toHaveLength(platformSuffixes.length * 3); + for (const platformSuffix of platformSuffixes) { + expect(findings).toEqual( + expect.arrayContaining([ + { path: `src/theme${platformSuffix}.ts`, name: "themeOnly" }, + { path: `src/icons${platformSuffix}.ts`, name: "iconOnly" }, + { path: `src/re-exported${platformSuffix}.ts`, name: "reExportOnly" }, + ]), + ); + } + }); +}); diff --git a/packages/core/tests/project-analysis-resolver-source-fallback.test.ts b/packages/core/tests/project-analysis-resolver-source-fallback.test.ts new file mode 100644 index 0000000000..dc7d4554b5 --- /dev/null +++ b/packages/core/tests/project-analysis-resolver-source-fallback.test.ts @@ -0,0 +1,34 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { defineProjectAnalysisConfig } from "../src/project-analysis/config.js"; +import { createResolver } from "../src/project-analysis/resolver/resolve.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const temporaryDirectory of temporaryDirectories.splice(0)) { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}); + +describe("project analysis source fallback", () => { + it("does not map an unresolved relative output path to an unrelated source module", () => { + const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-resolver-")); + temporaryDirectories.push(rootDirectory); + const sourceDirectory = path.join(rootDirectory, "src"); + fs.mkdirSync(sourceDirectory, { recursive: true }); + const entryPath = path.join(sourceDirectory, "index.ts"); + fs.writeFileSync(entryPath, 'import "./lib/foo.js";'); + fs.writeFileSync(path.join(sourceDirectory, "foo.ts"), "export const unrelated = true;"); + + const resolver = createResolver(defineProjectAnalysisConfig({ rootDir: rootDirectory })); + + expect(resolver.resolveModule("./lib/foo.js", entryPath)).toEqual({ + resolvedPath: undefined, + isExternal: false, + packageName: undefined, + }); + }); +}); diff --git a/packages/core/tests/project-analysis-runtime-contracts.test.ts b/packages/core/tests/project-analysis-runtime-contracts.test.ts new file mode 100644 index 0000000000..2dde69e08b --- /dev/null +++ b/packages/core/tests/project-analysis-runtime-contracts.test.ts @@ -0,0 +1,332 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { + analyzeProject, + analyzeProjectForWorker, +} from "../src/project-analysis/analyze-project.js"; +import { toPosixPath } from "../src/project-analysis/utils/to-posix-path.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +const createProject = ( + files: Readonly>, + dependencies: Readonly> = {}, + packageJsonFields: Readonly> = {}, +): string => { + const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-runtime-contracts-")); + temporaryDirectories.push(rootDirectory); + fs.writeFileSync( + path.join(rootDirectory, "package.json"), + JSON.stringify({ dependencies, ...packageJsonFields }), + ); + for (const [relativePath, source] of Object.entries(files)) { + const filePath = path.join(rootDirectory, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, source); + } + fs.writeFileSync( + path.join(rootDirectory, "package-lock.json"), + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { dependencies }, + ...Object.fromEntries( + Object.entries(dependencies).map(([dependencyName, version]) => [ + `node_modules/${dependencyName}`, + { version }, + ]), + ), + }, + }), + ); + return fs.realpathSync(rootDirectory); +}; + +const getUnusedExportNames = async (rootDirectory: string): Promise => { + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + return result.unusedExports.map((finding) => finding.name); +}; + +describe("runtime-owned project analysis contracts", () => { + it("keeps skipped dependency metadata on the worker wire only", async () => { + const rootDirectory = createProject( + { "src/index.ts": `import "used-package";` }, + { "used-package": "1.0.0", "unused-package": "1.0.0" }, + ); + const input = { rootDirectory, entryPatterns: ["src/index.ts"] }; + + const publicResult = await analyzeProject(input); + const workerResult = await analyzeProjectForWorker(input); + + expect("skippedDependencies" in publicResult).toBe(false); + expect(workerResult.skippedDependencies).toEqual([]); + }); + + it("discovers Supabase Edge Function entry modules", async () => { + const rootDirectory = createProject({ + "src/index.ts": `console.log("application");`, + "supabase/functions/scout-cron/index.ts": `import "./agent";`, + "supabase/functions/scout-cron/agent.ts": `export const runAgent = true;`, + "supabase/functions/shared/helper.ts": `export const helper = true;`, + }); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.unusedFiles.map((finding) => finding.path)).toEqual([ + toPosixPath(path.join(rootDirectory, "supabase/functions/shared/helper.ts")), + ]); + }); + + it("discovers source files selected by package import conditions", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `console.log("application");`, + "src/browser.ts": `export const browser = true;`, + "src/server.ts": `export const server = true;`, + "src/orphan.ts": `export const orphan = true;`, + }, + {}, + { + imports: { + "#runtime": { + browser: "./src/browser.ts", + default: "./src/server.ts", + }, + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.unusedFiles.map((finding) => finding.path)).toEqual([ + toPosixPath(path.join(rootDirectory, "src/orphan.ts")), + ]); + }); + + it("discovers style-expanded registry files consumed by build scripts", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `console.log("application");`, + "src/scripts/build-registry.mts": ` + import { blocks } from "../registry/registry-blocks"; + import { styles } from "../registry/registry-styles"; + import { unrelatedMetadata } from "../registry/unrelated-metadata"; + for (const style of styles) { + for (const item of blocks) { + item.files.map((file) => \`src/registry/\${style.name}/\${file.path}\`); + } + } + console.log(unrelatedMetadata); + `, + "src/registry/registry-blocks.ts": ` + export const blocks = [{ files: [{ path: "block/sidebar/hooks/use-sidebar.tsx" }] }]; + `, + "src/registry/registry-styles.ts": ` + export const styles = [{ name: "default" }, { name: "new-york" }]; + `, + "src/registry/unrelated-metadata.ts": ` + export const unrelatedMetadata = { + name: "default", + path: "block/sidebar/hooks/orphan.tsx", + }; + `, + "src/registry/default/block/sidebar/hooks/use-sidebar.tsx": `export const useSidebar = () => true;`, + "src/registry/new-york/block/sidebar/hooks/use-sidebar.tsx": `export const useSidebar = () => true;`, + "src/registry/default/block/sidebar/hooks/orphan.tsx": `export const orphan = true;`, + }, + {}, + { scripts: { build: "tsx src/scripts/build-registry.mts" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.unusedFiles.map((finding) => finding.path)).toEqual([ + toPosixPath(path.join(rootDirectory, "src/registry/default/block/sidebar/hooks/orphan.tsx")), + ]); + }); + + it("does not infer registry fanout from unrelated path and name fields", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `console.log("application");`, + "src/scripts/build.mts": ` + import { metadata } from "../registry/metadata"; + console.log(metadata); + `, + "src/registry/metadata.ts": ` + export const metadata = { + name: "default", + path: "block/sidebar/hooks/use-sidebar.tsx", + }; + `, + "src/registry/default/block/sidebar/hooks/use-sidebar.tsx": `export const useSidebar = () => true;`, + }, + {}, + { scripts: { build: "tsx src/scripts/build.mts" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.unusedFiles.map((finding) => finding.path)).toEqual([ + toPosixPath( + path.join(rootDirectory, "src/registry/default/block/sidebar/hooks/use-sidebar.tsx"), + ), + ]); + }); + + it("credits only the statically selected CommonJS export", async () => { + const rootDirectory = createProject({ + "src/index.ts": `console.log(require("./NativeFeature").default);`, + "src/NativeFeature.ts": ` + export default "native"; + export const unusedNativeHelper = "unused"; + `, + }); + + expect(await getUnusedExportNames(rootDirectory)).toEqual(["unusedNativeHelper"]); + }); + + it("supports string-literal CommonJS member selection without crediting siblings", async () => { + const rootDirectory = createProject({ + "src/index.ts": `console.log(require("./feature")["selected"]);`, + "src/feature.ts": ` + export const selected = "selected"; + export const unselected = "unselected"; + `, + }); + + expect(await getUnusedExportNames(rootDirectory)).toEqual(["unselected"]); + }); + + it("keeps dynamic CommonJS member access conservative", async () => { + const rootDirectory = createProject({ + "src/index.ts": ` + const selectedName = Math.random() > 0.5 ? "first" : "second"; + console.log(require("./feature")[selectedName]); + `, + "src/feature.ts": ` + export const first = "first"; + export const second = "second"; + `, + }); + + expect(await getUnusedExportNames(rootDirectory)).toEqual([]); + }); + + it("does not treat CommonJS syntax inside strings as an export reference", async () => { + const rootDirectory = createProject({ + "src/index.ts": ` + import "./feature"; + console.log('require("./feature").default'); + `, + "src/feature.ts": ` + export default "default"; + export const named = "named"; + `, + }); + + expect(await getUnusedExportNames(rootDirectory)).toEqual(["default", "named"]); + }); + + it("retains React Native Codegen Spec references below TypeScript value wrappers", async () => { + const rootDirectory = createProject({ + "src/index.ts": `import NativeFeature from "./NativeFeature"; console.log(NativeFeature);`, + "src/NativeFeature.ts": ` + interface TurboModule {} + declare const TurboModuleRegistry: { + get(name: string): Module | null; + }; + export interface Spec extends TurboModule { + read(): string; + } + export interface UnusedSpec extends TurboModule { + write(): void; + } + export default TurboModuleRegistry.get("NativeFeature") as Spec | null; + `, + }); + + expect(await getUnusedExportNames(rootDirectory)).toEqual(["UnusedSpec"]); + }); + + it("does not infer Codegen usage from a Native filename alone", async () => { + const rootDirectory = createProject({ + "src/index.ts": `import NativeFeature from "./NativeFeature"; console.log(NativeFeature);`, + "src/NativeFeature.ts": ` + export interface UnusedSpec { + read(): string; + } + export default "native"; + `, + }); + + expect(await getUnusedExportNames(rootDirectory)).toEqual(["UnusedSpec"]); + }); + + it("credits Expo Router's RSC dependency only when React Server Functions are enabled", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `import "expo-router";`, + "app.json": JSON.stringify({ + expo: { experiments: { reactServerFunctions: true } }, + }), + }, + { + "expo-router": "1.0.0", + "react-server-dom-webpack": "1.0.0", + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + expect(result.unusedDependencies.map((finding) => finding.name)).not.toContain( + "react-server-dom-webpack", + ); + }); + + it("reports the RSC dependency when the Expo experiment is disabled", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `import "expo-router";`, + "app.json": JSON.stringify({ + expo: { experiments: { reactServerFunctions: false } }, + }), + }, + { + "expo-router": "1.0.0", + "react-server-dom-webpack": "1.0.0", + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + expect(result.unusedDependencies.map((finding) => finding.name)).toContain( + "react-server-dom-webpack", + ); + }); + + it("does not credit the RSC dependency without an observed Expo Router runtime", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `console.log("standalone");`, + "app.json": JSON.stringify({ + expo: { experiments: { reactServerFunctions: true } }, + }), + }, + { + "react-server-dom-webpack": "1.0.0", + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + expect(result.unusedDependencies.map((finding) => finding.name)).toContain( + "react-server-dom-webpack", + ); + }); +}); diff --git a/packages/core/tests/project-analysis-runtime-discovery.test.ts b/packages/core/tests/project-analysis-runtime-discovery.test.ts new file mode 100644 index 0000000000..6ac6c7910d --- /dev/null +++ b/packages/core/tests/project-analysis-runtime-discovery.test.ts @@ -0,0 +1,669 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { analyzeProject } from "../src/project-analysis/analyze-project.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +const createProject = ( + files: Readonly>, + packageJson: Readonly> = {}, +): string => { + const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-runtime-discovery-")); + temporaryDirectories.push(rootDirectory); + fs.writeFileSync(path.join(rootDirectory, "package.json"), JSON.stringify(packageJson)); + for (const [relativePath, source] of Object.entries(files)) { + const filePath = path.join(rootDirectory, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, source); + } + return fs.realpathSync(rootDirectory); +}; + +const relativeUnusedPaths = ( + rootDirectory: string, + unusedFiles: ReadonlyArray<{ readonly path: string }>, +): string[] => + unusedFiles.map((unusedFile) => + path.relative(rootDirectory, unusedFile.path).replaceAll("\\", "/"), + ); + +describe("runtime-discovered project entries", () => { + it("links source files consumed through generated unplugin auto imports", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": ` + import AutoImport from 'unplugin-auto-import/vite'; + export default { plugins: [AutoImport({ eslintrc: { enabled: true } })] }; + `, + ".eslintrc-auto-import.json": JSON.stringify({ + globals: { useFeature: "readonly", useDormant: "readonly" }, + }), + "src/index.tsx": "export const App = () => useFeature();", + "src/hooks/use-feature.ts": "export const useFeature = () => true;", + "src/hooks/use-dormant.ts": "export const useDormant = () => false;", + "src/types/auto-imports.d.ts": ` + // Generated by unplugin-auto-import + declare global { + const useFeature: typeof import('../hooks/use-feature')['useFeature']; + const useDormant: typeof import('../hooks/use-dormant')['useDormant']; + } + `, + }, + { dependencies: { react: "1.0.0" }, devDependencies: { "unplugin-auto-import": "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.tsx"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/hooks/use-dormant.ts", + ]); + }); + + it("retains identical generated auto-import bindings and rejects conflicts", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": ` + import AutoImport from 'unplugin-auto-import/vite'; + export default { plugins: [AutoImport({ eslintrc: { enabled: true } })] }; + `, + ".eslintrc-auto-import.json": JSON.stringify({ + globals: { useFeature: "readonly", useConflict: "readonly" }, + }), + "src/index.ts": "export const result = [useFeature(), useConflict()];", + "src/hooks/use-feature.ts": "export const useFeature = () => true;", + "src/hooks/use-conflict-a.ts": "export const useConflict = () => 'a';", + "src/hooks/use-conflict-b.ts": "export const useConflict = () => 'b';", + "src/types/auto-imports.d.ts": ` + // Generated by unplugin-auto-import + declare global { + const useFeature: typeof import('../hooks/use-feature')['useFeature']; + const useConflict: typeof import('../hooks/use-conflict-a')['useConflict']; + } + `, + "src/generated/auto-imports.d.ts": ` + // Generated by unplugin-auto-import + declare global { + const useFeature: typeof import('../hooks/use-feature')['useFeature']; + const useConflict: typeof import('../hooks/use-conflict-b')['useConflict']; + } + `, + }, + { dependencies: { react: "1.0.0" }, devDependencies: { "unplugin-auto-import": "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/hooks/use-conflict-a.ts", + "src/hooks/use-conflict-b.ts", + ]); + }); + + it("resolves unplugin auto imports with lexical bindings and reference positions", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": ` + import AutoImport from 'unplugin-auto-import/vite'; + export default { plugins: [AutoImport({ eslintrc: { enabled: true } })] }; + `, + ".eslintrc-auto-import.json": JSON.stringify({ + globals: { + ref: "readonly", + scopedRef: "readonly", + localRef: "readonly", + propertyRef: "readonly", + shorthandRef: "readonly", + }, + }), + "src/index.ts": ` + const object = { ref: true, propertyRef: true }; + object.ref; + object.propertyRef; + const { propertyRef } = object; + ref(); + const values = { shorthandRef }; + const inside = (scopedRef: () => void) => scopedRef(); + scopedRef(); + const local = (localRef: () => void) => localRef(); + console.log(inside, local, propertyRef, values); + `, + "src/globals/ref.ts": "export const ref = () => true;", + "src/globals/scoped-ref.ts": "export const scopedRef = () => true;", + "src/globals/local-ref.ts": "export const localRef = () => true;", + "src/globals/property-ref.ts": "export const propertyRef = () => true;", + "src/globals/shorthand-ref.ts": "export const shorthandRef = () => true;", + "src/types/auto-imports.d.ts": ` + // Generated by unplugin-auto-import + declare global { + const ref: typeof import('../globals/ref')['ref']; + const scopedRef: typeof import('../globals/scoped-ref')['scopedRef']; + const localRef: typeof import('../globals/local-ref')['localRef']; + const propertyRef: typeof import('../globals/property-ref')['propertyRef']; + const shorthandRef: typeof import('../globals/shorthand-ref')['shorthandRef']; + } + `, + }, + { dependencies: { react: "1.0.0" }, devDependencies: { "unplugin-auto-import": "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/globals/local-ref.ts", + "src/globals/property-ref.ts", + ]); + }); + + it("does not trust stale generated auto-import declarations", async () => { + const rootDirectory = createProject( + { + "src/index.tsx": "export const App = () => useFeature();", + "src/hooks/use-feature.ts": "export const useFeature = () => true;", + "src/types/auto-imports.d.ts": ` + // Generated by unplugin-auto-import + declare global { + const useFeature: typeof import('../hooks/use-feature')['useFeature']; + } + `, + }, + { dependencies: { react: "1.0.0" }, devDependencies: { "unplugin-auto-import": "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.tsx"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/hooks/use-feature.ts", + ]); + }); + + it("keeps generated auto-import bindings inside their active workspace scope", async () => { + const generatedDeclaration = ` + // Generated by unplugin-auto-import + declare global { + const useFeature: typeof import('../hooks/use-feature')['useFeature']; + } + `; + const rootDirectory = createProject( + { + "packages/active/package.json": JSON.stringify({ name: "active" }), + "packages/active/vite.config.ts": ` + import AutoImport from 'unplugin-auto-import/vite'; + export default { plugins: [AutoImport({ eslintrc: { enabled: true } })] }; + `, + "packages/active/.eslintrc-auto-import.json": JSON.stringify({ + globals: { useFeature: "readonly" }, + }), + "packages/active/src/index.ts": "export const active = useFeature();", + "packages/active/src/hooks/use-feature.ts": "export const useFeature = () => true;", + "packages/active/src/types/auto-imports.d.ts": generatedDeclaration, + "packages/stale/package.json": JSON.stringify({ name: "stale" }), + "packages/stale/src/index.ts": "export const stale = useFeature();", + "packages/stale/src/hooks/use-feature.ts": "export const useFeature = () => false;", + "packages/stale/src/types/auto-imports.d.ts": generatedDeclaration, + }, + { + workspaces: ["packages/*"], + devDependencies: { "unplugin-auto-import": "1.0.0" }, + }, + ); + + const result = await analyzeProject({ + rootDirectory, + entryPatterns: ["packages/*/src/index.ts"], + }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "packages/stale/src/hooks/use-feature.ts", + ]); + }); + + it("credits only static Next config loader calls, not unrelated strings", async () => { + const rootDirectory = createProject( + { + "next.config.mjs": ` + import { createJiti } from "jiti"; + const runtimeLoader = createJiti(import.meta.url); + await runtimeLoader.import("./src/runtime-schema.ts"); + const loadModule = (source) => source; + loadModule("./src/orphan.ts"); + export default { env: { UNUSED_SOURCE: "./src/orphan.ts" } }; + `, + "src/index.ts": "export const entry = true;", + "src/runtime-schema.ts": "export const schema = true;", + "src/orphan.ts": "export const orphan = true;", + }, + { dependencies: { jiti: "1.0.0", next: "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + expect(result.unusedExports).not.toContainEqual(expect.objectContaining({ name: "schema" })); + }); + + it("keeps Jiti loader resolution lexical", async () => { + const rootDirectory = createProject( + { + "next.config.mjs": ` + import { createJiti } from "jiti"; + const runtimeLoader = createJiti(import.meta.url); + await runtimeLoader.import("./src/runtime-schema.ts"); + const runOrdinaryLoader = (runtimeLoader) => runtimeLoader("./src/orphan.ts"); + runOrdinaryLoader((source) => source); + export default {}; + `, + "src/index.ts": "export const entry = true;", + "src/runtime-schema.ts": "export const schema = true;", + "src/orphan.ts": "export const orphan = true;", + }, + { dependencies: { jiti: "1.0.0", next: "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + expect(result.unusedExports).not.toContainEqual(expect.objectContaining({ name: "schema" })); + }); + + it("does not report exports when a genuine Jiti target is computed", async () => { + const rootDirectory = createProject( + { + "src/index.ts": ` + import "./schema"; + import { createJiti } from "jiti"; + const runtimeLoader = createJiti(import.meta.url); + runtimeLoader.import(process.env.RUNTIME_MODULE).then((loadedModule) => loadedModule.special); + `, + "src/schema.ts": "export const special = true;", + }, + { dependencies: { jiti: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.unusedExports).toEqual([]); + }); + + it("credits CommonJS Jiti calls in Next config without promoting ordinary helpers", async () => { + const rootDirectory = createProject( + { + "next.config.js": ` + const jiti = require("jiti")(__filename); + const { loadModule } = require("./config-helper"); + jiti("./src/runtime-schema.ts"); + loadModule("./src/ordinary-helper-target.ts"); + module.exports = {}; + `, + "config-helper.js": "exports.loadModule = (value) => value;", + "src/index.ts": "export const entry = true;", + "src/runtime-schema.ts": "export const schema = true;", + "src/ordinary-helper-target.ts": "export const helperTarget = true;", + "src/orphan.ts": "export const orphan = true;", + }, + { dependencies: { jiti: "1.0.0", next: "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/ordinary-helper-target.ts", + "src/orphan.ts", + ]); + }); + + it("discovers the configured React Email preview directory", async () => { + const rootDirectory = createProject( + { + "src/templates/updates/launch.tsx": "export default () => null;", + "src/templates/_components/frame.tsx": "export default () => null;", + "src/orphan.ts": "export const orphan = true;", + }, + { + scripts: { dev: "email dev --dir ./src/templates -p 3333" }, + devDependencies: { "react-email": "1.0.0" }, + }, + ); + + const result = await analyzeProject({ + rootDirectory, + entryPatterns: ["src/templates/**/!(_components)/*.tsx"], + }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/orphan.ts", + "src/templates/_components/frame.tsx", + ]); + }); + + it("discovers a quoted React Email preview directory", async () => { + const rootDirectory = createProject( + { + "email templates/welcome.tsx": "export default () => null;", + "src/orphan.ts": "export const orphan = true;", + }, + { + scripts: { dev: `email dev --dir "email templates"` }, + devDependencies: { "react-email": "1.0.0" }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: [] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("keeps the default React Email preview directory exact", async () => { + const rootDirectory = createProject( + { + "emails/welcome.tsx": "export default () => null;", + "emails/helper.ts": "export const helper = true;", + "email-templates/misspelled.tsx": "export default () => null;", + }, + { + scripts: { dev: "email dev" }, + devDependencies: { "react-email": "1.0.0" }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["emails/**/*.tsx"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "email-templates/misspelled.tsx", + "emails/helper.ts", + ]); + }); + + it("discovers React Email templates through default export aliases", async () => { + const rootDirectory = createProject( + { + "emails/welcome.tsx": ` + const Welcome = () => null; + export { Welcome as default }; + `, + "emails/comment-only.tsx": ` + // export default () => null; + export const helper = true; + `, + }, + { + scripts: { dev: "email dev" }, + devDependencies: { "react-email": "1.0.0" }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: [] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "emails/comment-only.tsx", + ]); + }); + + it("maps sibling workspace imports from missing build output to source", async () => { + const monorepoDirectory = createProject( + { + "packages/library/package.json": JSON.stringify({ + name: "@example/library", + main: "./lib/index.js", + }), + "packages/library/src/index.ts": "export const root = true;", + "packages/library/src/utils/transaction.ts": "export const transaction = true;", + "packages/library/src/orphan.ts": "export const orphan = true;", + "packages/app/package.json": JSON.stringify({ name: "@example/app" }), + "packages/app/src/index.ts": `import { transaction } from "@example/library/lib/utils/transaction"; console.log(transaction);`, + }, + { private: true, workspaces: ["packages/*"] }, + ); + + const libraryDirectory = path.join(monorepoDirectory, "packages/library"); + const result = await analyzeProject({ + rootDirectory: libraryDirectory, + entryPatterns: ["src/index.ts"], + }); + + expect(relativeUnusedPaths(libraryDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("prefers an exact sibling workspace target over a source fallback", async () => { + const monorepoDirectory = createProject( + { + "packages/library/package.json": JSON.stringify({ + name: "@example/library", + exports: { "./lib/foo": "./lib/foo.js" }, + }), + "packages/library/src/index.ts": "export const root = true;", + "packages/library/lib/foo.js": "export const compiled = true;", + "packages/library/src/foo.ts": "export const unrelated = true;", + "packages/app/package.json": JSON.stringify({ name: "@example/app" }), + "packages/app/src/index.ts": 'import "@example/library/lib/foo";', + }, + { private: true, workspaces: ["packages/*"] }, + ); + + const libraryDirectory = path.join(monorepoDirectory, "packages/library"); + const result = await analyzeProject({ + rootDirectory: libraryDirectory, + entryPatterns: ["src/index.ts"], + }); + + expect(relativeUnusedPaths(libraryDirectory, result.unusedFiles)).toEqual(["src/foo.ts"]); + }); + + it("maps workspace Stylelint plugin paths from sibling configs", async () => { + const monorepoDirectory = createProject( + { + "packages/library/package.json": JSON.stringify({ + name: "@example/library", + exports: { + "./stylelint/custom-rule.mjs": "./src/stylelint/custom-rule.mjs", + }, + }), + "packages/library/src/stylelint/custom-rule.mjs": "export default {};", + "packages/library/src/orphan.mjs": "export default {};", + "packages/app/package.json": JSON.stringify({ name: "@example/app" }), + "packages/app/.stylelintrc.mjs": `export default { + description: "@example/library/orphan.mjs", + plugins: ["@example/library/stylelint/custom-rule.mjs"], + };`, + "packages/app/src/unrelated.ts": `export const label = "@example/library/orphan.mjs";`, + }, + { private: true, workspaces: ["packages/*"] }, + ); + + const libraryDirectory = path.join(monorepoDirectory, "packages/library"); + const result = await analyzeProject({ + rootDirectory: libraryDirectory, + entryPatterns: ["src/stylelint/custom-rule.mjs"], + }); + + expect(relativeUnusedPaths(libraryDirectory, result.unusedFiles)).toEqual(["src/orphan.mjs"]); + }); + + it("expands static globby calls in framework entries with their exact cwd and exclusions", async () => { + const rootDirectory = createProject( + { + "src/app/experiments/[...slug]/page.tsx": ` + import { dirname, resolve } from "node:path"; + import { fileURLToPath } from "node:url"; + import { globby } from "globby"; + const currentDirectory = dirname(fileURLToPath(import.meta.url)); + const experimentsRootDirectory = resolve(currentDirectory, ".."); + export const generateStaticParams = async () => globby( + ["**/*.tsx", "!infra/**/*", "!**/page.tsx"], + { cwd: experimentsRootDirectory }, + ); + export default () => null; + `, + "src/app/experiments/menu/basic.tsx": "export default null;", + "src/app/experiments/infra/private.tsx": "export default null;", + "src/app/experiments/unused/page.tsx": "export default null;", + "src/orphan.ts": "export const orphan = true;", + }, + { dependencies: { next: "1.0.0", globby: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/app/experiments/infra/private.tsx", + "src/orphan.ts", + ]); + }); + + it("does not activate globby calls from unreachable modules or unrelated functions", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "console.log('entry');", + "src/dormant-registry.ts": ` + import { globby } from "globby"; + export const modules = globby("./modules/*.ts"); + `, + "src/unrelated.ts": ` + const globby = (pattern: string) => pattern; + globby("./modules/*.ts"); + `, + "src/modules/hidden.ts": "export const hidden = true;", + }, + { dependencies: { globby: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/dormant-registry.ts", + "src/modules/hidden.ts", + "src/unrelated.ts", + ]); + }); + + it("discovers default and configured Netlify function directories only with netlify.toml", async () => { + const defaultRoot = createProject({ + "src/index.ts": "console.log('entry');", + "netlify.toml": "[build]\ncommand = 'build'", + "netlify/functions/notify.ts": "export default () => new Response();", + "functions/dormant.ts": "export default () => new Response();", + }); + const configuredRoot = createProject({ + "src/index.ts": "console.log('entry');", + "netlify.toml": "[functions]\ndirectory = 'server/functions'", + "netlify/functions/dormant.ts": "export default () => new Response();", + "server/functions/notify.ts": "export default () => new Response();", + }); + const unrelatedRoot = createProject({ + "src/index.ts": "console.log('entry');", + "netlify/functions/dormant.ts": "export default () => new Response();", + }); + + const [defaultResult, configuredResult, unrelatedResult] = await Promise.all([ + analyzeProject({ rootDirectory: defaultRoot, entryPatterns: ["src/index.ts"] }), + analyzeProject({ rootDirectory: configuredRoot, entryPatterns: ["src/index.ts"] }), + analyzeProject({ rootDirectory: unrelatedRoot, entryPatterns: ["src/index.ts"] }), + ]); + + expect(relativeUnusedPaths(defaultRoot, defaultResult.unusedFiles)).toEqual([ + "functions/dormant.ts", + ]); + expect(relativeUnusedPaths(configuredRoot, configuredResult.unusedFiles)).toEqual([ + "netlify/functions/dormant.ts", + ]); + expect(relativeUnusedPaths(unrelatedRoot, unrelatedResult.unusedFiles)).toEqual([ + "netlify/functions/dormant.ts", + ]); + }); +}); + +describe("tool-consumed source conventions", () => { + it("keeps MUI docs metadata files only when the docs infrastructure is installed", async () => { + const enabledRoot = createProject( + { + "src/index.ts": "console.log('entry');", + "src/button/ButtonDataAttributes.ts": + "export enum ButtonDataAttributes { pressed = 'data-pressed' }", + "src/button/ButtonCssVars.ts": "export enum ButtonCssVars { width = '--button-width' }", + "src/button/ButtonDormant.ts": "export const dormant = true;", + }, + { devDependencies: { "@mui/internal-docs-infra": "1.0.0" } }, + ); + const disabledRoot = createProject({ + "src/index.ts": "console.log('entry');", + "src/button/ButtonDataAttributes.ts": + "export enum ButtonDataAttributes { pressed = 'data-pressed' }", + }); + + const [enabledResult, disabledResult] = await Promise.all([ + analyzeProject({ rootDirectory: enabledRoot, entryPatterns: ["src/index.ts"] }), + analyzeProject({ rootDirectory: disabledRoot, entryPatterns: ["src/index.ts"] }), + ]); + + expect(relativeUnusedPaths(enabledRoot, enabledResult.unusedFiles)).toEqual([ + "src/button/ButtonDormant.ts", + ]); + expect(relativeUnusedPaths(disabledRoot, disabledResult.unusedFiles)).toEqual([ + "src/button/ButtonDataAttributes.ts", + ]); + }); + + it("keeps the internal bundle-size checker default config only with its package", async () => { + const enabledRoot = createProject( + { + "src/index.ts": "console.log('entry');", + "bundle-size-checker.config.mjs": "export default {};", + "unrelated.config.mjs": "export default {};", + }, + { devDependencies: { "@mui/internal-bundle-size-checker": "1.0.0" } }, + ); + const disabledRoot = createProject({ + "src/index.ts": "console.log('entry');", + "bundle-size-checker.config.mjs": "export default {};", + }); + + const [enabledResult, disabledResult] = await Promise.all([ + analyzeProject({ rootDirectory: enabledRoot, entryPatterns: ["src/index.ts"] }), + analyzeProject({ rootDirectory: disabledRoot, entryPatterns: ["src/index.ts"] }), + ]); + + expect(relativeUnusedPaths(enabledRoot, enabledResult.unusedFiles)).toEqual([ + "unrelated.config.mjs", + ]); + expect(relativeUnusedPaths(disabledRoot, disabledResult.unusedFiles)).toEqual([ + "bundle-size-checker.config.mjs", + ]); + }); + + it("keeps compiler-only public type fixture inputs without widening ordinary packages", async () => { + const rootDirectory = createProject({ + "src/index.ts": "console.log('entry');", + "public-types/package.json": JSON.stringify({ + private: true, + scripts: { test: "tsc --noEmit" }, + }), + "public-types/tsconfig.json": JSON.stringify({ + compilerOptions: { noEmit: true }, + include: ["**/*.ts", "**/*.tsx"], + exclude: ["excluded/**"], + }), + "public-types/use-render.tsx": "export const fixture =
;", + "public-types/excluded/dormant.ts": "export const dormant = true;", + "ordinary/package.json": JSON.stringify({ private: true, scripts: { test: "tsc --noEmit" } }), + "ordinary/tsconfig.json": JSON.stringify({ + compilerOptions: { noEmit: true }, + include: ["**/*.ts"], + }), + "ordinary/dormant.ts": "export const dormant = true;", + }); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "ordinary/dormant.ts", + "public-types/excluded/dormant.ts", + ]); + }); +}); diff --git a/packages/core/tests/project-analysis-svelte-conventions.test.ts b/packages/core/tests/project-analysis-svelte-conventions.test.ts new file mode 100644 index 0000000000..b01823d542 --- /dev/null +++ b/packages/core/tests/project-analysis-svelte-conventions.test.ts @@ -0,0 +1,147 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { analyzeProject } from "../src/project-analysis/analyze-project.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +const createProject = (files: Readonly>): string => { + const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-svelte-analysis-")); + temporaryDirectories.push(rootDirectory); + fs.writeFileSync( + path.join(rootDirectory, "package.json"), + JSON.stringify({ dependencies: { "@sveltejs/kit": "1.0.0" } }), + ); + for (const [relativePath, source] of Object.entries(files)) { + const filePath = path.join(rootDirectory, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, source); + } + return fs.realpathSync(rootDirectory); +}; + +const getUnusedFilePaths = async (rootDirectory: string): Promise => { + const result = await analyzeProject({ rootDirectory }); + return result.unusedFiles.map((unusedFile) => + path.relative(rootDirectory, unusedFile.path).replaceAll("\\", "/"), + ); +}; + +describe("SvelteKit project analysis", () => { + it("treats instance export let declarations as component props", async () => { + const rootDirectory = createProject({ + "src/routes/+page.svelte": ` + + + `, + "src/components/widget.svelte": ` + + +
{label}
+ `, + }); + + const result = await analyzeProject({ rootDirectory }); + + expect( + result.unusedExports.map((unusedExport) => ({ + path: path.relative(rootDirectory, unusedExport.path).replaceAll("\\", "/"), + name: unusedExport.name, + })), + ).toEqual([ + { path: "src/components/widget.svelte", name: "unusedModuleValue" }, + { path: "src/components/widget.svelte", name: "unusedValue" }, + ]); + }); + + it("resolves explicit wildcard aliases", async () => { + const rootDirectory = createProject({ + "svelte.config.js": `export default { kit: { alias: { + $docs: "src/docs", + "$components/*": "src/components/*", + } } };`, + "src/routes/+page.ts": ` + import docs from "$docs/index.js"; + import card from "$components/card.js"; + console.log(docs, card); + `, + "src/docs/index.ts": "export default true;", + "src/docs/orphan.ts": "export default true;", + "src/components/card.ts": "export default true;", + }); + + await expect(getUnusedFilePaths(rootDirectory)).resolves.toEqual(["src/docs/orphan.ts"]); + }); + + it("expands only static import.meta.glob registries", async () => { + const rootDirectory = createProject({ + "src/routes/+page.ts": ` + const previews = import.meta.glob("/src/previews/**/*.svelte"); + function Registry() { return new.target.glob("/src/dormant/**/*.svelte"); } + const scope = "dormant"; + const dynamic = import.meta.glob(\`/src/\${scope}/**/*.svelte\`); + console.log(previews, Registry, dynamic); + `, + "src/previews/button/index.svelte": "
Button
", + "src/dormant/dialog/index.svelte": "
Dialog
", + }); + + await expect(getUnusedFilePaths(rootDirectory)).resolves.toEqual([ + "src/dormant/dialog/index.svelte", + ]); + }); + + it("resolves query-suffixed source imports without broadening URL imports", async () => { + const rootDirectory = createProject({ + "src/routes/+page.ts": ` + import rawType from "$docs/data/long-types/focus-prop.js?raw"; + import "data:text/javascript,export default true?raw"; + console.log(rawType); + `, + "svelte.config.js": `export default { kit: { alias: { + "$docs/*": "src/docs/*", + } } };`, + "src/docs/data/long-types/focus-prop.ts": "export default true;", + "src/docs/data/long-types/orphan.ts": "export default true;", + }); + + await expect(getUnusedFilePaths(rootDirectory)).resolves.toEqual([ + "src/docs/data/long-types/orphan.ts", + ]); + }); +}); + +describe("Astro project analysis", () => { + it("treats exported Props as the component prop contract", async () => { + const rootDirectory = createProject({ + "src/pages/index.ts": `import "../components/card.astro";`, + "src/components/card.astro": `--- +export interface Props { title: string } +export const unusedValue = true; +const { title } = Astro.props; +--- +
{title}
`, + }); + + const result = await analyzeProject({ + rootDirectory, + entryPatterns: ["src/pages/index.ts"], + }); + + expect(result.unusedExports.map((unusedExport) => unusedExport.name)).toEqual(["unusedValue"]); + }); +}); diff --git a/packages/core/tests/project-analysis-unused-file-completeness.test.ts b/packages/core/tests/project-analysis-unused-file-completeness.test.ts new file mode 100644 index 0000000000..141acc0ba6 --- /dev/null +++ b/packages/core/tests/project-analysis-unused-file-completeness.test.ts @@ -0,0 +1,420 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { analyzeProjectForWorker as analyzeProject } from "../src/project-analysis/analyze-project.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +const createProject = ( + files: Readonly>, + packageJson: Readonly> = {}, +): string => { + const rootDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "react-doctor-unused-file-completeness-"), + ); + temporaryDirectories.push(rootDirectory); + fs.writeFileSync(path.join(rootDirectory, "package.json"), JSON.stringify(packageJson)); + for (const [relativePath, source] of Object.entries(files)) { + const filePath = path.join(rootDirectory, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, source); + } + return fs.realpathSync(rootDirectory); +}; + +const unusedFilePaths = ( + rootDirectory: string, + unusedFiles: ReadonlyArray<{ readonly path: string }>, +): string[] => + unusedFiles.map((unusedFile) => + path.relative(rootDirectory, unusedFile.path).replaceAll("\\", "/"), + ); + +describe("unused-file graph completeness", () => { + it("reports an unreachable file in a complete explicit entry graph", async () => { + const rootDirectory = createProject({ + "src/index.ts": 'import "./used";', + "src/used.ts": "export const used = true;", + "src/orphan.ts": "export const orphan = true;", + }); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(unusedFilePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("reports an unreachable file when configured aliases resolve", async () => { + const rootDirectory = createProject({ + "src/index.ts": 'import { value } from "@app/value"; console.log(value);', + "src/lib/value.ts": "export const value = true;", + "src/lib/orphan.ts": "export const orphan = true;", + "tsconfig.json": JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@app/*": ["src/lib/*"] } }, + }), + }); + + const result = await analyzeProject({ + rootDirectory, + entryPatterns: ["src/index.ts"], + tsConfigPath: path.join(rootDirectory, "tsconfig.json"), + }); + + expect(unusedFilePaths(rootDirectory, result.unusedFiles)).toEqual(["src/lib/orphan.ts"]); + }); + + it.each([ + { + name: "default entry heuristic", + files: { + "src/index.ts": "export const entry = true;", + "src/orphan.ts": "export const orphan = true;", + }, + packageJson: {}, + entryPatterns: undefined, + }, + { + name: "parse failure", + files: { + "src/index.ts": "export const entry = true;", + "src/broken.ts": "export const = ;", + "src/orphan.ts": "export const orphan = true;", + }, + packageJson: {}, + entryPatterns: ["src/index.ts"], + }, + { + name: "unresolved local import", + files: { + "src/index.ts": 'import "./missing";', + "src/orphan.ts": "export const orphan = true;", + }, + packageJson: {}, + entryPatterns: ["src/index.ts"], + }, + { + name: "unresolved import alias", + files: { + "src/index.ts": 'import "#feature";', + "src/orphan.ts": "export const orphan = true;", + }, + packageJson: {}, + entryPatterns: ["src/index.ts"], + }, + { + name: "computed dynamic loader", + files: { + "src/index.ts": "export const load = (name: string) => import('./features/' + name);", + "src/orphan.ts": "export const orphan = true;", + }, + packageJson: {}, + entryPatterns: ["src/index.ts"], + }, + { + name: "computed require", + files: { + "src/index.ts": "export const load = (name: string) => require(name);", + "src/orphan.ts": "export const orphan = true;", + }, + packageJson: {}, + entryPatterns: ["src/index.ts"], + }, + { + name: "computed createJiti instance import", + files: { + "src/index.ts": ` + import { createJiti } from "jiti"; + const runtimeLoader = createJiti(import.meta.url); + export const load = (name: string) => runtimeLoader.import(name); + `, + "src/orphan.ts": "export const orphan = true;", + }, + packageJson: { dependencies: { jiti: "1.0.0" } }, + entryPatterns: ["src/index.ts"], + }, + { + name: "computed inline createJiti import", + files: { + "src/index.ts": ` + import { createJiti as makeJiti } from "jiti"; + export const load = (name: string) => makeJiti(import.meta.url).import(name); + `, + "src/orphan.ts": "export const orphan = true;", + }, + packageJson: { dependencies: { jiti: "1.0.0" } }, + entryPatterns: ["src/index.ts"], + }, + { + name: "imported Jiti with a shadowed factory name in another scope", + files: { + "src/index.ts": ` + import { createJiti } from "jiti"; + const runtimeLoader = createJiti(import.meta.url); + const passthrough = (createJiti: unknown) => createJiti; + export const load = (name: string) => runtimeLoader.import(name); + console.log(passthrough); + `, + "src/orphan.ts": "export const orphan = true;", + }, + packageJson: { dependencies: { jiti: "1.0.0" } }, + entryPatterns: ["src/index.ts"], + }, + { + name: "runtime directory enumeration", + files: { + "src/index.ts": + 'import { readdirSync } from "node:fs"; export const files = readdirSync("./features");', + "src/orphan.ts": "export const orphan = true;", + }, + packageJson: {}, + entryPatterns: ["src/index.ts"], + }, + { + name: "partially parsed container", + files: { + "src/index.ts": "export const entry = true;", + "src/component.vue": "", + "src/orphan.ts": "export const orphan = true;", + }, + packageJson: {}, + entryPatterns: ["src/index.ts"], + }, + { + name: "unsupported framework contract", + files: { + "src/index.ts": "export const entry = true;", + "src/orphan.ts": "export const orphan = true;", + }, + packageJson: { dependencies: { electron: "1.0.0" } }, + entryPatterns: ["src/index.ts"], + }, + ])("suppresses findings for $name uncertainty", async ({ files, packageJson, entryPatterns }) => { + const rootDirectory = createProject(files, packageJson); + + const result = await analyzeProject({ rootDirectory, entryPatterns }); + + expect(result.unusedFiles).toEqual([]); + }); + + it.skipIf(process.platform === "win32")( + "suppresses findings when an owning-package source is unreadable", + async () => { + const rootDirectory = createProject({ + "src/index.ts": "export const entry = true;", + "src/unreadable.ts": "export const unreadable = true;", + "src/orphan.ts": "export const orphan = true;", + }); + fs.chmodSync(path.join(rootDirectory, "src/unreadable.ts"), 0); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.unusedFiles).toEqual([]); + }, + ); + + it("does not treat ordinary computed calls as module-loader uncertainty", async () => { + const rootDirectory = createProject( + { + "src/index.ts": ` + import { loadModule } from "runtime-loader"; + const jiti = (value: string) => value; + export const value = [ + calculateValue(process.env.INPUT), + jiti(process.env.INPUT), + loadModule(process.env.INPUT), + ]; + `, + "src/orphan.ts": "export const orphan = true;", + }, + { dependencies: { "runtime-loader": "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(unusedFilePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("links a static CommonJS Jiti module load", async () => { + const rootDirectory = createProject( + { + "src/index.ts": ` + const jiti = require("jiti")(__filename); + export const schema = jiti("./runtime-schema.ts"); + `, + "src/runtime-schema.ts": "export const runtimeSchema = true;", + "src/orphan.ts": "export const orphan = true;", + }, + { dependencies: { jiti: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(unusedFilePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("does not link paths passed to an ordinary imported helper", async () => { + const rootDirectory = createProject({ + "src/index.ts": ` + import { loadModule } from "./ordinary-helper"; + export const value = loadModule("./runtime-schema.ts"); + `, + "src/ordinary-helper.ts": "export const loadModule = (value: string) => value;", + "src/runtime-schema.ts": "export const runtimeSchema = true;", + "src/orphan.ts": "export const orphan = true;", + }); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(unusedFilePaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/orphan.ts", + "src/runtime-schema.ts", + ]); + }); + + it("normalizes Windows separators before recognizing test contracts", async () => { + const rootDirectory = createProject({ + "src/index.ts": "export const entry = true;", + "tests\\fixture.ts": "export const fixture = true;", + "src/orphan.ts": "export const orphan = true;", + }); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.unusedFiles).toEqual([]); + }); + + it("keeps a simple supported Next route graph useful without user entry patterns", async () => { + const rootDirectory = createProject( + { + "pages/index.tsx": "export default function Page() { return null; }", + "src/orphan.ts": "export const orphan = true;", + }, + { + scripts: { dev: "next dev", build: "next build", start: "next start" }, + dependencies: { next: "1.0.0", react: "1.0.0" }, + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(unusedFilePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("recognizes the Create React App development proxy entry", async () => { + const rootDirectory = createProject( + { + "src/index.tsx": "export const application = true;", + "src/setupProxy.js": "module.exports = (application) => application;", + "src/orphan.ts": "export const orphan = true;", + }, + { + scripts: { start: "react-scripts start", build: "react-scripts build" }, + dependencies: { "react-scripts": "1.0.0", react: "1.0.0" }, + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(unusedFilePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it.each([ + { + name: "unsupported package script", + files: {}, + packageJsonExtension: { scripts: { dev: "next dev", generate: "node generate.js" } }, + }, + { + name: "custom Next application directory", + files: {}, + packageJsonExtension: { scripts: { dev: "next dev custom-app" } }, + }, + { + name: "unsupported build config", + files: { "next.config.js": "module.exports = {};" }, + packageJsonExtension: { scripts: { dev: "next dev" } }, + }, + { + name: "wildcard package side effects", + files: {}, + packageJsonExtension: { scripts: { dev: "next dev" }, sideEffects: ["src/*.ts"] }, + }, + { + name: "unparseable compiler config", + files: { "tsconfig.json": "{" }, + packageJsonExtension: { scripts: { dev: "next dev" } }, + }, + ])( + "suppresses automatic roots for $name uncertainty", + async ({ files, packageJsonExtension }) => { + const rootDirectory = createProject( + { + "pages/index.tsx": "export default function Page() { return null; }", + "src/orphan.ts": "export const orphan = true;", + ...files, + }, + { + ...packageJsonExtension, + dependencies: { next: "1.0.0", react: "1.0.0" }, + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(result.unusedFiles).toEqual([]); + }, + ); + + it("isolates uncertainty to its owning workspace package", async () => { + const rootDirectory = createProject( + { + "packages/complete/package.json": JSON.stringify({ name: "complete" }), + "packages/complete/src/index.ts": "export const entry = true;", + "packages/complete/src/orphan.ts": "export const orphan = true;", + "packages/uncertain/package.json": JSON.stringify({ name: "uncertain" }), + "packages/uncertain/src/index.ts": + "export const load = (name: string) => import('./features/' + name);", + "packages/uncertain/src/orphan.ts": "export const orphan = true;", + }, + { private: true, workspaces: ["packages/*"] }, + ); + + const result = await analyzeProject({ + rootDirectory, + entryPatterns: ["packages/*/src/index.ts"], + }); + + expect(unusedFilePaths(rootDirectory, result.unusedFiles)).toEqual([ + "packages/complete/src/orphan.ts", + ]); + }); + + it("applies root package contract uncertainty to nested automatic roots", async () => { + const rootDirectory = createProject( + { + "packages/application/package.json": JSON.stringify({ + name: "application", + scripts: { dev: "next dev" }, + dependencies: { next: "1.0.0", react: "1.0.0" }, + }), + "packages/application/pages/index.tsx": "export default function Page() { return null; }", + "packages/application/src/orphan.ts": "export const orphan = true;", + }, + { + private: true, + workspaces: ["packages/*"], + scripts: { generate: "node generate.js" }, + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(result.unusedFiles).toEqual([]); + }); +}); diff --git a/packages/core/tests/project-analysis-webpack-context.test.ts b/packages/core/tests/project-analysis-webpack-context.test.ts new file mode 100644 index 0000000000..aeaaf491a3 --- /dev/null +++ b/packages/core/tests/project-analysis-webpack-context.test.ts @@ -0,0 +1,197 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { analyzeProject } from "../src/project-analysis/analyze-project.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +const createProject = ( + files: Readonly>, + packageJson: Readonly> = {}, +): string => { + const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-webpack-context-")); + temporaryDirectories.push(rootDirectory); + fs.writeFileSync(path.join(rootDirectory, "package.json"), JSON.stringify(packageJson)); + for (const [relativePath, source] of Object.entries(files)) { + const filePath = path.join(rootDirectory, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, source); + } + return fs.realpathSync(rootDirectory); +}; + +const relativeUnusedPaths = ( + rootDirectory: string, + unusedFiles: ReadonlyArray<{ readonly path: string }>, +): string[] => + unusedFiles.map((unusedFile) => + path.relative(rootDirectory, unusedFile.path).replaceAll("\\", "/"), + ); + +describe("legacy Webpack registries", () => { + it("resolves Vite alias-array entries", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": ` + import path from "node:path"; + export default { + resolve: { + alias: [{ find: "@", replacement: path.resolve(__dirname, "src") }], + }, + }; + `, + "src/index.ts": 'import { value } from "@/lib"; console.log(value);', + "src/lib.ts": "export const value = 1; export const unused = 2;", + }, + { devDependencies: { vite: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).not.toContain("src/lib.ts"); + expect(result.unusedExports.map((unusedExport) => unusedExport.name)).toEqual(["unused"]); + }); + + it("resolves aliases declared through local path helpers in build configs", async () => { + const rootDirectory = createProject( + { + "build/webpack.base.conf.js": ` + const path = require("path"); + require("../config/settings"); + function resolve(directory) { return path.join(__dirname, "..", directory); } + module.exports = { + entry: { app: "./src/index.jsx" }, + resolve: { alias: { "@": resolve("src") } }, + }; + `, + "src/index.jsx": `import routes from "./routes"; console.log(routes);`, + "src/routes.jsx": `import Home from "@/views/Home"; export default Home;`, + "src/views/Home.jsx": `import Card from "@/components/Card"; export default Card;`, + "src/components/Card.jsx": "export default null;", + "config/settings.js": "module.exports = {};", + "src/orphan.jsx": "export default null;", + }, + { devDependencies: { webpack: "2.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.jsx"]); + }); + + it("discovers Webpack entries assigned by invoked Gulp builds", async () => { + const rootDirectory = createProject( + { + "gulpfile.js": ` + const webpack = require("webpack"); + const config = require("./webpack.config"); + config.entry = { app: "./js/index.jsx" }; + gulp.task("build", () => webpack(config)); + `, + "webpack.config.js": "module.exports = {};", + "js/index.jsx": `import Controller from "./controller"; export default Controller;`, + "js/controller.jsx": "export default null;", + "js/customize-preview.js": "export const preview = true;", + "functions.php": `wp_enqueue_script( + "theme-preview", + get_template_directory_uri() . "/js/customize-preview.js", + array("jquery") + );`, + "js/orphan.jsx": "export default null;", + }, + { + scripts: { build: "NODE_ENV=production gulp" }, + devDependencies: { gulp: "3.0.0", webpack: "1.0.0" }, + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual(["js/orphan.jsx"]); + }); + + it("resolves ancestor modulesDirectories and applies the exact require.context filter", async () => { + const rootDirectory = createProject( + { + "webpack/prod/webpack.config.js": ` + const commonResolve = { modulesDirectories: ["shared", "node_modules"] }; + module.exports = [ + { entry: { app: ["./lib/client/app.js"] }, resolve: commonResolve }, + ]; + `, + "lib/client/app.js": 'import elements from "elements"; console.log(elements);', + "lib/shared/elements/index.js": ` + const context = require.context(".", true, /^\\.\\/[a-z\\-]+?\\/index\\.(js|jsx)$/); + export default context.keys(); + `, + "lib/shared/elements/text-box/index.jsx": 'import "./detail"; export default null;', + "lib/shared/elements/text-box/detail.js": "export const detail = true;", + "lib/shared/elements/custom-card/index.js": "export default null;", + "lib/shared/elements/not-matched/index.ts": "export default null;", + "lib/shared/elements/nested/card/index.js": "export default null;", + "shared/elements/index.js": "export default 'wrong root';", + }, + { devDependencies: { webpack: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "lib/shared/elements/nested/card/index.js", + "lib/shared/elements/not-matched/index.ts", + "shared/elements/index.js", + ]); + }); + + it("honors non-recursive contexts without widening their regex", async () => { + const rootDirectory = createProject({ + "src/index.js": `require.context("./items", false, /^\\.\\/[^/]+\\.js$/);`, + "src/items/direct.js": "export default null;", + "src/items/direct.ts": "export default null;", + "src/items/nested/child.js": "export default null;", + }); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.js"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/items/direct.ts", + "src/items/nested/child.js", + ]); + }); + + it("uses Webpack defaults when recursive and regex arguments are omitted", async () => { + const rootDirectory = createProject({ + "src/index.js": `require.context("./items");`, + "src/items/direct.js": "export default null;", + "src/items/nested/child.js": "export default null;", + "src/orphan.js": "export default null;", + }); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.js"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.js"]); + }); + + it("does not expand require.context calls with dynamic traversal arguments", async () => { + const rootDirectory = createProject({ + "src/index.js": ` + const isRecursive = true; + require.context("./items", isRecursive, /^\\.\\/[^/]+\\.js$/); + `, + "src/items/dormant.js": "export default null;", + }); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.js"] }); + + expect(relativeUnusedPaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/items/dormant.js", + ]); + }); +}); diff --git a/packages/core/tests/project-analysis-worker-slots.test.ts b/packages/core/tests/project-analysis-worker-slots.test.ts new file mode 100644 index 0000000000..4e581193bf --- /dev/null +++ b/packages/core/tests/project-analysis-worker-slots.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vite-plus/test"; +import { withProjectAnalysisWorkerSlot } from "../src/project-analysis/project-analysis-worker-slots.js"; +import { resolveProjectAnalysisConcurrency } from "../src/utils/resolve-project-analysis-concurrency.js"; + +interface Deferred { + readonly promise: Promise; + readonly resolve: () => void; +} + +const createDeferred = (): Deferred => { + let resolvePromise = (): void => {}; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: resolvePromise }; +}; + +const flushTasks = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +describe("withProjectAnalysisWorkerSlot", () => { + it("bounds concurrent project analysis workers", async () => { + const concurrency = resolveProjectAnalysisConcurrency(); + const releases = Array.from({ length: concurrency + 1 }, createDeferred); + let runningTaskCount = 0; + let peakRunningTaskCount = 0; + const tasks = releases.map((release) => + withProjectAnalysisWorkerSlot(async () => { + runningTaskCount += 1; + peakRunningTaskCount = Math.max(peakRunningTaskCount, runningTaskCount); + await release.promise; + runningTaskCount -= 1; + }), + ); + + await flushTasks(); + expect(peakRunningTaskCount).toBe(concurrency); + releases.forEach((release) => release.resolve()); + await Promise.all(tasks); + }); + + it("releases a slot after failure", async () => { + await expect( + withProjectAnalysisWorkerSlot(async () => { + throw new Error("failed"); + }), + ).rejects.toThrow("failed"); + await expect(withProjectAnalysisWorkerSlot(async () => "after")).resolves.toBe("after"); + }); + + it("rejects an aborted caller without running its task", async () => { + let didRunTask = false; + await expect( + withProjectAnalysisWorkerSlot(async () => { + didRunTask = true; + }, AbortSignal.abort()), + ).rejects.toThrow("cancelled"); + expect(didRunTask).toBe(false); + }); + + it("removes a caller cancelled while waiting without leaking a slot", async () => { + const concurrency = resolveProjectAnalysisConcurrency(); + const heldRelease = createDeferred(); + const heldTasks = Array.from({ length: concurrency }, () => + withProjectAnalysisWorkerSlot(() => heldRelease.promise), + ); + await flushTasks(); + + const abortController = new AbortController(); + let didRunCancelledTask = false; + const cancelledTask = withProjectAnalysisWorkerSlot(async () => { + didRunCancelledTask = true; + }, abortController.signal); + abortController.abort(); + + await expect(cancelledTask).rejects.toThrow("cancelled"); + expect(didRunCancelledTask).toBe(false); + heldRelease.resolve(); + await Promise.all(heldTasks); + await expect(withProjectAnalysisWorkerSlot(async () => "after")).resolves.toBe("after"); + }); +}); diff --git a/packages/core/tests/project-analysis.test.ts b/packages/core/tests/project-analysis.test.ts new file mode 100644 index 0000000000..9098db0dea --- /dev/null +++ b/packages/core/tests/project-analysis.test.ts @@ -0,0 +1,3207 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { execFileSync } from "node:child_process"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { analyzeProject } from "../src/project-analysis/analyze-project.js"; +import { MINIFIED_DETECTION_MIN_BYTES } from "../src/project-analysis/constants.js"; +import { isProjectAnalysisExcludedPath } from "../src/project-analysis/utils/is-project-analysis-excluded-path.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +const createProject = ( + files: Readonly>, + packageJson: Readonly>, +): string => { + const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-project-analysis-")); + temporaryDirectories.push(rootDirectory); + fs.writeFileSync(path.join(rootDirectory, "package.json"), JSON.stringify(packageJson)); + for (const [relativePath, source] of Object.entries(files)) { + const filePath = path.join(rootDirectory, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, source); + } + const dependencies = { + ...(typeof packageJson.dependencies === "object" && packageJson.dependencies !== null + ? packageJson.dependencies + : {}), + ...(typeof packageJson.devDependencies === "object" && packageJson.devDependencies !== null + ? packageJson.devDependencies + : {}), + }; + if ( + !("package-lock.json" in files) && + !Object.keys(files).some((relativePath) => relativePath.startsWith("node_modules/")) + ) { + fs.writeFileSync( + path.join(rootDirectory, "package-lock.json"), + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { + dependencies: packageJson.dependencies, + devDependencies: packageJson.devDependencies, + }, + ...Object.fromEntries( + Object.entries(dependencies).map(([dependencyName, version]) => [ + `node_modules/${dependencyName}`, + { version }, + ]), + ), + }, + }), + ); + } + return fs.realpathSync(rootDirectory); +}; + +const relativePath = (rootDirectory: string, filePath: string): string => + path.relative(rootDirectory, filePath).replaceAll("\\", "/"); + +const relativePaths = ( + rootDirectory: string, + findings: ReadonlyArray<{ readonly path: string }>, +): string[] => findings.map((finding) => relativePath(rootDirectory, finding.path)); + +describe("analyzeProject", () => { + it("does not inherit generated or vendor ownership from checkout ancestors", () => { + const projectRoot = path.join(os.tmpdir(), "vendor", "generated", "application"); + + expect( + isProjectAnalysisExcludedPath(path.join(projectRoot, "src", "index.ts"), projectRoot), + ).toBe(false); + expect( + isProjectAnalysisExcludedPath( + path.join(projectRoot, "src", "vendor", "library.ts"), + projectRoot, + ), + ).toBe(true); + }); + + it("reports all six graph diagnostics, including unused type exports", async () => { + const rootDirectory = createProject( + { + "src/index.ts": ` + import usedPackage from "used-package"; + import { liveValue } from "./library"; + import { cycleA } from "./cycle-a"; + console.log(usedPackage, liveValue, cycleA); + `, + "src/library.ts": ` + export const liveValue = 1; + export const unusedValue = 2; + export interface UnusedShape { value: string } + `, + "src/orphan.ts": "export const orphan = true;", + "src/cycle-a.ts": ` + import { cycleB } from "./cycle-b"; + export const cycleA = cycleB + 1; + `, + "src/cycle-b.ts": ` + import { cycleA } from "./cycle-a"; + export const cycleB = cycleA + 1; + `, + }, + { + dependencies: { "used-package": "1.0.0", "unused-package": "1.0.0" }, + devDependencies: { "unused-dev-package": "1.0.0" }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain("src/orphan.ts"); + expect(result.unusedExports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "unusedValue", isTypeOnly: false }), + expect.objectContaining({ name: "UnusedShape", isTypeOnly: true }), + ]), + ); + expect(result.unusedDependencies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "unused-package", isDevDependency: false }), + expect.objectContaining({ name: "unused-dev-package", isDevDependency: true }), + ]), + ); + expect(result.circularDependencies).toEqual([ + expect.objectContaining({ + files: expect.any(Array), + }), + ]); + expect( + result.circularDependencies[0]?.files.map((filePath) => + relativePath(rootDirectory, filePath), + ), + ).toEqual(expect.arrayContaining(["src/cycle-a.ts", "src/cycle-b.ts"])); + }); + + it("keeps exported types referenced by other exported type declarations", async () => { + const rootDirectory = createProject( + { + "src/index.ts": + 'import type { PublicShape } from "./library"; console.log({} as PublicShape);', + "src/library.ts": ` + export interface SharedShape { value: string } + export type PublicShape = SharedShape & { enabled: boolean }; + export interface ExtendedShape extends SharedShape { count: number } + export type UnusedShape = { stale: boolean }; + `, + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedExportNames = result.unusedExports.map((unusedExport) => unusedExport.name); + + expect(unusedExportNames).not.toContain("SharedShape"); + expect(unusedExportNames).not.toContain("PublicShape"); + expect(unusedExportNames).toContain("ExtendedShape"); + expect(unusedExportNames).toContain("UnusedShape"); + }); + + it("tracks TypeScript import type queries as type-only graph edges", async () => { + const rootDirectory = createProject( + { + "src/index.ts": ` + export type PublicShape = import("./library").LibraryShape; + export type PublicNamespace = typeof import("./namespace"); + `, + "src/library.ts": ` + export interface LibraryShape { value: string } + export interface UnusedShape { stale: boolean } + `, + "src/namespace.ts": "export interface NamespaceShape { value: string }", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedFilePaths = relativePaths(rootDirectory, result.unusedFiles); + const unusedExportNames = result.unusedExports.map((unusedExport) => unusedExport.name); + + expect(unusedFilePaths).not.toContain("src/library.ts"); + expect(unusedFilePaths).not.toContain("src/namespace.ts"); + expect(unusedExportNames).not.toContain("LibraryShape"); + expect(unusedExportNames).not.toContain("NamespaceShape"); + expect(unusedExportNames).toContain("UnusedShape"); + expect(result.circularDependencies).toEqual([]); + }); + + it("tracks namespace members without treating type-only edges as runtime cycles", async () => { + const rootDirectory = createProject( + { + "src/index.ts": ` + import * as library from "./library"; + import type { TypeA } from "./type-a"; + const value: TypeA | undefined = undefined; + console.log(library.used, value); + `, + "src/library.ts": ` + export const used = 1; + export const unused = 2; + `, + "src/type-a.ts": ` + import type { TypeB } from "./type-b"; + export interface TypeA { child: TypeB } + `, + "src/type-b.ts": ` + import type { TypeA } from "./type-a"; + export interface TypeB { parent: TypeA } + `, + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.unusedExports).toEqual([ + expect.objectContaining({ name: "unused", isTypeOnly: false }), + ]); + expect(result.circularDependencies).toEqual([]); + }); + + it("does not treat type-only re-exports as runtime cycle edges", async () => { + const rootDirectory = createProject( + { + "src/index.ts": ` + export const registry = 1; + export type { Handler } from "./handler"; + export { type HandlerOptions } from "./handler"; + `, + "src/handler.ts": ` + import { registry } from "./index"; + export interface Handler { registry: typeof registry } + export interface HandlerOptions { enabled: boolean } + export const registeredValue = registry; + `, + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.circularDependencies).toEqual([]); + }); + + it("keeps a mixed value and type re-export edge in runtime cycles", async () => { + const rootDirectory = createProject( + { + "src/index.ts": ` + export const registry = 1; + export { registeredValue, type Handler } from "./handler"; + `, + "src/handler.ts": ` + import { registry } from "./index"; + export interface Handler { registry: typeof registry } + export const registeredValue = registry; + `, + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.circularDependencies).toHaveLength(1); + }); + + it("reports cycles formed by side-effect imports", async () => { + const rootDirectory = createProject( + { + "src/index.ts": 'import "./cycle-a";', + "src/cycle-a.ts": 'import "./cycle-b";', + "src/cycle-b.ts": 'import "./cycle-a";', + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.circularDependencies).toEqual([ + expect.objectContaining({ + files: expect.any(Array), + }), + ]); + expect( + result.circularDependencies[0]?.files.map((filePath) => + relativePath(rootDirectory, filePath), + ), + ).toEqual(expect.arrayContaining(["src/cycle-a.ts", "src/cycle-b.ts"])); + }); + + it("links named, default, namespace, dynamic, and chained re-exports", async () => { + const rootDirectory = createProject( + { + "src/index.ts": ` + import defaultValue, { namedValue } from "./barrel"; + import * as namespace from "./namespace"; + void import("./dynamic").then((module) => module.dynamicValue); + console.log(defaultValue, namedValue, namespace.usedValue); + `, + "src/barrel.ts": ` + export { default, namedValue, unusedValue } from "./leaf"; + `, + "src/leaf.ts": ` + export default 1; + export const namedValue = 2; + export const unusedValue = 3; + `, + "src/namespace.ts": ` + export const usedValue = 1; + export const unusedNamespaceValue = 2; + `, + "src/dynamic.ts": ` + export const dynamicValue = 1; + `, + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedFilePaths = relativePaths(rootDirectory, result.unusedFiles); + const unusedExportNames = result.unusedExports.map((unusedExport) => unusedExport.name); + + expect(unusedFilePaths).not.toEqual( + expect.arrayContaining([ + "src/barrel.ts", + "src/leaf.ts", + "src/namespace.ts", + "src/dynamic.ts", + ]), + ); + expect(unusedExportNames).toEqual( + expect.arrayContaining(["unusedValue", "unusedNamespaceValue"]), + ); + expect(unusedExportNames).not.toEqual( + expect.arrayContaining(["default", "namedValue", "usedValue", "dynamicValue"]), + ); + }); + + it("resolves tsconfig path aliases", async () => { + const rootDirectory = createProject( + { + "src/index.ts": ` + import { aliasedValue } from "@app/value"; + console.log(aliasedValue); + `, + "src/lib/value.ts": "export const aliasedValue = 1;", + "src/lib/orphan.ts": "export const orphan = 1;", + "tsconfig.json": JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@app/*": ["src/lib/*"] } }, + }), + }, + {}, + ); + + const result = await analyzeProject({ + rootDirectory, + entryPatterns: ["src/index.ts"], + tsConfigPath: path.join(rootDirectory, "tsconfig.json"), + }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain("src/lib/orphan.ts"); + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain("src/lib/value.ts"); + }); + + it("parses JSONC tsconfig build directories without rewriting string contents", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "export const publicValue = 1;", + "src/orphan.ts": "export const orphan = 1;", + "tsconfig.json": `{ + // Build output maps back to authored sources. + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "sourceRoot": "https://example.com/source", + }, + }`, + }, + { main: "dist/index.js" }, + ); + + const result = await analyzeProject({ rootDirectory }); + const unusedFilePaths = relativePaths(rootDirectory, result.unusedFiles); + + expect(unusedFilePaths).not.toContain("src/index.ts"); + expect(unusedFilePaths).toContain("src/orphan.ts"); + }); + + it("discovers config and test entries without explicit patterns", async () => { + const rootDirectory = createProject( + { + "src/main.ts": "console.log('app');", + "src/vite-plugin.ts": "export const plugin = {};", + "src/test-helper.ts": "export const helper = 1;", + "src/orphan.ts": "export const orphan = 1;", + "vite.config.ts": ` + import { plugin } from "./src/vite-plugin"; + export default { plugins: [plugin] }; + `, + "tests/app.test.ts": ` + import { helper } from "../src/test-helper"; + console.log(helper); + `, + }, + { devDependencies: { vite: "1.0.0", vitest: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + const unusedFilePaths = relativePaths(rootDirectory, result.unusedFiles); + + expect(unusedFilePaths).toContain("src/orphan.ts"); + expect(unusedFilePaths).not.toEqual( + expect.arrayContaining(["src/vite-plugin.ts", "src/test-helper.ts"]), + ); + }); + + it("discovers framework route entries", async () => { + const rootDirectory = createProject( + { + "src/pages/index.tsx": ` + import { pageValue } from "../page-value"; + export default () =>
{pageValue}
; + `, + "src/page-value.ts": "export const pageValue = 1;", + "src/orphan.ts": "export const orphan = 1;", + }, + { dependencies: { next: "1.0.0", react: "1.0.0", "react-dom": "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + const unusedFilePaths = relativePaths(rootDirectory, result.unusedFiles); + + expect(unusedFilePaths).toContain("src/orphan.ts"); + expect(unusedFilePaths).not.toContain("src/page-value.ts"); + }); + + it.each([ + { frameworkDependency: "umi", routeConfigPath: "config/routes.ts" }, + { frameworkDependency: "@umijs/max", routeConfigPath: "config/routes.simple.ts" }, + ])( + "discovers $frameworkDependency application and route convention entries", + async ({ frameworkDependency, routeConfigPath }) => { + const rootDirectory = createProject( + { + "config/config.ts": `export default { title: "application" };`, + "config/config.dev.ts": `import { configValue } from "../src/config-value"; export default { configValue };`, + [routeConfigPath]: `import { routeValue } from "../src/route-value"; export default [{ path: "/", component: routeValue }];`, + "src/app.tsx": `import { appValue } from "./app-value"; export const render = () => appValue;`, + "src/app-value.ts": "export const appValue = 1;", + "src/config-value.ts": "export const configValue = 1;", + "src/locales/en-US.ts": `import { localeValue } from "../locale-value"; export default localeValue;`, + "src/locale-value.ts": "export const localeValue = { title: 'application' };", + "src/pages/index.tsx": `import { pageValue } from "../page-value"; export default () =>
{pageValue}
;`, + "src/page-value.ts": "export const pageValue = 1;", + "src/route-value.ts": "export const routeValue = '@/pages/index';", + "src/orphan.ts": "export const orphan = 1;", + }, + { dependencies: { [frameworkDependency]: "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }, + ); + + it("discovers Umi runtime, mock, and enabled DVA model conventions", async () => { + const rootDirectory = createProject( + { + "config/config.ts": "export default { dva: { hmr: true } };", + "src/global.tsx": 'import "./global-value";', + "src/global-value.ts": "export const globalValue = true;", + "src/loading.tsx": "export default () => null;", + "mock/users.ts": 'import "../src/mock-value"; export default {};', + "src/mock-value.ts": "export const mockValue = true;", + "src/models/session.ts": 'import "../model-value"; export default {};', + "src/model-value.ts": "export const modelValue = true;", + "src/orphan.ts": "export const orphan = true;", + }, + { dependencies: { react: "1.0.0", umi: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("keeps Umi models authored when DVA is only mentioned in comments", async () => { + const rootDirectory = createProject( + { + "config/config.ts": "// dva: { hmr: true }\nexport default {};", + "src/pages/index.tsx": "export default () => null;", + "src/models/manual.ts": "export const manual = true;", + }, + { dependencies: { react: "1.0.0", umi: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain("src/models/manual.ts"); + }); + + it("resolves relative Umi route and loading component contracts", async () => { + const rootDirectory = createProject( + { + "config/config.js": `export default { + dynamicImport: { loadingComponent: "./components/PageLoading" }, + };`, + "config/router.config.js": `export default [ + { component: "../layouts/BasicLayout" }, + { component: "./Dashboard/Home" }, + ];`, + "src/components/PageLoading.tsx": "export default () => null;", + "src/layouts/BasicLayout.tsx": "export default () => null;", + "src/pages/Dashboard/Home.tsx": "export default () => null;", + "src/orphan.ts": "export const orphan = true;", + }, + { dependencies: { react: "1.0.0", umi: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("discovers gulpfiles only when package scripts invoke Gulp", async () => { + const invokedRootDirectory = createProject( + { + "src/index.ts": "export const application = true;", + "gulpfile.js": 'import "./tasks/build";', + "tasks/build.js": "export const build = true;", + }, + { scripts: { build: "gulp compile" }, devDependencies: { gulp: "1.0.0" } }, + ); + const dormantRootDirectory = createProject( + { + "src/index.ts": "export const application = true;", + "gulpfile.js": "export const dormant = true;", + }, + { devDependencies: { gulp: "1.0.0" } }, + ); + + const invokedResult = await analyzeProject({ rootDirectory: invokedRootDirectory }); + const dormantResult = await analyzeProject({ rootDirectory: dormantRootDirectory }); + + expect(relativePaths(invokedRootDirectory, invokedResult.unusedFiles)).toEqual([]); + expect(relativePaths(dormantRootDirectory, dormantResult.unusedFiles)).toContain("gulpfile.js"); + }); + + it("discovers entry points from nested Angular project configuration", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "export const application = true;", + "examples/angular/angular.json": JSON.stringify({ + projects: { + demo: { + architect: { + build: { options: { main: "src/main.ts" } }, + }, + }, + }, + }), + "examples/angular/src/main.ts": 'import "./app";', + "examples/angular/src/app.ts": "export const app = true;", + "examples/angular/src/orphan.ts": "export const orphan = true;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual([ + "examples/angular/src/orphan.ts", + ]); + }); + + it("discovers Jasmine spec and fixture conventions", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "export const application = true;", + "spec/component-spec.jsx": 'import fixture from "./fixtures/user"; console.log(fixture);', + "spec/fixtures/user.js": "export default { name: 'Ada' };", + "src/manual-spec.jsx": "export const manual = true;", + "src/orphan.jsx": "export const orphan = true;", + }, + { devDependencies: { "jasmine-tagged": "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.jsx"]); + }); + + it("discovers static entries from CoffeeScript interpolated require factories", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "export const application = true;", + "src/registry.coffee": ` + class Registry + @load = (name, modulePath) -> + require "../components/#{modulePath}" + @load "Menu", "menu" + # @load "Dormant", "dormant" + # require "../components/dormant" + active = true # require "../components/dormant" + require "../components/static" + `, + "components/menu.jsx": 'import "./menu-value"; export default () => null;', + "components/menu-value.ts": "export const menuValue = true;", + "components/static.jsx": "export default () => null;", + "components/dormant.jsx": "export default () => null;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["components/dormant.jsx"]); + }); + + it("discovers unique extensionless filename registry arguments", async () => { + const rootDirectory = createProject( + { + "src/index.ts": ` + registerStore("FeatureUsageStore", "feature-usage-store"); + registerStore("AmbiguousStore", "ambiguous-store"); + `, + "src/stores/feature-usage-store.ts": "export const store = true;", + "src/first/ambiguous-store.ts": "export const first = true;", + "src/second/ambiguous-store.ts": "export const second = true;", + "src/unrelated-store.ts": "export const unrelated = true;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/first/ambiguous-store.ts", + "src/second/ambiguous-store.ts", + "src/unrelated-store.ts", + ]); + }); + + it("discovers imports from extensionless package script executables", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "export const application = true;", + "script/task": `#!/usr/bin/env node\nconst helper = require("./helper"); helper();`, + "script/helper.js": "module.exports = () => true;", + "script/orphan.js": "module.exports = () => false;", + }, + { scripts: { build: "script/task build" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["script/orphan.js"]); + }); + + it("discovers renderer scripts from Electron static index HTML", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "export const main = true;", + "static/index.html": '', + "static/index.js": "export const renderer = true;", + "static/orphan.js": "export const orphan = true;", + }, + { dependencies: { electron: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["static/orphan.js"]); + }); + + it("discovers source directories consumed through runtime enumeration and copying", async () => { + const rootDirectory = createProject( + { + "src/index.ts": ` + const extensionsPath = path.join(rootDirectory, "src", "extensions"); + fs.readdirSync(extensionsPath).forEach(filePath => require(filePath)); + const templatePath = path.join(rootDirectory, "static", "template"); + fs.copySync(templatePath, outputPath); + const cwdAssetsPath = path.resolve(process.cwd(), "src", "cwd-assets"); + fs.readdirSync(cwdAssetsPath); + const dormantPath = path.join(rootDirectory, "src", "dormant"); + console.log(dormantPath); + const uncertainPath = path.join(options.directory, "src", "uncertain"); + fs.readdirSync(uncertainPath); + `, + "src/extensions/logger.ts": "export default class Logger {}", + "static/template/main.ts": "export const template = true;", + "src/cwd-assets/main.ts": "export const cwdAsset = true;", + "src/dormant/manual.ts": "export const manual = true;", + "src/uncertain/manual.ts": "export const uncertain = true;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/dormant/manual.ts", + "src/uncertain/manual.ts", + ]); + }); + + it("discovers Taro application, config, and page convention entries", async () => { + const rootDirectory = createProject( + { + "config/index.ts": `import developmentConfig from "./dev"; export default developmentConfig;`, + "config/dev.ts": "export default { env: 'development' };", + "src/app.tsx": `import { appValue } from "./app-value"; export default () => appValue;`, + "src/app.config.ts": ` + export default defineAppConfig({ + pages: ["pages/home/index"], + subPackages: [{ root: "package-a", pages: ["profile/index"] }], + }); + `, + "src/app-value.ts": "export const appValue = 1;", + "src/pages/home/index.tsx": `import { pageValue } from "../../lib/page-value"; export default () =>
{pageValue}
;`, + "src/pages/home/old-unused.ts": "export const oldUnused = 1;", + "src/package-a/profile/index.tsx": "export default () =>
Profile
;", + "src/package-a/profile/old-unused.ts": "export const oldUnused = 1;", + "src/lib/page-value.ts": "export const pageValue = 1;", + "src/orphan.ts": "export const orphan = 1;", + }, + { + dependencies: { + "@tarojs/cli": "1.0.0", + "@tarojs/react": "1.0.0", + "@tarojs/runtime": "1.0.0", + react: "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/orphan.ts", + "src/package-a/profile/old-unused.ts", + "src/pages/home/old-unused.ts", + ]); + }); + + it("discovers Taro pages from identifier-bound application config", async () => { + const rootDirectory = createProject( + { + "src/app.tsx": "export default () => null;", + "src/app.config.ts": ` + const appConfig = defineAppConfig({ pages: ["pages/home/index"] }); + export default appConfig; + `, + "src/pages/home/index.tsx": "export default () => null;", + "src/orphan.ts": "export const orphan = 1;", + }, + { dependencies: { "@tarojs/react": "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("discovers Taro pages through conditional array calls and pushed subpackages", async () => { + const rootDirectory = createProject( + { + "src/app.tsx": "export default () => null;", + "src/app.config.ts": ` + const pages = ["pages/home/index", "pages/about/index"]; + const subpackages = [{ root: "package-a", pages: ["profile/index"] }]; + if (process.env.TARO_ENV === "rn") { + subpackages.push({ root: "package-b", pages: ["settings/index"] }); + } + export default { + pages: process.env.TARO_ENV === "rn" ? pages : pages.splice(1), + subpackages, + }; + `, + "src/pages/home/index.tsx": "export default () => null;", + "src/pages/about/index.tsx": "export default () => null;", + "src/package-a/profile/index.tsx": "export default () => null;", + "src/package-b/settings/index.tsx": "export default () => null;", + "src/orphan.ts": "export const orphan = 1;", + }, + { dependencies: { "@tarojs/react": "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("ignores commented and unrelated route-shaped strings in Taro config", async () => { + const rootDirectory = createProject( + { + "src/app.tsx": "export default () => null;", + "src/app.config.ts": ` + const pages = ["pages/home/index"]; + if (process.env.TARO_ENV === "rn") { + pages.push("pages/native/index"); + } + // pages.push("pages/commented/index"); + const metadata = { + preview: "pages/metadata/index", + examples: ["pages/examples/index"], + }; + export default { + pages, + metadata, + tabBar: { list: [{ pagePath: "pages/home/index" }] }, + }; + `, + "src/pages/home/index.tsx": "export default () => null;", + "src/pages/native/index.tsx": "export default () => null;", + "src/pages/commented/index.tsx": "export default () => null;", + "src/pages/metadata/index.tsx": "export default () => null;", + "src/pages/examples/index.tsx": "export default () => null;", + }, + { dependencies: { "@tarojs/react": "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/pages/commented/index.tsx", + "src/pages/examples/index.tsx", + "src/pages/metadata/index.tsx", + ]); + }); + + it("discovers Remix routes from a custom app directory", async () => { + const rootDirectory = createProject( + { + "remix.config.js": `module.exports = { appDirectory: "src" };`, + "src/root.tsx": `import { routeValue } from "./routes/index"; export default () =>
{routeValue}
;`, + "src/routes/index.tsx": "export const routeValue = 1;", + "src/orphan.ts": "export const orphan = 1;", + }, + { dependencies: { "@remix-run/react": "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + const unusedFilePaths = relativePaths(rootDirectory, result.unusedFiles); + + expect(unusedFilePaths).toContain("src/orphan.ts"); + expect(unusedFilePaths).not.toEqual( + expect.arrayContaining(["src/root.tsx", "src/routes/index.tsx"]), + ); + }); + + it("discovers GraphQL codegen inputs", async () => { + const rootDirectory = createProject( + { + "codegen-main.ts": ` + export default { + schema: "./schema.graphql", + // documents: ["./src/commented.ts"], + documents: ["./src/**/queries.ts", "!./src/legacy/**"], + }; + `, + "schema.graphql": "type Query { value: String }", + "src/commented.ts": "export const commented = true;", + "src/feature/queries.ts": `import { helper } from "../helper"; export const query = \`query { value }\`; console.log(helper);`, + "src/helper.ts": "export const helper = 1;", + "src/legacy/queries.ts": "export const legacyQuery = `query { value }`;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory }); + const unusedFilePaths = relativePaths(rootDirectory, result.unusedFiles); + + expect(unusedFilePaths).toContain("src/legacy/queries.ts"); + expect(unusedFilePaths).toContain("src/commented.ts"); + expect(unusedFilePaths).toContain("src/helper.ts"); + expect(unusedFilePaths).not.toContain("src/feature/queries.ts"); + expect(result.unusedExports).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "query" })]), + ); + }); + + it("discovers YAML block-list codegen inputs without activating comments", async () => { + const rootDirectory = createProject( + { + "codegen.yml": ` + schema: + - "./src/schema-loader.ts" + documents: + - "./src/documents/**/*.ts" # active documents + - "!./src/documents/excluded/**" + # - "./src/commented-list-item/**/*.ts" + # documents: + # - "./src/commented-property/**/*.ts" + `, + "src/schema-loader.ts": `import { schemaHelper } from "./schema-helper"; export default schemaHelper;`, + "src/schema-helper.ts": "export const schemaHelper = {};", + "src/documents/query.ts": `import { documentHelper } from "../document-helper"; export const query = \`query { value }\`; console.log(documentHelper);`, + "src/document-helper.ts": "export const documentHelper = 1;", + "src/documents/excluded/legacy.ts": "export const legacyQuery = `query { legacy }`;", + "src/commented-list-item/query.ts": "export const commentedListItemQuery = 1;", + "src/commented-property/query.ts": "export const commentedPropertyQuery = 1;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory }); + const unusedFilePaths = relativePaths(rootDirectory, result.unusedFiles); + + expect(unusedFilePaths).not.toEqual( + expect.arrayContaining(["src/schema-loader.ts", "src/schema-helper.ts"]), + ); + expect(unusedFilePaths).not.toContain("src/documents/query.ts"); + expect(unusedFilePaths).toEqual( + expect.arrayContaining([ + "src/document-helper.ts", + "src/documents/excluded/legacy.ts", + "src/commented-list-item/query.ts", + "src/commented-property/query.ts", + ]), + ); + expect(result.unusedExports).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "query" })]), + ); + }); + + it("keeps GraphQL codegen outputs in the graph without reporting them", async () => { + const rootDirectory = createProject( + { + "codegen.ts": ` + export default { + schema: "./schema.graphql", + generates: { + "./src/api-types.ts": { plugins: ["typescript"] }, + }, + }; + `, + "schema.graphql": "type Query { value: String }", + "src/index.ts": `import { usedType } from "./api-types"; console.log(usedType);`, + "src/api-types.ts": `export const usedType = 1; export interface GeneratedShape { value: string }`, + "src/orphan.ts": "export const orphan = 1;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + expect(result.unusedExports).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: "GeneratedShape" })]), + ); + }); + + it("discovers GraphQL codegen outputs from one-line JavaScript objects", async () => { + const rootDirectory = createProject( + { + "codegen.ts": `export default { generates: { /* output map { */ "./src/api-runtime.ts": { config: { "./src/not-an-output.ts": true }, plugins: ["typescript"] } } };`, + "src/index.ts": "console.log('app');", + "src/api-runtime.ts": "export const apiRuntime = 1;", + "src/not-an-output.ts": "export const notAnOutput = 1;", + "src/orphan.ts": "export const orphan = 1;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/not-an-output.ts", + "src/orphan.ts", + ]); + }); + + it("discovers GraphQL codegen outputs from graphqlrc and the Vite codegen plugin", async () => { + const rootDirectory = createProject( + { + ".graphqlrc.yml": `generates:\n ./src/gql/:\n preset: client`, + "vite.config.ts": ` + import graphqlCodegen from "vite-plugin-graphql-codegen"; + export default { plugins: [graphqlCodegen({ generates: { "./src/vite-gql/": { preset: "client" } } })] }; + `, + "src/index.ts": "console.log('app');", + "src/gql/graphql.ts": "export interface GraphqlOutput { value: string }", + "src/vite-gql/graphql.ts": "export interface ViteGraphqlOutput { value: string }", + "src/authored/graphql.ts": "export interface AuthoredGraphqlShape { value: string }", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/authored/graphql.ts"]); + expect(result.unusedExports).toEqual([]); + }); + + it("recognizes explicit generated-via provenance only in leading comments", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "console.log('app');", + "src/api.ts": + "/* THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API */\nexport interface ApiShape { value: string }", + "src/manual.ts": + "export interface ManualShape { value: string }\n/* generated via a test example */", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/manual.ts"]); + }); + + it("does not discover GraphQL codegen outputs from strings or comments", async () => { + const rootDirectory = createProject( + { + "codegen.ts": ` + const example = 'generates: { "./src/string-decoy.ts": {} }'; + const enabled = true; // generates: { "./src/comment-decoy.ts": {} } + export default { generates: { "./src/api-runtime.ts": {} } }; + `, + "src/index.ts": "console.log('app');", + "src/api-runtime.ts": "export const apiRuntime = 1;", + "src/string-decoy.ts": "export const stringDecoy = 1;", + "src/comment-decoy.ts": "export const commentDecoy = 1;", + "src/orphan.ts": "export const orphan = 1;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/comment-decoy.ts", + "src/orphan.ts", + "src/string-decoy.ts", + ]); + }); + + it("suppresses provenance-backed outputs without inferring generation from type shapes", async () => { + const rootDirectory = createProject( + { + "codegen.yml": ` + schema: schema.graphql + generates: + src/api-client.ts: + plugins: + - typescript + `, + "schema.graphql": "type Query { value: String }", + "src/index.ts": "console.log('app');", + "src/generated/schema.ts": "export interface GeneratedDirectoryShape { value: string }", + "src/schema.generated.ts": "export interface GeneratedFilenameShape { value: string }", + "src/protocol.ts": + "// @generated by protocol compiler\nexport interface GeneratedHeaderShape { value: string }", + "src/graphql-types.ts": + "export type Maybe = T | null; export type Exact = T; export interface GeneratedGraphqlShape { value: string }", + "src/apollo-types.ts": + "export type QueryKeySpecifier = ['query']; export type QueryFieldPolicy = { read(): unknown };", + "src/codegen-types.ts": [ + "export type Maybe = T | null;", + "export type Exact = { [K in keyof T]: T[K] };", + "export type MakeOptional = Omit & { [SubKey in K]?: Maybe };", + "export type MakeMaybe = Omit & { [SubKey in K]: Maybe };", + "export type Scalars = { ID: string };", + ].join("\n"), + "src/apollo-helpers.ts": [ + 'import { type FieldPolicy, type FieldReadFunction, type TypePolicies, type TypePolicy } from "@apollo/client/cache";', + 'export type QueryKeySpecifier = ("viewer" | QueryKeySpecifier)[];', + "export type QueryFieldPolicy = { viewer?: FieldPolicy | FieldReadFunction };", + "export type TypedTypePolicies = TypePolicies & { Query?: Omit };", + ].join("\n"), + "src/authored-apollo-policy.ts": [ + 'import { type FieldPolicy, type TypePolicies } from "@apollo/client/cache";', + "export type QueryFieldPolicy = { viewer?: FieldPolicy };", + "export type ApplicationPolicies = TypePolicies;", + ].join("\n"), + "src/protocol.h.ts": "export interface HandWrittenProtocol { value: string }", + "src/late-generated-marker.ts": + "export const handWritten = true;\n// This example was generated by a test helper.", + "src/do-not-edit.ts": + "// Do not edit this file directly; use the admin UI.\nexport const handWritten = true;", + "src/api-client.ts": "export interface GeneratedConfigShape { value: string }", + "src/__testfixtures__/parser-output.ts": "export const fixture = 1;", + "src/vendor/library.ts": "export const vendored = 1;", + "src/assets/libs/runtime.ts": "export const staticRuntime = 1;", + "src/button.figma.tsx": "export const codeConnectExample = 1;", + "src/legacy-sdk/README.md": "legacy-sdk is an in-progress migration of another package.", + "src/legacy-sdk/types.ts": "export interface MigratedPackageShape { value: string }", + "public/runtime.ts": "export const publicRuntime = 1;", + "src/public/manual.ts": "export const manuallyOwned = 1;", + "src/orphan.ts": "export const orphan = 1;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/apollo-types.ts", + "src/authored-apollo-policy.ts", + "src/do-not-edit.ts", + "src/graphql-types.ts", + "src/late-generated-marker.ts", + "src/legacy-sdk/types.ts", + "src/orphan.ts", + "src/protocol.h.ts", + "src/public/manual.ts", + ]); + expect(result.unusedExports).toEqual([]); + }); + + it("resolves Vite HTML entries from the configured root", async () => { + const rootDirectory = createProject( + { + "vite.config.mts": ` + import { join } from "node:path"; + const rendererRoot = join(__dirname, "src", "renderer"); + export default { + root: rendererRoot, + build: { rollupOptions: { input: { search: join(rendererRoot, "search.html") } } }, + }; + `, + "src/renderer/search.html": ``, + "src/renderer/search.tsx": `import { Search } from "./search"; console.log(Search);`, + "src/renderer/search.ts": "export const Search = 1;", + "src/orphan.ts": "export const orphan = 1;", + }, + { devDependencies: { vite: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("resolves unquoted Vite HTML entries with query strings", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": `export default {};`, + "index.html": ``, + "src/main.ts": `import { value } from "./value"; console.log(value);`, + "src/value.ts": "export const value = 1;", + "src/orphan.ts": "export const orphan = 1;", + }, + { devDependencies: { vite: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("resolves a Vite callback root without selecting nested test roots", async () => { + const rootDirectory = createProject( + { + "vite.config.mts": ` + import { join } from "node:path"; + import { defineConfig } from "vite"; + const rendererRoot = join(__dirname, "src", "renderer"); + export default defineConfig(() => ({ + root: rendererRoot, + test: { root: "src" }, + })); + `, + "src/renderer/index.html": ``, + "src/renderer/main.ts": `import { app } from "./app"; console.log(app);`, + "src/renderer/app.ts": "export const app = 1;", + "src/orphan.ts": "export const orphan = 1;", + }, + { devDependencies: { vite: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("expands project-root import.meta.glob patterns", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `const previews = import.meta.glob("/src/previews/**/*.tsx"); console.log(previews);`, + "src/previews/button/index.tsx": "export default () => null;", + "src/previews/dialog/index.tsx": "export default () => null;", + "src/orphan.ts": "export const orphan = 1;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("expands static template-literal import.meta.glob patterns", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "import './registry';", + "src/registry.ts": "export const pages = import.meta.glob(`/src/pages/**/loading.tsx`);", + "src/pages/loading.tsx": "export default () => null;", + "src/orphan.ts": "export const orphan = true;", + }, + { devDependencies: { vite: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("binds project-root Vite globs to the owning root across workspaces", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": "export default {};", + "index.html": ``, + "src/main.ts": `import { previews } from "../packages/library/glob"; console.log(previews);`, + "src/previews/root.tsx": "export default () => null;", + "packages/library/package.json": JSON.stringify({ name: "@example/library" }), + "packages/library/glob.ts": + 'export const previews = import.meta.glob("/src/previews/**/*.tsx");', + "packages/library/src/previews/workspace.tsx": "export default () => null;", + }, + { private: true, workspaces: ["packages/*"], devDependencies: { vite: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain( + "packages/library/src/previews/workspace.tsx", + ); + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain("src/previews/root.tsx"); + }); + + it("uses a custom Vite root for globs imported from sibling source", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": `export default { root: "app" };`, + "app/index.html": ``, + "app/main.ts": `import { previews } from "../shared/glob"; console.log(previews);`, + "app/src/previews/application.tsx": "export default () => null;", + "shared/glob.ts": 'export const previews = import.meta.glob("/src/previews/**/*.tsx");', + "shared/src/previews/shared.tsx": "export default () => null;", + "src/previews/project.tsx": "export default () => null;", + }, + { devDependencies: { vite: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual([ + "shared/src/previews/shared.tsx", + "src/previews/project.tsx", + ]); + }); + + it("resolves SvelteKit aliases before expanding project-root globs", async () => { + const rootDirectory = createProject( + { + "svelte.config.js": `export default { kit: { alias: { $docs: "src/docs" } } };`, + "src/routes/+page.ts": `import { previews } from "$docs/preview.js"; console.log(previews);`, + "src/docs/preview.ts": `export const previews = import.meta.glob("/src/previews/**/*.svelte");`, + "src/previews/button/index.svelte": "
Button
", + "src/orphan.ts": "export const orphan = 1;", + }, + { dependencies: { "@sveltejs/kit": "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("extracts live MDX imports without parsing fenced examples as module syntax", async () => { + const rootDirectory = createProject( + { + "src/app/components/page.mdx": [ + "# Component", + "", + "```tsx", + "export function IncompleteExample(", + "```", + "", + 'import { Demo } from "./demos/example";', + "", + "", + ].join("\n"), + "src/app/components/demos/example/index.ts": `export { Demo } from "./render";`, + "src/app/components/demos/example/render.tsx": + "export const Demo = () =>
Demo
;", + "src/orphan.ts": "export const orphan = 1;", + }, + { dependencies: { next: "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + expect(result.analysisErrors).not.toEqual( + expect.arrayContaining([expect.objectContaining({ code: "parse-recovered-partial" })]), + ); + }); + + it("resolves Webpack v1 entries from the project root", async () => { + const rootDirectory = createProject( + { + "webpack/prod/webpack.config.js": ` + const commonResolve = { modulesDirectories: ["shared", "node_modules"] }; + module.exports = { entry: { app: ["./lib/client/app.js"] }, resolve: commonResolve }; + `, + "lib/client/app.js": `import { screen } from "screens/home"; console.log(screen);`, + "lib/shared/screens/home.js": "export const screen = 1;", + "lib/orphan.js": "export const orphan = 1;", + }, + { devDependencies: { webpack: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["lib/orphan.js"]); + }); + + it("discovers computed entries in imported Webpack configuration modules", async () => { + const rootDirectory = createProject( + { + "webpack.config.ts": `import adminConfig from "./webpack-configs/admin"; export default adminConfig;`, + "webpack-configs/admin.ts": ` + import PathUtil from "../scripts/path-util"; + export default { entry: PathUtil.admin("index") }; + `, + "scripts/path-util.ts": "export default {};", + "src/admin/index.tsx": `import "./main.scss"; export const Admin = () => null;`, + "src/admin/main.scss": "$color: red;", + }, + { devDependencies: { webpack: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain("src/admin/index.tsx"); + }); + + it("resolves Webpack path helpers relative to the configuration module", async () => { + const rootDirectory = createProject( + { + "webpack.config.ts": ` + import path from "node:path"; + export default { entry: path.join("src", "index.ts") }; + `, + "src/index.ts": "export const application = true;", + "src/orphan.ts": "export const orphan = true;", + }, + { devDependencies: { webpack: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("does not infer computed Webpack entries from arbitrary helpers", async () => { + const rootDirectory = createProject( + { + "webpack.config.ts": ` + const Routes = { admin: (name) => "/admin/" + name }; + export default { entry: Routes.admin("index") }; + `, + "src/admin/index.tsx": "export const Admin = () => null;", + }, + { devDependencies: { webpack: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain("src/admin/index.tsx"); + }); + + it("uses only the top-level Vite root and resolves direct path calls", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": ` + import { resolve } from "node:path"; + // root: "./wrong-root", + export default { + plugins: [{ options: { root: "./wrong-root" } }], + root: resolve(__dirname, "src", "renderer"), + }; + `, + "src/renderer/index.html": ``, + "src/renderer/main.ts": `import { application } from "./application"; console.log(application);`, + "src/renderer/application.ts": "export const application = 1;", + "wrong-root/index.html": ``, + "wrong-root/unused.ts": "export const unused = 1;", + }, + { devDependencies: { vite: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain("wrong-root/unused.ts"); + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toEqual( + expect.arrayContaining(["src/renderer/main.ts", "src/renderer/application.ts"]), + ); + }); + + it("discovers Electron Forge renderer entry points", async () => { + const rootDirectory = createProject( + { + "forge.config.ts": ` + export default { + plugins: [{ renderer: { entryPoints: [{ + html: "./src/renderer/index.html", + js: "./src/renderer/index.tsx", + preload: { js: "./src/preload.ts" }, + }] } }], + }; + `, + "src/renderer/index.html": "
", + "src/renderer/index.tsx": `import { application } from "./application"; console.log(application);`, + "src/renderer/application.ts": "export const application = 1;", + "src/preload.ts": "console.log('preload');", + "src/orphan.ts": "export const orphan = 1;", + }, + { devDependencies: { "@electron-forge/cli": "1.0.0", electron: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("discovers Vitest includes declared in Vite config", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": ` + export default { + test: { + include: ["**/*_{test,spec}.?(c|m)[jt]s?(x)"], + coverage: { include: ["src/**"] }, + }, + }; + `, + "test/component_test.tsx": `import { component } from "../src/component"; console.log(component);`, + "src/component.ts": "export const component = 1;", + "src/coverage-only.ts": "export const coverageOnly = 1;", + }, + { devDependencies: { vitest: "1.0.0", vite: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/coverage-only.ts"]); + }); + + it("discovers Vitest includes after astral Unicode", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": ` + const label = "😀"; + export default { test: { include: ["cases/**/*.case.ts"] } }; + `, + "src/index.ts": "console.log('app');", + "cases/actual.case.ts": "export const actualCase = true;", + "src/orphan.ts": "export const orphan = 1;", + }, + { devDependencies: { vite: "1.0.0", vitest: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("does not treat the array after a Vitest include variable as the include value", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": ` + const testFiles = ["test/**/*.case.ts"]; + export default { + test: { + include: testFiles, + exclude: ["src/ignored.ts"], + }, + }; + `, + "src/index.ts": "console.log('app');", + "src/ignored.ts": "export const ignored = 1;", + }, + { devDependencies: { vite: "1.0.0", vitest: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/ignored.ts"]); + }); + + it("does not treat Vite plugin include filters as Vitest entries", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": ` + /* test: { */ + export default { + plugins: [{ include: ["src/plugin-filtered.ts"] }], + }; + `, + "src/index.ts": "console.log('app');", + "src/plugin-filtered.ts": "export const pluginFiltered = 1;", + }, + { devDependencies: { vite: "1.0.0", vitest: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/plugin-filtered.ts"]); + }); + + it("discovers externally consumed component composition registries", async () => { + const rootDirectory = createProject( + { "src/composition.tsx": "export const Composition = () =>
;" }, + { + private: "true", + description: "Registry for component compositions", + dependencies: { react: "1.0.0" }, + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain("src/composition.tsx"); + }); + + it("resolves composition registry entries relative to a workspace package", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "export const root = 1;", + "packages/compositions/package.json": JSON.stringify({ + name: "@example/compositions", + private: true, + description: "Registry for component compositions", + }), + "packages/compositions/src/composition.tsx": "export const Composition = () =>
;", + }, + { private: true, workspaces: ["packages/*"] }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain( + "packages/compositions/src/composition.tsx", + ); + }); + + it("resolves extensionless entry fields from implicitly discovered packages", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "export const root = 1;", + "internal_packages/composer/package.json": JSON.stringify({ + name: "composer", + private: true, + main: "./lib/main", + }), + "internal_packages/composer/lib/main.es6": + 'import { View } from "./view"; export const Legacy = View;', + "internal_packages/composer/lib/view.jsx": + 'import { Legacy } from "./main"; export const View = Legacy;', + "internal_packages/composer/lib/orphan.jsx": "export const Orphan = () => null;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual([ + "internal_packages/composer/lib/orphan.jsx", + ]); + expect(result.circularDependencies).toEqual([]); + }); + + it("suppresses public assets at workspace roots without suppressing nested source folders", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "export const root = 1;", + "packages/client/package.json": JSON.stringify({ + name: "@example/client", + devDependencies: { vite: "1.0.0" }, + }), + "packages/client/src/index.ts": "export const client = 1;", + "packages/client/public/runtime.ts": "export const publicRuntime = 1;", + "packages/client/src/public/manual.ts": "export const manuallyOwned = 1;", + }, + { private: true, workspaces: ["packages/*"] }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain( + "packages/client/src/public/manual.ts", + ); + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain( + "packages/client/public/runtime.ts", + ); + }); + + it("reports authored public source in library workspaces", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "export const root = 1;", + "packages/library/package.json": JSON.stringify({ + name: "@example/library", + exports: "./src/index.ts", + }), + "packages/library/src/index.ts": "export const library = 1;", + "packages/library/public/manual.ts": "export const manual = 1;", + }, + { private: true, workspaces: ["packages/*"] }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain( + "packages/library/public/manual.ts", + ); + }); + + it("treats package export entries as externally consumed public surfaces", async () => { + const rootDirectory = createProject( + { + "src/index.ts": 'export * from "./components";', + "src/components.ts": ` + export const Button = () => null; + export interface ButtonProps { label: string } + `, + "src/internal.ts": "export const internal = true;", + "src/dormant.ts": "export const dormant = true;", + "src/app.ts": 'import "./internal"; console.log("app");', + }, + { + name: "@example/library", + exports: { ".": "./src/index.ts" }, + scripts: { start: "tsx src/app.ts" }, + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain("src/dormant.ts"); + expect(result.unusedExports).toEqual([]); + }); + + it("treats nested package export patterns as public surfaces", async () => { + const rootDirectory = createProject( + { + "src/Common/Avatar/Overlay/index.tsx": ` + export interface AvatarOverlayProps { label: string } + export const AvatarOverlay = (props: AvatarOverlayProps) => props.label; + `, + }, + { + name: "@example/components", + exports: { "./*": "./src/*" }, + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(result.unusedExports).toEqual([]); + }); + + it("credits bundleless glob entry modules", async () => { + const rootDirectory = createProject( + { + "tsup.config.ts": `export default { entry: ["./src/**/*.{ts,tsx}"] };`, + "src/index.ts": 'export { publicUtility } from "./utils/public-utility";', + "src/utils/public-utility.ts": "export const publicUtility = true;", + }, + { + name: "@example/bundleless-library", + scripts: { build: "tsup" }, + exports: { "./*": "./*" }, + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(result.unusedExports).toEqual([]); + }); + + it("resolves imports from TSX with large inline data", async () => { + const embeddedSvgPath = "M".repeat(MINIFIED_DETECTION_MIN_BYTES); + const rootDirectory = createProject( + { + "app/(demo)/page.tsx": ` + import { ClientPage } from "./page.client"; + export default () => ; + `, + "app/(demo)/page.client.tsx": "export const ClientPage = () => null;", + }, + { dependencies: { next: "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(result.unusedExports).toEqual([]); + }); + + it("resolves package proxies to root-preserving build output", async () => { + const rootDirectory = createProject( + { + "app.plugin.js": 'module.exports = require("./dist/plugins/with-example");', + "plugins/with-example.ts": "export default () => null;", + "tsconfig.json": JSON.stringify({ compilerOptions: { outDir: "dist" } }), + }, + { + name: "@example/expo-plugin", + files: ["dist", "app.plugin.js"], + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(result.unusedExports).toEqual([]); + }); + + it("credits modules dynamically loaded by Next config", async () => { + const rootDirectory = createProject( + { + "next.config.mjs": ` + import { createJiti } from "jiti"; + const loadTypeScript = createJiti(import.meta.url); + const { schema } = await loadTypeScript.import("./src/schema.ts"); + schema.parse({}); + export default {}; + `, + "src/schema.ts": "export const schema = { parse: (value: unknown) => value };", + }, + { + dependencies: { next: "1.0.0", react: "1.0.0", jiti: "1.0.0" }, + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(result.unusedExports).toEqual([]); + }); + + it("credits packages referenced by scripts, config, and CI workflows", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "console.log('app');", + "vite.config.ts": `import configPackage from "config-package"; export default configPackage;`, + ".github/workflows/release.yml": `steps:\n - run: npx ci-package deploy`, + }, + { + scripts: { build: "script-package build" }, + devDependencies: { + "script-package": "1.0.0", + "config-package": "1.0.0", + "ci-package": "1.0.0", + "unused-package": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + expect(unusedPackageNames).toContain("unused-package"); + expect(unusedPackageNames).not.toEqual( + expect.arrayContaining(["script-package", "config-package", "ci-package"]), + ); + }); + + it("credits package-valued CLI options without accepting unrelated script tokens", async () => { + const rootDirectory = createProject( + { "src/index.ts": "console.log('app');" }, + { + scripts: { + test: "jest --testResultsProcessor jest-sonar-reporter", + explain: "echo unused-token-package", + }, + devDependencies: { + jest: "1.0.0", + "jest-sonar-reporter": "1.0.0", + "unused-token-package": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + expect(unusedPackageNames).not.toContain("jest-sonar-reporter"); + expect(unusedPackageNames).toContain("unused-token-package"); + }); + + it("credits binaries invoked by a local shell script without reading shell-script arguments", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "console.log('app');", + "scripts/build.sh": "firebase deploy", + "scripts/dormant.sh": "unused-shell-package deploy", + }, + { + scripts: { + build: "bash ./scripts/build.sh", + explain: "echo ./scripts/dormant.sh", + }, + devDependencies: { + "firebase-tools": "1.0.0", + "unused-shell-package": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + expect(unusedPackageNames).not.toContain("firebase-tools"); + expect(unusedPackageNames).toContain("unused-shell-package"); + }); + + it("credits framework-owned image and MDX dependencies", async () => { + const rootDirectory = createProject( + { "src/index.tsx": "export default () =>
;" }, + { + dependencies: { + react: "1.0.0", + next: "1.0.0", + sharp: "1.0.0", + "@next/mdx": "1.0.0", + "@mdx-js/loader": "1.0.0", + "@mdx-js/react": "1.0.0", + remix: "1.0.0", + "@remix-run/react": "1.0.0", + "web-vitals": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.tsx"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + expect(unusedPackageNames).toContain("web-vitals"); + expect(unusedPackageNames).not.toEqual( + expect.arrayContaining(["sharp", "@mdx-js/loader", "@mdx-js/react", "@remix-run/react"]), + ); + }); + + it("credits sharp conservatively when Next image optimization is configured", async () => { + const rootDirectory = createProject( + { + "src/index.tsx": "export default () =>
;", + "next.config.js": "module.exports = { images: { unoptimized: true } };", + }, + { dependencies: { react: "1.0.0", next: "1.0.0", sharp: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.tsx"] }); + + expect(result.unusedDependencies).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: "sharp" })]), + ); + }); + + it("credits the Docusaurus MDX runtime", async () => { + const rootDirectory = createProject( + { "src/index.tsx": "export default () =>
;" }, + { + dependencies: { + react: "1.0.0", + "@docusaurus/core": "1.0.0", + "@mdx-js/react": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.tsx"] }); + + expect(result.unusedDependencies).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: "@mdx-js/react" })]), + ); + }); + + it("follows live imports from Docusaurus Markdown content without treating README examples as code", async () => { + const rootDirectory = createProject( + { + "docs/page.md": `import "../src/player";`, + "README.md": `import "./src/readme-example";`, + "src/player.ts": "export const player = true;", + "src/readme-example.ts": "export const example = true;", + }, + { dependencies: { "@docusaurus/core": "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain("src/readme-example.ts"); + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain("src/player.ts"); + }); + + it("honors configured ignores when collecting executable Docusaurus Markdown", async () => { + const rootDirectory = createProject( + { + "docs/ignored/page.md": `import "../../src/ignored-player";`, + "src/ignored-player.ts": "export const player = true;", + }, + { dependencies: { "@docusaurus/core": "1.0.0" } }, + ); + + const result = await analyzeProject({ + rootDirectory, + ignorePatterns: ["docs/ignored/**"], + }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain("src/ignored-player.ts"); + }); + + it("credits known binary aliases and release config plugins without guessing wrapper peers", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `import Chart from "react-apexcharts"; console.log(Chart);`, + ".releaserc.json": JSON.stringify({ plugins: ["release-plugin"] }), + }, + { + scripts: { build: "babel src --out-dir dist", start: "remix-serve build" }, + release: { plugins: ["release-package-json-plugin"] }, + dependencies: { + "react-apexcharts": "1.0.0", + apexcharts: "1.0.0", + }, + devDependencies: { + "@babel/cli": "1.0.0", + "@remix-run/serve": "1.0.0", + "release-plugin": "1.0.0", + "release-package-json-plugin": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + for (const usedPackageName of [ + "@babel/cli", + "@remix-run/serve", + "release-plugin", + "release-package-json-plugin", + ]) { + expect(unusedPackageNames).not.toContain(usedPackageName); + } + expect(unusedPackageNames).toContain("apexcharts"); + }); + + it("credits packages named by tool config surfaces", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "console.log('app');", + ".stylelintrc.json": JSON.stringify({ + plugins: ["stylelint-order", "stylelint-prettier"], + }), + "typedoc.json": JSON.stringify({ plugin: ["typedoc-plugin-markdown"] }), + "netlify.toml": '[[plugins]]\npackage = "@netlify/plugin-nextjs"', + ".release-it.json": JSON.stringify({ + plugins: { "@release-it/conventional-changelog": {} }, + }), + "tsconfig.json": JSON.stringify({ + compilerOptions: { plugins: [{ name: "typescript-plugin-css-modules" }] }, + }), + "styles/globals.css": '@plugin "tailwindcss-animate";', + }, + { + "pre-commit": ["lint"], + devDependencies: { + "stylelint-order": "1.0.0", + "stylelint-prettier": "1.0.0", + "typedoc-plugin-markdown": "1.0.0", + "@netlify/plugin-nextjs": "1.0.0", + "typescript-plugin-css-modules": "1.0.0", + "tailwindcss-animate": "1.0.0", + "@release-it/conventional-changelog": "1.0.0", + "pre-commit": "1.0.0", + "release-it": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + expect(unusedPackageNames).toEqual([]); + }); + + it("credits package.json tool owner sections", async () => { + const rootDirectory = createProject( + { "src/index.ts": "console.log('app');" }, + { + "pre-commit": ["lint"], + "release-it": {}, + devDependencies: { + "pre-commit": "1.0.0", + "release-it": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.unusedDependencies).toEqual([]); + }); + + it("credits stylesheet package directives without matching ordinary stylesheet text", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "console.log('app');", + "styles/globals.scss": ` + /* @plugin "commented-package"; */ + @plugin "tailwindcss-animate"; + @import url(modern-normalize/modern-normalize.css); + @use "pkg:sass-mq"; + $color: red; + .swiper-slide { color: $color; } + .example::before { content: '@plugin "string-package"'; } + `, + }, + { + devDependencies: { + color: "1.0.0", + "commented-package": "1.0.0", + "modern-normalize": "1.0.0", + "sass-mq": "1.0.0", + "string-package": "1.0.0", + swiper: "1.0.0", + "tailwindcss-animate": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies + .map((dependency) => dependency.name) + .sort(); + + expect(unusedPackageNames).toEqual(["color", "commented-package", "string-package", "swiper"]); + }); + + it("credits script-implied tools and binary aliases", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "console.log('app');", + "node_modules/openfin-cli/package.json": JSON.stringify({ + bin: { openfin: "dist/cli.js" }, + }), + }, + { + scripts: { + check: "astro check && oxlint --type-aware", + postinstall: "patch-package", + email: "email dev", + serve: "react-router-serve build/server/index.js", + deploy: 'sh -c "npx cross-env NODE_ENV=production env -u DEBUG firebase deploy"', + publish: "rc-np", + rebuild: "electron-rebuild", + extract: "api-extractor run", + taro: "taro build", + chakra: 'bash -lc "chakra tokens src/theme.ts"', + flow: "flow status", + parcel: "parcel src/index.html", + babel: "cross-env BABEL_ENV=production babel src --out-dir dist", + "babel-node": 'nodemon --exec "babel-node --inspect" server.js', + openfin: + 'cross-env-shell "wait-on -l $npm_config_manifest_url && openfin -l -c $npm_config_manifest_url"', + coverage: "node node_modules/coveralls/bin/coveralls.js", + }, + devDependencies: { + "@astrojs/check": "1.0.0", + "oxlint-tsgolint": "1.0.0", + "postinstall-postinstall": "1.0.0", + "react-email": "1.0.0", + "@react-router/serve": "1.0.0", + "firebase-tools": "1.0.0", + "@rc-component/np": "1.0.0", + "@electron/rebuild": "1.0.0", + "@microsoft/api-extractor": "1.0.0", + "@tarojs/cli": "1.0.0", + "@chakra-ui/cli": "1.0.0", + "flow-bin": "1.0.0", + "parcel-bundler": "1.0.0", + "babel-cli": "1.0.0", + coveralls: "1.0.0", + "openfin-cli": "1.0.0", + "wait-on": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + expect(unusedPackageNames).toEqual([]); + }); + + it.each([ + "npm exec -- firebase deploy", + "pnpm exec firebase deploy", + "yarn exec firebase deploy", + "pnpm dlx firebase-tools deploy", + "yarn dlx firebase-tools deploy", + ])("credits binaries invoked through package-manager runners: $command", async (command) => { + const rootDirectory = createProject( + { "src/index.ts": "console.log('app');" }, + { + scripts: { deploy: command }, + devDependencies: { "firebase-tools": "1.0.0" }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.unusedDependencies).toEqual([]); + }); + + it("skips ambiguous static binary providers", async () => { + const rootDirectory = createProject( + { "src/index.ts": "console.log('app');" }, + { + scripts: { build: "babel src --out-dir dist" }, + devDependencies: { + "@babel/cli": "1.0.0", + "babel-cli": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + expect(result.unusedDependencies).toEqual([]); + }); + + it("requires a complete node_modules binary name match", async () => { + const rootDirectory = createProject( + { "src/index.ts": "console.log('app');" }, + { + scripts: { + coverage: "node node_modules/coveralls/bin/coveralls.js", + tool: "node node_modules/.bin/foobar", + }, + devDependencies: { + coveralls: "1.0.0", + foo: "1.0.0", + foobar: "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + expect(unusedPackageNames).toEqual(["foo"]); + }); + + it("does not infer wrapper peers without authoritative metadata", async () => { + const rootDirectory = createProject( + { + "src/index.ts": ` + import prettyCode from "rehype-pretty-code"; + import { PrismaClient } from "@prisma/client"; + import ReactRefreshWebpackPlugin from "@pmmmwh/react-refresh-webpack-plugin"; + import { Elements } from "@stripe/react-stripe-js"; + console.log(prettyCode, PrismaClient, ReactRefreshWebpackPlugin, Elements); + `, + "prisma/schema.prisma": `generator client { provider = "prisma-client-js" }`, + }, + { + dependencies: { + "rehype-pretty-code": "1.0.0", + shiki: "1.0.0", + "@prisma/client": "1.0.0", + prisma: "1.0.0", + "@pmmmwh/react-refresh-webpack-plugin": "1.0.0", + "react-refresh": "1.0.0", + "@stripe/react-stripe-js": "1.0.0", + "@stripe/stripe-js": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + expect(unusedPackageNames).toEqual( + expect.arrayContaining(["shiki", "react-refresh", "@stripe/stripe-js"]), + ); + expect(unusedPackageNames).not.toContain("prisma"); + }); + + it("does not infer Prisma CLI use from the optional client peer alone", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `import { PrismaClient } from "@prisma/client"; console.log(PrismaClient);`, + "node_modules/@prisma/client/package.json": JSON.stringify({ + name: "@prisma/client", + version: "1.0.0", + peerDependencies: { prisma: "*" }, + peerDependenciesMeta: { prisma: { optional: true } }, + }), + }, + { + dependencies: { + "@prisma/client": "1.0.0", + prisma: "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + expect(unusedPackageNames).toEqual(["prisma"]); + }); + + it("does not infer Prisma CLI use from fixture schemas", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `import { PrismaClient } from "@prisma/client"; console.log(PrismaClient);`, + "test/fixtures/prisma/schema.prisma": `generator client { provider = "prisma-client-js" }`, + }, + { + dependencies: { + "@prisma/client": "1.0.0", + prisma: "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + expect(unusedPackageNames).toEqual(["prisma"]); + }); + + it("credits native Capacitor platforms without inferring Sass use from an orphan stylesheet", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "console.log('app');", + "src/theme.scss": "$color: red;", + "capacitor.config.ts": "export default {};", + "android/.gitkeep": "", + "ios/.gitkeep": "", + }, + { + scripts: { build: "vite build" }, + dependencies: { + "@capacitor/core": "1.0.0", + "@capacitor/android": "1.0.0", + "@capacitor/ios": "1.0.0", + vite: "1.0.0", + "sass-embedded": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + for (const usedPackageName of ["@capacitor/android", "@capacitor/ios"]) { + expect(unusedPackageNames).not.toContain(usedPackageName); + } + expect(unusedPackageNames).toContain("sass-embedded"); + }); + + it("uses Sass Embedded before Sass when an observed host can compile Sass", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `import "./theme.scss";`, + "src/theme.scss": "$color: red;", + }, + { + scripts: { build: "vite build" }, + devDependencies: { + sass: "1.0.0", + "sass-embedded": "1.0.0", + vite: "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + expect(unusedPackageNames).toContain("sass"); + expect(unusedPackageNames).not.toContain("sass-embedded"); + }); + + it("credits Sass when it is the installed compiler for an observed host", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `import "./theme.scss";`, + "src/theme.scss": "$color: red;", + }, + { + scripts: { build: "vite build" }, + devDependencies: { sass: "1.0.0", vite: "1.0.0" }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + expect(unusedPackageNames).not.toContain("sass"); + }); + + it("credits Sass for observed framework hosts while preserving source evidence", async () => { + for (const frameworkPackage of ["next", "react-scripts", "gatsby", "astro"]) { + const rootDirectory = createProject( + { + "src/index.ts": `import "./theme.scss";`, + "src/theme.scss": "$color: red;", + }, + { + scripts: { build: `${frameworkPackage} build` }, + devDependencies: { sass: "1.0.0", [frameworkPackage]: "1.0.0" }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.unusedDependencies.map((dependency) => dependency.name)).not.toContain("sass"); + } + }); + + it("does not infer Capacitor or Sass compiler use from declarations and stale paths", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "console.log('app');", + "src/theme.scss": "$color: red;", + "android/.gitkeep": "", + "ios/.gitkeep": "", + }, + { + dependencies: { + "@capacitor/core": "1.0.0", + "@capacitor/android": "1.0.0", + "@capacitor/ios": "1.0.0", + vite: "1.0.0", + "sass-embedded": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + expect(unusedPackageNames).toEqual( + expect.arrayContaining(["@capacitor/android", "@capacitor/ios", "sass-embedded"]), + ); + }); + + it("does not credit convention packages without their activation signal", async () => { + const rootDirectory = createProject( + { "src/index.ts": "console.log('app');" }, + { + scripts: { lint: "oxlint" }, + dependencies: { + "postinstall-postinstall": "1.0.0", + "@astrojs/check": "1.0.0", + "oxlint-tsgolint": "1.0.0", + "@capacitor/core": "1.0.0", + "@capacitor/android": "1.0.0", + vite: "1.0.0", + "sass-embedded": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + expect(unusedPackageNames).toEqual( + expect.arrayContaining([ + "postinstall-postinstall", + "@astrojs/check", + "oxlint-tsgolint", + "@capacitor/android", + "sass-embedded", + ]), + ); + }); + + it("credits required installed peers but treats installed binaries as an index", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `import usedPackage from "used-package"; console.log(usedPackage);`, + "node_modules/used-package/package.json": JSON.stringify({ + name: "used-package", + version: "1.0.0", + peerDependencies: { "peer-package": "*" }, + }), + "node_modules/bin-package/package.json": JSON.stringify({ + name: "bin-package", + version: "1.0.0", + bin: { "bin-command": "cli.js" }, + }), + }, + { + dependencies: { + "used-package": "1.0.0", + "peer-package": "1.0.0", + "bin-package": "1.0.0", + "unused-package": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedPackageNames = result.unusedDependencies.map((dependency) => dependency.name); + + expect(unusedPackageNames).toContain("unused-package"); + expect(unusedPackageNames).toContain("bin-package"); + expect(unusedPackageNames).not.toContain("used-package"); + expect(unusedPackageNames).not.toContain("peer-package"); + }); + + it("suppresses diagnostics for gitignored and generated files", async () => { + const rootDirectory = createProject( + { + ".gitignore": "src/ignored.ts\n", + "src/index.ts": "console.log('app');", + "src/ignored.ts": "export const ignored = 1;", + "dist/generated.ts": "export const generated = 1;", + "src/orphan.ts": "export const orphan = 1;", + }, + {}, + ); + execFileSync("git", ["init", "--quiet"], { cwd: rootDirectory }); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + const unusedFilePaths = relativePaths(rootDirectory, result.unusedFiles); + + expect(unusedFilePaths).toContain("src/orphan.ts"); + expect(unusedFilePaths).not.toEqual( + expect.arrayContaining(["src/ignored.ts", "dist/generated.ts"]), + ); + }); + + it("suppresses dynamic and function-only cycles", async () => { + const rootDirectory = createProject( + { + "src/index.ts": ` + import { loadDynamic } from "./dynamic-a"; + import { callFunctionCycle } from "./function-a"; + console.log(loadDynamic, callFunctionCycle); + `, + "src/dynamic-a.ts": ` + export const loadDynamic = () => import("./dynamic-b"); + `, + "src/dynamic-b.ts": ` + import { loadDynamic } from "./dynamic-a"; + export const dynamicB = () => loadDynamic; + `, + "src/function-a.ts": ` + import { functionB } from "./function-b"; + export const callFunctionCycle = () => functionB(); + `, + "src/function-b.ts": ` + import { callFunctionCycle } from "./function-a"; + export const functionB = () => callFunctionCycle(); + `, + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.circularDependencies).toEqual([]); + }); + + it("does not mistake shadowed import names for module-initialization reads", async () => { + const rootDirectory = createProject( + { + "src/index.ts": 'import { cycleA } from "./cycle-a"; console.log(cycleA);', + "src/cycle-a.ts": ` + import { cycleB } from "./cycle-b"; + { + const cycleB = 1; + console.log(cycleB); + } + ((cycleB) => console.log(cycleB))(1); + export const cycleA = () => cycleB(); + `, + "src/cycle-b.ts": ` + import { cycleA } from "./cycle-a"; + export const cycleB = () => cycleA(); + `, + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.circularDependencies).toEqual([]); + }); + + it("reports imported bindings read by an immediately invoked function", async () => { + const rootDirectory = createProject( + { + "src/index.ts": 'import { cycleA } from "./cycle-a"; console.log(cycleA);', + "src/cycle-a.ts": ` + import { cycleB } from "./cycle-b"; + (() => console.log(cycleB))(); + export const cycleA = () => cycleB(); + `, + "src/cycle-b.ts": ` + import { cycleA } from "./cycle-a"; + export const cycleB = () => cycleA(); + `, + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.circularDependencies).toHaveLength(1); + }); + + it("suppresses cycles that pass through generated route trees", async () => { + const rootDirectory = createProject( + { + "src/index.ts": ` + import { routeTree } from "./routeTree.gen"; + console.log(routeTree); + `, + "src/routeTree.gen.ts": ` + // This file was automatically generated by TanStack Router. + import { Route } from "./routes/root"; + export const routeTree = Route; + `, + "src/routes/root.ts": ` + import { routeTree } from "../routeTree.gen"; + export const Route = routeTree; + `, + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.circularDependencies).toEqual([]); + }); + + it("scopes generated outputs to real GraphQL codegen configuration", async () => { + const rootDirectory = createProject( + { + ".graphqlrc.json": JSON.stringify({ generates: { "./src/gql/": {} } }), + "vite.config.ts": ` + import graphqlCodegen from "vite-plugin-graphql-codegen"; + const dormant = (graphqlCodegen) => graphqlCodegen({ generates: { "./src/authored/": {} } }); + export default { plugins: [] }; + `, + "src/index.ts": "console.log('app');", + "src/gql/graphql.ts": "export interface GeneratedShape { value: string }", + "src/authored/manual.ts": "export interface ManualShape { value: string }", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/authored/manual.ts"]); + }); + + it("keeps negated and explanatory generated-via comments authored", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "console.log('app');", + "src/negated.ts": + "// This file is not generated via codegen; maintain manually.\nexport const negated = true;", + "src/explainer.ts": + "// This file documents how code is generated via our build.\nexport const explainer = true;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/explainer.ts", + "src/negated.ts", + ]); + }); + + it("only treats component paths inside Umi route arrays as entries", async () => { + const rootDirectory = createProject( + { + ".umirc.ts": `export default { + pluginOptions: { component: "@/unused" }, + routes: [{ path: "/", component: "@/used" }], + };`, + "src/used.tsx": "export default () => null;", + "src/unused.tsx": "export default () => null;", + }, + { dependencies: { umi: "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain("src/unused.tsx"); + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain("src/used.tsx"); + }); + + it("uses the root returned by a Vite config callback", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": `export default defineConfig(() => { + const preview = { root: "fixtures" }; + console.log(preview); + return {}; + });`, + "index.html": ``, + "src/main.ts": "console.log('app');", + "fixtures/index.html": ``, + "fixtures/main.ts": "console.log('fixture');", + }, + { devDependencies: { vite: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain("fixtures/main.ts"); + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain("src/main.ts"); + }); + + it("ignores dormant shadowed Taro page pushes", async () => { + const rootDirectory = createProject( + { + "src/app.config.ts": ` + const pages = ["pages/home/index"]; + const dormant = () => { const pages = []; pages.push("pages/unused/index"); }; + console.log(dormant); + export default { pages }; + `, + "src/pages/home/index.tsx": "export default () => null;", + "src/pages/unused/index.tsx": "export default () => null;", + }, + { dependencies: { "@tarojs/react": "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain( + "src/pages/unused/index.tsx", + ); + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain( + "src/pages/home/index.tsx", + ); + }); + + it("keeps filtered Taro pages without merging block-scoped pushes", async () => { + const rootDirectory = createProject( + { + "src/app.config.ts": ` + const pages = ["pages/home/index"]; + { + const pages = []; + pages.push("pages/unused/index"); + } + export default { pages: pages.filter(Boolean) }; + `, + "src/pages/home/index.tsx": "export default () => null;", + "src/pages/unused/index.tsx": "export default () => null;", + }, + { dependencies: { "@tarojs/react": "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain( + "src/pages/unused/index.tsx", + ); + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain( + "src/pages/home/index.tsx", + ); + }); + + it("ignores catch-bound Taro page pushes while preserving live catch pushes", async () => { + const rootDirectory = createProject( + { + "src/app.config.ts": ` + const pages = ["pages/home/index"]; + try { + throw []; + } catch (pages) { + pages.push("pages/shadowed/index"); + } + try { + throw new Error("include fallback"); + } catch (error) { + console.log(error); + pages.push("pages/fallback/index"); + } + export default { pages }; + `, + "src/pages/home/index.tsx": "export default () => null;", + "src/pages/fallback/index.tsx": "export default () => null;", + "src/pages/shadowed/index.tsx": "export default () => null;", + }, + { dependencies: { "@tarojs/react": "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + const unusedFilePaths = relativePaths(rootDirectory, result.unusedFiles); + + expect(unusedFilePaths).toContain("src/pages/shadowed/index.tsx"); + expect(unusedFilePaths).not.toContain("src/pages/home/index.tsx"); + expect(unusedFilePaths).not.toContain("src/pages/fallback/index.tsx"); + }); + + it("ignores loop-bound Taro page pushes while preserving live loop pushes", async () => { + const rootDirectory = createProject( + { + "src/app.config.ts": ` + const pages = ["pages/home/index"]; + for (const pages of [[]]) { + pages.push("pages/shadowed-for-of/index"); + } + for (let pages = []; false; ) { + pages.push("pages/shadowed-for/index"); + } + for (const fallback of ["fallback"]) { + console.log(fallback); + pages.push("pages/fallback/index"); + } + export default { pages }; + `, + "src/pages/home/index.tsx": "export default () => null;", + "src/pages/fallback/index.tsx": "export default () => null;", + "src/pages/shadowed-for/index.tsx": "export default () => null;", + "src/pages/shadowed-for-of/index.tsx": "export default () => null;", + }, + { dependencies: { "@tarojs/react": "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + const unusedFilePaths = relativePaths(rootDirectory, result.unusedFiles); + + expect(unusedFilePaths).toEqual( + expect.arrayContaining([ + "src/pages/shadowed-for/index.tsx", + "src/pages/shadowed-for-of/index.tsx", + ]), + ); + expect(unusedFilePaths).not.toContain("src/pages/home/index.tsx"); + expect(unusedFilePaths).not.toContain("src/pages/fallback/index.tsx"); + }); + + it("ignores nested Webpack resolve objects outside the exported config", async () => { + const rootDirectory = createProject( + { + "webpack.config.js": ` + module.exports = { + entry: "./src/index.js", + plugins: [{ options: { resolve: { modules: ["shared"] } } }], + }; + `, + "src/index.js": `import { value } from "thing"; console.log(value);`, + "shared/thing.js": "export const value = true;", + }, + { devDependencies: { webpack: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain("shared/thing.js"); + }); + + it("resolves callback-local Vite config identifiers", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": ` + import { defineConfig } from "vite"; + export default defineConfig(() => { + const config = { root: "app" }; + return config; + }); + `, + "app/index.html": ``, + "app/main.ts": "export const main = true;", + }, + { devDependencies: { vite: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain("app/main.ts"); + }); + + it("resolves explicit extensions through a TypeScript base URL", async () => { + const rootDirectory = createProject( + { + "tsconfig.json": JSON.stringify({ compilerOptions: { baseUrl: "src" } }), + "src/pages/index.astro": `---\nimport Layout from "Layout.astro";\n---\n`, + "src/Layout.astro": "
Layout
", + }, + { dependencies: { astro: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain("src/Layout.astro"); + }); + + it("ignores GraphQL codegen calls inside dormant Vite helpers", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": ` + import { defineConfig } from "vite"; + import graphqlCodegen from "vite-plugin-graphql-codegen"; + export default defineConfig(() => { + function dormant() { + return graphqlCodegen({ generates: { "./src/authored/": {} } }); + } + console.log(dormant); + return { plugins: [] }; + }); + `, + "src/authored/manual.ts": "export const manual = true;", + }, + { + devDependencies: { + vite: "1.0.0", + "vite-plugin-graphql-codegen": "1.0.0", + }, + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain("src/authored/manual.ts"); + }); + + it("expands statically bound Umi route array spreads", async () => { + const rootDirectory = createProject( + { + ".umirc.ts": ` + const extraRoutes = [{ path: "/manual", component: "@/manual" }]; + export default { routes: [...extraRoutes] }; + `, + "src/manual.tsx": "export default () => null;", + }, + { dependencies: { react: "1.0.0", umi: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain("src/manual.tsx"); + }); + + it("skips CommonMark code and HTML comments while retaining live MDX imports", async () => { + const rootDirectory = createProject( + { + "src/page.mdx": [ + " export function IndentedExample(", + "", + "```tsx", + "```not-a-close", + "export function FencedExample(", + "```", + 'import { Demo } from "./demo";', + "", + "", + ].join("\n"), + "src/demo.tsx": "export const Demo = () => null;", + "src/orphan.ts": "export const orphan = true;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/page.mdx"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("does not infer Sass compiler use from unreachable Sass files", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "console.log('app');", + "src/orphan.scss": "$color: red;", + }, + { scripts: { build: "next build" }, devDependencies: { next: "1.0.0", sass: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.unusedDependencies.map((dependency) => dependency.name)).toContain("sass"); + }); + + it("does not infer Parcel Sass use from an HTML file outside the Parcel entry command", async () => { + const rootDirectory = createProject( + { + "src/index.html": "
Application
", + "examples/old.html": '', + "examples/old.scss": "$color: red;", + }, + { + scripts: { build: "parcel src/index.html" }, + devDependencies: { "parcel-bundler": "1.0.0", sass: "1.0.0" }, + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(result.unusedDependencies.map((dependency) => dependency.name)).toContain("sass"); + }); + + it("does not infer Parcel Sass use from a commented stylesheet link", async () => { + const rootDirectory = createProject( + { + "src/index.html": '', + "src/old.scss": "$color: red;", + }, + { + scripts: { build: "parcel src/index.html" }, + devDependencies: { "parcel-bundler": "1.0.0", sass: "1.0.0" }, + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(result.unusedDependencies.map((dependency) => dependency.name)).toContain("sass"); + }); + + it("credits Sass through a live Sass loader and an extensionless build script", async () => { + for (const files of [ + { + "src/index.ts": `import "./theme.scss";`, + "src/theme.scss": "$color: red;", + "webpack.config.js": `module.exports = { module: { rules: [{ use: ["sass-loader"] }] } };`, + }, + { + "src/index.ts": "console.log('app');", + "src/theme.scss": "$color: red;", + "bin/build-css": "#!/usr/bin/env bash\nsass src/theme.scss dist/theme.css", + }, + ]) { + const rootDirectory = createProject(files, { + scripts: { build: "bin/build-css" in files ? "./bin/build-css" : "webpack" }, + devDependencies: { sass: "1.0.0", "sass-loader": "1.0.0", webpack: "1.0.0" }, + }); + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + expect(result.unusedDependencies.map((dependency) => dependency.name)).not.toContain("sass"); + } + }); + + it.each([ + { + compilerInput: "an Astro import", + files: { + "src/index.ts": "console.log('app');", + "src/pages/index.astro": '---\nimport "../styles.scss";\n---\n
', + "src/styles.scss": "$color: red;", + }, + scripts: { build: "astro build" }, + tools: { astro: "1.0.0" }, + }, + { + compilerInput: "a Parcel HTML stylesheet link", + files: { + "src/index.ts": "console.log('app');", + "src/html/index.html": + '', + "src/styles.scss": "$color: red;", + }, + scripts: { build: "parcel src/html/index.html" }, + tools: { "parcel-bundler": "1.0.0" }, + }, + { + compilerInput: "a Sass loader module import", + files: { + "src/index.ts": "console.log('app');", + "src/admin.tsx": 'import "./admin.scss"; export const Admin = () => null;', + "src/admin.scss": "$color: red;", + "webpack.config.js": + 'module.exports = { entry: "./src/admin.tsx", module: { rules: [{ use: ["sass-loader"] }] } };', + }, + scripts: { build: "webpack" }, + tools: { "sass-loader": "1.0.0", webpack: "1.0.0" }, + }, + ])("credits Sass through $compilerInput outside the reachable module graph", async (project) => { + const rootDirectory = createProject(project.files, { + scripts: project.scripts, + devDependencies: { sass: "1.0.0", ...project.tools }, + }); + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(result.unusedDependencies.map((dependency) => dependency.name)).not.toContain("sass"); + }); + + it("does not execute escaped cross-env-shell separators or heredoc payloads", async () => { + const rootDirectory = createProject( + { + "src/index.ts": "console.log('app');", + "bin/write-docs": [ + "#!/usr/bin/env bash", + "cat < dependency.name); + + expect(unusedPackageNames).toEqual(expect.arrayContaining(["escaped-tool", "payload-tool"])); + expect(unusedPackageNames).not.toContain("real-tool"); + }); + + it("follows Webpack require.context entries through partially parsed modules", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `import items from "./items"; console.log(items);`, + "src/items/index.js": ` + const context = require.context(".", true, /^\\.\\/[a-z\\-]+?\\/index\\.(js|jsx)$/); + export default context.keys(); + `, + "src/items/button/index.jsx": ` + import detail from "./detail"; + export default class Button { bind = ::this.render; render() { return detail; } } + `, + "src/items/button/detail.js": `export default "button";`, + "src/orphan.ts": "export const orphan = true;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("preserves fixed paths in Webpack require.context expressions", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `require.context(".", true, /^\\.\\/admin\\/index\\.(js|jsx)$/);`, + "src/admin/index.jsx": "export default null;", + "src/other/index.jsx": "export default null;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/other/index.jsx"]); + }); + + it("does not reinterpret absolute Webpack require.context directories as project roots", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `require.context("/outside", true, /^\\.\\/index\\.(js|jsx)$/);`, + "outside/index.jsx": "export default null;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["outside/index.jsx"]); + }); + + it("keeps live filename registries populated with push", async () => { + const rootDirectory = createProject( + { + "src/index.ts": ` + const pages = []; + pages.push("pages/consumed/index"); + console.log(pages); + `, + "src/pages/consumed/index.ts": "export const consumed = true;", + "src/orphan.ts": "export const orphan = true;", + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("keeps reachable Taro platform siblings", async () => { + const rootDirectory = createProject( + { + "src/app.tsx": `import { platform } from "./platform"; console.log(platform);`, + "src/app.rn.tsx": `import { platform } from "./platform"; console.log(platform);`, + "src/platform.ts": `export const platform = "default";`, + "src/platform.h5.ts": `export const platform = "h5";`, + "src/platform.rn.ts": `export const platform = "rn";`, + "src/platform.weapp.ts": `export const platform = "weapp";`, + "src/orphan.ts": "export const orphan = true;", + }, + { dependencies: { "@tarojs/react": "1.0.0", react: "1.0.0" } }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual(["src/orphan.ts"]); + }); + + it("reports framework-specific platform siblings outside their hosts", async () => { + const rootDirectory = createProject( + { + "src/index.ts": `import { platform } from "./platform"; console.log(platform);`, + "src/platform.ts": `export const platform = "default";`, + "src/platform.h5.ts": `export const platform = "h5";`, + "src/platform.rn.ts": `export const platform = "rn";`, + }, + {}, + ); + + const result = await analyzeProject({ rootDirectory, entryPatterns: ["src/index.ts"] }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toEqual([ + "src/platform.h5.ts", + "src/platform.rn.ts", + ]); + }); + + it("scopes framework platform siblings to their workspace package", async () => { + const rootDirectory = createProject( + { + "packages/taro/package.json": JSON.stringify({ + name: "@example/taro", + dependencies: { "@tarojs/react": "1.0.0", react: "1.0.0" }, + }), + "packages/taro/src/app.tsx": `import { value } from "./value"; console.log(value);`, + "packages/taro/src/value.ts": "export const value = true;", + "packages/taro/src/value.h5.ts": "export const value = true;", + "packages/web/package.json": JSON.stringify({ + name: "@example/web", + exports: "./src/index.ts", + }), + "packages/web/src/index.ts": `import { value } from "./value"; console.log(value);`, + "packages/web/src/value.ts": "export const value = true;", + "packages/web/src/value.h5.ts": "export const value = true;", + }, + { private: true, workspaces: ["packages/*"] }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).toContain( + "packages/web/src/value.h5.ts", + ); + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain( + "packages/taro/src/value.h5.ts", + ); + }); + + it("inherits root framework platform capabilities inside workspace packages", async () => { + const rootDirectory = createProject( + { + "packages/application/package.json": JSON.stringify({ + name: "@example/application", + exports: "./src/index.ts", + }), + "packages/application/src/index.ts": 'import { value } from "./value"; console.log(value);', + "packages/application/src/value.ts": "export const value = true;", + "packages/application/src/value.h5.ts": "export const value = true;", + "packages/application/src/value.weapp.ts": "export const value = true;", + }, + { + private: true, + workspaces: ["packages/*"], + dependencies: { "@tarojs/react": "1.0.0", react: "1.0.0" }, + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain( + "packages/application/src/value.h5.ts", + ); + expect(relativePaths(rootDirectory, result.unusedFiles)).not.toContain( + "packages/application/src/value.weapp.ts", + ); + }); +}); diff --git a/packages/core/tests/read-text-file-up-to-character-limit.test.ts b/packages/core/tests/read-text-file-up-to-character-limit.test.ts new file mode 100644 index 0000000000..75e99d7291 --- /dev/null +++ b/packages/core/tests/read-text-file-up-to-character-limit.test.ts @@ -0,0 +1,42 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { readTextFileUpToCharacterLimit } from "../src/utils/read-text-file-up-to-character-limit.js"; + +const MAXIMUM_LENGTH_CHARS = 3; +const EXPECTED_ASCII_PROBE_LENGTH = 10; + +let temporaryDirectory: string | null = null; + +afterEach(async () => { + if (temporaryDirectory !== null) { + await fs.rm(temporaryDirectory, { recursive: true, force: true }); + temporaryDirectory = null; + } +}); + +const readLimitedText = async (sourceText: string): Promise => { + temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "react-doctor-source-limit-")); + const filePath = path.join(temporaryDirectory, "source.tsx"); + await fs.writeFile(filePath, sourceText); + return readTextFileUpToCharacterLimit({ + filePath, + maximumLengthChars: MAXIMUM_LENGTH_CHARS, + sizeBytes: Buffer.byteLength(sourceText), + }); +}; + +describe("readTextFileUpToCharacterLimit", () => { + it("bounds an oversized ASCII read to the UTF-8 probe length", async () => { + const sourceText = await readLimitedText("a".repeat(100)); + + expect(sourceText).toHaveLength(EXPECTED_ASCII_PROBE_LENGTH); + }); + + it("retains multibyte text that fits the character limit", async () => { + const sourceText = await readLimitedText("界".repeat(MAXIMUM_LENGTH_CHARS)); + + expect(sourceText).toBe("界".repeat(MAXIMUM_LENGTH_CHARS)); + }); +}); diff --git a/packages/core/tests/resolve-dead-code-concurrency.test.ts b/packages/core/tests/resolve-dead-code-concurrency.test.ts deleted file mode 100644 index dd442c5548..0000000000 --- a/packages/core/tests/resolve-dead-code-concurrency.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { resolveDeadCodeConcurrency } from "../src/utils/resolve-dead-code-concurrency.js"; - -const GIB = 1024 * 1024 * 1024; - -describe("resolveDeadCodeConcurrency", () => { - it("is core-bound when memory is plentiful", () => { - // floor(64 GiB / 2 GiB) = 32 workers fit, so the 8 cores bind. - expect( - resolveDeadCodeConcurrency({ - availableCores: 8, - totalMemoryBytes: 64 * GIB, - cgroupMemoryLimitBytes: undefined, - }), - ).toBe(8); - }); - - it("keeps full project-level parallelism on a roomy dev box (10 cores / 16 GiB)", () => { - // floor(16 / 2) = 8 ≥ the 4 projects scanned concurrently, so every project - // still spawns its own worker — no serialization vs the prior uncapped path. - expect( - resolveDeadCodeConcurrency({ - availableCores: 10, - totalMemoryBytes: 16 * GIB, - cgroupMemoryLimitBytes: undefined, - }), - ).toBe(8); - }); - - it("collapses toward serial on a memory-starved runner", () => { - // floor(3 GiB / 2 GiB) = 1 — a small CI runner serializes the spawns through - // one slot instead of oversubscribing memory with N simultaneous children. - expect( - resolveDeadCodeConcurrency({ - availableCores: 8, - totalMemoryBytes: 3 * GIB, - cgroupMemoryLimitBytes: undefined, - }), - ).toBe(1); - }); - - it("honors a cgroup memory limit below the host total", () => { - // The container sees 200 GiB of HOST memory but its cgroup caps it at 4 GiB - // → floor(4 / 2) = 2. - expect( - resolveDeadCodeConcurrency({ - availableCores: 64, - totalMemoryBytes: 200 * GIB, - cgroupMemoryLimitBytes: 4 * GIB, - }), - ).toBe(2); - }); - - it("never drops below one worker", () => { - expect( - resolveDeadCodeConcurrency({ - availableCores: 8, - totalMemoryBytes: 512 * 1024 * 1024, - cgroupMemoryLimitBytes: undefined, - }), - ).toBe(1); - }); - - it("returns a positive integer on the real system", () => { - const resolved = resolveDeadCodeConcurrency(); - expect(Number.isInteger(resolved)).toBe(true); - expect(resolved).toBeGreaterThanOrEqual(1); - }); -}); diff --git a/packages/core/tests/resolve-dead-code-timeout.test.ts b/packages/core/tests/resolve-dead-code-timeout.test.ts deleted file mode 100644 index 3abea68925..0000000000 --- a/packages/core/tests/resolve-dead-code-timeout.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { - DEAD_CODE_TIMEOUT_CEILING_MS, - DEAD_CODE_TIMEOUT_MS_PER_SOURCE_FILE, - DEAD_CODE_WORKER_TIMEOUT_MS, -} from "../src/constants.js"; -import { resolveDeadCodeTimeout } from "../src/utils/resolve-dead-code-timeout.js"; - -const fullCores = 10; - -describe("resolveDeadCodeTimeout", () => { - it("floors a small repo at the fixed worker timeout (deslop needs a minimum)", () => { - const { workerTimeoutMs } = resolveDeadCodeTimeout({ - sourceFileCount: 50, - deadCodeConcurrency: fullCores, - fullConcurrency: fullCores, - }); - expect(workerTimeoutMs).toBe(DEAD_CODE_WORKER_TIMEOUT_MS); - }); - - it("scales the budget with file count for a large repo (the regression this fixes)", () => { - // ~8.9k files (Sentry-scale) where deslop legitimately runs ~120s+ and the - // old fixed 120s cap was tipped over by any contention, dropping findings. - const sourceFileCount = 8866; - const { workerTimeoutMs } = resolveDeadCodeTimeout({ - sourceFileCount, - deadCodeConcurrency: fullCores, - fullConcurrency: fullCores, - }); - expect(workerTimeoutMs).toBe(sourceFileCount * DEAD_CODE_TIMEOUT_MS_PER_SOURCE_FILE); - expect(workerTimeoutMs).toBeGreaterThan(DEAD_CODE_WORKER_TIMEOUT_MS); - }); - - it("scales up inversely with the core share when dead-code is overlapped onto fewer cores", () => { - const sourceFileCount = 4000; - const fullCore = resolveDeadCodeTimeout({ - sourceFileCount, - deadCodeConcurrency: fullCores, - fullConcurrency: fullCores, - }); - const halfCore = resolveDeadCodeTimeout({ - sourceFileCount, - deadCodeConcurrency: fullCores / 2, - fullConcurrency: fullCores, - }); - // Half the cores ⇒ deslop is ~2x slower ⇒ ~2x the budget so it still finishes. - expect(halfCore.workerTimeoutMs).toBe(fullCore.workerTimeoutMs * 2); - }); - - it("caps a pathologically large repo at the ceiling so a wedged worker is still reclaimed", () => { - const { workerTimeoutMs } = resolveDeadCodeTimeout({ - sourceFileCount: 10_000_000, - deadCodeConcurrency: fullCores, - fullConcurrency: fullCores, - }); - expect(workerTimeoutMs).toBe(DEAD_CODE_TIMEOUT_CEILING_MS); - }); -}); diff --git a/packages/core/tests/resolve-project-analysis-concurrency.test.ts b/packages/core/tests/resolve-project-analysis-concurrency.test.ts new file mode 100644 index 0000000000..a45aa5c4be --- /dev/null +++ b/packages/core/tests/resolve-project-analysis-concurrency.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vite-plus/test"; +import { resolveProjectAnalysisConcurrency } from "../src/utils/resolve-project-analysis-concurrency.js"; + +const GIB = 1024 * 1024 * 1024; +const MIB = 1024 * 1024; + +describe("resolveProjectAnalysisConcurrency", () => { + it("is core-bound when memory is plentiful", () => { + expect( + resolveProjectAnalysisConcurrency({ + availableCores: 8, + totalMemoryBytes: 64 * GIB, + cgroupMemoryLimitBytes: undefined, + }), + ).toBe(8); + }); + + it("is memory-bound on a constrained host", () => { + expect( + resolveProjectAnalysisConcurrency({ + availableCores: 32, + totalMemoryBytes: 6 * GIB, + cgroupMemoryLimitBytes: undefined, + }), + ).toBe(3); + }); + + it("honors a cgroup memory limit below the host total", () => { + expect( + resolveProjectAnalysisConcurrency({ + availableCores: 32, + totalMemoryBytes: 128 * GIB, + cgroupMemoryLimitBytes: 4 * GIB, + }), + ).toBe(2); + }); + + it("always permits one worker", () => { + expect( + resolveProjectAnalysisConcurrency({ + availableCores: 8, + totalMemoryBytes: 512 * MIB, + cgroupMemoryLimitBytes: undefined, + }), + ).toBe(1); + }); + + it("returns a positive integer on the current system", () => { + const concurrency = resolveProjectAnalysisConcurrency(); + expect(Number.isInteger(concurrency)).toBe(true); + expect(concurrency).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/core/tests/resolve-project-analysis-timeout.test.ts b/packages/core/tests/resolve-project-analysis-timeout.test.ts new file mode 100644 index 0000000000..aa7aa784bd --- /dev/null +++ b/packages/core/tests/resolve-project-analysis-timeout.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + PROJECT_ANALYSIS_WORKER_TIMEOUT_CEILING_MS, + PROJECT_ANALYSIS_WORKER_TIMEOUT_MS, + PROJECT_ANALYSIS_WORKER_TIMEOUT_MS_PER_SOURCE_FILE, +} from "../src/constants.js"; +import { resolveProjectAnalysisTimeout } from "../src/utils/resolve-project-analysis-timeout.js"; + +describe("resolveProjectAnalysisTimeout", () => { + it("uses the minimum timeout for small projects", () => { + expect(resolveProjectAnalysisTimeout(50)).toBe(PROJECT_ANALYSIS_WORKER_TIMEOUT_MS); + }); + + it("scales with the source file count", () => { + const sourceFileCount = 8_866; + expect(resolveProjectAnalysisTimeout(sourceFileCount)).toBe( + sourceFileCount * PROJECT_ANALYSIS_WORKER_TIMEOUT_MS_PER_SOURCE_FILE, + ); + }); + + it("caps the timeout for pathologically large projects", () => { + expect(resolveProjectAnalysisTimeout(10_000_000)).toBe( + PROJECT_ANALYSIS_WORKER_TIMEOUT_CEILING_MS, + ); + }); +}); diff --git a/packages/core/tests/resolve-project-rule-selections.test.ts b/packages/core/tests/resolve-project-rule-selections.test.ts new file mode 100644 index 0000000000..5094704de1 --- /dev/null +++ b/packages/core/tests/resolve-project-rule-selections.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + countOptInProjectRuleSelections, + resolveProjectRuleSelections, + shouldUseMaintainabilityLayer, + type RuleSeverityControls, +} from "@react-doctor/core"; + +const selectedRuleKeys = (controls?: RuleSeverityControls): string[] => + resolveProjectRuleSelections(controls).map((selection) => selection.ruleKey); + +describe("resolveProjectRuleSelections", () => { + it("enables only the default project rule without controls", () => { + expect(resolveProjectRuleSelections(undefined)).toEqual([ + { + ruleId: "duplicate-jsx-subtree", + ruleKey: "react-doctor/duplicate-jsx-subtree", + severity: "warn", + hasExplicitSeverity: false, + }, + ]); + }); + + it("does not activate opt-in project rules through a category override", () => { + expect(resolveProjectRuleSelections({ categories: { Maintainability: "error" } })).toEqual([ + { + ruleId: "duplicate-jsx-subtree", + ruleKey: "react-doctor/duplicate-jsx-subtree", + severity: "error", + hasExplicitSeverity: true, + }, + ]); + }); + + it("activates an opt-in project rule through its canonical key", () => { + expect( + resolveProjectRuleSelections({ + rules: { "react-doctor/unused-export": "error" }, + }), + ).toEqual( + expect.arrayContaining([ + { + ruleId: "unused-export", + ruleKey: "react-doctor/unused-export", + severity: "error", + hasExplicitSeverity: true, + }, + ]), + ); + }); + + it("activates opt-in project rules through legacy deslop aliases", () => { + expect( + resolveProjectRuleSelections({ + rules: { + "deslop/circular-dependency": "warn", + "deslop/unused-file": "error", + }, + }), + ).toEqual( + expect.arrayContaining([ + { + ruleId: "circular-dependency", + ruleKey: "react-doctor/circular-dependency", + severity: "warn", + hasExplicitSeverity: true, + }, + { + ruleId: "unused-file", + ruleKey: "react-doctor/unused-file", + severity: "error", + hasExplicitSeverity: true, + }, + ]), + ); + }); + + it("lets a per-rule opt-in override a disabled category", () => { + expect( + selectedRuleKeys({ + categories: { Maintainability: "off" }, + rules: { "react-doctor/unused-dependency": "warn" }, + }), + ).toEqual(["react-doctor/unused-dependency"]); + }); + + it("honors explicit off overrides", () => { + expect( + selectedRuleKeys({ + rules: { + "react-doctor/duplicate-jsx-subtree": "off", + "react-doctor/unused-type": "off", + }, + }), + ).toEqual([]); + }); + + it("counts only enabled opt-in graph rules", () => { + expect( + countOptInProjectRuleSelections({ + categories: { Maintainability: "error" }, + rules: { + "deslop/unused-export": "warn", + "react-doctor/unused-dependency": "error", + "react-doctor/unused-type": "off", + }, + }), + ).toBe(2); + }); + + it("loads maintainability for duplicate JSX or an opt-in graph rule", () => { + expect( + shouldUseMaintainabilityLayer({ + shouldRunDuplicateJsx: false, + userConfig: { rules: { "react-doctor/unused-export": "warn" } }, + }), + ).toBe(true); + expect( + shouldUseMaintainabilityLayer({ + shouldRunDuplicateJsx: false, + userConfig: null, + }), + ).toBe(false); + }); +}); diff --git a/packages/core/tests/rule-key-aliases.test.ts b/packages/core/tests/rule-key-aliases.test.ts index 6aae40f8ab..c2db08afd6 100644 --- a/packages/core/tests/rule-key-aliases.test.ts +++ b/packages/core/tests/rule-key-aliases.test.ts @@ -31,6 +31,14 @@ describe("rule-key-aliases", () => { ); }); + it("matches legacy deslop project rules to canonical React Doctor keys", () => { + expect(isSameRuleKey("deslop/unused-export", "react-doctor/unused-export")).toBe(true); + expect(isSameRuleKey("deslop/unused-file", "react-doctor/unused-file")).toBe(true); + expect(isSameRuleKey("deslop/circular-dependency", "react-doctor/circular-dependency")).toBe( + true, + ); + }); + it("matches short id to qualified key", () => { expect(isSameRuleKey("jsx-key", "react-doctor/jsx-key")).toBe(true); expect(isSameRuleKey("alt-text", "react-doctor/alt-text")).toBe(true); @@ -104,6 +112,13 @@ describe("rule-key-aliases", () => { expect(keys).toContain("react/jsx-key"); }); + it("returns canonical project-rule keys and their deslop aliases", () => { + expect(getEquivalentRuleKeys("react-doctor/unused-dependency")).toEqual([ + "react-doctor/unused-dependency", + "deslop/unused-dependency", + ]); + }); + it("returns only the key itself for unknown keys", () => { const keys = getEquivalentRuleKeys("some-unknown-rule"); expect(keys).toEqual(["some-unknown-rule"]); diff --git a/packages/core/tests/run-inspect.test.ts b/packages/core/tests/run-inspect.test.ts index 76a4ac4da3..2e0f375653 100644 --- a/packages/core/tests/run-inspect.test.ts +++ b/packages/core/tests/run-inspect.test.ts @@ -6,15 +6,16 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; -import { afterAll, describe, expect, it } from "vite-plus/test"; +import { afterAll, describe, expect, it, vi } from "vite-plus/test"; import type { + ChangedFileLineRanges, Diagnostic, ProjectInfo, ReactDoctorConfig, SourceFileEntry, } from "@react-doctor/core"; import { - DeadCodeAnalysisFailed, + MaintainabilityAnalysisFailed, GitInvocationFailed, NoReactDependency, OxlintSpawnFailed, @@ -38,10 +39,7 @@ import { Project } from "../src/services/project.js"; import { Reporter, ReporterCapture } from "../src/services/reporter.js"; import { Score } from "../src/services/score.js"; import { SupplyChain } from "../src/services/supply-chain.js"; -import { - DEAD_CODE_TIMEOUT_MS_PER_SOURCE_FILE, - DEAD_CODE_WORKER_TIMEOUT_MS, -} from "../src/constants.js"; +import { PROJECT_ANALYSIS_WORKER_TIMEOUT_MS } from "../src/constants.js"; const temporaryDirectories: string[] = []; afterAll(() => { @@ -91,12 +89,12 @@ const lintDiagnostic: Diagnostic = { }; const deadCodeDiagnostic: Diagnostic = { - filePath: "src/Unused.tsx", - plugin: "deslop", - rule: "unused-file", + filePath: "src/Card.tsx", + plugin: "react-doctor", + rule: "duplicate-jsx-subtree", severity: "warning", - message: "Unused file", - help: "Delete it.", + message: "Duplicated JSX structure", + help: "Extract a shared component.", line: 0, column: 0, category: "Maintainability", @@ -208,7 +206,7 @@ describe("runInspect — phase timeouts & overall deadline", () => { expect(output.diagnostics.map((diagnostic) => diagnostic.rule)).toContain("no-derived-state"); }); - it("caps the dead-code phase into didDeadCodeFail without sinking the rest of the scan", async () => { + it("maps a maintainability timeout into the legacy failure fields", async () => { const output = await Effect.runPromise( runInspect(baseInput).pipe( Effect.provide( @@ -222,7 +220,7 @@ describe("runInspect — phase timeouts & overall deadline", () => { ); expect(output.didDeadCodeFail).toBe(true); - expect(output.deadCodeFailureReason).toContain("Dead-code analysis exceeded"); + expect(output.deadCodeFailureReason).toContain("Maintainability analysis exceeded"); expect(output.deadCodeFailureReason).toContain("skipped"); // The scan still completed: lint diagnostics came through — but the score // is null because the scored set is missing the dead-code findings. @@ -277,7 +275,9 @@ describe("runInspect — phase timeouts & overall deadline", () => { expect(output.didDeadCodeFail).toBe(true); expect(output.deadCodeFailureReason).toContain("max scan duration reached"); - expect(output.diagnostics.map((diagnostic) => diagnostic.rule)).not.toContain("unused-file"); + expect(output.diagnostics.map((diagnostic) => diagnostic.rule)).not.toContain( + "duplicate-jsx-subtree", + ); expect(output.score).toBeNull(); }); @@ -363,13 +363,10 @@ describe("runInspect — happy path", () => { expect(receivedSourceFiles).toEqual([{ path: "src/App.tsx", sizeBytes: 120 }]); }); - it("keeps descendant projects out of ancestor lint and dead-code results", async () => { + it("keeps descendant projects out of ancestor lint and maintainability results", async () => { let lintIncludePaths: ReadonlyArray | undefined; - let didDeadCodeReceiveIgnorePatterns = false; let discoveredSourceFileCount: number | undefined; - const deadCodeWorkerTimeouts: Array = []; - const descendantSourceFileCount = - Math.floor(DEAD_CODE_WORKER_TIMEOUT_MS / DEAD_CODE_TIMEOUT_MS_PER_SOURCE_FILE) + 1; + const descendantSourceFileCount = 2; const sourceFiles = new Map([ ["/repo/src/root.tsx", "export const Root = null;"], ]); @@ -393,7 +390,11 @@ describe("runInspect — happy path", () => { }); }, }), - Config.layerOf({ config: null, resolvedDirectory: "/repo", configSourceDirectory: null }), + Config.layerOf({ + config: { rules: { "react-doctor/unused-export": "warn" } }, + resolvedDirectory: "/repo", + configSourceDirectory: null, + }), Files.layerInMemory(sourceFiles), Layer.mock(Linter, { run: (input) => { @@ -403,17 +404,19 @@ describe("runInspect — happy path", () => { }), LintPartialFailures.layerLive, Layer.mock(DeadCode, { - run: (input) => { - deadCodeWorkerTimeouts.push(input.workerTimeoutMs); - didDeadCodeReceiveIgnorePatterns = "ignorePatterns" in input; - return Stream.fromIterable([ + run: () => + Stream.fromIterable([ deadCodeDiagnostic, { ...deadCodeDiagnostic, - filePath: "packages/web/src/Unused.tsx", + filePath: "packages/web/src/Card.tsx", }, - ]); - }, + { + ...deadCodeDiagnostic, + filePath: "packages/web/src/unused.ts", + rule: "unused-export", + }, + ]), }), Git.layerOf({}), Score.layerOf({ score: 85, label: "Good" }), @@ -432,8 +435,7 @@ describe("runInspect — happy path", () => { ); expect(lintIncludePaths).toEqual(["src/root.tsx"]); - expect(didDeadCodeReceiveIgnorePatterns).toBe(false); - expect(output.diagnostics.map((diagnostic) => diagnostic.filePath)).toEqual(["src/Unused.tsx"]); + expect(output.diagnostics.map((diagnostic) => diagnostic.filePath)).toEqual(["src/Card.tsx"]); expect(output.scannedFilePaths).toEqual([path.resolve("/repo/src/root.tsx")]); expect(discoveredSourceFileCount).toBe(1); @@ -447,12 +449,8 @@ describe("runInspect — happy path", () => { ); expect(workspaceOutput.diagnostics.map((diagnostic) => diagnostic.filePath)).toEqual([ - "packages/web/src/Unused.tsx", - "src/Unused.tsx", - ]); - expect(deadCodeWorkerTimeouts).toEqual([ - DEAD_CODE_WORKER_TIMEOUT_MS, - (descendantSourceFileCount + 1) * DEAD_CODE_TIMEOUT_MS_PER_SOURCE_FILE, + "packages/web/src/Card.tsx", + "src/Card.tsx", ]); }); @@ -471,7 +469,7 @@ describe("runInspect — happy path", () => { expect(result.output.diagnostics).toHaveLength(2); expect(result.output.diagnostics.map((d) => d.rule)).toEqual([ "no-derived-state", - "unused-file", + "duplicate-jsx-subtree", ]); expect(result.output.didLintFail).toBe(false); expect(result.output.didDeadCodeFail).toBe(false); @@ -489,7 +487,10 @@ describe("runInspect — happy path", () => { expect(result.output.resolvedDirectory).toBe("/repo"); expect(result.output.lintPartialFailures).toEqual([]); expect(result.captured).toHaveLength(2); - expect(result.captured.map((d) => d.rule)).toEqual(["no-derived-state", "unused-file"]); + expect(result.captured.map((d) => d.rule)).toEqual([ + "no-derived-state", + "duplicate-jsx-subtree", + ]); }); it("returns empty diagnostics when no service emits", async () => { @@ -787,13 +788,13 @@ describe("runInspect — mid-stream lint failure", () => { }); }); -describe("runInspect — dead-code failure", () => { - it("folds DeadCode failure without sinking the scan", async () => { +describe("runInspect — maintainability failure", () => { + it("folds Maintainability failure without sinking the scan", async () => { const failingDeadCode = Layer.mock(DeadCode, { run: () => Stream.fail( new ReactDoctorError({ - reason: new DeadCodeAnalysisFailed({ cause: "synthetic boom" }), + reason: new MaintainabilityAnalysisFailed({ cause: "synthetic boom" }), }), ), }); @@ -814,21 +815,17 @@ describe("runInspect — dead-code failure", () => { ); const output = await Effect.runPromise(runInspect(baseInput).pipe(Effect.provide(layers))); expect(output.didDeadCodeFail).toBe(true); - expect(output.deadCodeFailureReason).toContain("Dead-code analysis failed"); + expect(output.deadCodeFailureReason).toContain("Maintainability analysis failed"); expect(output.didLintFail).toBe(false); expect(output.diagnostics).toHaveLength(1); expect(output.diagnostics[0].rule).toBe("no-derived-state"); }); }); -describe("runInspect — dead-code/lint overlap", () => { - it("records synchronous cache callbacks from the overlap fiber", async () => { +describe("runInspect — dead-code compatibility fields", () => { + it("keeps removed cache outcomes null", async () => { const deadCodeWithCacheCallbacks = Layer.mock(DeadCode, { - run: (input) => { - input.onCacheOutcome?.(true); - input.onSummaryCacheStats?.({ hits: 7, misses: 2 }); - return Stream.fromIterable([deadCodeDiagnostic]); - }, + run: () => Stream.fromIterable([deadCodeDiagnostic]), }); const output = await Effect.runPromise( runInspect(baseInput).pipe( @@ -855,12 +852,12 @@ describe("runInspect — dead-code/lint overlap", () => { ), ); - expect(output.deadCodeCacheHit).toBe(true); - expect(output.deadCodeSummaryCacheHits).toBe(7); - expect(output.deadCodeSummaryCacheMisses).toBe(2); + expect(output.deadCodeCacheHit).toBeNull(); + expect(output.deadCodeSummaryCacheHits).toBeNull(); + expect(output.deadCodeSummaryCacheMisses).toBeNull(); }); - it("forced on: diagnostics + score identical to sequential, overlap recorded", async () => { + it("ignores the removed overlap mode while preserving diagnostics", async () => { const result = await Effect.runPromise( Effect.gen(function* () { const output = yield* runInspect(baseInput); @@ -881,16 +878,16 @@ describe("runInspect — dead-code/lint overlap", () => { // independent of which fiber finished first — the core overlap invariant. expect(result.output.diagnostics.map((diagnostic) => diagnostic.rule)).toEqual([ "no-derived-state", - "unused-file", + "duplicate-jsx-subtree", ]); - expect(result.output.deadCodeOverlapped).toBe(true); + expect(result.output.deadCodeOverlapped).toBe(false); expect(result.output.didDeadCodeFail).toBe(false); expect(result.output.score).toEqual({ score: 85, label: "Good" }); // Emit order MAY interleave under overlap (the forked fiber emits during // lint), so assert the captured SET rather than the sequence. Production // uses Reporter.layerNoop, so emit order is unobservable there regardless. expect(new Set(result.captured.map((diagnostic) => diagnostic.rule))).toEqual( - new Set(["no-derived-state", "unused-file"]), + new Set(["no-derived-state", "duplicate-jsx-subtree"]), ); }); @@ -908,7 +905,7 @@ describe("runInspect — dead-code/lint overlap", () => { ); expect(output.diagnostics.map((diagnostic) => diagnostic.rule)).toEqual([ "no-derived-state", - "unused-file", + "duplicate-jsx-subtree", ]); expect(output.deadCodeOverlapped).toBe(false); expect(output.didDeadCodeFail).toBe(false); @@ -969,7 +966,7 @@ describe("runInspect — hooks fire in order", () => { }); describe("runInspect — scan progress phases", () => { - it("runs dead-code after lint and labels it as a separate progress phase", async () => { + it("runs maintainability after lint and labels it as a separate progress phase", async () => { const phaseEvents: string[] = []; const trackingLinter = Layer.mock(Linter, { run: () => @@ -984,7 +981,7 @@ describe("runInspect — scan progress phases", () => { run: () => Stream.unwrap( Effect.sync(() => { - phaseEvents.push("dead-code"); + phaseEvents.push("maintainability"); return Stream.fromIterable([deadCodeDiagnostic]); }), ), @@ -1023,21 +1020,23 @@ describe("runInspect — scan progress phases", () => { expect(result.output.diagnostics.map((diagnostic) => diagnostic.rule)).toEqual([ "no-derived-state", - "unused-file", + "duplicate-jsx-subtree", ]); - expect(phaseEvents).toEqual(["lint", "afterLint", "dead-code"]); + expect(phaseEvents).toEqual(["lint", "afterLint", "maintainability"]); const progressTexts = result.progressEvents.map((event) => event.text); expect(progressTexts).toContain("Scanning..."); // The dead-code phase carries the scanned file total so the counter never // appears to stall short of N before the handoff (issue #815). expect( - progressTexts.some((text) => /^Scanned \d+ files?, analyzing dead code\.\.\.$/.test(text)), - `dead-code phase should report the scanned file total, got: ${progressTexts.join(" | ")}`, + progressTexts.some((text) => + /^Scanned \d+ files?, analyzing maintainability\.\.\.$/.test(text), + ), + `maintainability phase should report the scanned file total, got: ${progressTexts.join(" | ")}`, ).toBe(true); }); }); -describe("runInspect — diff mode skips dead-code", () => { +describe("runInspect — diff mode focuses maintainability", () => { it("canonicalizes file coverage before counting completed include paths", async () => { const coverageLinter = Layer.mock(Linter, { run: (input) => @@ -1063,17 +1062,180 @@ describe("runInspect — diff mode skips dead-code", () => { expect(output.analyzedFiles).toEqual(["src/App.tsx"]); }); - it("treats includePaths.length > 0 as diff mode and skips DeadCode.run", async () => { + it("runs maintainability in diff mode", async () => { const output = await Effect.runPromise( runInspect({ ...baseInput, includePaths: ["src/App.tsx"] }).pipe( Effect.provide(layersOf({ diagnostics: [lintDiagnostic], deadCode: [deadCodeDiagnostic] })), ), ); - // Lint diagnostic flows through; dead-code stream is replaced with empty. - expect(output.diagnostics.map((d) => d.rule)).toEqual(["no-derived-state"]); + expect(output.diagnostics.map((d) => d.rule)).toEqual([ + "no-derived-state", + "duplicate-jsx-subtree", + ]); expect(output.didDeadCodeFail).toBe(false); }); + it("forwards changed line ranges to maintainability", async () => { + const changedLineRanges: ReadonlyArray = [ + { file: "src/App.tsx", ranges: [[4, 8]] }, + ]; + let receivedChangedLineRanges: ReadonlyArray | undefined; + const captureMaintainabilityInput = Layer.mock(DeadCode, { + run: (input) => { + receivedChangedLineRanges = input.changedLineRanges; + return Stream.empty; + }, + }); + + await Effect.runPromise( + runInspect({ + ...baseInput, + includePaths: ["src/App.tsx"], + changedLineRanges, + }).pipe( + Effect.provide( + Layer.merge(layersOf({ diagnostics: [lintDiagnostic] }), captureMaintainabilityInput), + ), + ), + ); + + expect(receivedChangedLineRanges).toEqual(changedLineRanges); + }); + + it("runs explicitly enabled warning rules when global warnings are hidden", async () => { + let receivedRuleIds: ReadonlySet | undefined; + const captureMaintainabilityInput = Layer.mock(DeadCode, { + run: (input) => { + receivedRuleIds = input.enabledProjectRuleIds; + return Stream.empty; + }, + }); + + await Effect.runPromise( + runInspect({ ...baseInput, warnings: false }).pipe( + Effect.provide( + Layer.merge( + layersOf({ + reactDoctorConfig: { + rules: { "react-doctor/unused-export": "warn" }, + }, + }), + captureMaintainabilityInput, + ), + ), + ), + ); + + expect([...(receivedRuleIds ?? new Set())]).toEqual(["unused-export"]); + }); + + it("keeps opt-in graph rules active when duplicate JSX is disabled", async () => { + let receivedRuleIds: ReadonlySet | undefined; + let receivedIgnorePatterns: ReadonlyArray | undefined; + let receivedWorkerTimeoutMs: number | undefined; + const runMaintainability = vi.fn((input) => { + receivedRuleIds = input.enabledProjectRuleIds; + receivedIgnorePatterns = input.ignorePatterns; + receivedWorkerTimeoutMs = input.workerTimeoutMs; + return Stream.empty; + }); + + await Effect.runPromise( + runInspect({ ...baseInput, runDeadCode: false }).pipe( + Effect.provide( + Layer.merge( + layersOf({ + reactDoctorConfig: { + rules: { "react-doctor/unused-export": "warn" }, + ignore: { files: ["src/generated/**"] }, + }, + }), + Layer.mock(DeadCode, { run: runMaintainability }), + ), + ), + ), + ); + + expect(runMaintainability).toHaveBeenCalledTimes(1); + expect([...(receivedRuleIds ?? new Set())]).toEqual(["unused-export"]); + expect(receivedIgnorePatterns).toEqual(["src/generated/**"]); + expect(receivedWorkerTimeoutMs).toBe(PROJECT_ANALYSIS_WORKER_TIMEOUT_MS); + }); + + it("skips project analysis when its tag is ignored", async () => { + const runMaintainability = vi.fn(() => Stream.fromIterable([deadCodeDiagnostic])); + + await Effect.runPromise( + runInspect({ + ...baseInput, + ignoredTags: new Set(["project-analysis"]), + }).pipe( + Effect.provide( + Layer.merge( + layersOf({ + reactDoctorConfig: { + rules: { "react-doctor/unused-export": "warn" }, + }, + }), + Layer.mock(DeadCode, { run: runMaintainability }), + ), + ), + ), + ); + + expect(runMaintainability).not.toHaveBeenCalled(); + }); + + it("skips project analysis in design-only mode", async () => { + const runMaintainability = vi.fn(() => Stream.fromIterable([deadCodeDiagnostic])); + + await Effect.runPromise( + runInspect({ + ...baseInput, + includedTags: new Set(["design"]), + }).pipe( + Effect.provide( + Layer.merge( + layersOf({ + reactDoctorConfig: { + rules: { "react-doctor/unused-export": "warn" }, + }, + }), + Layer.mock(DeadCode, { run: runMaintainability }), + ), + ), + ), + ); + + expect(runMaintainability).not.toHaveBeenCalled(); + }); + + it("skips graph rules in partial scans", async () => { + const runMaintainability = vi.fn(() => Stream.empty); + const captureMaintainabilityInput = Layer.mock(DeadCode, { run: runMaintainability }); + + await Effect.runPromise( + runInspect({ + ...baseInput, + runDeadCode: false, + includePaths: ["src/App.tsx"], + }).pipe( + Effect.provide( + Layer.merge( + layersOf({ + reactDoctorConfig: { + rules: { "react-doctor/unused-export": "warn" }, + }, + }), + captureMaintainabilityInput, + ), + ), + ), + ); + + expect(runMaintainability).not.toHaveBeenCalled(); + }); + it("passes every supported explicit source file through to the linter", async () => { const nextProject: ProjectInfo = { ...sampleProject, @@ -1110,6 +1272,7 @@ describe("runInspect — diff mode skips dead-code", () => { Effect.gen(function* () { const output = yield* runInspect({ ...baseInput, + runDeadCode: false, includePaths: ["middleware.ts", "src/proxy.mjs", "src/server.ts", "src/App.tsx"], }); const ref = yield* ReporterCapture; @@ -1298,11 +1461,11 @@ describe("runInspect — supply-chain lint overlap", () => { // `sortDiagnosticsStable`-ordered by (filePath, line, …) — deterministic // regardless of which fiber settled first. filePath order: // "/repo/src/App.tsx" (no-derived-state) < "package.json" - // (low-supply-chain-score) < "src/Unused.tsx" (unused-file). + // (low-supply-chain-score) < "src/Card.tsx" (duplicate-jsx-subtree). expect(output.diagnostics.map((d) => d.rule)).toEqual([ "no-derived-state", "low-supply-chain-score", - "unused-file", + "duplicate-jsx-subtree", ]); expect(output.supplyChainOverlapTimedOut).toBe(false); expect(output.securityScanFailed).toBe(false); diff --git a/packages/core/tests/services/dead-code.test.ts b/packages/core/tests/services/dead-code.test.ts deleted file mode 100644 index 0f7e80792c..0000000000 --- a/packages/core/tests/services/dead-code.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Stream from "effect/Stream"; -import { describe, expect, it } from "vite-plus/test"; -import type { Diagnostic } from "@react-doctor/core"; -import { DeadCode } from "../../src/services/dead-code.js"; - -const sampleDiagnostic: Diagnostic = { - filePath: "src/UnusedFile.tsx", - plugin: "deslop", - rule: "unused-file", - severity: "warning", - message: "Unused file", - help: "Delete or import it.", - line: 0, - column: 0, - category: "Maintainability", -}; - -describe("DeadCode.layerOf", () => { - it("emits the supplied diagnostics as a stream", async () => { - const collected = await Effect.runPromise( - Effect.gen(function* () { - const deadCode = yield* DeadCode; - return yield* Stream.runCollect(deadCode.run({ rootDirectory: "/repo" })); - }).pipe(Effect.provide(DeadCode.layerOf([sampleDiagnostic, sampleDiagnostic]))), - ); - const items = Array.from(collected); - expect(items).toHaveLength(2); - expect(items[0].rule).toBe("unused-file"); - }); - - it("emits an empty stream when constructed with []", async () => { - const collected = await Effect.runPromise( - Effect.gen(function* () { - const deadCode = yield* DeadCode; - return yield* Stream.runCollect(deadCode.run({ rootDirectory: "/repo" })); - }).pipe(Effect.provide(DeadCode.layerOf([]))), - ); - expect(Array.from(collected)).toEqual([]); - }); -}); - -describe("DeadCode.layerNode", () => { - it("returns an empty stream when the directory has no package.json", async () => { - const exit = await Effect.runPromiseExit( - Effect.gen(function* () { - const deadCode = yield* DeadCode; - return yield* Stream.runCollect( - deadCode.run({ - rootDirectory: "/this/path/should/not/exist/dead-code-test-12345", - }), - ); - }).pipe(Effect.provide(DeadCode.layerNode)), - ); - // checkDeadCode short-circuits to [] when package.json doesn't exist, - // so the stream completes successfully with no diagnostics. - expect(Exit.isSuccess(exit)).toBe(true); - if (Exit.isSuccess(exit)) { - expect(Array.from(exit.value)).toEqual([]); - } - }); -}); diff --git a/packages/core/tests/services/maintainability.test.ts b/packages/core/tests/services/maintainability.test.ts new file mode 100644 index 0000000000..9577c55a2e --- /dev/null +++ b/packages/core/tests/services/maintainability.test.ts @@ -0,0 +1,152 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import type { ChangedFileLineRanges } from "../../src/types/index.js"; +import { DeadCode } from "../../src/services/dead-code.js"; +import { Maintainability } from "../../src/services/maintainability.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +const componentSource = (componentName: string, valueName: string): string => ` +export const ${componentName} = () => ( + <${componentName}Screen> + +
+
</header> + <main><Value value={${valueName}} /></main> + <footer><Button /></footer> + </section> + </Page> + </${componentName}Screen> +); +`; + +const createProject = (): string => { + const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-maintainability-")); + temporaryDirectories.push(rootDirectory); + fs.mkdirSync(path.join(rootDirectory, "src")); + fs.writeFileSync( + path.join(rootDirectory, "src", "account.tsx"), + componentSource("Account", "account"), + ); + fs.writeFileSync(path.join(rootDirectory, "src", "user.tsx"), componentSource("User", "user")); + fs.writeFileSync( + path.join(rootDirectory, "src", "ignored.test.tsx"), + componentSource("Ignored", "ignored"), + ); + return rootDirectory; +}; + +const createSingleFileProject = (): string => { + const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-maintainability-")); + temporaryDirectories.push(rootDirectory); + fs.mkdirSync(path.join(rootDirectory, "src")); + fs.writeFileSync( + path.join(rootDirectory, "src", "cards.tsx"), + [componentSource("Account", "account"), componentSource("User", "user")].join("\n"), + ); + return rootDirectory; +}; + +const runService = ( + rootDirectory: string, + focusPaths?: ReadonlyArray<string>, + changedLineRanges?: ReadonlyArray<ChangedFileLineRanges>, + ignorePatterns?: ReadonlyArray<string>, +) => + Effect.runPromise( + Effect.gen(function* () { + const maintainability = yield* Maintainability; + return yield* Stream.runCollect( + maintainability.run({ rootDirectory, focusPaths, changedLineRanges, ignorePatterns }), + ); + }).pipe(Effect.provide(Maintainability.layerNode)), + ); + +describe("Maintainability.layerNode", () => { + it("reports maximal cross-file JSX families with related composition paths", async () => { + const diagnostics = Array.from(await runService(createProject())); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + filePath: "src/account.tsx", + plugin: "react-doctor", + rule: "duplicate-jsx-subtree", + category: "Maintainability", + relatedLocations: [ + { + filePath: "src/user.tsx", + message: expect.stringContaining("User > UserScreen > Page"), + }, + ], + }); + expect(diagnostics[0].message).toContain("Composition path: Account > AccountScreen > Page"); + expect(diagnostics[0].message).toContain("2 copies"); + }); + + it("analyzes the whole corpus but focuses the primary location on a changed file", async () => { + const diagnostics = Array.from(await runService(createProject(), ["src/user.tsx"])); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].filePath).toBe("src/user.tsx"); + expect(diagnostics[0].relatedLocations?.[0].filePath).toBe("src/account.tsx"); + }); + + it("reports substantial duplication across components in one production file", async () => { + const diagnostics = Array.from(await runService(createSingleFileProject())); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].filePath).toBe("src/cards.tsx"); + expect(diagnostics[0].message).toContain("Composition path: Account > AccountScreen > Page"); + expect(diagnostics[0].relatedLocations?.[0]).toMatchObject({ + filePath: "src/cards.tsx", + message: expect.stringContaining("User > UserScreen > Page"), + }); + }); + + it("excludes ignored files from the duplicate JSX corpus", async () => { + const rootDirectory = createProject(); + const diagnostics = Array.from( + await runService(rootDirectory, undefined, undefined, ["src/user.tsx"]), + ); + + expect(diagnostics).toEqual([]); + }); + + it("promotes a changed-line occurrence and suppresses unchanged matches", async () => { + const rootDirectory = createSingleFileProject(); + const changedUser = Array.from( + await runService( + rootDirectory, + ["src/cards.tsx"], + [{ file: "src/cards.tsx", ranges: [[14, 24]] }], + ), + ); + const unchangedOnly = Array.from( + await runService( + rootDirectory, + ["src/cards.tsx"], + [{ file: "src/cards.tsx", ranges: [[1, 1]] }], + ), + ); + + expect(changedUser).toHaveLength(1); + expect(changedUser[0].message).toContain("Composition path: User > UserScreen > Page"); + expect(unchangedOnly).toEqual([]); + }); +}); + +describe("DeadCode compatibility alias", () => { + it("resolves to the maintainability service", () => { + expect(DeadCode).toBe(Maintainability); + }); +}); diff --git a/packages/core/tests/static-config-entries.test.ts b/packages/core/tests/static-config-entries.test.ts new file mode 100644 index 0000000000..93fc36fbfe --- /dev/null +++ b/packages/core/tests/static-config-entries.test.ts @@ -0,0 +1,233 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { analyzeProject } from "../src/project-analysis/analyze-project.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +const createProject = ( + files: Readonly<Record<string, string>>, + packageJson: Readonly<Record<string, unknown>>, +): string => { + const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-static-config-")); + temporaryDirectories.push(rootDirectory); + fs.writeFileSync(path.join(rootDirectory, "package.json"), JSON.stringify(packageJson)); + for (const [relativePath, source] of Object.entries(files)) { + const filePath = path.join(rootDirectory, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, source); + } + return fs.realpathSync(rootDirectory); +}; + +const relativeUnusedFiles = async ( + rootDirectory: string, + entryPatterns: string[] = [], +): Promise<string[]> => { + const result = await analyzeProject({ rootDirectory, entryPatterns }); + return result.unusedFiles.map((finding) => + path.relative(rootDirectory, finding.path).replaceAll("\\", "/"), + ); +}; + +describe("static JavaScript config entries", () => { + it("resolves statically bound shorthand Vite roots and Rollup inputs", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": ` + import { join } from "node:path"; + import { defineConfig } from "vite"; + const root = join(__dirname, "app"); + const input = { main: join(root, "main.html") }; + const rollupOptions = { input }; + const build = { rollupOptions }; + const config = { root, build }; + export default defineConfig(config); + `, + "app/main.html": `<script type="module" src="/main.ts"></script>`, + "app/main.ts": `import { value } from "./value"; console.log(value);`, + "app/value.ts": "export const value = 1;", + "src/orphan.ts": "export const orphan = 1;", + }, + { devDependencies: { vite: "1.0.0" } }, + ); + + expect(await relativeUnusedFiles(rootDirectory)).toEqual(["src/orphan.ts"]); + }); + + it("resolves statically bound shorthand tsup and tsdown entries", async () => { + const rootDirectory = createProject( + { + "tsup.config.ts": ` + import { defineConfig } from "tsup"; + const entry = ["./src/tsup-entry.ts"]; + const config = { entry }; + export default defineConfig(config); + `, + "tsdown.config.ts": ` + import { defineConfig } from "tsdown"; + const entry = { cli: "./src/tsdown-entry.ts" }; + export default defineConfig({ entry }); + `, + "src/tsup-entry.ts": "export const tsupEntry = 1;", + "src/tsdown-entry.ts": "export const tsdownEntry = 1;", + "src/orphan.ts": "export const orphan = 1;", + }, + { devDependencies: { tsup: "1.0.0", tsdown: "1.0.0" } }, + ); + + expect(await relativeUnusedFiles(rootDirectory)).toEqual(["src/orphan.ts"]); + }); + + it("resolves statically bound Jest matches and setup files", async () => { + const rootDirectory = createProject( + { + "jest.config.ts": ` + const testMatch = ["**/*.custom.ts"]; + const setupFilesAfterEnv = ["./src/jest-setup.ts"]; + const config = { testMatch, setupFilesAfterEnv }; + export default config; + `, + "src/example.custom.ts": `import { helper } from "./helper"; console.log(helper);`, + "src/helper.ts": "export const helper = 1;", + "src/jest-setup.ts": "globalThis.setup = true;", + "src/orphan.ts": "export const orphan = 1;", + }, + { devDependencies: { jest: "1.0.0" } }, + ); + + expect(await relativeUnusedFiles(rootDirectory)).toEqual(["src/orphan.ts"]); + }); + + it("resolves statically bound Vitest includes and setup files", async () => { + const rootDirectory = createProject( + { + "vitest.config.ts": ` + import { defineConfig } from "vitest/config"; + const include = ["src/**/*.check.ts"]; + const setupFiles = ["./src/vitest-setup.ts"]; + const test = { include, setupFiles }; + export default defineConfig({ test }); + `, + "src/example.check.ts": `import { helper } from "./helper"; console.log(helper);`, + "src/helper.ts": "export const helper = 1;", + "src/vitest-setup.ts": "globalThis.setup = true;", + "src/orphan.ts": "export const orphan = 1;", + }, + { devDependencies: { vitest: "1.0.0" } }, + ); + + expect(await relativeUnusedFiles(rootDirectory)).toEqual(["src/orphan.ts"]); + }); + + it("resolves statically bound GraphQL codegen inputs and outputs", async () => { + const rootDirectory = createProject( + { + "codegen.ts": ` + const generates = { "./src/generated.ts": { plugins: ["typescript"] } }; + const documents = ["./src/documents/**/*.ts"]; + const schema = "./src/schema.ts"; + const config = { generates, documents, schema }; + export default config; + `, + "src/index.ts": "console.log('app');", + "src/generated.ts": "export interface GeneratedShape { value: string }", + "src/documents/query.ts": `import { documentHelper } from "../document-helper"; console.log(documentHelper);`, + "src/document-helper.ts": "export const documentHelper = 1;", + "src/schema.ts": `import { schemaHelper } from "./schema-helper"; console.log(schemaHelper);`, + "src/schema-helper.ts": "export const schemaHelper = 1;", + "src/orphan.ts": "export const orphan = 1;", + }, + { devDependencies: { "@graphql-codegen/cli": "1.0.0" } }, + ); + + expect(await relativeUnusedFiles(rootDirectory, ["src/index.ts"])).toEqual([ + "src/document-helper.ts", + "src/orphan.ts", + ]); + }); + + it("resolves statically bound GraphQL codegen Vite plugin options", async () => { + const rootDirectory = createProject( + { + "vite.config.ts": ` + import graphqlCodegen from "vite-plugin-graphql-codegen"; + const generates = { "./src/generated.ts": {} }; + const codegenConfig = { generates }; + const plugins = [graphqlCodegen(codegenConfig)]; + export default { plugins }; + `, + "src/index.ts": "console.log('app');", + "src/generated.ts": "export interface GeneratedShape { value: string }", + "src/orphan.ts": "export const orphan = 1;", + }, + { devDependencies: { "vite-plugin-graphql-codegen": "1.0.0" } }, + ); + + expect(await relativeUnusedFiles(rootDirectory, ["src/index.ts"])).toEqual(["src/orphan.ts"]); + }); + + it("ignores statically bound GraphQL codegen objects that are not exported", async () => { + const rootDirectory = createProject( + { + "codegen.ts": ` + const generates = { "./src/manual.ts": {} }; + console.log(generates); + export default {}; + `, + "src/index.ts": "console.log('app');", + "src/manual.ts": "export const manual = 1;", + }, + { devDependencies: { "@graphql-codegen/cli": "1.0.0" } }, + ); + + expect(await relativeUnusedFiles(rootDirectory, ["src/index.ts"])).toEqual(["src/manual.ts"]); + }); + + it("resolves GraphQL codegen YAML anchors, flow maps, and object-map inputs", async () => { + const rootDirectory = createProject( + { + "codegen.yml": ` + shared: &shared + documents: { "./src/documents/**/*.ts": { noRequire: true } } + schema: ["https://example.com/graphql", "./src/schema.ts"] + generates: { "./src/generated.ts": { <<: *shared } } + `, + "src/index.ts": "console.log('app');", + "src/generated.ts": "export interface GeneratedShape { value: string }", + "src/documents/query.ts": "export const query = 1;", + "src/schema.ts": "export const schema = 1;", + "src/orphan.ts": "export const orphan = 1;", + }, + { devDependencies: { "@graphql-codegen/cli": "1.0.0" } }, + ); + + expect(await relativeUnusedFiles(rootDirectory, ["src/index.ts"])).toEqual(["src/orphan.ts"]); + }); + + it("preserves URL-bearing GraphQL codegen JSON strings", async () => { + const rootDirectory = createProject( + { + ".graphqlrc.json": JSON.stringify({ + schema: "https://example.com/graphql", + documents: { "./src/documents/**/*.ts": {} }, + generates: { "./src/generated.ts": {} }, + }), + "src/index.ts": "console.log('app');", + "src/generated.ts": "export interface GeneratedShape { value: string }", + "src/documents/query.ts": "export const query = 1;", + "src/orphan.ts": "export const orphan = 1;", + }, + { devDependencies: { "@graphql-codegen/cli": "1.0.0" } }, + ); + + expect(await relativeUnusedFiles(rootDirectory, ["src/index.ts"])).toEqual(["src/orphan.ts"]); + }); +}); diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index 2a68bfdfef..57f5c54a1e 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -12,12 +12,15 @@ const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.j export default defineConfig({ pack: [ { - entry: { index: "./src/index.ts", schemas: "./src/schemas.ts" }, + entry: { + index: "./src/index.ts", + "project-analysis-worker": "./src/project-analysis-worker.ts", + schemas: "./src/schemas.ts", + }, deps: { neverBundle: [ "@astrojs/compiler", "@effect/platform-node-shared", - "deslop-js", "effect", "oxc-parser", "oxc-resolver", diff --git a/packages/deslop-cli/LICENSE b/packages/deslop-cli/LICENSE deleted file mode 100644 index aef05f7022..0000000000 --- a/packages/deslop-cli/LICENSE +++ /dev/null @@ -1,34 +0,0 @@ -Modified MIT License - -Copyright (c) 2026 Million Software, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -Our only modification is that the following uses require prior written -permission from the copyright holder. To request permission, contact -founders@million.dev. - -1. Using the Software, its source code, or any derivative works thereof, in - whole or in part, as training, fine-tuning, or evaluation data, or as input - to any automated pipeline for training or improving any machine learning - model or AI system. - -2. Selling the Software, or offering it to third parties as a paid, hosted, or - managed product or service (including any commercial API or SaaS offering) - whose value derives entirely or substantially from the Software. diff --git a/packages/deslop-cli/README.md b/packages/deslop-cli/README.md deleted file mode 100644 index 70be48fc98..0000000000 --- a/packages/deslop-cli/README.md +++ /dev/null @@ -1,102 +0,0 @@ -# deslop-cli - -Deslop JavaScript code. - -CLI for [deslop-js](https://github.com/millionco/react-doctor/tree/main/packages/deslop-js). Finds unused files, dead exports, dead dependencies, circular imports, redundant aliases, duplicate types, and other DRY violations. - -## Install - -```bash -npm install -g deslop-cli -``` - -Requires Node.js 22 or later. - -## Usage - -Pass an explicit project root when possible (especially in monorepos): - -```bash -deslop ./my-app -deslop analyze ./my-app -``` - -Analyze the current directory: - -```bash -deslop -``` - -Output JSON for programmatic consumption: - -```bash -deslop ./my-app --json -``` - -Fail CI when unused code is found (files, exports, or dependencies, not circular imports): - -```bash -deslop ./my-app --fail-on-issues -``` - -Fail CI when circular imports are found: - -```bash -deslop ./my-app --fail-on-cycles -``` - -## What `deslop` reports - -The default scan emits the following finding categories (each grouped in human output, fully detailed in `--json`): - -| Category | What it catches | -| -------------------------- | ----------------------------------------------------------------------------- | -| `unusedFiles` | Files unreachable from any entry point | -| `unusedExports` | Exported symbols never imported anywhere | -| `unusedDependencies` | `package.json` deps not imported | -| `circularDependencies` | Import cycles | -| `redundantAliases` | `import { x as x }`, useless re-export renames | -| `duplicateExports` | Same name exported twice from one module | -| `duplicateImports` | Same specifier imported on multiple lines (merge them) | -| `redundantTypePatterns` | `T & {}`, `Partial<Partial<T>>`, `Pick<T, keyof T>`, empty `extends` | -| `identityWrappers` | `const wrap = (x) => fn(x)`, calls without transforming | -| `duplicateTypeDefinitions` | Same structural type declared in multiple files | -| `duplicateInlineTypes` | Anonymous `{ a, b, c }` shapes repeated across modules | -| `simplifiableFunctions` | `(x) => { return f(x) }`, `await x; return x;`, useless `async` | -| `simplifiableExpressions` | `!!x`, `x ? x : y`, `cond ? true : false`, `x !== null && x !== undefined` | -| `duplicateConstants` | Same literal value used in N files under different names | -| `analysisErrors` | Structured info / warning / error notes (parse failures, skipped files, etc.) | - -Type-aware findings (`unusedTypes`, `unusedClassMembers`, `misclassifiedDependencies`, etc.) require enabling the semantic layer programmatically. See the [`deslop-js` README](https://github.com/aidenybai/deslop-js#semantic-type-aware-analysis). They are not exposed via CLI flags yet. - -### Options - -| Option | Description | -| ------------------------- | -------------------------------------------------------------- | -| `[root]` | Project root directory (default: `.`; must exist) | -| `-e, --entry <pattern>` | Entry point glob patterns | -| `-i, --ignore <pattern>` | Glob patterns to exclude | -| `--extensions <ext>` | File extensions to scan (e.g. `.ts` `.vue`) | -| `--tsconfig <path>` | Path to tsconfig.json for alias resolution | -| `--paths <alias=target>` | Explicit path-alias mappings (e.g. `@app/*=src/*`), repeatable | -| `--report-types` | Include type-only exports in results | -| `--include-entry-exports` | Report unused exports from entry files | -| `--json` | Output results as JSON | -| `--fail-on-issues` | Exit 1 when unused files, exports, or dependencies are found | -| `--fail-on-cycles` | Exit 1 when circular imports are found | - -### Exit codes - -| Code | Meaning | -| ---- | ------------------------------------------------------- | -| `0` | Success (no failure flags triggered) | -| `1` | Issues found (per `--fail-on-*` flags) or runtime error | -| `2` | Invalid project root | - -### Confidence tiers - -Every redundancy finding carries a confidence tier (`high` / `medium` / `low`) visible in human and JSON output. Use `high` for CI gates; `medium` and `low` are best as code-review prompts. Some patterns flagged at `medium` (`x ?? null`, single-name `duplicateConstants` across packages) have legitimate intent ripgrep alone can't disambiguate. - -### Skipped files - -Files identified as empty, binary, or minified bundles are skipped with an `info`-severity `analysisErrors` note. This isn't an error. It means the file looked machine-generated or non-source and was excluded from analysis to avoid producing irrelevant findings. diff --git a/packages/deslop-cli/package.json b/packages/deslop-cli/package.json deleted file mode 100644 index 69c02ee80d..0000000000 --- a/packages/deslop-cli/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "deslop-cli", - "version": "0.9.12", - "description": "CLI to remove AI slop from JavaScript codebases.", - "keywords": [ - "cli", - "dead-code", - "dependencies", - "exports", - "javascript", - "typescript", - "unused" - ], - "homepage": "https://github.com/millionco/react-doctor#readme", - "bugs": { - "url": "https://github.com/millionco/react-doctor/issues" - }, - "license": "SEE LICENSE IN LICENSE", - "author": { - "name": "Aiden Bai", - "email": "aiden@million.dev" - }, - "repository": { - "type": "git", - "url": "https://github.com/millionco/react-doctor.git", - "directory": "packages/deslop-cli" - }, - "bin": { - "deslop": "./dist/cli.mjs" - }, - "files": [ - "dist", - "package.json", - "README.md", - "LICENSE" - ], - "type": "module", - "publishConfig": { - "access": "public" - }, - "scripts": { - "build": "vp pack", - "dev": "vp pack --watch", - "test": "node --import tsx --test tests/*.test.ts", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "commander": "^14.0.3", - "deslop-js": "workspace:*" - }, - "devDependencies": { - "@types/node": "^25.6.0" - }, - "engines": { - "node": ">=22" - } -} diff --git a/packages/deslop-cli/src/cli.ts b/packages/deslop-cli/src/cli.ts deleted file mode 100644 index 1064515daf..0000000000 --- a/packages/deslop-cli/src/cli.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Command, type OptionValues } from "commander"; -import { DEFAULT_ROOT_DIRECTORY, EXIT_CODE_RUNTIME_ERROR } from "./constants.js"; -import type { AnalyzeOptions } from "./types.js"; -import { runAnalyze } from "./run-analyze.js"; -import { readPackageVersion } from "./utils/read-package-version.js"; -import { parsePathMappings } from "./utils/parse-path-mappings.js"; - -const toAnalyzeOptions = (root: string | undefined, optionValues: OptionValues): AnalyzeOptions => { - const parsedPathMappings = parsePathMappings(optionValues.paths); - for (const invalidEntry of parsedPathMappings.invalidEntries) { - process.stderr.write( - `deslop: ignoring malformed --paths entry "${invalidEntry}" (expected "alias=target", e.g. "@app/*=src/*")\n`, - ); - } - return { - root: root ?? DEFAULT_ROOT_DIRECTORY, - entry: optionValues.entry, - ignore: optionValues.ignore, - extensions: optionValues.extensions, - tsconfig: optionValues.tsconfig, - paths: parsedPathMappings.paths, - reportTypes: Boolean(optionValues.reportTypes), - includeEntryExports: Boolean(optionValues.includeEntryExports), - json: Boolean(optionValues.json), - failOnIssues: Boolean(optionValues.failOnIssues), - failOnCycles: Boolean(optionValues.failOnCycles), - }; -}; - -const runAnalyzeAction = async ( - root: string | undefined, - optionValues: OptionValues, -): Promise<void> => { - const exitCode = await runAnalyze(toAnalyzeOptions(root, optionValues)); - process.exitCode = exitCode; -}; - -const addAnalyzeOptions = (command: Command): Command => - command - .argument("[root]", "project root directory", DEFAULT_ROOT_DIRECTORY) - .option("-e, --entry <pattern...>", "entry point glob patterns") - .option("-i, --ignore <pattern...>", "glob patterns to exclude from analysis") - .option("--extensions <extension...>", "file extensions to scan (e.g. .ts .vue)") - .option("--tsconfig <path>", "path to tsconfig.json for path alias resolution") - .option("--paths <alias=target...>", "path alias mappings (e.g. @lib/*=src/lib/*)") - .option("--report-types", "include type-only exports in results") - .option("--include-entry-exports", "report unused exports from entry files") - .option("--json", "output results as JSON") - .option( - "--fail-on-issues", - "exit with code 1 when unused files, exports, or dependencies are found", - ) - .option("--fail-on-cycles", "exit with code 1 when circular imports are found"); - -const program = new Command(); - -program - .name("deslop") - .description( - "Find unused files, exports, dependencies, and circular imports in JavaScript projects", - ) - .version(readPackageVersion(import.meta.url)); - -addAnalyzeOptions(program).action(runAnalyzeAction); - -addAnalyzeOptions( - program - .command("analyze") - .description("Find unused files, exports, dependencies, and circular imports"), -).action(runAnalyzeAction); - -program.parseAsync(process.argv).catch((error: unknown) => { - const message = error instanceof Error ? (error.stack ?? error.message) : String(error); - process.stderr.write(`deslop: ${message}\n`); - process.exitCode = EXIT_CODE_RUNTIME_ERROR; -}); diff --git a/packages/deslop-cli/src/constants.ts b/packages/deslop-cli/src/constants.ts deleted file mode 100644 index fd870b27ae..0000000000 --- a/packages/deslop-cli/src/constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const DEFAULT_ROOT_DIRECTORY = "."; - -export const EXIT_CODE_SUCCESS = 0; -export const EXIT_CODE_ISSUES_FOUND = 1; -export const EXIT_CODE_INVALID_ROOT = 2; -export const EXIT_CODE_RUNTIME_ERROR = 1; - -export const PACKAGE_JSON_FILENAME = "package.json"; - -export const MISSING_PACKAGE_JSON_WARNING = - "Warning: no package.json found in project root; dependency analysis may be incomplete."; diff --git a/packages/deslop-cli/src/format-result.ts b/packages/deslop-cli/src/format-result.ts deleted file mode 100644 index a57b29af5e..0000000000 --- a/packages/deslop-cli/src/format-result.ts +++ /dev/null @@ -1,301 +0,0 @@ -import type { ScanResult } from "deslop-js"; - -const formatIssueCount = (count: number, singularLabel: string, pluralLabel: string): string => { - const label = count === 1 ? singularLabel : pluralLabel; - return `${count} unused ${label}`; -}; - -export const formatHumanReadableResult = (result: ScanResult): string => { - const lines: string[] = []; - - lines.push( - `Analyzed ${result.totalFiles} files (${result.totalExports} exports) in ${result.analysisTimeMs.toFixed(0)}ms`, - ); - lines.push(""); - - if (result.unusedFiles.length > 0) { - lines.push(formatIssueCount(result.unusedFiles.length, "file", "files")); - for (const unusedFile of result.unusedFiles) { - lines.push(` ${unusedFile.path}`); - } - lines.push(""); - } - - if (result.unusedExports.length > 0) { - lines.push(formatIssueCount(result.unusedExports.length, "export", "exports")); - for (const unusedExport of result.unusedExports) { - lines.push(` ${unusedExport.path}:${unusedExport.line} ${unusedExport.name}`); - } - lines.push(""); - } - - if (result.unusedDependencies.length > 0) { - lines.push(formatIssueCount(result.unusedDependencies.length, "dependency", "dependencies")); - for (const unusedDependency of result.unusedDependencies) { - const dependencyKind = unusedDependency.isDevDependency ? "dev" : "prod"; - lines.push(` ${unusedDependency.name} (${dependencyKind})`); - } - lines.push(""); - } - - const skippedDependencies = result.skippedDependencies ?? []; - if (skippedDependencies.length > 0) { - const dependencyLabel = - skippedDependencies.length === 1 ? "dependency was" : "dependencies were"; - lines.push( - `Note: ${skippedDependencies.length} declared ${dependencyLabel} conservatively excluded from unused-dependency analysis (allowlisted names or binary providers).`, - ); - lines.push(""); - } - - if (result.circularDependencies.length > 0) { - const cycleLabel = result.circularDependencies.length === 1 ? "cycle" : "cycles"; - lines.push(`${result.circularDependencies.length} circular ${cycleLabel}`); - for (const circularDependency of result.circularDependencies) { - lines.push(` ${circularDependency.files.join(" → ")}`); - } - lines.push(""); - } - - if (result.unusedTypes.length > 0) { - const typeLabel = result.unusedTypes.length === 1 ? "type" : "types"; - lines.push(`${result.unusedTypes.length} unused ${typeLabel}`); - for (const unusedType of result.unusedTypes) { - lines.push( - ` ${unusedType.path}:${unusedType.line} ${unusedType.name} (${unusedType.kind}, ${unusedType.confidence})`, - ); - } - lines.push(""); - } - - if (result.unusedEnumMembers.length > 0) { - const memberLabel = result.unusedEnumMembers.length === 1 ? "enum member" : "enum members"; - lines.push(`${result.unusedEnumMembers.length} unused ${memberLabel}`); - for (const unusedMember of result.unusedEnumMembers) { - lines.push( - ` ${unusedMember.path}:${unusedMember.line} ${unusedMember.enumName}.${unusedMember.memberName} (${unusedMember.confidence})`, - ); - } - lines.push(""); - } - - if (result.unusedClassMembers.length > 0) { - const classLabel = result.unusedClassMembers.length === 1 ? "class member" : "class members"; - lines.push(`${result.unusedClassMembers.length} unused ${classLabel}`); - for (const unusedMember of result.unusedClassMembers) { - lines.push( - ` ${unusedMember.path}:${unusedMember.line} ${unusedMember.className}.${unusedMember.memberName} (${unusedMember.memberKind}, ${unusedMember.confidence})`, - ); - } - lines.push(""); - } - - if (result.misclassifiedDependencies.length > 0) { - const depLabel = result.misclassifiedDependencies.length === 1 ? "dependency" : "dependencies"; - lines.push(`${result.misclassifiedDependencies.length} misclassified ${depLabel}`); - for (const finding of result.misclassifiedDependencies) { - lines.push( - ` ${finding.name} ${finding.declaredAs} → ${finding.suggestedAs} (${finding.confidence})`, - ); - } - lines.push(""); - } - - if (result.redundantAliases.length > 0) { - const aliasLabel = result.redundantAliases.length === 1 ? "alias" : "aliases"; - lines.push(`${result.redundantAliases.length} redundant ${aliasLabel}`); - for (const finding of result.redundantAliases) { - lines.push(` ${finding.path}:${finding.line} [${finding.kind}] ${finding.name}`); - } - lines.push(""); - } - - if (result.duplicateExports.length > 0) { - const exportLabel = result.duplicateExports.length === 1 ? "export" : "exports"; - lines.push(`${result.duplicateExports.length} duplicate ${exportLabel}`); - for (const finding of result.duplicateExports) { - lines.push(` ${finding.path} ${finding.name} (${finding.occurrences.length}x)`); - } - lines.push(""); - } - - if (result.duplicateImports.length > 0) { - const importLabel = result.duplicateImports.length === 1 ? "import" : "imports"; - lines.push(`${result.duplicateImports.length} duplicate ${importLabel}`); - for (const finding of result.duplicateImports) { - lines.push(` ${finding.path} ${finding.specifier} (${finding.occurrences.length}x)`); - } - lines.push(""); - } - - if (result.redundantTypePatterns.length > 0) { - const patternLabel = - result.redundantTypePatterns.length === 1 ? "type pattern" : "type patterns"; - lines.push(`${result.redundantTypePatterns.length} redundant ${patternLabel}`); - for (const finding of result.redundantTypePatterns) { - lines.push( - ` ${finding.path}:${finding.line} ${finding.typeName} [${finding.kind}] → ${finding.suggestion}`, - ); - } - lines.push(""); - } - - if (result.identityWrappers.length > 0) { - const wrapperLabel = result.identityWrappers.length === 1 ? "wrapper" : "wrappers"; - lines.push(`${result.identityWrappers.length} identity ${wrapperLabel}`); - for (const finding of result.identityWrappers) { - lines.push( - ` ${finding.path}:${finding.line} ${finding.wrapperName} → ${finding.wrappedExpression}`, - ); - } - lines.push(""); - } - - if (result.duplicateTypeDefinitions.length > 0) { - const defLabel = - result.duplicateTypeDefinitions.length === 1 - ? "type definition group" - : "type definition groups"; - lines.push(`${result.duplicateTypeDefinitions.length} duplicate ${defLabel}`); - for (const finding of result.duplicateTypeDefinitions) { - const instanceLabels = finding.instances - .map((instance) => `${instance.typeName}@${instance.path}:${instance.line}`) - .join(", "); - lines.push(` [${finding.confidence}] ${instanceLabels}`); - } - lines.push(""); - } - - if (result.analysisErrors.length > 0) { - const fatalCount = result.analysisErrors.filter((error) => error.severity === "fatal").length; - const warningCount = result.analysisErrors.filter( - (error) => error.severity === "warning", - ).length; - const infoCount = result.analysisErrors.filter((error) => error.severity === "info").length; - const counts = [ - fatalCount > 0 ? `${fatalCount} fatal` : null, - warningCount > 0 ? `${warningCount} warning` : null, - infoCount > 0 ? `${infoCount} info` : null, - ] - .filter((entry) => entry !== null) - .join(", "); - lines.push( - `${result.analysisErrors.length} analysis ${result.analysisErrors.length === 1 ? "error" : "errors"} (${counts})`, - ); - for (const error of result.analysisErrors.slice(0, 20)) { - const location = error.path ? ` ${error.path}` : ""; - lines.push( - ` [${error.severity}/${error.module}/${error.code}]${location} ${error.message}`, - ); - } - if (result.analysisErrors.length > 20) { - lines.push(` … and ${result.analysisErrors.length - 20} more`); - } - lines.push(""); - } - - if (result.duplicateInlineTypes.length > 0) { - const inlineLabel = - result.duplicateInlineTypes.length === 1 ? "inline type group" : "inline type groups"; - lines.push(`${result.duplicateInlineTypes.length} duplicate ${inlineLabel}`); - for (const finding of result.duplicateInlineTypes) { - lines.push( - ` [${finding.confidence}] ${finding.preview} (${finding.occurrences.length} sites)`, - ); - for (const occurrence of finding.occurrences) { - lines.push( - ` ${occurrence.path}:${occurrence.line} ${occurrence.context}${occurrence.nearestName ? ` ${occurrence.nearestName}` : ""}`, - ); - } - } - lines.push(""); - } - - if (result.simplifiableFunctions.length > 0) { - const fnLabel = result.simplifiableFunctions.length === 1 ? "function" : "functions"; - lines.push(`${result.simplifiableFunctions.length} simplifiable ${fnLabel}`); - for (const finding of result.simplifiableFunctions) { - lines.push( - ` ${finding.path}:${finding.line} [${finding.kind}, ${finding.confidence}] ${finding.functionName ?? "?"} → ${finding.suggestion}`, - ); - } - lines.push(""); - } - - if (result.simplifiableExpressions.length > 0) { - const exprLabel = result.simplifiableExpressions.length === 1 ? "expression" : "expressions"; - lines.push(`${result.simplifiableExpressions.length} simplifiable ${exprLabel}`); - for (const finding of result.simplifiableExpressions) { - lines.push( - ` ${finding.path}:${finding.line} [${finding.kind}, ${finding.confidence}] ${finding.snippet} → ${finding.suggestion}`, - ); - } - lines.push(""); - } - - if (result.duplicateConstants.length > 0) { - const constLabel = - result.duplicateConstants.length === 1 ? "constant group" : "constant groups"; - lines.push(`${result.duplicateConstants.length} duplicate ${constLabel}`); - for (const finding of result.duplicateConstants) { - lines.push( - ` [${finding.confidence}] ${finding.literalPreview} (${finding.occurrences.length} copies)`, - ); - for (const occurrence of finding.occurrences.slice(0, 3)) { - lines.push(` ${occurrence.path}:${occurrence.line} const ${occurrence.constantName}`); - } - if (finding.occurrences.length > 3) { - lines.push(` … and ${finding.occurrences.length - 3} more`); - } - } - lines.push(""); - } - - const totalIssues = - result.unusedFiles.length + - result.unusedExports.length + - result.unusedDependencies.length + - result.circularDependencies.length + - result.unusedTypes.length + - result.unusedEnumMembers.length + - result.unusedClassMembers.length + - result.misclassifiedDependencies.length + - result.redundantAliases.length + - result.duplicateExports.length + - result.duplicateImports.length + - result.redundantTypePatterns.length + - result.identityWrappers.length + - result.duplicateTypeDefinitions.length + - result.duplicateInlineTypes.length + - result.simplifiableFunctions.length + - result.simplifiableExpressions.length + - result.duplicateConstants.length; - - if (totalIssues === 0) { - lines.push("No unused files, exports, dependencies, or circular imports found."); - } - - return lines.join("\n").trimEnd() + "\n"; -}; - -export const hasUnusedIssues = (result: ScanResult): boolean => - result.unusedFiles.length > 0 || - result.unusedExports.length > 0 || - result.unusedDependencies.length > 0 || - result.unusedTypes.length > 0 || - result.unusedEnumMembers.length > 0 || - result.unusedClassMembers.length > 0 || - result.misclassifiedDependencies.length > 0 || - result.redundantAliases.length > 0 || - result.duplicateExports.length > 0 || - result.duplicateImports.length > 0 || - result.redundantTypePatterns.length > 0 || - result.identityWrappers.length > 0 || - result.duplicateTypeDefinitions.length > 0 || - result.duplicateInlineTypes.length > 0 || - result.simplifiableFunctions.length > 0 || - result.simplifiableExpressions.length > 0 || - result.duplicateConstants.length > 0; - -export const hasCircularIssues = (result: ScanResult): boolean => - result.circularDependencies.length > 0; diff --git a/packages/deslop-cli/src/run-analyze.ts b/packages/deslop-cli/src/run-analyze.ts deleted file mode 100644 index bfaa8e430d..0000000000 --- a/packages/deslop-cli/src/run-analyze.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { analyze, defineConfig } from "deslop-js"; -import type { ScanResult } from "deslop-js"; -import type { Writable } from "node:stream"; -import { - EXIT_CODE_INVALID_ROOT, - EXIT_CODE_ISSUES_FOUND, - EXIT_CODE_SUCCESS, - MISSING_PACKAGE_JSON_WARNING, -} from "./constants.js"; -import { formatHumanReadableResult, hasCircularIssues, hasUnusedIssues } from "./format-result.js"; -import type { AnalyzeOptions } from "./types.js"; -import { validateRootDirectory } from "./utils/validate-root-directory.js"; - -interface AnalyzeOutput { - stdout: Writable; - stderr: Writable; -} - -const defaultAnalyzeOutput = (): AnalyzeOutput => ({ - stdout: process.stdout, - stderr: process.stderr, -}); - -export const resolveAnalyzeExitCode = ( - result: ScanResult, - options: Pick<AnalyzeOptions, "failOnIssues" | "failOnCycles">, -): number => { - if (options.failOnIssues && hasUnusedIssues(result)) { - return EXIT_CODE_ISSUES_FOUND; - } - if (options.failOnCycles && hasCircularIssues(result)) { - return EXIT_CODE_ISSUES_FOUND; - } - return EXIT_CODE_SUCCESS; -}; - -export const runAnalyze = async ( - options: AnalyzeOptions, - output: AnalyzeOutput = defaultAnalyzeOutput(), -): Promise<number> => { - const rootValidation = validateRootDirectory(options.root); - - if (!rootValidation.isValid) { - output.stderr.write(`deslop: ${rootValidation.errorMessage}\n`); - return EXIT_CODE_INVALID_ROOT; - } - - if (rootValidation.missingPackageJson) { - output.stderr.write(`deslop: ${MISSING_PACKAGE_JSON_WARNING}\n`); - } - - const config = defineConfig({ - rootDir: rootValidation.resolvedPath, - entryPatterns: options.entry, - ignorePatterns: options.ignore ?? [], - includeExtensions: options.extensions, - tsConfigPath: options.tsconfig, - paths: options.paths, - reportTypes: options.reportTypes, - includeEntryExports: options.includeEntryExports, - }); - - const result: ScanResult = await analyze(config); - - if (options.json) { - output.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - } else { - output.stdout.write(formatHumanReadableResult(result)); - } - - return resolveAnalyzeExitCode(result, options); -}; diff --git a/packages/deslop-cli/src/types.ts b/packages/deslop-cli/src/types.ts deleted file mode 100644 index cc3a77e2bb..0000000000 --- a/packages/deslop-cli/src/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface AnalyzeOptions { - root: string; - entry?: string[]; - ignore?: string[]; - extensions?: string[]; - tsconfig?: string; - paths?: Record<string, string[]>; - reportTypes: boolean; - includeEntryExports: boolean; - json: boolean; - failOnIssues: boolean; - failOnCycles: boolean; -} - -export interface RootValidationResult { - isValid: boolean; - resolvedPath: string; - errorMessage?: string; - missingPackageJson: boolean; -} diff --git a/packages/deslop-cli/src/utils/parse-path-mappings.ts b/packages/deslop-cli/src/utils/parse-path-mappings.ts deleted file mode 100644 index e16b2f048e..0000000000 --- a/packages/deslop-cli/src/utils/parse-path-mappings.ts +++ /dev/null @@ -1,33 +0,0 @@ -interface ParsedPathMappings { - paths: Record<string, string[]> | undefined; - invalidEntries: string[]; -} - -export const parsePathMappings = (rawMappings: string[] | undefined): ParsedPathMappings => { - if (!rawMappings || rawMappings.length === 0) { - return { paths: undefined, invalidEntries: [] }; - } - - const paths: Record<string, string[]> = {}; - const invalidEntries: string[] = []; - for (const entry of rawMappings) { - const separatorIndex = entry.indexOf("="); - const pattern = separatorIndex === -1 ? "" : entry.slice(0, separatorIndex); - const target = separatorIndex === -1 ? "" : entry.slice(separatorIndex + 1); - if (!pattern || !target) { - invalidEntries.push(entry); - continue; - } - const existingTargets = paths[pattern]; - if (existingTargets) { - existingTargets.push(target); - } else { - paths[pattern] = [target]; - } - } - - return { - paths: Object.keys(paths).length > 0 ? paths : undefined, - invalidEntries, - }; -}; diff --git a/packages/deslop-cli/src/utils/read-package-version.ts b/packages/deslop-cli/src/utils/read-package-version.ts deleted file mode 100644 index cc46539afb..0000000000 --- a/packages/deslop-cli/src/utils/read-package-version.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const isPackageJsonWithVersion = (value: unknown): value is { version: string } => - typeof value === "object" && - value !== null && - "version" in value && - typeof value.version === "string"; - -export const readPackageVersion = (moduleUrl: string): string => { - const currentDirectory = dirname(fileURLToPath(moduleUrl)); - const packageJsonPath = resolve(currentDirectory, "../package.json"); - const parsedJson: unknown = JSON.parse(readFileSync(packageJsonPath, "utf-8")); - - if (!isPackageJsonWithVersion(parsedJson)) { - throw new Error(`Invalid package.json at ${packageJsonPath}: missing version field`); - } - - return parsedJson.version; -}; diff --git a/packages/deslop-cli/src/utils/validate-root-directory.ts b/packages/deslop-cli/src/utils/validate-root-directory.ts deleted file mode 100644 index af96c1060f..0000000000 --- a/packages/deslop-cli/src/utils/validate-root-directory.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { existsSync, statSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { PACKAGE_JSON_FILENAME } from "../constants.js"; -import type { RootValidationResult } from "../types.js"; - -export const validateRootDirectory = (root: string): RootValidationResult => { - const resolvedPath = resolve(root); - - if (!existsSync(resolvedPath)) { - return { - isValid: false, - resolvedPath, - errorMessage: `Project root does not exist: ${resolvedPath}`, - missingPackageJson: false, - }; - } - - const fileStat = statSync(resolvedPath); - if (!fileStat.isDirectory()) { - return { - isValid: false, - resolvedPath, - errorMessage: `Project root is not a directory: ${resolvedPath}`, - missingPackageJson: false, - }; - } - - const packageJsonPath = join(resolvedPath, PACKAGE_JSON_FILENAME); - return { - isValid: true, - resolvedPath, - missingPackageJson: !existsSync(packageJsonPath), - }; -}; diff --git a/packages/deslop-cli/tests/cli.test.ts b/packages/deslop-cli/tests/cli.test.ts deleted file mode 100644 index 5120e03c16..0000000000 --- a/packages/deslop-cli/tests/cli.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { Writable } from "node:stream"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { analyze, defineConfig } from "deslop-js"; -import type { ScanResult } from "deslop-js"; -import { - EXIT_CODE_INVALID_ROOT, - EXIT_CODE_ISSUES_FOUND, - EXIT_CODE_RUNTIME_ERROR, - EXIT_CODE_SUCCESS, -} from "../src/constants.js"; -import { - formatHumanReadableResult, - hasCircularIssues, - hasUnusedIssues, -} from "../src/format-result.js"; -import { resolveAnalyzeExitCode, runAnalyze } from "../src/run-analyze.js"; -import { validateRootDirectory } from "../src/utils/validate-root-directory.js"; -import { parsePathMappings } from "../src/utils/parse-path-mappings.js"; -import { FIXTURES_DIR } from "./helpers/fixtures-dir.js"; - -const testDirectory = resolve(fileURLToPath(import.meta.url), ".."); -const packageDirectory = resolve(testDirectory, ".."); -const simpleAppFixture = resolve(FIXTURES_DIR, "simple-app"); -const cycleSimpleFixture = resolve(FIXTURES_DIR, "cycle-simple"); -const workspaceLocalBinFixture = resolve(FIXTURES_DIR, "workspace-local-bin"); -const cliEntryPath = resolve(packageDirectory, "src/cli.ts"); - -const emptyScanResult: ScanResult = { - unusedFiles: [], - unusedExports: [], - unusedDependencies: [], - skippedDependencies: [], - circularDependencies: [], - unusedTypes: [], - misclassifiedDependencies: [], - unusedEnumMembers: [], - unusedClassMembers: [], - redundantAliases: [], - duplicateExports: [], - duplicateImports: [], - redundantTypePatterns: [], - identityWrappers: [], - duplicateTypeDefinitions: [], - duplicateInlineTypes: [], - simplifiableFunctions: [], - simplifiableExpressions: [], - duplicateConstants: [], - analysisErrors: [], - totalFiles: 0, - totalExports: 0, - analysisTimeMs: 0, -}; - -const createCaptureOutput = () => { - const capturedText = { stdout: "", stderr: "" }; - - const captureStdout = new Writable({ - write(chunk, _encoding, callback) { - capturedText.stdout += chunk.toString(); - callback(); - }, - }); - const captureStderr = new Writable({ - write(chunk, _encoding, callback) { - capturedText.stderr += chunk.toString(); - callback(); - }, - }); - - return { - capturedText, - output: { stdout: captureStdout, stderr: captureStderr }, - }; -}; - -const runCli = ( - argumentsList: string[], - workingDirectory: string, -): Promise<{ exitCode: number; stdout: string; stderr: string }> => - new Promise((resolvePromise, rejectPromise) => { - const child = spawn(process.execPath, ["--import", "tsx", cliEntryPath, ...argumentsList], { - cwd: workingDirectory, - env: process.env, - }); - - let stdout = ""; - let stderr = ""; - - child.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - child.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - - child.on("error", rejectPromise); - child.on("close", (exitCode) => { - resolvePromise({ - exitCode: exitCode ?? EXIT_CODE_RUNTIME_ERROR, - stdout, - stderr, - }); - }); - }); - -describe("validateRootDirectory", () => { - it("should reject a path that does not exist", () => { - const validation = validateRootDirectory("/nonexistent-deslop-root-xyz"); - assert.equal(validation.isValid, false); - assert.match(validation.errorMessage ?? "", /does not exist/); - }); - - it("should accept the simple-app fixture", () => { - const validation = validateRootDirectory(simpleAppFixture); - assert.equal(validation.isValid, true); - assert.equal(validation.missingPackageJson, false); - }); -}); - -describe("parsePathMappings", () => { - it("groups repeated aliases and returns malformed entries separately", () => { - const parsed = parsePathMappings([ - "@app/*=src/*", - "@app/*=generated/*", - "missing-separator", - "=missing-pattern", - ]); - - assert.deepEqual(parsed.paths, { - "@app/*": ["src/*", "generated/*"], - }); - assert.deepEqual(parsed.invalidEntries, ["missing-separator", "=missing-pattern"]); - }); -}); - -describe("resolveAnalyzeExitCode", () => { - it("should return success when no fail flags are set", () => { - const exitCode = resolveAnalyzeExitCode( - { ...emptyScanResult, unusedFiles: [{ path: "orphan.ts" }] }, - { failOnIssues: false, failOnCycles: false }, - ); - assert.equal(exitCode, EXIT_CODE_SUCCESS); - }); - - it("should fail on unused issues only when --fail-on-issues is set", () => { - const exitCode = resolveAnalyzeExitCode( - { - ...emptyScanResult, - unusedFiles: [{ path: "orphan.ts" }], - circularDependencies: [{ files: ["a.ts", "b.ts"] }], - }, - { failOnIssues: true, failOnCycles: false }, - ); - assert.equal(exitCode, EXIT_CODE_ISSUES_FOUND); - }); - - it("should not fail on cycles when only --fail-on-issues is set", () => { - const exitCode = resolveAnalyzeExitCode( - { ...emptyScanResult, circularDependencies: [{ files: ["a.ts", "b.ts"] }] }, - { failOnIssues: true, failOnCycles: false }, - ); - assert.equal(exitCode, EXIT_CODE_SUCCESS); - }); - - it("should fail on cycles when --fail-on-cycles is set", () => { - const exitCode = resolveAnalyzeExitCode( - { ...emptyScanResult, circularDependencies: [{ files: ["a.ts", "b.ts"] }] }, - { failOnIssues: false, failOnCycles: true }, - ); - assert.equal(exitCode, EXIT_CODE_ISSUES_FOUND); - }); -}); - -describe("hasUnusedIssues / hasCircularIssues", () => { - it("should treat circular imports separately from unused code", () => { - const result: ScanResult = { - ...emptyScanResult, - circularDependencies: [{ files: ["a.ts", "b.ts"] }], - }; - assert.equal(hasUnusedIssues(result), false); - assert.equal(hasCircularIssues(result), true); - }); -}); - -describe("runAnalyze", () => { - it("should explain conservatively skipped dependencies in human output", async () => { - const scanResult = await analyze(defineConfig({ rootDir: workspaceLocalBinFixture })); - const output = formatHumanReadableResult(scanResult); - - assert.match(output, /2 declared dependencies were conservatively excluded/); - assert.match(output, /allowlisted names or binary providers/); - }); - - it("should return invalid root exit code for missing directories", async () => { - const capture = createCaptureOutput(); - const exitCode = await runAnalyze( - { - root: "/nonexistent-deslop-root-xyz", - reportTypes: false, - includeEntryExports: false, - json: false, - failOnIssues: false, - failOnCycles: false, - }, - capture.output, - ); - assert.equal(exitCode, EXIT_CODE_INVALID_ROOT); - assert.match(capture.capturedText.stderr, /does not exist/); - }); - - it("should return success for simple-app without fail flags", async () => { - const scanResult = await analyze(defineConfig({ rootDir: simpleAppFixture })); - assert.equal(hasUnusedIssues(scanResult), true); - - const capture = createCaptureOutput(); - const exitCode = await runAnalyze( - { - root: simpleAppFixture, - reportTypes: false, - includeEntryExports: false, - json: true, - failOnIssues: false, - failOnCycles: false, - }, - capture.output, - ); - assert.equal(exitCode, EXIT_CODE_SUCCESS); - assert.match(capture.capturedText.stdout, /"unusedFiles"/); - }); - - it("should exit 1 with --fail-on-issues when unused code exists", async () => { - const capture = createCaptureOutput(); - const exitCode = await runAnalyze( - { - root: simpleAppFixture, - reportTypes: false, - includeEntryExports: false, - json: true, - failOnIssues: true, - failOnCycles: false, - }, - capture.output, - ); - assert.equal(exitCode, EXIT_CODE_ISSUES_FOUND); - }); - - it("should not exit 1 with --fail-on-issues for cycle-only fixtures", async () => { - const capture = createCaptureOutput(); - const exitCode = await runAnalyze( - { - root: cycleSimpleFixture, - reportTypes: false, - includeEntryExports: false, - json: true, - failOnIssues: true, - failOnCycles: false, - }, - capture.output, - ); - assert.equal(exitCode, EXIT_CODE_SUCCESS); - assert.equal(JSON.parse(capture.capturedText.stdout).circularDependencies.length, 1); - }); - - it("should exit 1 with --fail-on-cycles for cycle fixtures", async () => { - const capture = createCaptureOutput(); - const exitCode = await runAnalyze( - { - root: cycleSimpleFixture, - reportTypes: false, - includeEntryExports: false, - json: true, - failOnIssues: false, - failOnCycles: true, - }, - capture.output, - ); - assert.equal(exitCode, EXIT_CODE_ISSUES_FOUND); - }); -}); - -describe("cli process", () => { - it("should reject an invalid root path", async () => { - const outcome = await runCli(["/nonexistent-deslop-root-xyz"], packageDirectory); - assert.equal(outcome.exitCode, EXIT_CODE_INVALID_ROOT); - assert.match(outcome.stderr, /does not exist/); - }); - - it("should print version", async () => { - const outcome = await runCli(["--version"], packageDirectory); - assert.equal(outcome.exitCode, EXIT_CODE_SUCCESS); - assert.match(outcome.stdout, /^\d+\.\d+\.\d+\n$/); - }); - - it("should exit 1 with --fail-on-issues for projects with unused code", async () => { - const outcome = await runCli( - [simpleAppFixture, "--fail-on-issues", "--json"], - packageDirectory, - ); - assert.equal(outcome.exitCode, EXIT_CODE_ISSUES_FOUND); - }); -}); diff --git a/packages/deslop-cli/tests/helpers/fixtures-dir.ts b/packages/deslop-cli/tests/helpers/fixtures-dir.ts deleted file mode 100644 index 0c85990ddd..0000000000 --- a/packages/deslop-cli/tests/helpers/fixtures-dir.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { cpSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -// deslop-cli reuses deslop-js's fixtures, so it needs the same isolation: copy -// them OUTSIDE the repository and `git init` the copy so the CLI's -// findMonorepoRoot walk stops at the temp boundary instead of escaping into the -// enclosing react-doctor workspace and folding its packages into the scan. -// Mirrors packages/deslop-js/tests/helpers/fixtures-dir.ts (kept local rather -// than shared because the two packages publish independently). -const sourceFixturesDirectory = resolve(import.meta.dirname, "../../../deslop-js/tests/fixtures"); -const temporaryFixturesRoot = mkdtempSync(join(tmpdir(), "deslop-cli-fixtures-")); -cpSync(sourceFixturesDirectory, temporaryFixturesRoot, { recursive: true }); - -spawnSync("git", ["init", "-q"], { cwd: temporaryFixturesRoot }); - -export const FIXTURES_DIR = realpathSync(temporaryFixturesRoot); - -process.on("exit", () => { - rmSync(temporaryFixturesRoot, { recursive: true, force: true }); -}); diff --git a/packages/deslop-cli/tsconfig.json b/packages/deslop-cli/tsconfig.json deleted file mode 100644 index 49482aaf38..0000000000 --- a/packages/deslop-cli/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "module": "NodeNext", - "esModuleInterop": true, - "strictNullChecks": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "lib": ["esnext"], - "skipLibCheck": true, - "outDir": "dist" - }, - "include": ["src", "vite.config.ts"], - "exclude": ["**/node_modules/**", "dist", "tests"] -} diff --git a/packages/deslop-cli/vite.config.ts b/packages/deslop-cli/vite.config.ts deleted file mode 100644 index 34df3c247e..0000000000 --- a/packages/deslop-cli/vite.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from "vite-plus"; - -export default defineConfig({ - pack: [ - { - entry: ["./src/cli.ts"], - format: ["esm"], - clean: false, - platform: "node", - sourcemap: false, - minify: process.env.NODE_ENV === "production", - banner: { js: "#!/usr/bin/env node" }, - }, - ], - test: { - include: ["tests/**/*.test.ts"], - }, -}); diff --git a/packages/deslop-js/LICENSE b/packages/deslop-js/LICENSE deleted file mode 100644 index aef05f7022..0000000000 --- a/packages/deslop-js/LICENSE +++ /dev/null @@ -1,34 +0,0 @@ -Modified MIT License - -Copyright (c) 2026 Million Software, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -Our only modification is that the following uses require prior written -permission from the copyright holder. To request permission, contact -founders@million.dev. - -1. Using the Software, its source code, or any derivative works thereof, in - whole or in part, as training, fine-tuning, or evaluation data, or as input - to any automated pipeline for training or improving any machine learning - model or AI system. - -2. Selling the Software, or offering it to third parties as a paid, hosted, or - managed product or service (including any commercial API or SaaS offering) - whose value derives entirely or substantially from the Software. diff --git a/packages/deslop-js/README.md b/packages/deslop-js/README.md deleted file mode 100644 index 9a04f8747b..0000000000 --- a/packages/deslop-js/README.md +++ /dev/null @@ -1,314 +0,0 @@ -# deslop-js - -[![version](https://img.shields.io/npm/v/deslop-js?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/deslop-js) -[![downloads](https://img.shields.io/npm/dt/deslop-js.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/deslop-js) - -Deslop JavaScript code. - -Finds unused files, dead exports, dead dependencies, circular imports, redundant aliases, duplicate types, and other DRY violations. Each finding carries a confidence tier so you can gate CI on the high-signal ones and treat the rest as code-review prompts. - -## Install - -```bash -npm install deslop-js -``` - -## CLI - -The `deslop-cli` package provides a command-line interface: - -```bash -npm install -g deslop-cli -``` - -### Quick start - -```bash -# scan the current directory -deslop - -# scan a specific project -deslop ./my-project - -# use the explicit analyze sub-command (equivalent to the above) -deslop analyze ./my-project -``` - -### Options - -```bash -deslop [root] [options] - -# custom entry points -deslop --entry src/main.ts --entry src/worker.ts - -# ignore test files -deslop --ignore "**/*.test.ts" --ignore "**/__mocks__/**" - -# only scan specific extensions -deslop --extensions .ts .tsx - -# resolve path aliases via tsconfig -deslop --tsconfig ./tsconfig.json - -# add explicit path aliases (in addition to the auto-detected ones) -deslop --paths "@app/*=src/*" --paths "@lib/*=packages/lib/*" - -# include type-only exports in results -deslop --report-types - -# report unused exports from entry files too -deslop --include-entry-exports - -# output results as JSON (useful for CI or piping to other tools) -deslop --json - -# exit with code 1 when unused code is found (for CI gates) -deslop --fail-on-issues - -# exit with code 1 when circular imports are found -deslop --fail-on-cycles -``` - -### CI example - -```bash -# fail the build if there are unused exports or circular imports -deslop ./src --fail-on-issues --fail-on-cycles --ignore "**/*.test.ts" -``` - -## Programmatic Usage - -```ts -import { analyze, defineConfig } from "deslop-js"; - -const config = defineConfig({ rootDir: "./my-project" }); -const result = await analyze(config); - -// unused-code findings (syntactic) -result.unusedFiles; // files unreachable from any entry point -result.unusedExports; // exported symbols never imported -result.unusedDependencies; // package.json deps not imported anywhere -result.skippedDependencies; // declared deps conservatively excluded from unused-dependency analysis -result.circularDependencies; // import cycles - -// redundancy / DRY findings (syntactic, on by default) -result.redundantAliases; // `import { x as x }`, useless re-export renames -result.duplicateExports; // same name exported twice from one module -result.duplicateImports; // same specifier imported multiple times -result.redundantTypePatterns; // `T & {}`, `Partial<Partial<T>>`, etc. -result.identityWrappers; // `const wrap = (x) => fn(x)` -result.duplicateTypeDefinitions; // same-shape type declared in N files -result.duplicateInlineTypes; // anonymous `{ a, b, c }` repeated across modules -result.simplifiableFunctions; // `() => { return x }`, `await x; return x` -result.simplifiableExpressions; // `!!x`, `x ? x : y`, `cond ? true : false` -result.duplicateConstants; // same literal value across files -result.crossFileDuplicateExports; // same export name shipped by 2+ files that share an importer -result.reExportCycles; // `export * from "./a"` cycles (self-loop or multi-node) -result.privateTypeLeaks; // exported signature references a non-exported local type - -// duplicate-block detection (token-based copy-paste; on by default, disable via `duplicateBlocks.enabled: false`) -result.duplicateBlocks; // suffix-array + LCP detected duplicate code blocks -result.duplicateBlockClusters; // clones grouped by file set + refactoring suggestions -result.shadowedDirectoryPairs; // directory pairs with many identical files - -// feature flag inventory (on by default, disable via `featureFlags.enabled: false`) -result.featureFlags; // LaunchDarkly/Statsig/Unleash/PostHog/Vercel Flags/process.env.* uses - -// function complexity hotspots (on by default, disable via `complexity.enabled: false`) -result.complexFunctions; // McCabe cyclomatic + SonarSource cognitive per function - -// TypeScript-specific smells (on by default) -result.unnecessaryAssertions; // `x as unknown as T`, `x as any`, `x!!`, `<T>x`, `"foo"!` -result.lazyImportsAtTopLevel; // top-level `await import(...)` / `.then(...)` that should be static -result.commonjsInEsm; // `require()`, `module.exports`, `exports.x` inside ESM modules -result.typeScriptEscapeHatches; // `// @ts-ignore`, `// @ts-nocheck`, undocumented `@ts-expect-error` - -// semantic findings (type-aware; on by default, disable via `semantic: { enabled: false }`) -result.unusedTypes; // type aliases / interfaces never referenced -result.unusedEnumMembers; // enum members no reference site uses -result.unusedClassMembers; // class members no caller invokes (skips React/Angular lifecycle methods) -result.misclassifiedDependencies; // `dependencies` entries used only as types - -// diagnostics -result.analysisErrors; // structured errors from any pipeline stage -result.totalFiles; -result.totalExports; -result.analysisTimeMs; -``` - -`skippedDependencies` makes conservative unused-dependency exemptions explicit. It includes -packages matched by the framework/tooling name allowlist and installed packages that provide a -binary, because those uses can occur outside source imports and package scripts. A clean -`unusedDependencies` result is not a verdict for those packages. - -## Programmatic Options - -`defineConfig` accepts a required `rootDir` and optional overrides: - -```ts -const config = defineConfig({ - rootDir: "./my-project", - entryPatterns: ["src/main.ts"], - ignorePatterns: ["**/*.test.ts"], - tsConfigPath: "./tsconfig.json", - reportTypes: true, - includeEntryExports: true, - reportRedundancy: true, - semantic: { enabled: true }, -}); -``` - -| Option | Type | Default | Description | -| --------------------- | --------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `rootDir` | `string` | required | Project root directory | -| `entryPatterns` | `string[]` | auto-detected | Entry point glob patterns | -| `ignorePatterns` | `string[]` | `[]` | Glob patterns to exclude from analysis | -| `includeExtensions` | `string[]` | `[".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".cjs", ".cts"]` | File extensions to scan | -| `tsConfigPath` | `string \| undefined` | `undefined` | Path to tsconfig.json for path alias resolution | -| `paths` | `Record<string, string[]> \| undefined` | `undefined` | Explicit path-alias mappings (e.g. `{ "@app/*": ["src/*"] }`), resolved alongside auto-detected aliases | -| `reportTypes` | `boolean` | `false` | Include type-only exports in `unusedExports` | -| `includeEntryExports` | `boolean` | `false` | Report unused exports from entry files | -| `reportRedundancy` | `boolean` | `true` | Emit the redundancy / DRY findings listed above | -| `semantic` | `SemanticConfig` | `undefined` | Opt-in TypeScript type-aware analysis (see below) | - -Path aliases are auto-detected by default — from `tsconfig` `paths`, Vite (`resolve.alias`), webpack, Babel (`module-resolver`), and Jest (`moduleNameMapper`) configs, plus the workspace layout (a `@scope/<dir>` import resolves to the matching workspace package even when its `package.json` name differs). Use `paths` / `--paths` only for mappings none of those cover. - -### Semantic (type-aware) analysis - -On by default. Loads the TypeScript program when the project has a valid `tsconfig.json`; gracefully no-ops on JS-only projects. Disable with `semantic: { enabled: false }` to skip the ~1–3s program load. - -```ts -const config = defineConfig({ - rootDir: "./my-project", - semantic: { - enabled: true, - reportUnusedTypes: true, - reportUnusedEnumMembers: true, - reportUnusedClassMembers: false, // off by default, noisy on framework code - reportMisclassifiedDependencies: true, - reportRedundantVariableAliases: true, - reportRoundTripAliases: true, - }, -}); -``` - -| Option | Default | Notes | -| --------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | `false` | Master switch; semantic analysis loads the TS program and adds ~1–3s per scan | -| `reportUnusedTypes` | `true` | Type aliases / interfaces / type-only exports never referenced | -| `reportUnusedEnumMembers` | `true` | Enum members no reference site reads or writes | -| `reportUnusedClassMembers` | **`false`** | Subclass overrides, framework method-by-name invocation (`@HttpGet`, lifecycle hooks) produce too many stylistic FPs to enable by default. Opt in selectively. | -| `reportMisclassifiedDependencies` | `true` | `dependencies` packages used only via `import type` | -| `reportRedundantVariableAliases` | `true` | Local aliases like `const X = Y; export { X }` | -| `reportRoundTripAliases` | `true` | `import { X as Y } from "./a"; export { Y as X }` | - -### Duplicate blocks (token-based copy-paste detection) - -On by default. Detects maximal duplicated token sequences across files via a suffix array + LCP pass over a normalized AST token stream. Tune thresholds or disable entirely: - -```ts -const config = defineConfig({ - rootDir: "./my-project", - duplicateBlocks: { - enabled: true, // default - mode: "semantic", // "strict" preserves identifiers/literals; "semantic" (default) blinds them - minTokens: 50, - minLines: 5, - minOccurrences: 2, - skipLocal: false, // true => only report cross-directory duplicates - }, -}); -``` - -Surfaces in three result fields: - -- `result.duplicateBlocks` — every duplicated block group with all its occurrences -- `result.duplicateBlockClusters` — duplicate blocks sharing the same file set, plus an `extract-function` / `extract-module` refactoring hint -- `result.shadowedDirectoryPairs` — directory pairs (e.g. `src/` and `deno/lib/`) whose files mirror each other - -### Feature flag inventory - -On by default. Scans the codebase for every place a feature flag is _read_ and produces a finding per use. The detector recognizes three families: - -1. **Env-var flags** — `process.env.X` whose name starts with one of the built-in prefixes `FEATURE_`, `NEXT_PUBLIC_FEATURE_`, `REACT_APP_FEATURE_`, `VITE_FEATURE_`, `NUXT_PUBLIC_FEATURE_`, `ENABLE_`, `FF_`, `FLAG_`, `TOGGLE_` (extend with `extraEnvPrefixes`). -2. **SDK calls** with provider attribution — LaunchDarkly (`useFlag`/`variation`/...), Statsig (`useGate`/`checkGate`/...), Unleash (`isEnabled`/`getVariant`), GrowthBook (`isOn`/`isOff`/`getFeatureValue`), Split (`getTreatment`), PostHog (`useFeatureFlagEnabled`/...), ConfigCat, Flagsmith, Optimizely, Eppo, and Vercel Flags (`flag()` / `evaluate()` from `flags` or `@vercel/flags`). -3. **Config-object access** — `config.features.X` style; off by default because it's heuristic. Opt in with `detectConfigObjects: true`. - -Each finding carries `name`, `path`, `line`, `column`, `sdkProvider` (when known), and `kind: "env-var" | "sdk-call" | "config-object"`. The detector also tracks the surrounding `if` / ternary guard span and sets `guardsDeadCode: true` when an `unusedExports` finding falls inside that guard — so a flag whose enabled branch contains only dead code lights up immediately. - -**The actionable angle**: cross-reference `result.featureFlags` with your live flag dashboard. - -- Flags in the dashboard but missing from `result.featureFlags` → no longer read by the codebase, safe to retire from the platform. -- Flags in `result.featureFlags` with `guardsDeadCode: true` → the guarded code is unreachable, delete both the flag and its body. - -```ts -const config = defineConfig({ - rootDir: "./my-project", - featureFlags: { - enabled: true, // default - extraEnvPrefixes: ["MYAPP_FF_"], - extraSdkFunctionNames: ["myCustomFlag"], - detectConfigObjects: false, // heuristic config.features.x — opt in if you use that pattern - }, -}); -``` - -### Function complexity (cyclomatic + cognitive) - -On by default. Reports per-function McCabe cyclomatic and SonarSource cognitive complexity, function size, and parameter count for every function that breaches at least one threshold. Tune the thresholds or disable entirely: - -```ts -const config = defineConfig({ - rootDir: "./my-project", - complexity: { - enabled: true, // default - cyclomaticThreshold: 10, - cognitiveThreshold: 15, - paramCountThreshold: 5, - functionLineThreshold: 80, - }, -}); -``` - -### TypeScript code smells - -On by default. Four families of TypeScript-specific patterns surfaced at high or medium confidence — no extra config required. - -```ts -result.unnecessaryAssertions; // type assertions that drop type-safety or do nothing -result.lazyImportsAtTopLevel; // dynamic imports at the module top level -result.commonjsInEsm; // CommonJS forms inside ESM modules -result.typeScriptEscapeHatches; // @ts-ignore / @ts-nocheck / undocumented @ts-expect-error -``` - -| Finding | Kinds | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `result.unnecessaryAssertions` | `redundant-double-assertion` (`x as unknown as T`), `assertion-to-any`, `redundant-non-null-on-literal` (`"foo"!`), `double-non-null` (`x!!`), `angle-bracket-assertion` (`<T>x`) | -| `result.lazyImportsAtTopLevel` | `top-level-await-import`, `top-level-then-import` | -| `result.commonjsInEsm` | `require`, `module-exports`, `exports-assignment` | -| `result.typeScriptEscapeHatches` | `ts-ignore`, `ts-nocheck`, `ts-expect-error-without-explanation` | - -ESM detection follows the runtime rules: `.mts`/`.mjs` extensions are always ESM, `.cts`/`.cjs` are always CommonJS, and other files inherit from the nearest `package.json`'s `"type"` field. - -## Findings have confidence tiers - -Every redundancy / semantic finding carries `confidence: "high" | "medium" | "low"`. Use `"high"` for CI gates; `"medium"` and `"low"` are best treated as code-review prompts since intent is sometimes unknowable from syntax alone (e.g. `?? null` may be required by a typed callback signature). - -## Error handling - -`analyze()` never throws on a corrupted file, unparseable `tsconfig`, or missing dependency. Failures surface as `analysisErrors: DeslopError[]` with structured `code`, `module`, `severity`, and `path` fields. See `errors.ts` for the full taxonomy. Errors at `severity: "info"` (empty files, binary files, minified bundles skipped from redundancy analysis) are informational and do not indicate problems. - -## Development - -```bash -pnpm install -pnpm build -pnpm test -pnpm lint -pnpm format -``` - -## License - -MIT diff --git a/packages/deslop-js/package.json b/packages/deslop-js/package.json deleted file mode 100644 index acae0259e5..0000000000 --- a/packages/deslop-js/package.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "name": "deslop-js", - "version": "0.9.12", - "description": "Remove AI slop from JavaScript code.", - "keywords": [ - "dead-code", - "dependencies", - "exports", - "files", - "javascript", - "oxc", - "typescript", - "unused" - ], - "homepage": "https://github.com/millionco/react-doctor#readme", - "bugs": { - "url": "https://github.com/millionco/react-doctor/issues" - }, - "license": "SEE LICENSE IN LICENSE", - "author": { - "name": "Aiden Bai", - "email": "aiden@million.dev" - }, - "repository": { - "type": "git", - "url": "https://github.com/millionco/react-doctor.git", - "directory": "packages/deslop-js" - }, - "files": [ - "dist", - "package.json", - "README.md", - "LICENSE" - ], - "type": "module", - "main": "dist/index.cjs", - "module": "dist/index.mjs", - "types": "dist/index.d.mts", - "exports": { - "./package.json": "./package.json", - ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } - }, - "./analyzed-inputs": { - "import": { - "types": "./dist/analyzed-inputs.d.mts", - "default": "./dist/analyzed-inputs.mjs" - }, - "require": { - "types": "./dist/analyzed-inputs.d.cts", - "default": "./dist/analyzed-inputs.cjs" - } - } - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "build": "vp pack", - "dev": "vp pack --watch", - "test": "node --import tsx --test tests/*.test.ts", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@oxc-project/types": "^0.143.0", - "fast-glob": "^3.3.3", - "minimatch": "^10.2.5", - "oxc-parser": "^0.143.0", - "oxc-resolver": "^11.24.2", - "typescript": ">=5.0.4 <6" - }, - "devDependencies": { - "@types/minimatch": "^5.1.2", - "@types/node": "^25.6.0", - "tsx": "^4.21.0" - } -} diff --git a/packages/deslop-js/src/analyzed-inputs.ts b/packages/deslop-js/src/analyzed-inputs.ts deleted file mode 100644 index 882ad34249..0000000000 --- a/packages/deslop-js/src/analyzed-inputs.ts +++ /dev/null @@ -1,7 +0,0 @@ -// The canonical "what does an analysis pass read?" contract, published as the -// `deslop-js/analyzed-inputs` subpath so external result caches (react-doctor's -// dead-code cache) can fingerprint exactly the files a pass depends on. Kept -// as a dedicated dependency-free entry: the package root eagerly loads -// `typescript` and the native oxc bindings, which a fingerprinting caller must -// never pay for. -export { ANALYZED_MANIFEST_FILENAMES, DEFAULT_EXTENSIONS } from "./constants.js"; diff --git a/packages/deslop-js/src/collect/config-string-entries.ts b/packages/deslop-js/src/collect/config-string-entries.ts deleted file mode 100644 index 27a44bb7d6..0000000000 --- a/packages/deslop-js/src/collect/config-string-entries.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import fg from "fast-glob"; -import { resolveEntryWithExtensions } from "../utils/resolve-entry-with-extensions.js"; - -const CONFIG_STRING_ENTRY_GLOBS = [ - "webpack.config.{js,ts,mjs,cjs}", - "**/webpack*.config.{js,ts,mjs,cjs,babel.js}", - "**/configs/webpack.config.{js,ts,mjs,cjs,babel.js}", - "**/configs/webpack*.config.{js,ts,mjs,cjs,babel.js}", - "jest.config.{js,ts,mjs,cjs,cts}", - "**/jest.config.{js,ts,mjs,cjs,cts}", - "vitest.config.{js,ts,mjs,mts}", - "**/vitest.config.{js,ts,mjs,mts}", - "**/vitest.*.config.{js,ts,mjs,mts}", - "vite.config.{js,ts,mjs,mts}", - "tailwind.config.{js,ts,cjs,mjs}", - "**/tailwind.config.{js,ts,cjs,mjs}", - "electron.vite.config.{js,ts,mjs}", - "electron-builder.config.{js,ts,cjs}", - "esbuild*.ts", - "**/esbuild.entrypoints.ts", - "metro.config.{js,ts}", - "playwright.config.{js,ts}", - "cypress.config.{js,ts}", - "rollup.config.{js,ts,mjs,cjs}", - "rollup.*.config.js", - "**/.erb/configs/webpack*.config.{js,ts}", - "**/.erb/configs/webpack.config.*.{js,ts}", - "**/astro-tina-directive/register.js", - "rspack.config.{js,ts,mjs,cjs}", - "rsbuild.config.{js,ts,mjs,cjs}", - "**/scripts/build.ts", - "**/scripts/utils/createJestConfig.js", -]; - -const CONFIG_RELATIVE_PATH_PATTERN = /['"`]((\.{1,2}\/|\.\.\/)[^'"`\n]+?|\.\/[^'"`\n]+?)['"`]/g; - -const JEST_ROOT_DIR_PATH_PATTERN = /<rootDir>\/([^'"`\n]+?)(?:['"`]|$)/g; - -const RESOLVE_CALL_PATH_PATTERN = /resolve\s*\(\s*['"`]([^'"`\n]+?)['"`]\s*\)/g; - -const PATH_JOIN_STRING_PATTERN = /path\.(?:join|resolve)\(\s*[^,]+,\s*['"`]([^'"`\n]+?)['"`]/g; - -const ENTRY_POINTS_STRING_PATTERN = /entryPoints:\s*\[\s*['"`]([^'"`\n]+?)['"`]/g; - -const ADD_PREAMBLE_PATTERN = /addPreamble\s*\(\s*['"`]([^'"`\n]+?)['"`]\s*\)/g; - -const ROLLUP_INPUT_PATTERN = /\binput\s*:\s*['"`]([^'"`\n]+?)['"`]/g; - -const VITEST_ENVIRONMENT_PATTERN = /environment\s*:\s*['"`](\.\/[^'"`\n]+?)['"`]/g; - -const ASTRO_ENTRYPOINT_PATTERN = /entrypoint\s*:\s*['"`](\.\/[^'"`\n]+?)['"`]/g; - -const WEBPACK_PATH_JOIN_ENTRY_PATTERN = /path\.join\(\s*[^,]+,\s*['"`]([^'"`\n]+?)['"`]\s*\)/g; - -const WEBPACK_RENDERER_PATH_JOIN_PATTERN = - /path\.join\(\s*webpackPaths\.srcRendererPath\s*,\s*['"`]([^'"`\n]+?)['"`]\s*\)/g; - -const WEBPACK_MAIN_PATH_JOIN_PATTERN = - /path\.join\(\s*webpackPaths\.srcMainPath\s*,\s*['"`]([^'"`\n]+?)['"`]\s*\)/g; - -const BARE_CONFIG_PATH_PATTERN = /['"`](config\/[^'"`\n]+?)['"`]/g; - -const stripModuleImportStatements = (content: string): string => - content - .replace(/^\s*import\s+(?:type\s+)?[\s\S]*?\sfrom\s+['"`][^'"`\n]+['"`]\s*;?\s*$/gm, "") - .replace(/^\s*import\s+['"`][^'"`\n]+['"`]\s*;?\s*$/gm, ""); - -const shouldSkipConfigPath = (rawPath: string): boolean => { - if (rawPath.includes("*") || rawPath.includes("?")) return true; - if (rawPath.endsWith(".json") && !rawPath.includes("/src/")) return true; - if (rawPath.startsWith("node:")) return true; - if (rawPath.startsWith("@")) return true; - return false; -}; - -const addResolvedConfigPath = ( - rawPath: string, - configDirectory: string, - projectRootDirectory: string, - entries: Set<string>, -): void => { - if (shouldSkipConfigPath(rawPath)) return; - - const rootDirectory = rawPath.startsWith(".") ? configDirectory : projectRootDirectory; - const normalizedPath = rawPath.startsWith(".") ? rawPath : `./${rawPath}`; - const absolutePath = resolve(rootDirectory, normalizedPath); - const resolvedEntry = resolveEntryWithExtensions(absolutePath); - if (resolvedEntry) { - entries.add(resolvedEntry); - return; - } - - if (rawPath.startsWith(".")) { - const projectRootResolvedEntry = resolveEntryWithExtensions( - resolve(projectRootDirectory, rawPath), - ); - if (projectRootResolvedEntry) entries.add(projectRootResolvedEntry); - } -}; - -const collectResolvedPathsFromStrings = ( - content: string, - configDirectory: string, - projectRootDirectory: string, - entries: Set<string>, -): void => { - const contentWithoutImports = stripModuleImportStatements(content); - - const patterns = [ - CONFIG_RELATIVE_PATH_PATTERN, - RESOLVE_CALL_PATH_PATTERN, - PATH_JOIN_STRING_PATTERN, - ENTRY_POINTS_STRING_PATTERN, - ADD_PREAMBLE_PATTERN, - ROLLUP_INPUT_PATTERN, - VITEST_ENVIRONMENT_PATTERN, - ASTRO_ENTRYPOINT_PATTERN, - WEBPACK_PATH_JOIN_ENTRY_PATTERN, - BARE_CONFIG_PATH_PATTERN, - ]; - - for (const pattern of patterns) { - let pathMatch: RegExpExecArray | null; - pattern.lastIndex = 0; - while ((pathMatch = pattern.exec(contentWithoutImports)) !== null) { - addResolvedConfigPath(pathMatch[1], configDirectory, projectRootDirectory, entries); - } - } - - let rendererEntryMatch: RegExpExecArray | null; - WEBPACK_RENDERER_PATH_JOIN_PATTERN.lastIndex = 0; - while ( - (rendererEntryMatch = WEBPACK_RENDERER_PATH_JOIN_PATTERN.exec(contentWithoutImports)) !== null - ) { - addResolvedConfigPath( - `src/renderer/${rendererEntryMatch[1]}`, - configDirectory, - projectRootDirectory, - entries, - ); - } - - let mainEntryMatch: RegExpExecArray | null; - WEBPACK_MAIN_PATH_JOIN_PATTERN.lastIndex = 0; - while ((mainEntryMatch = WEBPACK_MAIN_PATH_JOIN_PATTERN.exec(contentWithoutImports)) !== null) { - addResolvedConfigPath( - `src/main/${mainEntryMatch[1]}`, - configDirectory, - projectRootDirectory, - entries, - ); - } - - let rootDirMatch: RegExpExecArray | null; - JEST_ROOT_DIR_PATH_PATTERN.lastIndex = 0; - while ((rootDirMatch = JEST_ROOT_DIR_PATH_PATTERN.exec(content)) !== null) { - addResolvedConfigPath(rootDirMatch[1], configDirectory, projectRootDirectory, entries); - } -}; - -export const extractConfigStringReferencedEntries = (directory: string): string[] => { - const entries = new Set<string>(); - - const configPaths = fg.sync(CONFIG_STRING_ENTRY_GLOBS, { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], - deep: 6, - }); - - for (const configPath of configPaths) { - try { - const content = readFileSync(configPath, "utf-8"); - collectResolvedPathsFromStrings(content, dirname(configPath), directory, entries); - } catch { - continue; - } - } - - return [...entries]; -}; diff --git a/packages/deslop-js/src/collect/entries-in-worker.ts b/packages/deslop-js/src/collect/entries-in-worker.ts deleted file mode 100644 index 262064a6e6..0000000000 --- a/packages/deslop-js/src/collect/entries-in-worker.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { Worker } from "node:worker_threads"; -import type { DeslopConfig, ResolvedEntries } from "../types.js"; -import { resolveEntries } from "./entries.js"; -import { launchSiblingWorker } from "./launch-worker.js"; - -interface EntriesWorkerReadyMessage { - readonly type: "ready"; -} - -interface EntriesWorkerResultMessage { - readonly type: "result"; - readonly entries: ResolvedEntries; -} - -interface EntriesWorkerErrorMessage { - readonly type: "error"; - readonly errorMessage: string; -} - -type EntriesWorkerMessage = - | EntriesWorkerReadyMessage - | EntriesWorkerResultMessage - | EntriesWorkerErrorMessage; - -interface EntriesWorkerInfraFailure { - readonly kind: "infra-failure"; -} - -interface EntriesWorkerAnalysisError { - readonly kind: "analysis-error"; - readonly errorMessage: string; -} - -interface EntriesWorkerSuccess { - readonly kind: "result"; - readonly entries: ResolvedEntries; -} - -type EntriesWorkerOutcome = - | EntriesWorkerSuccess - | EntriesWorkerAnalysisError - | EntriesWorkerInfraFailure; - -/** - * Runs `resolveEntries` on a dedicated worker thread so its ~90%-synchronous - * fs work overlaps the main-thread analysis phases — the incremental-cache - * warm path has no long parse `await` left to hide it behind. Entry content - * reads stay live (fresh every run) exactly as inline. Worker infrastructure - * failures fall back to the inline call (same result, just serialized); an - * error thrown by `resolveEntries` itself rejects with the same message an - * inline throw would, so the caller's fallback-to-empty-entries handling is - * unchanged. - */ -export const resolveEntriesInWorker = async (config: DeslopConfig): Promise<ResolvedEntries> => { - let worker: Worker; - try { - worker = launchSiblingWorker(import.meta.url, "entries-worker"); - } catch { - return resolveEntries(config); - } - try { - const outcome = await new Promise<EntriesWorkerOutcome>((resolveOutcome) => { - worker.on("message", (message: EntriesWorkerMessage) => { - if (message.type === "ready") { - worker.postMessage({ type: "resolve-entries", config }); - } else if (message.type === "result") { - resolveOutcome({ kind: "result", entries: message.entries }); - } else if (message.type === "error") { - resolveOutcome({ kind: "analysis-error", errorMessage: message.errorMessage }); - } - }); - worker.on("error", () => resolveOutcome({ kind: "infra-failure" })); - worker.on("exit", () => resolveOutcome({ kind: "infra-failure" })); - }); - if (outcome.kind === "result") return outcome.entries; - if (outcome.kind === "analysis-error") throw new Error(outcome.errorMessage); - return resolveEntries(config); - } finally { - void worker.terminate(); - } -}; diff --git a/packages/deslop-js/src/collect/entries-worker.ts b/packages/deslop-js/src/collect/entries-worker.ts deleted file mode 100644 index a59c86e222..0000000000 --- a/packages/deslop-js/src/collect/entries-worker.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { parentPort } from "node:worker_threads"; -import type { DeslopConfig, ResolvedEntries } from "../types.js"; -import { resolveEntries } from "./entries.js"; - -interface ResolveEntriesTaskMessage { - readonly type: "resolve-entries"; - readonly config: DeslopConfig; -} - -interface EntriesResultMessage { - readonly type: "result"; - readonly entries: ResolvedEntries; -} - -interface EntriesErrorMessage { - readonly type: "error"; - readonly errorMessage: string; -} - -const port = parentPort!; - -port.on("message", (message: ResolveEntriesTaskMessage) => { - if (message.type !== "resolve-entries") return; - void resolveEntries(message.config).then( - (entries) => { - const response: EntriesResultMessage = { type: "result", entries }; - port.postMessage(response); - }, - (taskError: unknown) => { - const response: EntriesErrorMessage = { - type: "error", - errorMessage: taskError instanceof Error ? taskError.message : String(taskError), - }; - port.postMessage(response); - }, - ); -}); - -port.postMessage({ type: "ready" }); diff --git a/packages/deslop-js/src/collect/entries.ts b/packages/deslop-js/src/collect/entries.ts deleted file mode 100644 index 42a9fd5d2a..0000000000 --- a/packages/deslop-js/src/collect/entries.ts +++ /dev/null @@ -1,2772 +0,0 @@ -import fg from "fast-glob"; -import { dirname, join, resolve } from "node:path"; -import { readFileSync, existsSync } from "node:fs"; -import type { SourceFile, DeslopConfig, ResolvedEntries } from "../types.js"; -import { - DEFAULT_EXTENSIONS, - DEFAULT_EXCLUSIONS, - HIDDEN_DIRECTORY_ALLOWLIST, - SCRIPT_FILE_PATTERN, - SCRIPT_EXTENSIONLESS_FILE_PATTERN, - SCRIPT_CONFIG_FILE_PATTERN, - SHALLOW_WORKSPACE_MAX_DEPTH, -} from "../constants.js"; -import { resolveWorkspaces, detectFrameworkEntries } from "./workspaces.js"; -import type { WorkspacePackage } from "./workspaces.js"; -import { extractExpoConfigPluginEntries } from "./expo-config-plugin-entries.js"; -import { resolveSourcePath } from "../resolver/source-path.js"; -import { findMonorepoRoot } from "../utils/find-monorepo-root.js"; -import { extractConfigStringReferencedEntries } from "./config-string-entries.js"; -import { extractSectionsModuleEntries } from "./sections-module-entries.js"; -import { extractSiblingWorkspaceImportEntries } from "./sibling-workspace-import-entries.js"; -import { extractPackageJsonEntries, findDefaultIndexEntry } from "./package-json-entries.js"; -import { resolveEntryWithExtensions } from "../utils/resolve-entry-with-extensions.js"; -import { toPosixPath } from "../utils/to-posix-path.js"; - -export const collectSourceFiles = async (config: DeslopConfig): Promise<SourceFile[]> => { - const extensions = - config.includeExtensions.length > 0 ? config.includeExtensions : DEFAULT_EXTENSIONS; - - const extensionGlob = - extensions.length === 1 ? `**/*${extensions[0]}` : `**/*{${extensions.join(",")}}`; - - const ignorePatterns = [...DEFAULT_EXCLUSIONS, ...config.ignorePatterns].map(toPosixPath); - const absoluteRoot = resolve(config.rootDir); - - const mainFiles = await fg(extensionGlob, { - cwd: absoluteRoot, - absolute: true, - ignore: ignorePatterns, - dot: false, - onlyFiles: true, - }); - - const allowedHiddenGlobs = HIDDEN_DIRECTORY_ALLOWLIST.flatMap((directory) => [ - `${directory}/**/*{${extensions.join(",")}}`, - `**/${directory}/**/*{${extensions.join(",")}}`, - ]); - const hiddenFiles = - allowedHiddenGlobs.length > 0 - ? await fg(allowedHiddenGlobs, { - cwd: absoluteRoot, - absolute: true, - ignore: ignorePatterns, - dot: true, - onlyFiles: true, - }) - : []; - - const files = [...new Set([...mainFiles, ...hiddenFiles].map(toPosixPath))]; - - const sortedFiles = files.sort(); - - return sortedFiles.map((filePath, fileIndex) => ({ - index: fileIndex, - path: filePath, - })); -}; - -export const getFrameworkExclusions = (rootDir: string): string[] => { - const absoluteRoot = resolve(rootDir); - const workspacePackages = resolveWorkspaces(absoluteRoot).packages; - const directoriesToCheck = [ - absoluteRoot, - ...workspacePackages.map((workspacePackage) => workspacePackage.directory), - ]; - const ignorePatterns: string[] = []; - - for (const directory of directoriesToCheck) { - const packageJsonPath = join(directory, "package.json"); - if (!existsSync(packageJsonPath)) continue; - - let allDependencies: Record<string, string> = {}; - try { - const content = readFileSync(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(content); - allDependencies = { - ...packageJson.dependencies, - ...packageJson.devDependencies, - ...packageJson.optionalDependencies, - }; - } catch { - continue; - } - - for (const plugin of FRAMEWORK_PATTERNS) { - if (plugin.contentIgnorePatterns && isToolingPluginEnabled(plugin, allDependencies)) { - for (const pattern of plugin.contentIgnorePatterns) { - const absolutePattern = join(directory, pattern); - ignorePatterns.push(absolutePattern); - } - } - } - } - - return ignorePatterns; -}; - -export const resolveEntries = async (config: DeslopConfig): Promise<ResolvedEntries> => { - const absoluteRoot = resolve(config.rootDir); - - const entryFiles = - config.entryPatterns.length > 0 - ? await fg(config.entryPatterns, { - cwd: absoluteRoot, - absolute: true, - onlyFiles: true, - }) - : []; - - const packageJsonPath = resolve(absoluteRoot, "package.json"); - const packageJsonEntries = await extractPackageJsonEntries(packageJsonPath); - - const workspaceDiscovery = resolveWorkspaces(absoluteRoot); - const workspacePackages = workspaceDiscovery.packages; - const isEntryEligible = (workspacePackage: WorkspacePackage): boolean => { - if (workspaceDiscovery.hasRootLevelWorkspacePatterns) return true; - return workspacePackage.depthFromRoot <= SHALLOW_WORKSPACE_MAX_DEPTH; - }; - - const hasDeclaredWorkspaces = workspacePackages.some( - (workspacePackage) => workspacePackage.isDeclaredWorkspace, - ); - - const workspaceEntries: string[] = []; - for (const workspacePackage of workspacePackages) { - const isEligible = isEntryEligible(workspacePackage); - - const shouldRunFrameworkDetection = - workspaceDiscovery.hasRootLevelWorkspacePatterns && hasDeclaredWorkspaces - ? workspacePackage.isDeclaredWorkspace && isEligible - : isEligible; - if (shouldRunFrameworkDetection) { - const workspaceFrameworkEntries = detectFrameworkEntries(workspacePackage.directory); - workspaceEntries.push(...workspaceFrameworkEntries); - } - - const shouldExtractEntries = - isEligible && - (workspacePackage.isDeclaredWorkspace || !workspaceDiscovery.hasRootLevelWorkspacePatterns); - if (shouldExtractEntries) { - const workspacePackageJsonPath = resolve(workspacePackage.directory, "package.json"); - const workspacePackageJsonEntries = await extractPackageJsonEntries(workspacePackageJsonPath); - const hasValidEntries = workspacePackageJsonEntries.some((entryPath) => - existsSync(entryPath), - ); - if (hasValidEntries) { - workspaceEntries.push(...workspacePackageJsonEntries); - } else { - const defaultFallback = findDefaultIndexEntry(workspacePackage.directory); - if (defaultFallback) { - workspaceEntries.push(defaultFallback); - } - } - } - } - - const frameworkEntries = detectFrameworkEntries(absoluteRoot); - - const entryEligiblePackages = workspacePackages.filter(isEntryEligible); - - const monorepoRootForEntries = findMonorepoRoot(absoluteRoot); - const ancestorPackageJsonRoots = - monorepoRootForEntries && monorepoRootForEntries !== absoluteRoot - ? [monorepoRootForEntries] - : []; - - const scriptEntries = extractScriptEntries(absoluteRoot); - for (const workspacePackage of entryEligiblePackages) { - scriptEntries.push(...extractScriptEntries(workspacePackage.directory)); - } - for (const ancestorRoot of ancestorPackageJsonRoots) { - for (const entryPath of extractScriptEntries(ancestorRoot)) { - if (entryPath.startsWith(`${absoluteRoot}/`)) scriptEntries.push(entryPath); - } - } - - const webpackEntries = extractWebpackEntryPoints(absoluteRoot); - for (const workspacePackage of entryEligiblePackages) { - webpackEntries.push(...extractWebpackEntryPoints(workspacePackage.directory)); - } - - const viteEntries = extractViteEntryPoints(absoluteRoot); - for (const workspacePackage of entryEligiblePackages) { - viteEntries.push(...extractViteEntryPoints(workspacePackage.directory)); - } - - const bundlerConfigEntries = extractBundlerConfigEntryPoints(absoluteRoot); - for (const workspacePackage of entryEligiblePackages) { - bundlerConfigEntries.push(...extractBundlerConfigEntryPoints(workspacePackage.directory)); - } - - const htmlScriptEntries = extractHtmlScriptEntries(absoluteRoot); - for (const workspacePackage of entryEligiblePackages) { - htmlScriptEntries.push(...extractHtmlScriptEntries(workspacePackage.directory)); - } - - const allDiscoveredEntries = [ - ...scriptEntries, - ...webpackEntries, - ...viteEntries, - ...bundlerConfigEntries, - ]; - for (const entryPath of allDiscoveredEntries) { - if (entryPath.endsWith(".html") && existsSync(entryPath)) { - htmlScriptEntries.push(...extractScriptTagsFromHtmlFile(entryPath)); - } - } - - const angularEntries = extractAngularEntryPoints(absoluteRoot); - for (const workspacePackage of entryEligiblePackages) { - angularEntries.push(...extractAngularEntryPoints(workspacePackage.directory)); - } - - const browserExtensionEntries = extractBrowserExtensionEntries(absoluteRoot); - for (const workspacePackage of entryEligiblePackages) { - browserExtensionEntries.push(...extractBrowserExtensionEntries(workspacePackage.directory)); - } - - const webWorkerEntries = extractWebWorkerEntries(absoluteRoot); - for (const workspacePackage of entryEligiblePackages) { - webWorkerEntries.push(...extractWebWorkerEntries(workspacePackage.directory)); - } - - const tsConfigIncludeEntries = extractTsConfigIncludeFilesEntries(absoluteRoot); - for (const workspacePackage of entryEligiblePackages) { - tsConfigIncludeEntries.push(...extractTsConfigIncludeFilesEntries(workspacePackage.directory)); - } - - const configStringEntries = extractConfigStringReferencedEntries(absoluteRoot); - for (const workspacePackage of entryEligiblePackages) { - configStringEntries.push(...extractConfigStringReferencedEntries(workspacePackage.directory)); - } - - const rootPackageDependencies = readPackageJsonDependencies(join(absoluteRoot, "package.json")); - const expoConfigPluginCollection = extractExpoConfigPluginEntries( - absoluteRoot, - rootPackageDependencies, - absoluteRoot, - false, - ); - const expoConfigPluginEntries = [...expoConfigPluginCollection.filePaths]; - for (const workspacePackage of entryEligiblePackages) { - const workspacePackageDependencies = readPackageJsonDependencies( - join(workspacePackage.directory, "package.json"), - ); - const workspaceExpoCollection = extractExpoConfigPluginEntries( - workspacePackage.directory, - workspacePackageDependencies, - absoluteRoot, - ); - expoConfigPluginEntries.push(...workspaceExpoCollection.filePaths); - } - - const sectionsModuleEntries = extractSectionsModuleEntries(absoluteRoot); - - const siblingWorkspaceImportEntries = extractSiblingWorkspaceImportEntries(absoluteRoot); - - const wranglerEntries = extractWranglerEntries(absoluteRoot); - for (const workspacePackage of entryEligiblePackages) { - wranglerEntries.push(...extractWranglerEntries(workspacePackage.directory)); - } - - const testSetupEntries = extractTestSetupFiles(absoluteRoot); - for (const workspacePackage of entryEligiblePackages) { - testSetupEntries.push(...extractTestSetupFiles(workspacePackage.directory)); - } - - const pluginFileEntries = extractNextConfigPluginFiles(absoluteRoot); - for (const workspacePackage of entryEligiblePackages) { - pluginFileEntries.push(...extractNextConfigPluginFiles(workspacePackage.directory)); - } - - const testRunnerDiscovery = discoverTestRunnerEntryPoints(absoluteRoot, entryEligiblePackages); - const toolingDiscovery = discoverToolingEntryPoints(absoluteRoot, entryEligiblePackages); - const ciEntries = extractCiWorkflowEntries(absoluteRoot); - - const testEntries = [ - ...new Set([...testRunnerDiscovery.entryFiles, ...testSetupEntries].map(toPosixPath)), - ]; - const testEntryPathSet = new Set(testEntries); - const productionEntries = [ - ...new Set( - [ - ...entryFiles, - ...packageJsonEntries, - ...workspaceEntries, - ...frameworkEntries, - ...scriptEntries, - ...webpackEntries, - ...viteEntries, - ...bundlerConfigEntries, - ...htmlScriptEntries, - ...angularEntries, - ...browserExtensionEntries, - ...webWorkerEntries, - ...tsConfigIncludeEntries, - ...configStringEntries, - ...expoConfigPluginEntries, - ...sectionsModuleEntries, - ...siblingWorkspaceImportEntries, - ...wranglerEntries, - ...pluginFileEntries, - ...toolingDiscovery.entryFiles, - ...ciEntries, - ].map(toPosixPath), - ), - ].filter((entryPath) => !testEntryPathSet.has(entryPath)); - const alwaysUsedFiles = [ - ...new Set( - [...toolingDiscovery.alwaysUsedFiles, ...testRunnerDiscovery.alwaysUsedFiles].map( - toPosixPath, - ), - ), - ]; - - return { productionEntries, testEntries, alwaysUsedFiles }; -}; - -const SHELL_OPERATORS_PATTERN = /\s*(?:&&|\|\||[;&|])\s*/; - -const SCRIPT_MULTIPLEXERS = new Set([ - "concurrently", - "run-s", - "run-p", - "npm-run-all", - "npm-run-all2", - "wireit", - "turbo", - "lerna", - "ultra", -]); - -const TSCONFIG_PROJECT_FLAGS = new Set(["--project", "-p"]); - -const CONFIG_LIKE_FLAGS = new Set([ - "--config", - "-c", - "--format", - "--formatter", - "--tsconfig", - "--project", - "-p", - "--setup", - "--global-setup", -]); - -const ENV_WRAPPER_BINARIES = new Set(["cross-env", "dotenv", "dotenv-flow", "env-cmd"]); - -const IGNORED_CLI_TOOLS = new Set([ - "prettier", - "eslint", - "tslint", - "stylelint", - "biome", - "oxlint", - "oxfmt", - "tsc", - "tsup", - "tsdown", - "rollup", - "webpack", - "rimraf", - "del-cli", - "shx", - "cpy-cli", - "cpx", - "echo", - "cat", - "mkdir", - "rm", - "cp", - "mv", - "ls", - "pwd", - "test", - - "husky", - "lint-staged", - "commitlint", - "changeset", - "changesets", - "typedoc", - "api-extractor", - "madge", - "depcheck", - "deslop", - "sort-package-json", - "pnpm", - "npm", - "yarn", - "ni", - "nr", - "nun", - "next", - "nuxt", - "astro", - "vite", - "svelte-kit", - "prisma", - "drizzle-kit", - "formatjs", - "i18next", - "i18next-parser", - "lingui", - "storybook", - "chromatic", - "msw", - "patch-package", - "syncpack", - "manypkg", - "jest", - "vitest", - "mocha", - "ava", - "tap", - "c8", - "nyc", - "playwright", - "cypress", - "puppeteer", - "webdriver", - "sequelize", - "typeorm", - "mikro-orm", - "wait-on", - "start-server-and-test", - "remark", - "markdownlint", - "markdownlint-cli2", - "textlint", - "alex", - "cspell", - "ncu", - "npm-check-updates", - "size-limit", - "bundlewatch", - "dbdocs", - "lobe-i18n", - "lobe-seo", -]); - -const looksLikeFilePath = (token: string): boolean => { - if (token.startsWith("-") || token.includes("${{") || token.includes("://")) return false; - if (token.includes("}}") && !token.includes("{{")) return false; - const hasKnownExtension = - /\.(?:[cm]?[jt]sx?|css|scss|json|yaml|yml|toml|html|mjs|cjs|mts|cts|graphql|gql|mdx|astro|vue|svelte)$/.test( - token, - ); - if (hasKnownExtension) return true; - const hasGlobWithExtension = /\.\{[^}]+\}$/.test(token); - if (hasGlobWithExtension) return true; - if (token.startsWith("./") || token.startsWith("../")) return true; - return token.includes("/") && !token.startsWith("@"); -}; - -const isGlobPattern = (token: string): boolean => { - return token.includes("*") || token.includes("{") || token.includes("?"); -}; - -const extractScriptFileArguments = (scriptCommand: string, directory: string): string[] => { - const entries: string[] = []; - const segments = scriptCommand.split(SHELL_OPERATORS_PATTERN); - - for (const segment of segments) { - const trimmedSegment = segment.trim(); - if (!trimmedSegment) continue; - - const tokens = trimmedSegment.split(/\s+/); - if (tokens.length === 0) continue; - - let startIndex = 0; - const firstBinary = tokens[0].replace(/^.*\//, ""); - if (ENV_WRAPPER_BINARIES.has(firstBinary)) { - startIndex = 1; - while (startIndex < tokens.length && /^[A-Z_][A-Z0-9_]*=/.test(tokens[startIndex])) { - startIndex++; - } - if (startIndex >= tokens.length) continue; - } - - const binaryName = tokens[startIndex].replace(/^.*\//, ""); - if (SCRIPT_MULTIPLEXERS.has(binaryName)) continue; - - const effectiveBinaryName = - binaryName === "npx" || binaryName === "pnpx" || binaryName === "bunx" - ? (tokens[startIndex + 1]?.replace(/^.*\//, "") ?? "") - : binaryName; - const isNonEntryBinary = - IGNORED_CLI_TOOLS.has(binaryName) || - (effectiveBinaryName !== "" && IGNORED_CLI_TOOLS.has(effectiveBinaryName)); - - for (let tokenIndex = startIndex + 1; tokenIndex < tokens.length; tokenIndex++) { - const token = tokens[tokenIndex].replace(/^['"]|['"]$/g, ""); - - if (CONFIG_LIKE_FLAGS.has(token)) { - if (tokenIndex + 1 < tokens.length && !tokens[tokenIndex + 1].startsWith("-")) { - const configPath = tokens[tokenIndex + 1].replace(/^['"]|['"]$/g, ""); - if (looksLikeFilePath(configPath)) { - const absoluteConfigPath = resolve(directory, configPath); - if (existsSync(absoluteConfigPath)) { - const isTscProjectFlag = - TSCONFIG_PROJECT_FLAGS.has(token) && - TSCONFIG_PROJECT_PATTERN.test(absoluteConfigPath); - if (isTscProjectFlag) { - entries.push(...expandTsConfigProjectEntries(absoluteConfigPath)); - } else { - entries.push(absoluteConfigPath); - } - } - } - tokenIndex++; - } - continue; - } - - const equalsIndex = token.indexOf("="); - if (equalsIndex > 0 && CONFIG_LIKE_FLAGS.has(token.slice(0, equalsIndex))) { - const configValue = token.slice(equalsIndex + 1); - const flagName = token.slice(0, equalsIndex); - if (configValue && looksLikeFilePath(configValue)) { - const absoluteConfigPath = resolve(directory, configValue); - if (existsSync(absoluteConfigPath)) { - const isTscProjectFlag = - TSCONFIG_PROJECT_FLAGS.has(flagName) && - TSCONFIG_PROJECT_PATTERN.test(absoluteConfigPath); - if (isTscProjectFlag) { - entries.push(...expandTsConfigProjectEntries(absoluteConfigPath)); - } else { - entries.push(absoluteConfigPath); - } - } - } - continue; - } - - if (token.startsWith("-")) continue; - - if (isNonEntryBinary) continue; - - if (!looksLikeFilePath(token)) continue; - - if (isGlobPattern(token)) { - const expandedFiles = fg.sync(token, { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - }); - entries.push(...expandedFiles); - } else { - const absoluteFilePath = resolve(directory, token); - if (existsSync(absoluteFilePath)) { - entries.push(absoluteFilePath); - } else { - const sourcePath = resolveSourcePath(absoluteFilePath, directory); - if (sourcePath) { - entries.push(sourcePath); - } - } - } - } - } - - return entries; -}; - -const EXTENSIONLESS_SCRIPT_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".cjs"]; - -const resolveExtensionlessScriptPath = (basePath: string): string | undefined => { - for (const extension of EXTENSIONLESS_SCRIPT_EXTENSIONS) { - const candidate = basePath + extension; - if (existsSync(candidate)) return candidate; - } - const indexCandidate = resolve(basePath, "index.ts"); - if (existsSync(indexCandidate)) return indexCandidate; - return undefined; -}; - -const extractScriptEntries = (directory: string): string[] => { - const packageJsonPath = resolve(directory, "package.json"); - if (!existsSync(packageJsonPath)) return []; - - const entries: string[] = []; - try { - const content = readFileSync(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(content); - const scripts = packageJson.scripts; - if (scripts && typeof scripts === "object") { - for (const scriptCommand of Object.values(scripts)) { - if (typeof scriptCommand !== "string") continue; - - const match = scriptCommand.match(SCRIPT_FILE_PATTERN); - if (match?.[1]) { - const scriptFilePath = resolve(directory, match[1]); - if (existsSync(scriptFilePath)) { - entries.push(scriptFilePath); - } else { - const sourcePath = resolveSourcePath(scriptFilePath, directory); - if (sourcePath) { - entries.push(sourcePath); - } - } - } else { - const extensionlessMatch = scriptCommand.match(SCRIPT_EXTENSIONLESS_FILE_PATTERN); - if (extensionlessMatch?.[1]) { - const extensionlessPath = extensionlessMatch[1]; - const resolved = resolveExtensionlessScriptPath(resolve(directory, extensionlessPath)); - if (resolved) { - entries.push(resolved); - } - } - } - - const configMatch = scriptCommand.match(SCRIPT_CONFIG_FILE_PATTERN); - if (configMatch?.[1]) { - const configFilePath = resolve(directory, configMatch[1]); - if (existsSync(configFilePath)) { - entries.push(configFilePath); - } else { - const sourcePath = resolveSourcePath(configFilePath, directory); - if (sourcePath) { - entries.push(sourcePath); - } - } - } - - entries.push(...extractScriptFileArguments(scriptCommand, directory)); - } - } - } catch {} - - return entries; -}; - -const isYamlMapping = (line: string): boolean => { - const firstWord = line.split(/\s/)[0]; - if (!firstWord) return false; - return firstWord.endsWith(":") && !firstWord.startsWith("http") && !firstWord.startsWith("ftp"); -}; - -const extractCiRunCommands = (content: string): string[] => { - const commands: string[] = []; - let inMultilineRun = false; - let multilineIndent = 0; - - for (const line of content.split("\n")) { - const trimmedLine = line.trim(); - if (trimmedLine === "" || trimmedLine.startsWith("#")) continue; - - if (inMultilineRun) { - const indent = line.length - line.trimStart().length; - if (indent > multilineIndent && trimmedLine !== "") { - commands.push(trimmedLine); - continue; - } - inMultilineRun = false; - } - - const runMatch = trimmedLine.match(/^(?:-\s+)?run:\s*(.*)$/); - if (runMatch) { - const runValue = runMatch[1].trim(); - if (runValue === "|" || runValue === "|-" || runValue === "|+") { - inMultilineRun = true; - multilineIndent = line.length - line.trimStart().length; - } else if (runValue !== "") { - commands.push(runValue); - } - continue; - } - - if (trimmedLine.startsWith("- ")) { - const listItem = trimmedLine.slice(2).trim(); - if ( - listItem !== "" && - !listItem.startsWith("{") && - !listItem.startsWith("[") && - !isYamlMapping(listItem) - ) { - commands.push(listItem); - } - } - } - return commands; -}; - -const extractCiWorkflowEntries = (rootDir: string): string[] => { - const entries: string[] = []; - const workflowsDir = join(rootDir, ".github", "workflows"); - if (!existsSync(workflowsDir)) return entries; - - // Standalone tool packages vendored under .github (a workflow `cp`s the - // directory and runs `npm run build` inside it) reference their scripts - // through their own package.json, not the workflow yml. - const nestedToolPackageJsonPaths = fg.sync("**/package.json", { - cwd: join(rootDir, ".github"), - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - }); - for (const nestedPackageJsonPath of nestedToolPackageJsonPaths) { - entries.push(...extractScriptEntries(dirname(nestedPackageJsonPath))); - } - - const workflowFiles = fg.sync("*.{yml,yaml}", { - cwd: workflowsDir, - absolute: true, - onlyFiles: true, - }); - - for (const workflowFile of workflowFiles) { - try { - const content = readFileSync(workflowFile, "utf-8"); - const runCommands = extractCiRunCommands(content); - for (const command of runCommands) { - const scriptMatch = command.match(SCRIPT_FILE_PATTERN); - if (scriptMatch?.[1]) { - const scriptFilePath = resolve(rootDir, scriptMatch[1]); - if (existsSync(scriptFilePath)) { - entries.push(scriptFilePath); - } - } - const configMatch = command.match(SCRIPT_CONFIG_FILE_PATTERN); - if (configMatch?.[1]) { - const configFilePath = resolve(rootDir, configMatch[1]); - if (existsSync(configFilePath)) { - entries.push(configFilePath); - } - } - } - } catch {} - } - - return entries; -}; - -const VITE_INPUT_BLOCK_PATTERN = /input\s*:\s*(?:\{[^}]*\}|\[[^\]]*\]|['"][^'"]+['"])/gs; -const BUNDLER_ENTRY_FILE_PATTERN = - /['"]([^'"]+\.(?:js|ts|tsx|jsx|mjs|mts|less|scss|css|sass|html))['"]/g; - -const extractViteEntryPoints = (directory: string): string[] => { - const entries: string[] = []; - const viteConfigPaths = fg.sync("vite.config.{js,ts,mjs,mts}", { - cwd: directory, - absolute: true, - onlyFiles: true, - }); - - for (const configPath of viteConfigPaths) { - try { - const content = readFileSync(configPath, "utf-8"); - let inputMatch: RegExpExecArray | null; - VITE_INPUT_BLOCK_PATTERN.lastIndex = 0; - while ((inputMatch = VITE_INPUT_BLOCK_PATTERN.exec(content)) !== null) { - const inputBlock = inputMatch[0]; - let valueMatch: RegExpExecArray | null; - BUNDLER_ENTRY_FILE_PATTERN.lastIndex = 0; - while ((valueMatch = BUNDLER_ENTRY_FILE_PATTERN.exec(inputBlock)) !== null) { - const entryPath = valueMatch[1]; - if ( - entryPath.startsWith("./") || - entryPath.startsWith("../") || - !entryPath.startsWith("/") - ) { - const absoluteEntryPath = resolve(directory, entryPath); - if (existsSync(absoluteEntryPath)) { - entries.push(absoluteEntryPath); - } - } - } - } - } catch {} - } - - return entries; -}; - -const BUNDLER_CONFIG_ENTRY_BLOCK_PATTERN = /entry\s*:\s*\[([^\]]*)\]/gs; -const BUNDLER_CONFIG_ENTRY_STRING_PATTERN = /['"]([^'"]+)['"]/g; - -const extractBundlerConfigEntryPoints = (directory: string): string[] => { - const entries: string[] = []; - const configPaths = fg.sync(["tsdown.config.{ts,js,cjs,mjs}", "tsup.config.{ts,js,cjs,mjs}"], { - cwd: directory, - absolute: true, - onlyFiles: true, - }); - - for (const configPath of configPaths) { - try { - const content = readFileSync(configPath, "utf-8"); - let blockMatch: RegExpExecArray | null; - BUNDLER_CONFIG_ENTRY_BLOCK_PATTERN.lastIndex = 0; - while ((blockMatch = BUNDLER_CONFIG_ENTRY_BLOCK_PATTERN.exec(content)) !== null) { - const arrayContent = blockMatch[1]; - let stringMatch: RegExpExecArray | null; - BUNDLER_CONFIG_ENTRY_STRING_PATTERN.lastIndex = 0; - while ((stringMatch = BUNDLER_CONFIG_ENTRY_STRING_PATTERN.exec(arrayContent)) !== null) { - const entryPath = stringMatch[1]; - const absoluteEntryPath = resolve(directory, entryPath); - const resolvedPath = resolveEntryWithExtensions(absoluteEntryPath); - if (resolvedPath) { - entries.push(resolvedPath); - } - } - } - } catch {} - } - - return entries; -}; - -const WEBPACK_ENTRY_BLOCK_PATTERN = - /entry\s*:\s*(?:\{[^}]*\}|\[[^\]]*\]|['"][^'"]+['"]|path\.(?:join|resolve)\([^)]*\))/gs; -const WEBPACK_ENTRY_FILE_PATTERN = /['"]([^'"]+)['"]/g; -const WEBPACK_PATH_JOIN_PATTERN = - /path\.(?:join|resolve)\(\s*__dirname\s*,\s*((?:['"][^'"]*['"][\s,]*)+)\)/g; -const REQUIRE_RESOLVE_PATTERN = /require\.resolve\(\s*['"]([^'"]+)['"]\s*\)/g; - -const extractWebpackEntryPoints = (directory: string): string[] => { - const entries: string[] = []; - const webpackConfigPaths = fg.sync( - [ - "webpack.config.{js,ts,mjs,cjs}", - "**/webpack*.config.{js,ts,mjs,cjs}", - "**/webpack.config*.{js,ts,mjs,cjs}", - "**/webpack*.config*.babel.{js,ts}", - ], - { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - deep: 3, - }, - ); - - for (const configPath of webpackConfigPaths) { - try { - const content = readFileSync(configPath, "utf-8"); - const configDirectory = dirname(configPath); - - let pathJoinMatch: RegExpExecArray | null; - WEBPACK_PATH_JOIN_PATTERN.lastIndex = 0; - while ((pathJoinMatch = WEBPACK_PATH_JOIN_PATTERN.exec(content)) !== null) { - const segmentsRaw = pathJoinMatch[1]; - const segments = [...segmentsRaw.matchAll(/['"]([^'"]*)['"]/g)].map((match) => match[1]); - if (segments.length > 0) { - const joinedPath = resolve(configDirectory, ...segments); - const resolvedEntry = resolveEntryWithExtensions(joinedPath); - if (resolvedEntry) { - entries.push(resolvedEntry); - } - } - } - - let requireResolveMatch: RegExpExecArray | null; - REQUIRE_RESOLVE_PATTERN.lastIndex = 0; - while ((requireResolveMatch = REQUIRE_RESOLVE_PATTERN.exec(content)) !== null) { - const requirePath = requireResolveMatch[1]; - if (requirePath.startsWith("./") || requirePath.startsWith("../")) { - const absoluteRequirePath = resolve(configDirectory, requirePath); - const resolvedEntry = resolveEntryWithExtensions(absoluteRequirePath); - if (resolvedEntry) { - entries.push(resolvedEntry); - } - } - } - - let entryMatch: RegExpExecArray | null; - WEBPACK_ENTRY_BLOCK_PATTERN.lastIndex = 0; - while ((entryMatch = WEBPACK_ENTRY_BLOCK_PATTERN.exec(content)) !== null) { - const entryBlock = entryMatch[0]; - if (entryBlock.includes("path.join") || entryBlock.includes("path.resolve")) continue; - let valueMatch: RegExpExecArray | null; - WEBPACK_ENTRY_FILE_PATTERN.lastIndex = 0; - while ((valueMatch = WEBPACK_ENTRY_FILE_PATTERN.exec(entryBlock)) !== null) { - const entryPath = valueMatch[1]; - if ( - entryPath.startsWith("./") || - entryPath.startsWith("../") || - !entryPath.startsWith("/") - ) { - const absoluteEntryPath = resolve(configDirectory, entryPath); - const resolvedEntry = resolveEntryWithExtensions(absoluteEntryPath); - if (resolvedEntry) { - entries.push(resolvedEntry); - } - } - } - } - } catch {} - } - - return entries; -}; - -const HTML_SCRIPT_SRC_PATTERN = - /<script[^>]+src=["']([^"']+\.(?:ts|tsx|js|jsx|mts|mjs))["'][^>]*>/gi; - -const extractHtmlScriptEntries = (directory: string): string[] => { - const entries: string[] = []; - const htmlFiles = fg.sync(["index.html", "*.html"], { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], - deep: 1, - }); - - for (const htmlPath of htmlFiles) { - try { - const content = readFileSync(htmlPath, "utf-8"); - let scriptMatch: RegExpExecArray | null; - HTML_SCRIPT_SRC_PATTERN.lastIndex = 0; - while ((scriptMatch = HTML_SCRIPT_SRC_PATTERN.exec(content)) !== null) { - const scriptSrc = scriptMatch[1].replace(/^\//, ""); - const htmlDirectory = htmlPath.replace(/\/[^/]+$/, ""); - const absoluteScriptPath = resolve(htmlDirectory, scriptSrc); - if (existsSync(absoluteScriptPath)) { - entries.push(absoluteScriptPath); - } - } - } catch {} - } - - return entries; -}; - -const extractScriptTagsFromHtmlFile = (htmlFilePath: string): string[] => { - const entries: string[] = []; - try { - const content = readFileSync(htmlFilePath, "utf-8"); - let scriptMatch: RegExpExecArray | null; - HTML_SCRIPT_SRC_PATTERN.lastIndex = 0; - while ((scriptMatch = HTML_SCRIPT_SRC_PATTERN.exec(content)) !== null) { - const scriptSrc = scriptMatch[1].replace(/^\//, ""); - const htmlDirectory = dirname(htmlFilePath); - const absoluteScriptPath = resolve(htmlDirectory, scriptSrc); - if (existsSync(absoluteScriptPath)) { - entries.push(absoluteScriptPath); - } - } - } catch {} - return entries; -}; - -const TSCONFIG_FILENAME_GLOBS = ["tsconfig.json", "tsconfig.*.json"]; -const TSCONFIG_PROJECT_PATTERN = /(?:^|[\\/])tsconfig(?:\.[^.]+)?\.json$/; - -const stripJsoncCommentsLocal = (sourceText: string): string => { - let result = ""; - let insideString = false; - let index = 0; - while (index < sourceText.length) { - const ch = sourceText[index]; - if (insideString) { - if (ch === "\\" && index + 1 < sourceText.length) { - result += sourceText[index] + sourceText[index + 1]; - index += 2; - continue; - } - if (ch === '"') insideString = false; - result += ch; - index++; - continue; - } - if (ch === '"') { - insideString = true; - result += ch; - index++; - continue; - } - if (ch === "/" && index + 1 < sourceText.length) { - if (sourceText[index + 1] === "/") { - while (index < sourceText.length && sourceText[index] !== "\n") index++; - continue; - } - if (sourceText[index + 1] === "*") { - index += 2; - while ( - index + 1 < sourceText.length && - !(sourceText[index] === "*" && sourceText[index + 1] === "/") - ) - index++; - index += 2; - continue; - } - } - result += ch; - index++; - } - return result.replace(/,(\s*[}\]])/g, "$1"); -}; - -const extractTsConfigIncludeFilesEntries = (directory: string): string[] => { - const entries: string[] = []; - const tsconfigPaths = fg.sync(TSCONFIG_FILENAME_GLOBS, { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], - deep: 1, - }); - - for (const tsconfigPath of tsconfigPaths) { - try { - const rawText = readFileSync(tsconfigPath, "utf-8"); - const cleaned = stripJsoncCommentsLocal(rawText); - const tsconfigJson = JSON.parse(cleaned); - const tsconfigDir = dirname(tsconfigPath); - const collectPaths = (rawList: unknown): void => { - if (!Array.isArray(rawList)) return; - for (const item of rawList) { - if (typeof item !== "string") continue; - if (item.includes("*") || item.includes("?")) continue; - const candidatePath = resolve(tsconfigDir, item); - if (existsSync(candidatePath)) { - entries.push(candidatePath); - } - } - }; - collectPaths(tsconfigJson.include); - collectPaths(tsconfigJson.files); - } catch {} - } - - return entries; -}; - -const expandTsConfigProjectEntries = (tsconfigAbsolutePath: string): string[] => { - const entries: string[] = []; - try { - const rawText = readFileSync(tsconfigAbsolutePath, "utf-8"); - const cleaned = stripJsoncCommentsLocal(rawText); - const tsconfigJson = JSON.parse(cleaned); - const tsconfigDir = dirname(tsconfigAbsolutePath); - - if (Array.isArray(tsconfigJson.files)) { - for (const fileItem of tsconfigJson.files) { - if (typeof fileItem !== "string") continue; - const candidatePath = resolve(tsconfigDir, fileItem); - if (existsSync(candidatePath)) entries.push(candidatePath); - } - } - - if (Array.isArray(tsconfigJson.include)) { - for (const includePattern of tsconfigJson.include) { - if (typeof includePattern !== "string") continue; - const expandedFiles = fg.sync(includePattern, { - cwd: tsconfigDir, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], - }); - entries.push(...expandedFiles); - } - } - } catch {} - return entries; -}; - -const WRANGLER_TOML_MAIN_PATTERN = /^\s*main\s*=\s*['"]([^'"\n]+)['"]/m; -const WRANGLER_JSON_MAIN_PATTERN = /"main"\s*:\s*"([^"]+)"/; -const WRANGLER_SERVICE_BINDINGS_PATTERN = /entry_point\s*=\s*['"]([^'"\n]+)['"]/g; - -const extractWranglerEntries = (directory: string): string[] => { - const entries: string[] = []; - const wranglerPaths = fg.sync(["wrangler.toml", "wrangler.json", "wrangler.jsonc"], { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - deep: 1, - }); - - for (const wranglerPath of wranglerPaths) { - try { - const content = readFileSync(wranglerPath, "utf-8"); - const wranglerDir = dirname(wranglerPath); - const isToml = wranglerPath.endsWith(".toml"); - const mainMatch = isToml - ? content.match(WRANGLER_TOML_MAIN_PATTERN) - : content.match(WRANGLER_JSON_MAIN_PATTERN); - if (mainMatch?.[1]) { - const candidatePath = resolve(wranglerDir, mainMatch[1]); - if (existsSync(candidatePath)) entries.push(candidatePath); - else { - const sourceCandidate = resolveSourcePath(candidatePath, wranglerDir); - if (sourceCandidate) entries.push(sourceCandidate); - } - } - let entryPointMatch: RegExpExecArray | null; - WRANGLER_SERVICE_BINDINGS_PATTERN.lastIndex = 0; - while ((entryPointMatch = WRANGLER_SERVICE_BINDINGS_PATTERN.exec(content)) !== null) { - const candidatePath = resolve(wranglerDir, entryPointMatch[1]); - if (existsSync(candidatePath)) entries.push(candidatePath); - } - } catch {} - } - - return entries; -}; - -const WORKER_FILE_GLOBS = [ - "**/*.worker.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", - "**/*.sw.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", - "**/sw.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", - "**/service-worker.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", -]; - -const extractWebWorkerEntries = (directory: string): string[] => { - const workerFiles = fg.sync(WORKER_FILE_GLOBS, { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.next/**", "**/out/**"], - deep: 8, - }); - return workerFiles; -}; - -const collectBrowserExtensionManifestPaths = (manifest: unknown): string[] => { - const candidatePaths: string[] = []; - if (typeof manifest !== "object" || manifest === null) return candidatePaths; - const manifestRecord = manifest as Record<string, unknown>; - - const background = manifestRecord.background; - if (typeof background === "object" && background !== null) { - const backgroundRecord = background as Record<string, unknown>; - if (typeof backgroundRecord.service_worker === "string") { - candidatePaths.push(backgroundRecord.service_worker); - } - if (typeof backgroundRecord.page === "string") { - candidatePaths.push(backgroundRecord.page); - } - if (typeof backgroundRecord.scripts === "string") { - candidatePaths.push(backgroundRecord.scripts); - } - if (Array.isArray(backgroundRecord.scripts)) { - for (const scriptPath of backgroundRecord.scripts) { - if (typeof scriptPath === "string") candidatePaths.push(scriptPath); - } - } - } - - const contentScripts = manifestRecord.content_scripts; - if (Array.isArray(contentScripts)) { - for (const contentScript of contentScripts) { - if (typeof contentScript !== "object" || contentScript === null) continue; - const contentScriptRecord = contentScript as Record<string, unknown>; - if (Array.isArray(contentScriptRecord.js)) { - for (const scriptPath of contentScriptRecord.js) { - if (typeof scriptPath === "string") candidatePaths.push(scriptPath); - } - } - if (Array.isArray(contentScriptRecord.css)) { - for (const stylePath of contentScriptRecord.css) { - if (typeof stylePath === "string") candidatePaths.push(stylePath); - } - } - } - } - - const action = - manifestRecord.action ?? manifestRecord.browser_action ?? manifestRecord.page_action; - if (typeof action === "object" && action !== null) { - const actionRecord = action as Record<string, unknown>; - if (typeof actionRecord.default_popup === "string") { - candidatePaths.push(actionRecord.default_popup); - } - } - - if (typeof manifestRecord.devtools_page === "string") { - candidatePaths.push(manifestRecord.devtools_page); - } - if (typeof manifestRecord.options_page === "string") { - candidatePaths.push(manifestRecord.options_page); - } - if (typeof manifestRecord.options_ui === "object" && manifestRecord.options_ui !== null) { - const optionsRecord = manifestRecord.options_ui as Record<string, unknown>; - if (typeof optionsRecord.page === "string") { - candidatePaths.push(optionsRecord.page); - } - } - if (typeof manifestRecord.sandbox === "object" && manifestRecord.sandbox !== null) { - const sandboxRecord = manifestRecord.sandbox as Record<string, unknown>; - if (Array.isArray(sandboxRecord.pages)) { - for (const pagePath of sandboxRecord.pages) { - if (typeof pagePath === "string") candidatePaths.push(pagePath); - } - } - } - - return candidatePaths; -}; - -const isLikelyBrowserExtensionManifest = (manifest: unknown): boolean => { - if (typeof manifest !== "object" || manifest === null) return false; - const manifestRecord = manifest as Record<string, unknown>; - return typeof manifestRecord.manifest_version === "number"; -}; - -const extractBrowserExtensionEntries = (directory: string): string[] => { - const entries: string[] = []; - const manifestPaths = fg.sync( - [ - "manifest.json", - "manifest.*.json", - "src/manifest.json", - "src/manifest.*.json", - "public/manifest.json", - "public/manifest.*.json", - "static/manifest.json", - ], - { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], - deep: 3, - }, - ); - - for (const manifestPath of manifestPaths) { - try { - const content = readFileSync(manifestPath, "utf-8"); - const manifest = JSON.parse(content); - if (!isLikelyBrowserExtensionManifest(manifest)) continue; - - const manifestDir = dirname(manifestPath); - const candidatePaths = collectBrowserExtensionManifestPaths(manifest); - const resolutionRoots = [manifestDir, resolve(manifestDir, ".."), directory]; - - for (const candidatePath of candidatePaths) { - for (const resolutionRoot of resolutionRoots) { - const candidateAbsolutePath = resolve(resolutionRoot, candidatePath); - if (existsSync(candidateAbsolutePath)) { - entries.push(candidateAbsolutePath); - break; - } - const sourceFile = resolveSourcePath(candidateAbsolutePath, resolutionRoot); - if (sourceFile) { - entries.push(sourceFile); - break; - } - } - } - } catch {} - } - - return entries; -}; - -const ANGULAR_ENTRY_KEYS = ["main", "polyfills", "styles"] as const; - -const extractAngularEntryPoints = (directory: string): string[] => { - const entries: string[] = []; - const angularJsonPaths = fg.sync(["angular.json", ".angular-cli.json"], { - cwd: directory, - absolute: true, - onlyFiles: true, - }); - - for (const angularJsonPath of angularJsonPaths) { - try { - const content = readFileSync(angularJsonPath, "utf-8"); - const angularConfig = JSON.parse(content); - const projects = angularConfig.projects ?? {}; - const angularDir = angularJsonPath.replace(/\/[^/]+$/, ""); - - for (const projectConfig of Object.values(projects)) { - const projectRecord = projectConfig as Record<string, unknown>; - const architect = projectRecord.architect as - | Record<string, Record<string, unknown>> - | undefined; - if (architect) { - for (const targetConfig of Object.values(architect)) { - const options = targetConfig.options as Record<string, unknown> | undefined; - if (!options) continue; - - for (const entryKey of ANGULAR_ENTRY_KEYS) { - const entryValue = options[entryKey]; - if (typeof entryValue === "string") { - const absolutePath = resolve(angularDir, entryValue); - if (existsSync(absolutePath)) { - entries.push(absolutePath); - } - } - if (Array.isArray(entryValue)) { - for (const entryItem of entryValue) { - if (typeof entryItem === "string") { - const absolutePath = resolve(angularDir, entryItem); - if (existsSync(absolutePath)) { - entries.push(absolutePath); - } - } - } - } - } - } - } - - const projectRoot = typeof projectRecord.root === "string" ? projectRecord.root : ""; - const projectDir = resolve(angularDir, projectRoot); - const ngPackagePaths = fg.sync(["ng-package.json", "**/ng-package.json"], { - cwd: projectDir, - absolute: true, - onlyFiles: true, - deep: 2, - ignore: ["**/node_modules/**"], - }); - for (const ngPackagePath of ngPackagePaths) { - try { - const ngContent = readFileSync(ngPackagePath, "utf-8"); - const ngPackage = JSON.parse(ngContent); - const ngDir = ngPackagePath.replace(/\/[^/]+$/, ""); - const libEntry = ngPackage?.lib?.entryFile; - if (typeof libEntry === "string") { - const absoluteEntry = resolve(ngDir, libEntry); - if (existsSync(absoluteEntry)) { - entries.push(absoluteEntry); - } - } - } catch {} - } - } - } catch {} - } - - return entries; -}; - -const PLUGIN_FILE_ARGUMENT_PATTERN = - /(?:createNextIntlPlugin|createMDX|withContentlayer|withPlaiceholder)\s*\(\s*['"]([^'"]+)['"]/g; -const NEXT_INTL_IMPORT_PATTERN = /createNextIntlPlugin/; -const NEXT_INTL_DEFAULT_PATHS = [ - "src/i18n/request.ts", - "src/i18n/request.tsx", - "src/i18n/request.js", - "i18n/request.ts", - "i18n/request.tsx", - "i18n/request.js", - "i18n.ts", - "i18n.tsx", -]; - -const extractNextConfigPluginFiles = (directory: string): string[] => { - const entries: string[] = []; - const nextConfigPaths = fg.sync(["next.config.{ts,js,mjs,mts}"], { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - }); - - for (const configPath of nextConfigPaths) { - try { - const content = readFileSync(configPath, "utf-8"); - const configDirectory = configPath.replace(/\/[^/]+$/, ""); - let pluginMatch: RegExpExecArray | null; - PLUGIN_FILE_ARGUMENT_PATTERN.lastIndex = 0; - let didMatchNextIntlWithPath = false; - while ((pluginMatch = PLUGIN_FILE_ARGUMENT_PATTERN.exec(content)) !== null) { - const filePath = pluginMatch[1]; - const absolutePath = resolve(configDirectory, filePath); - if (existsSync(absolutePath)) { - entries.push(absolutePath); - } - if (pluginMatch[0].includes("createNextIntlPlugin")) { - didMatchNextIntlWithPath = true; - } - } - - if (!didMatchNextIntlWithPath && NEXT_INTL_IMPORT_PATTERN.test(content)) { - for (const defaultPath of NEXT_INTL_DEFAULT_PATHS) { - const absolutePath = resolve(configDirectory, defaultPath); - if (existsSync(absolutePath)) { - entries.push(absolutePath); - break; - } - } - } - } catch {} - } - - return entries; -}; - -const VITEST_INCLUDE_ITEM_PATTERN = /['"]([^'"]+)['"]/g; -const COVERAGE_BLOCK_PATTERN = /coverage\s*:\s*\{/g; -const TEST_MATCH_ARRAY_PATTERN = /testMatch\s*:\s*\[([^\]]*)\]/; -const STRING_LITERAL_PATTERN = /['"]([^'"]+)['"]/g; - -const extractJestTestMatchPatterns = (directory: string): string[] => { - const configPaths = fg.sync(["jest.config.{ts,js,mjs,cjs}"], { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - }); - - if (configPaths.length === 0) { - try { - const packageJsonPath = join(directory, "package.json"); - const packageContent = readFileSync(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(packageContent); - if (packageJson.jest?.testMatch) { - return convertJestTestMatchToGlobs(packageJson.jest.testMatch); - } - } catch {} - return []; - } - - for (const configPath of configPaths) { - try { - const content = readFileSync(configPath, "utf-8"); - const testMatchMatch = TEST_MATCH_ARRAY_PATTERN.exec(content); - if (!testMatchMatch) continue; - - const arrayContent = testMatchMatch[1]; - const patterns: string[] = []; - STRING_LITERAL_PATTERN.lastIndex = 0; - let itemMatch: RegExpExecArray | null; - while ((itemMatch = STRING_LITERAL_PATTERN.exec(arrayContent)) !== null) { - patterns.push(itemMatch[1]); - } - if (patterns.length > 0) { - return convertJestTestMatchToGlobs(patterns); - } - } catch {} - } - return []; -}; - -const convertJestTestMatchToGlobs = (patterns: string[]): string[] => { - return patterns.map((pattern) => { - let converted = pattern.replace(/<rootDir>\/?/g, ""); - converted = converted.replace(/\?\(\*\.\)/g, "*."); - converted = converted.replace(/\?\(([^)]+)\)/g, (_, group: string) => { - const options = group.includes("|") ? group.split("|") : [group]; - return `{${[...options, ""].join(",")}}`; - }); - converted = converted.replace(/\+\(([^)]+)\)/g, (_, group: string) => { - return group.includes("|") ? `{${group.replace(/\|/g, ",")}}` : group; - }); - converted = converted.replace(/\(([^)]+)\)/g, (_, group: string) => { - return group.includes("|") ? `{${group.replace(/\|/g, ",")}}` : group; - }); - return converted; - }); -}; - -const extractVitestIncludePatterns = (directory: string): string[] => { - const configPaths = fg.sync( - ["vitest.config.{ts,js,mts,mjs}", "vitest.web.config.{ts,js,mts,mjs}"], - { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - }, - ); - - const patterns: string[] = []; - for (const configPath of configPaths) { - try { - const content = readFileSync(configPath, "utf-8"); - const coverageBlockRanges = findNestedBlockRanges(content, COVERAGE_BLOCK_PATTERN); - const includePattern = /include\s*:\s*\[([^\]]*)\]/g; - includePattern.lastIndex = 0; - let includeMatch: RegExpExecArray | null; - while ((includeMatch = includePattern.exec(content)) !== null) { - const matchStart = includeMatch.index; - const isInsideCoverageBlock = coverageBlockRanges.some( - ([blockStart, blockEnd]) => matchStart > blockStart && matchStart < blockEnd, - ); - if (isInsideCoverageBlock) continue; - - const arrayContent = includeMatch[1]; - VITEST_INCLUDE_ITEM_PATTERN.lastIndex = 0; - let itemMatch: RegExpExecArray | null; - while ((itemMatch = VITEST_INCLUDE_ITEM_PATTERN.exec(arrayContent)) !== null) { - patterns.push(itemMatch[1]); - } - } - } catch {} - } - return patterns; -}; - -const findNestedBlockRanges = (content: string, blockStartPattern: RegExp): [number, number][] => { - const ranges: [number, number][] = []; - blockStartPattern.lastIndex = 0; - let blockMatch: RegExpExecArray | null; - while ((blockMatch = blockStartPattern.exec(content)) !== null) { - const openBraceIndex = content.indexOf("{", blockMatch.index); - if (openBraceIndex === -1) continue; - let braceDepth = 1; - let position = openBraceIndex + 1; - while (position < content.length && braceDepth > 0) { - if (content[position] === "{") braceDepth++; - if (content[position] === "}") braceDepth--; - position++; - } - ranges.push([blockMatch.index, position]); - } - return ranges; -}; - -const SETUP_FILES_PATTERN = - /(?:setupFiles|setupFilesAfterEnv|globalSetup|globalTeardown)\s*:\s*(?:\[([^\]]*)\]|['"]([^'"]+)['"])/gs; -const SETUP_FILE_PATH_PATTERN = /['"]([^'"]+)['"]/g; - -const extractTestSetupFiles = (directory: string): string[] => { - const entries: string[] = []; - const configPaths = fg.sync( - [ - "vitest.config.{ts,js,mts,mjs}", - "vitest.web.config.{ts,js,mts,mjs}", - "vite.config.{ts,js,mts,mjs}", - "jest.config.{ts,js,mjs,cjs}", - "**/vitest.config.{ts,js,mts,mjs}", - ], - { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - deep: 3, - }, - ); - - for (const configPath of configPaths) { - try { - const content = readFileSync(configPath, "utf-8"); - const configDirectory = configPath.replace(/\/[^/]+$/, ""); - let setupMatch: RegExpExecArray | null; - SETUP_FILES_PATTERN.lastIndex = 0; - while ((setupMatch = SETUP_FILES_PATTERN.exec(content)) !== null) { - const arrayContent = setupMatch[1]; - const singleValue = setupMatch[2]; - - if (singleValue) { - const absolutePath = resolve(configDirectory, singleValue); - const resolvedPath = resolveEntryWithExtensions(absolutePath); - if (resolvedPath) entries.push(resolvedPath); - } - - if (arrayContent) { - let pathMatch: RegExpExecArray | null; - SETUP_FILE_PATH_PATTERN.lastIndex = 0; - while ((pathMatch = SETUP_FILE_PATH_PATTERN.exec(arrayContent)) !== null) { - const absolutePath = resolve(configDirectory, pathMatch[1]); - const resolvedPath = resolveEntryWithExtensions(absolutePath); - if (resolvedPath) entries.push(resolvedPath); - } - } - } - } catch {} - } - - return entries; -}; - -interface TestRunnerDefinition { - enablers: string[]; - configFileActivators: string[]; - entryPatterns: string[]; - fixturePatterns: string[]; - alwaysUsed: string[]; -} - -const TEST_FRAMEWORK_PATTERNS: TestRunnerDefinition[] = [ - { - enablers: ["vitest", "@vitest/runner", "vite-plus"], - configFileActivators: [ - "vitest.config.ts", - "vitest.config.js", - "vitest.config.mts", - "vitest.config.mjs", - ], - entryPatterns: [ - "**/*.test.{ts,tsx,js,jsx}", - "**/*.spec.{ts,tsx,js,jsx}", - "**/__tests__/**/*.{ts,tsx,js,jsx}", - "**/*.bench.{ts,tsx,js,jsx}", - ], - fixturePatterns: [ - "**/__fixtures__/**/*.{ts,tsx,js,jsx,json}", - "**/fixtures/**/*.{ts,tsx,js,jsx,json}", - ], - alwaysUsed: [ - "vitest.config.{ts,js,mts,mjs}", - "vitest.setup.{ts,js}", - "vitest.workspace.{ts,js}", - "**/src/setupTests.{ts,tsx,js,jsx}", - "**/src/test-setup.{ts,tsx,js,jsx}", - ], - }, - { - enablers: ["jest", "@jest/core", "ts-jest", "react-scripts", "react-app-rewired"], - configFileActivators: [ - "jest.config.ts", - "jest.config.js", - "jest.config.mjs", - "jest.config.cjs", - ], - entryPatterns: [ - "**/*.test.{ts,tsx,js,jsx}", - "**/*.spec.{ts,tsx,js,jsx}", - "**/__tests__/**/*.{ts,tsx,js,jsx}", - "**/__mocks__/**/*.{ts,tsx,js,jsx,mjs,cjs}", - ], - fixturePatterns: [ - "**/__fixtures__/**/*.{ts,tsx,js,jsx,json}", - "**/fixtures/**/*.{ts,tsx,js,jsx,json}", - ], - alwaysUsed: ["jest.config.{ts,js,mjs,cjs}", "jest.setup.{ts,js,tsx,jsx}"], - }, - { - enablers: ["@playwright/test", "playwright"], - configFileActivators: ["playwright.config.ts", "playwright.config.js"], - entryPatterns: [ - "**/*.spec.{ts,tsx,js,jsx}", - "**/*.test.{ts,tsx,js,jsx}", - "tests/**/*.{ts,tsx,js,jsx}", - "e2e/**/*.{ts,tsx,js,jsx}", - ], - fixturePatterns: ["**/fixtures/**/*.{ts,tsx,js,jsx,json}"], - alwaysUsed: ["playwright.config.{ts,js}"], - }, - { - enablers: ["mocha"], - configFileActivators: [".mocharc.js", ".mocharc.yaml", ".mocharc.yml", ".mocharc.json"], - entryPatterns: [ - "test/**/*.{ts,tsx,js,jsx}", - "tests/**/*.{ts,tsx,js,jsx}", - "spec/**/*.{ts,tsx,js,jsx}", - "**/*.test.{ts,tsx,js,jsx}", - "**/*.spec.{ts,tsx,js,jsx}", - ], - fixturePatterns: [], - alwaysUsed: [".mocharc.*"], - }, - { - enablers: ["ava", "@ava/typescript"], - configFileActivators: ["ava.config.js", "ava.config.cjs", "ava.config.mjs"], - entryPatterns: [ - "test/**/*.{ts,tsx,js,jsx}", - "tests/**/*.{ts,tsx,js,jsx}", - "**/*.test.{ts,tsx,js,jsx}", - "**/*.spec.{ts,tsx,js,jsx}", - ], - fixturePatterns: [], - alwaysUsed: ["ava.config.{js,cjs,mjs}"], - }, - { - enablers: ["cypress"], - configFileActivators: ["cypress.config.ts", "cypress.config.js"], - entryPatterns: [ - "**/*.cy.{ts,tsx,js,jsx}", - "cypress/**/*.{ts,tsx,js,jsx}", - "cypress/support/**/*.{ts,js}", - ], - fixturePatterns: ["**/fixtures/**/*.{ts,tsx,js,jsx,json}"], - alwaysUsed: ["cypress.config.{ts,js}", "cypress.config.*.{ts,js}"], - }, -]; - -interface ToolingPluginDefinition { - enablers: string[]; - enablerPrefixes: string[]; - entryPatterns: string[]; - alwaysUsed: string[]; - contentIgnorePatterns?: string[]; -} - -const JS_TS_COMPONENT_EXTENSIONS = "{ts,tsx,js,jsx}"; -const INERTIA_COMPONENT_EXTENSIONS = "{ts,tsx,js,jsx,vue,svelte}"; -const VIKE_ROUTE_EXTENSIONS = "{ts,tsx,js,jsx,md,mdx}"; - -const FRAMEWORK_PATTERNS: ToolingPluginDefinition[] = [ - { - enablers: ["storybook"], - enablerPrefixes: ["@storybook/"], - entryPatterns: ["**/*.stories.{ts,tsx,js,jsx,mdx}", ".storybook/**/*.{ts,tsx,js,jsx}"], - alwaysUsed: [ - ".storybook/main.{ts,js,mjs,cjs}", - ".storybook/preview.{ts,tsx,js,jsx}", - ".storybook/manager.{ts,tsx,js,jsx}", - ], - }, - { - enablers: ["msw"], - enablerPrefixes: [], - entryPatterns: [ - "mocks/**/*.{ts,tsx,js,jsx}", - "src/mocks/**/*.{ts,tsx,js,jsx}", - "**/mocks/**/*.{ts,tsx,js,jsx}", - ], - alwaysUsed: [], - }, - { - enablers: ["typeorm"], - enablerPrefixes: [], - entryPatterns: [ - "migrations/**/*.{ts,js}", - "src/migrations/**/*.{ts,js}", - "src/migration/**/*.{ts,js}", - "migration/**/*.{ts,js}", - "src/entity/**/*.{ts,js}", - ], - alwaysUsed: ["ormconfig.{ts,js,json}"], - }, - { - enablers: ["knex"], - enablerPrefixes: [], - entryPatterns: ["migrations/**/*.{ts,js}", "seeds/**/*.{ts,js}"], - alwaysUsed: ["knexfile.{ts,js}"], - }, - { - enablers: ["drizzle-orm"], - enablerPrefixes: [], - entryPatterns: ["drizzle/**/*.{ts,js}"], - alwaysUsed: ["drizzle.config.{ts,js,mjs}"], - }, - { - enablers: ["kysely"], - enablerPrefixes: [], - entryPatterns: ["migrations/**/*.{ts,js}", "src/migrations/**/*.{ts,js}"], - alwaysUsed: [], - }, - { - enablers: ["prisma", "@prisma/client"], - enablerPrefixes: [], - entryPatterns: ["prisma/**/*.{ts,js}", "prisma/seed.{ts,js}"], - alwaysUsed: [ - "prisma/schema.prisma", - "schema.prisma", - "prisma/schema/*.prisma", - "prisma.config.{ts,mts,cts,js,mjs,cjs}", - ".config/prisma.{ts,mts,cts,js,mjs,cjs}", - ], - }, - { - enablers: ["@nestjs/core"], - enablerPrefixes: ["@nestjs/"], - entryPatterns: [ - "src/main.ts", - "src/**/*.module.ts", - "src/**/*.controller.ts", - "src/**/*.service.ts", - "src/**/*.guard.ts", - "src/**/*.interceptor.ts", - "src/**/*.pipe.ts", - "src/**/*.filter.ts", - "src/**/*.middleware.ts", - "src/**/*.decorator.ts", - "src/**/*.gateway.ts", - "src/**/*.resolver.ts", - ], - alwaysUsed: ["nest-cli.json"], - }, - { - enablers: ["wrangler"], - enablerPrefixes: ["@cloudflare/"], - entryPatterns: ["src/index.{ts,js}", "src/worker.{ts,js}", "functions/**/*.{ts,js}"], - alwaysUsed: [], - }, - { - enablers: ["gatsby"], - enablerPrefixes: ["gatsby-"], - entryPatterns: [ - "src/pages/**/*.{ts,tsx,js,jsx}", - "src/templates/**/*.{ts,tsx,js,jsx}", - "src/api/**/*.{ts,js}", - ], - alwaysUsed: [ - "gatsby-config.{ts,js,mjs}", - "gatsby-node.{ts,js,mjs}", - "gatsby-browser.{ts,tsx,js,jsx}", - "gatsby-ssr.{ts,tsx,js,jsx}", - ], - }, - { - enablers: ["@angular/core"], - enablerPrefixes: ["@angular/"], - entryPatterns: [ - "src/main.ts", - "src/app/**/*.ts", - "src/environments/**/*.ts", - "src/polyfills.ts", - "src/test.ts", - ], - alwaysUsed: ["angular.json", "**/karma.conf.js"], - }, - { - enablers: [ - "@inertiajs/react", - "@inertiajs/inertia-react", - "@inertiajs/vue3", - "@inertiajs/inertia-vue3", - "@inertiajs/svelte", - "@inertiajs/inertia-svelte", - "@inertiajs/inertia", - ], - enablerPrefixes: [], - entryPatterns: [ - `resources/js/app.${INERTIA_COMPONENT_EXTENSIONS}`, - `resources/js/App.${INERTIA_COMPONENT_EXTENSIONS}`, - `resources/js/Pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, - `resources/js/pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, - `app/frontend/Pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, - `app/frontend/pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, - `app/frontend/entrypoints/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, - `app/javascript/Pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, - `app/javascript/pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, - `frontend/src/Pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, - `frontend/src/pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, - `inertia/Pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, - `inertia/pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, - `src/app.${INERTIA_COMPONENT_EXTENSIONS}`, - `src/App.${INERTIA_COMPONENT_EXTENSIONS}`, - `src/Pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, - `src/pages/**/*.${INERTIA_COMPONENT_EXTENSIONS}`, - ], - alwaysUsed: [], - }, - { - enablers: ["@redwoodjs/router", "@redwoodjs/web"], - enablerPrefixes: [], - entryPatterns: [ - `web/src/App.${JS_TS_COMPONENT_EXTENSIONS}`, - `web/src/Routes.${JS_TS_COMPONENT_EXTENSIONS}`, - `web/src/index.${JS_TS_COMPONENT_EXTENSIONS}`, - `web/src/layouts/**/*.${JS_TS_COMPONENT_EXTENSIONS}`, - `web/src/pages/**/*.${JS_TS_COMPONENT_EXTENSIONS}`, - ], - alwaysUsed: [], - }, - { - enablers: ["react-scripts", "react-app-rewired"], - enablerPrefixes: [], - entryPatterns: ["src/index.{ts,tsx,js,jsx}"], - alwaysUsed: [ - "src/setupTests.{ts,tsx,js,jsx}", - "src/reportWebVitals.{ts,tsx,js,jsx}", - "src/react-app-env.d.ts", - ], - }, - { - enablers: [ - "@remix-run/node", - "@remix-run/react", - "@remix-run/cloudflare", - "@react-router/node", - "@react-router/serve", - "@react-router/dev", - ], - enablerPrefixes: ["@remix-run/", "@react-router/"], - entryPatterns: [ - "app/routes/**/*.{ts,tsx,js,jsx}", - "app/root.{ts,tsx,js,jsx}", - "app/entry.client.{ts,tsx,js,jsx}", - "app/entry.server.{ts,tsx,js,jsx}", - "app/routes.{ts,js,mts,mjs}", - "src/routes.{ts,js,mts,mjs}", - ], - alwaysUsed: ["react-router.config.{ts,js,mjs}", "remix.config.{ts,js,mjs}"], - }, - { - enablers: ["@docusaurus/core"], - enablerPrefixes: ["@docusaurus/"], - entryPatterns: [ - "**/*.{md,mdx}", - "src/pages/**/*.{ts,tsx,js,jsx}", - "src/theme/**/*.{ts,tsx,js,jsx}", - "src/theme/**/index.{ts,tsx,js,jsx}", - "plugins/**/*.{ts,js,mjs}", - ], - alwaysUsed: [ - "docusaurus.config.{ts,js,mjs}", - "sidebars.{ts,js,mjs,cjs}", - "sidebar*.{ts,js,mjs,cjs}", - "*-sidebar.{ts,js,mjs,cjs}", - "*-sidebars.{ts,js,mjs,cjs}", - "*Sidebar*.{ts,js,mjs,cjs}", - "*sidebar*.{ts,js,mjs,cjs}", - ], - contentIgnorePatterns: ["versioned_sidebars/**"], - }, - { - enablers: ["fumadocs-core", "fumadocs-ui", "fumadocs-mdx"], - enablerPrefixes: ["fumadocs-"], - entryPatterns: ["content/**/*.{md,mdx}", "content/**/*.{ts,tsx,js,jsx}"], - alwaysUsed: ["source.config.{ts,js,mjs}"], - }, - { - enablers: ["nextra", "nextra-theme-docs", "nextra-theme-blog"], - enablerPrefixes: ["nextra-"], - entryPatterns: ["pages/**/*.{md,mdx}", "src/pages/**/*.{md,mdx}", "content/**/*.{md,mdx}"], - alwaysUsed: [], - }, - { - enablers: ["contentlayer", "contentlayer2", "contentlayer-source-files"], - enablerPrefixes: ["contentlayer"], - entryPatterns: ["content/**/*.{md,mdx}", "posts/**/*.{md,mdx}"], - alwaysUsed: ["contentlayer.config.{ts,js,mjs}"], - }, - { - enablers: ["@graphql-codegen/cli", "@graphql-codegen/core"], - enablerPrefixes: ["@graphql-codegen/"], - entryPatterns: ["**/*.graphql", "**/*.gql"], - alwaysUsed: [ - "codegen.{ts,js,yml,yaml}", - "codegen.config.{ts,js}", - ".graphqlrc.{ts,js,json,yml,yaml}", - "graphql.config.{ts,js,json,yml,yaml}", - ], - }, - { - enablers: ["eslint", "@eslint/js"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: ["eslint.config.{js,mjs,cjs,ts,mts,cts}", ".eslintrc.{js,cjs,mjs,json,yaml,yml}"], - }, - { - enablers: ["prettier"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: [".prettierrc.{js,cjs,mjs,json,yaml,yml}", "prettier.config.{js,mjs,cjs,ts}"], - }, - { - enablers: ["tailwindcss", "@tailwindcss/postcss"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: ["tailwind.config.{ts,js,cjs,mjs}"], - }, - { - enablers: ["postcss"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: ["postcss.config.{ts,js,cjs,mjs}"], - }, - { - enablers: ["typescript"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: ["tsconfig.json", "tsconfig.*.json"], - }, - { - enablers: ["lint-staged"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: [".lintstagedrc.{js,cjs,mjs,json}", "lint-staged.config.{js,mjs,cjs}"], - }, - { - enablers: ["husky"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: [".husky/**/*"], - }, - { - enablers: ["@biomejs/biome"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: ["biome.json", "biome.jsonc"], - }, - { - enablers: ["@commitlint/cli"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: ["commitlint.config.{js,cjs,mjs,ts}", ".commitlintrc.{js,cjs,mjs,json,yaml,yml}"], - }, - { - enablers: ["semantic-release"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: [".releaserc.{js,cjs,mjs,json,yaml,yml}", "release.config.{js,cjs,mjs,ts}"], - }, - { - enablers: ["@changesets/cli"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: [".changeset/**/*"], - }, - { - enablers: ["next"], - enablerPrefixes: [], - entryPatterns: [ - "app/**/page.{ts,tsx,js,jsx}", - "app/**/layout.{ts,tsx,js,jsx}", - "app/**/loading.{ts,tsx,js,jsx}", - "app/**/error.{ts,tsx,js,jsx}", - "app/**/not-found.{ts,tsx,js,jsx}", - "app/**/template.{ts,tsx,js,jsx}", - "app/**/default.{ts,tsx,js,jsx}", - "app/**/route.{ts,tsx,js,jsx}", - "app/**/global-error.{ts,tsx,js,jsx}", - "app/**/forbidden.{ts,tsx,js,jsx}", - "app/**/unauthorized.{ts,tsx,js,jsx}", - "app/global-not-found.{ts,tsx,js,jsx}", - "app/**/opengraph-image.{ts,tsx,js,jsx}", - "app/**/twitter-image.{ts,tsx,js,jsx}", - "app/**/icon.{ts,tsx,js,jsx}", - "app/**/apple-icon.{ts,tsx,js,jsx}", - "app/**/manifest.{ts,tsx,js,jsx}", - "app/**/sitemap.{ts,tsx,js,jsx}", - "app/**/robots.{ts,tsx,js,jsx}", - "pages/**/*.{ts,tsx,js,jsx}", - "src/app/**/page.{ts,tsx,js,jsx}", - "src/app/**/layout.{ts,tsx,js,jsx}", - "src/app/**/loading.{ts,tsx,js,jsx}", - "src/app/**/error.{ts,tsx,js,jsx}", - "src/app/**/not-found.{ts,tsx,js,jsx}", - "src/app/**/template.{ts,tsx,js,jsx}", - "src/app/**/default.{ts,tsx,js,jsx}", - "src/app/**/route.{ts,tsx,js,jsx}", - "src/app/**/global-error.{ts,tsx,js,jsx}", - "src/app/**/forbidden.{ts,tsx,js,jsx}", - "src/app/**/unauthorized.{ts,tsx,js,jsx}", - "src/app/global-not-found.{ts,tsx,js,jsx}", - "src/app/**/opengraph-image.{ts,tsx,js,jsx}", - "src/app/**/twitter-image.{ts,tsx,js,jsx}", - "src/app/**/icon.{ts,tsx,js,jsx}", - "src/app/**/apple-icon.{ts,tsx,js,jsx}", - "src/app/**/manifest.{ts,tsx,js,jsx}", - "src/app/**/sitemap.{ts,tsx,js,jsx}", - "src/app/**/robots.{ts,tsx,js,jsx}", - "src/pages/**/*.{ts,tsx,js,jsx}", - "middleware.{ts,js}", - "src/middleware.{ts,js}", - "proxy.{ts,js}", - "src/proxy.{ts,js}", - "instrumentation.{ts,js}", - "instrumentation-client.{ts,js}", - "src/instrumentation.{ts,js}", - "src/instrumentation-client.{ts,js}", - ], - alwaysUsed: [ - "next.config.{ts,js,mjs,mts}", - "next-env.d.ts", - "mdx-components.{ts,tsx,js,jsx}", - "src/mdx-components.{ts,tsx,js,jsx}", - "src/i18n/request.{ts,js}", - "src/i18n/routing.{ts,js}", - "i18n/request.{ts,js}", - "i18n/routing.{ts,js}", - ], - }, - { - enablers: [ - "@tanstack/react-router", - "@tanstack/react-start", - "@tanstack/start", - "@tanstack/solid-router", - "@tanstack/solid-start", - ], - enablerPrefixes: ["@tanstack/router"], - entryPatterns: [ - "src/routes/**/*.{ts,tsx,js,jsx}", - "app/routes/**/*.{ts,tsx,js,jsx}", - "src/server.{ts,tsx,js,jsx}", - "src/client.{ts,tsx,js,jsx}", - "src/router.{ts,tsx,js,jsx}", - "src/routeTree.gen.{ts,js}", - ], - alwaysUsed: ["tsr.config.json", "app.config.{ts,js}"], - }, - { - enablers: ["waku"], - enablerPrefixes: [], - entryPatterns: [ - `src/pages/**/*.${JS_TS_COMPONENT_EXTENSIONS}`, - `src/waku.client.${JS_TS_COMPONENT_EXTENSIONS}`, - `src/waku.server.${JS_TS_COMPONENT_EXTENSIONS}`, - ], - alwaysUsed: [], - }, - { - enablers: ["vike", "vite-plugin-ssr"], - enablerPrefixes: [], - entryPatterns: [ - `pages/**/*.${VIKE_ROUTE_EXTENSIONS}`, - `renderer/**/*.${JS_TS_COMPONENT_EXTENSIONS}`, - `src/pages/**/*.${VIKE_ROUTE_EXTENSIONS}`, - `src/renderer/**/*.${JS_TS_COMPONENT_EXTENSIONS}`, - ], - alwaysUsed: [], - }, - { - enablers: ["rakkasjs"], - enablerPrefixes: [], - entryPatterns: [ - `src/client.${JS_TS_COMPONENT_EXTENSIONS}`, - `src/server.${JS_TS_COMPONENT_EXTENSIONS}`, - `src/routes/**/*.${JS_TS_COMPONENT_EXTENSIONS}`, - ], - alwaysUsed: [], - }, - { - enablers: [ - "@module-federation/enhanced", - "@module-federation/node", - "@module-federation/vite", - "@originjs/vite-plugin-federation", - ], - enablerPrefixes: [], - entryPatterns: [ - "federation.config.{ts,js,mjs,cjs,mts,cts}", - "module-federation.config.{ts,js,mjs,cjs,mts,cts}", - ], - alwaysUsed: [], - }, - { - enablers: [ - "vite", - "rolldown-vite", - "vite-plus", - "@voidzero-dev/vite-plus-core", - "@voidzero-dev/vite-plus-test", - ], - enablerPrefixes: ["@vitejs/", "@voidzero-dev/vite-plus"], - entryPatterns: ["src/main.{ts,tsx,js,jsx}", "src/index.{ts,tsx,js,jsx}", "index.html"], - alwaysUsed: ["vite.config.{ts,js,mts,mjs}"], - }, - { - enablers: ["vue", "@vue/cli-service"], - enablerPrefixes: ["@vue/"], - entryPatterns: ["src/main.{ts,js}", "src/App.vue"], - alwaysUsed: ["vue.config.{ts,js,mjs,cjs}"], - }, - { - enablers: ["nuxt", "nuxt3"], - enablerPrefixes: ["@nuxt/"], - entryPatterns: [ - "pages/**/*.vue", - "layouts/**/*.vue", - "components/**/*.vue", - "composables/**/*.{ts,js}", - "plugins/**/*.{ts,js}", - "middleware/**/*.{ts,js}", - "server/**/*.{ts,js}", - "app.vue", - ], - alwaysUsed: ["nuxt.config.{ts,js,mjs}"], - }, - { - enablers: ["svelte", "@sveltejs/kit"], - enablerPrefixes: ["@sveltejs/"], - entryPatterns: [ - "src/routes/**/*.svelte", - "src/lib/**/*.svelte", - "src/routes/**/+page.{ts,js,svelte}", - "src/routes/**/+layout.{ts,js,svelte}", - "src/routes/**/+server.{ts,js}", - ], - alwaysUsed: ["svelte.config.{ts,js,mjs}"], - }, - { - enablers: ["webpack", "webpack-cli"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: ["webpack.config.{ts,js,mjs,cjs}", "webpack.*.config.{ts,js,mjs,cjs}"], - }, - { - enablers: ["rollup"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: ["rollup.config.{ts,js,mjs,cjs}", "rollup.*.config.{ts,js,mjs,cjs}"], - }, - { - enablers: ["@rspack/core", "@rspack/cli"], - enablerPrefixes: ["@rspack/"], - entryPatterns: ["src/index.{ts,tsx,js,jsx}"], - alwaysUsed: ["rspack.config.{ts,js,mjs,cjs}", "rspack.*.config.{ts,js,mjs,cjs}"], - }, - { - enablers: ["@rsbuild/core"], - enablerPrefixes: ["@rsbuild/"], - entryPatterns: ["src/index.{ts,tsx,js,jsx}"], - alwaysUsed: ["rsbuild.config.{ts,js,mjs,cjs}"], - }, - { - enablers: ["tsup"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: ["tsup.config.{ts,js,cjs,mjs}"], - }, - { - enablers: ["tsdown"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: ["tsdown.config.{ts,js,cjs,mjs}"], - }, - { - enablers: ["@trigger.dev/sdk"], - enablerPrefixes: ["@trigger.dev/"], - entryPatterns: [], - alwaysUsed: ["trigger.config.{ts,js,mjs,mts}"], - }, - { - enablers: ["@swc/core"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: [".swcrc"], - }, - { - enablers: ["@babel/core"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: ["babel.config.{js,cjs,mjs,json}", ".babelrc.{js,cjs,mjs,json}"], - }, - { - enablers: ["sanity", "@sanity/cli"], - enablerPrefixes: ["@sanity/"], - entryPatterns: [], - alwaysUsed: ["sanity.config.{ts,js}", "sanity.cli.{ts,js}"], - }, - { - enablers: ["astro"], - enablerPrefixes: ["@astrojs/"], - entryPatterns: [ - "src/pages/**/*.{astro,ts,tsx,js,jsx,mts,mjs,cts,cjs,md,mdx}", - "src/content/**/*.{ts,js,mts,mjs,cts,cjs,md,mdx}", - "src/layouts/**/*.astro", - "src/middleware.{js,ts,mjs,mts,cjs,cts}", - "src/middleware/index.{js,ts,mjs,mts,cjs,cts}", - "src/actions/index.{js,ts,mjs,mts,cjs,cts}", - ], - alwaysUsed: [ - "astro.config.{ts,js,mjs,cjs}", - "src/content/config.{js,ts,mjs,mts,cjs,cts}", - "src/content.config.{js,ts,mjs,mts,cjs,cts}", - "src/live.config.{js,ts,mjs,mts,cjs,cts}", - ], - }, - { - enablers: ["i18next", "react-i18next", "vue-i18n", "next-i18next"], - enablerPrefixes: [], - entryPatterns: [ - "src/i18n.{ts,js,mjs}", - "src/i18n/index.{ts,js}", - "i18n.{ts,js,mjs}", - "i18n/index.{ts,js}", - ], - alwaysUsed: [ - "src/i18n.{ts,js,mjs}", - "src/i18n/index.{ts,js}", - "i18n.{ts,js,mjs}", - "i18n/index.{ts,js}", - "i18next.config.{js,ts,mjs}", - "next-i18next.config.{js,mjs}", - "locales/**/*.json", - "public/locales/**/*.json", - "src/locales/**/*.json", - ], - }, - { - enablers: ["turbo"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: ["turbo.json", "turbo/generators/config.{ts,js}"], - }, - { - enablers: ["@sentry/nextjs", "@sentry/react", "@sentry/node", "@sentry/browser"], - enablerPrefixes: ["@sentry/"], - entryPatterns: [], - alwaysUsed: [ - "sentry.client.config.{ts,js,mjs}", - "sentry.server.config.{ts,js,mjs}", - "sentry.edge.config.{ts,js,mjs}", - ], - }, - { - enablers: ["nodemon"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: ["nodemon.json", ".nodemonrc", ".nodemonrc.{json,yml,yaml}"], - }, - { - enablers: ["nx"], - enablerPrefixes: ["@nx/"], - entryPatterns: [], - alwaysUsed: ["nx.json", "**/project.json"], - }, - { - enablers: ["react-native"], - enablerPrefixes: ["@react-native/", "@react-native-community/"], - entryPatterns: ["index.{ts,tsx,js,jsx}", "App.{ts,tsx,js,jsx}", "src/App.{ts,tsx,js,jsx}"], - alwaysUsed: ["metro.config.{ts,js}", "react-native.config.{ts,js}", "app.json"], - }, - { - enablers: ["expo"], - enablerPrefixes: ["@expo/"], - entryPatterns: [ - "App.{ts,tsx,js,jsx}", - "app/_layout.{ts,tsx,js,jsx}", - "app/index.{ts,tsx,js,jsx}", - ], - alwaysUsed: ["app.json", "app.config.{ts,mts,cts,js,mjs,cjs}"], - }, - { - enablers: ["wrangler"], - enablerPrefixes: ["@cloudflare/"], - entryPatterns: ["src/index.{ts,js}", "src/worker.{ts,js}", "functions/**/*.{ts,js}"], - alwaysUsed: ["wrangler.toml", "wrangler.json", "wrangler.jsonc"], - }, - { - enablers: [ - "electron", - "electron-builder", - "@electron-forge/cli", - "electron-vite", - "electron-webpack", - "electron-next", - ], - enablerPrefixes: ["@electron-forge/", "@electron/"], - entryPatterns: [ - "src/main/**/*.{ts,tsx,js,jsx}", - "src/preload/**/*.{ts,tsx,js,jsx}", - "electron/main.{ts,js}", - "main/index.{ts,tsx,js,jsx}", - "renderer/pages/**/*.{ts,tsx,js,jsx}", - ], - alwaysUsed: [ - "electron-builder.{yml,yaml,json,json5,toml}", - "forge.config.{ts,js,cjs}", - "electron.vite.config.{ts,js,mjs}", - ], - }, - - { - enablers: ["lefthook"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: ["lefthook.yml", "lefthook.yaml", ".lefthook.yml"], - }, - { - enablers: ["syncpack"], - enablerPrefixes: [], - entryPatterns: [], - alwaysUsed: [".syncpackrc", ".syncpackrc.{json,yaml,yml}", "syncpack.config.{js,mjs,cjs}"], - }, - - { - enablers: ["@capacitor/core", "@capacitor/cli"], - enablerPrefixes: ["@capacitor/"], - entryPatterns: [], - alwaysUsed: ["capacitor.config.{ts,js,json}"], - }, -]; - -const detectNodeTestRunner = (directory: string): boolean => { - try { - const packageJsonPath = join(directory, "package.json"); - if (!existsSync(packageJsonPath)) return false; - const content = readFileSync(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(content); - const scripts = packageJson.scripts ?? {}; - return Object.values(scripts).some( - (scriptValue) => typeof scriptValue === "string" && /\bnode\b.*\s--test\b/.test(scriptValue), - ); - } catch { - return false; - } -}; - -const detectBunTestRunner = (directory: string): boolean => { - try { - const packageJsonPath = join(directory, "package.json"); - if (!existsSync(packageJsonPath)) return false; - const content = readFileSync(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(content); - const scripts = packageJson.scripts ?? {}; - return Object.values(scripts).some( - (scriptValue) => typeof scriptValue === "string" && /\bbun\s+test\b/.test(scriptValue), - ); - } catch { - return false; - } -}; - -interface TestRunnerDiscoveryResult { - entryFiles: string[]; - alwaysUsedFiles: string[]; -} - -const readPackageJsonDependencies = (packageJsonPath: string): Record<string, string> => { - try { - const content = readFileSync(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(content); - return { - ...packageJson.dependencies, - ...packageJson.devDependencies, - ...packageJson.optionalDependencies, - }; - } catch { - return {}; - } -}; - -const discoverTestRunnerEntryPoints = ( - rootDir: string, - workspacePackages: WorkspacePackage[], -): TestRunnerDiscoveryResult => { - const allEntries: string[] = []; - const allAlwaysUsed: string[] = []; - const directoriesToCheck = [ - rootDir, - ...workspacePackages.map((workspacePackage) => workspacePackage.directory), - ]; - - const monorepoRoot = findMonorepoRoot(rootDir); - const monorepoRootDeps = - monorepoRoot && monorepoRoot !== rootDir - ? readPackageJsonDependencies(join(monorepoRoot, "package.json")) - : {}; - - for (const directory of directoriesToCheck) { - const packageJsonPath = join(directory, "package.json"); - if (!existsSync(packageJsonPath)) continue; - - let allDependencies: Record<string, string> = {}; - try { - const content = readFileSync(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(content); - allDependencies = { - ...packageJson.dependencies, - ...packageJson.devDependencies, - ...packageJson.optionalDependencies, - }; - } catch { - continue; - } - - const activatedPatterns: string[] = []; - const activatedFixturePatterns: string[] = []; - const activatedAlwaysUsed: string[] = []; - - const isRunnerEnabled = ( - runner: TestRunnerDefinition, - dependencies: Record<string, string>, - checkDirectory: string, - ): boolean => { - const hasDependency = runner.enablers.some((enabler) => { - return enabler in dependencies; - }); - if (hasDependency) return true; - return runner.configFileActivators.some((configFile) => - existsSync(join(checkDirectory, configFile)), - ); - }; - - for (const runner of TEST_FRAMEWORK_PATTERNS) { - const enabledLocally = isRunnerEnabled(runner, allDependencies, directory); - const enabledViaMonorepo = - !enabledLocally && - monorepoRoot && - (isRunnerEnabled(runner, monorepoRootDeps, monorepoRoot) || - runner.configFileActivators.some((configFile) => - existsSync(join(monorepoRoot, configFile)), - )); - if (enabledLocally || enabledViaMonorepo) { - const isVitestRunner = runner.enablers.includes("vitest"); - const isJestRunner = runner.enablers.includes("jest"); - let customPatterns: string[] = []; - if (isVitestRunner) { - customPatterns = extractVitestIncludePatterns(directory); - if (customPatterns.length === 0 && monorepoRoot) { - customPatterns = extractVitestIncludePatterns(monorepoRoot); - } - } else if (isJestRunner) { - customPatterns = extractJestTestMatchPatterns(directory); - if (customPatterns.length === 0 && monorepoRoot) { - customPatterns = extractJestTestMatchPatterns(monorepoRoot); - } - } - if (customPatterns.length > 0) { - activatedPatterns.push(...customPatterns); - // A custom `testMatch` narrows which SPEC files run, but Jest's - // `__mocks__` automock convention is independent of it — those - // files stay runner-consumed entries no matter what testMatch says. - if (isJestRunner) { - activatedPatterns.push("**/__mocks__/**/*.{ts,tsx,js,jsx,mjs,cjs}"); - } - } else { - activatedPatterns.push(...runner.entryPatterns); - } - activatedFixturePatterns.push(...runner.fixturePatterns); - activatedAlwaysUsed.push(...runner.alwaysUsed); - } - } - - if (activatedPatterns.length === 0 && directory !== rootDir) { - const rootPackageJsonPath = join(rootDir, "package.json"); - if (existsSync(rootPackageJsonPath)) { - try { - const rootContent = readFileSync(rootPackageJsonPath, "utf-8"); - const rootPackageJson = JSON.parse(rootContent); - const rootDeps = { - ...rootPackageJson.dependencies, - ...rootPackageJson.devDependencies, - ...rootPackageJson.optionalDependencies, - }; - for (const runner of TEST_FRAMEWORK_PATTERNS) { - if (isRunnerEnabled(runner, rootDeps, rootDir)) { - activatedPatterns.push(...runner.entryPatterns); - activatedFixturePatterns.push(...runner.fixturePatterns); - activatedAlwaysUsed.push(...runner.alwaysUsed); - } - } - } catch {} - } - } - - const hasNodeTestScript = detectNodeTestRunner(directory) || detectNodeTestRunner(rootDir); - if (hasNodeTestScript) { - activatedPatterns.push( - "**/*.test.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", - "**/*.spec.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", - "**/__tests__/**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", - ); - } - - const hasBunTestScript = detectBunTestRunner(directory) || detectBunTestRunner(rootDir); - if (hasBunTestScript) { - activatedPatterns.push( - "**/*.test.{ts,tsx,js,jsx,mts,mjs}", - "**/*.spec.{ts,tsx,js,jsx,mts,mjs}", - "**/*_test.{ts,tsx,js,jsx,mts,mjs}", - "**/*_spec.{ts,tsx,js,jsx,mts,mjs}", - "**/__tests__/**/*.{ts,tsx,js,jsx,mts,mjs}", - ); - } - - if (activatedPatterns.length === 0) continue; - - const uniquePatterns = [...new Set(activatedPatterns)]; - const testFiles = fg.sync(uniquePatterns, { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**", "**/*.gen.{ts,tsx,js,jsx}"], - }); - allEntries.push(...testFiles); - - const uniqueFixturePatterns = [...new Set(activatedFixturePatterns)]; - if (uniqueFixturePatterns.length > 0) { - const fixtureFiles = fg.sync(uniqueFixturePatterns, { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - }); - allEntries.push(...fixtureFiles); - } - - const uniqueAlwaysUsed = [...new Set(activatedAlwaysUsed)]; - if (uniqueAlwaysUsed.length > 0) { - const alwaysUsedFiles = fg.sync(uniqueAlwaysUsed, { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - dot: true, - }); - allAlwaysUsed.push(...alwaysUsedFiles); - } - } - - return { entryFiles: allEntries, alwaysUsedFiles: allAlwaysUsed }; -}; - -const isToolingPluginEnabled = ( - plugin: ToolingPluginDefinition, - dependencies: Record<string, string>, -): boolean => { - if (plugin.enablers.some((enabler) => enabler in dependencies)) return true; - if (plugin.enablerPrefixes.length > 0) { - const depNames = Object.keys(dependencies); - return plugin.enablerPrefixes.some((prefix) => - depNames.some((depName) => depName.startsWith(prefix)), - ); - } - return false; -}; - -interface ToolingDiscoveryResult { - entryFiles: string[]; - alwaysUsedFiles: string[]; -} - -const FRAMEWORK_SCRIPT_BINARIES: Record<string, string[]> = { - next: ["next"], - nuxt: ["nuxt"], - astro: ["astro"], - gatsby: ["gatsby"], - "@remix-run/dev": ["remix"], - "@react-router/dev": ["react-router"], - "@sveltejs/kit": ["svelte-kit", "vite-svelte-kit"], - "@docusaurus/core": ["docusaurus"], - "@angular/core": ["ng"], - "@nestjs/core": ["nest"], - storybook: ["storybook", "start-storybook", "build-storybook"], -}; - -const detectFrameworkFromScripts = (scripts: Record<string, unknown> | undefined): Set<string> => { - const enabledEnablers = new Set<string>(); - if (!scripts || typeof scripts !== "object") return enabledEnablers; - for (const scriptValue of Object.values(scripts)) { - if (typeof scriptValue !== "string") continue; - const tokenized = scriptValue.split(/[\s|&;]+/); - for (const token of tokenized) { - const cleaned = token.replace(/^.*\//, ""); - for (const [enabler, binaries] of Object.entries(FRAMEWORK_SCRIPT_BINARIES)) { - if (binaries.includes(cleaned)) enabledEnablers.add(enabler); - } - } - } - return enabledEnablers; -}; - -const readPackageScripts = (directory: string): Record<string, unknown> | undefined => { - const packageJsonPath = join(directory, "package.json"); - if (!existsSync(packageJsonPath)) return undefined; - try { - const content = readFileSync(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(content); - return packageJson.scripts; - } catch { - return undefined; - } -}; - -const discoverToolingEntryPoints = ( - rootDir: string, - workspacePackages: WorkspacePackage[], -): ToolingDiscoveryResult => { - const allEntries: string[] = []; - const allAlwaysUsed: string[] = []; - const directoriesToCheck = [ - rootDir, - ...workspacePackages.map((workspacePackage) => workspacePackage.directory), - ]; - - let rootDependencies: Record<string, string> = {}; - const rootPackageJsonPath = join(rootDir, "package.json"); - if (existsSync(rootPackageJsonPath)) { - try { - const rootContent = readFileSync(rootPackageJsonPath, "utf-8"); - const rootPackageJson = JSON.parse(rootContent); - rootDependencies = { - ...rootPackageJson.dependencies, - ...rootPackageJson.devDependencies, - ...rootPackageJson.optionalDependencies, - }; - } catch {} - } - - const monorepoRoot = findMonorepoRoot(rootDir); - const monorepoRootDeps = - monorepoRoot && monorepoRoot !== rootDir - ? readPackageJsonDependencies(join(monorepoRoot, "package.json")) - : {}; - - for (const directory of directoriesToCheck) { - const packageJsonPath = join(directory, "package.json"); - if (!existsSync(packageJsonPath)) continue; - - let workspaceDependencies: Record<string, string> = {}; - try { - const content = readFileSync(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(content); - workspaceDependencies = { - ...packageJson.dependencies, - ...packageJson.devDependencies, - ...packageJson.optionalDependencies, - }; - } catch { - continue; - } - - const workspaceScripts = readPackageScripts(directory); - const scriptDetectedEnablers = detectFrameworkFromScripts(workspaceScripts); - - const mergedDependencies: Record<string, string> = { - ...workspaceDependencies, - }; - if (directory === rootDir) { - Object.assign(mergedDependencies, rootDependencies); - } - - for (const enabler of scriptDetectedEnablers) { - if ( - enabler in workspaceDependencies || - enabler in rootDependencies || - enabler in monorepoRootDeps - ) { - mergedDependencies[enabler] = "*"; - } - } - - const activatedPatterns: string[] = []; - const activatedAlwaysUsed: string[] = []; - - for (const plugin of FRAMEWORK_PATTERNS) { - if (isToolingPluginEnabled(plugin, mergedDependencies)) { - activatedPatterns.push(...plugin.entryPatterns); - activatedAlwaysUsed.push(...plugin.alwaysUsed); - } - } - - if (activatedPatterns.length === 0 && activatedAlwaysUsed.length === 0) continue; - - const uniquePatterns = [...new Set(activatedPatterns)]; - const toolingFiles = fg.sync(uniquePatterns, { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - dot: true, - }); - allEntries.push(...toolingFiles); - - const uniqueAlwaysUsed = [...new Set(activatedAlwaysUsed)]; - if (uniqueAlwaysUsed.length > 0) { - const alwaysUsedFiles = fg.sync(uniqueAlwaysUsed, { - cwd: directory, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - dot: true, - }); - allAlwaysUsed.push(...alwaysUsedFiles); - } - } - - const rootActivatedGlobalPatterns: string[] = []; - for (const plugin of FRAMEWORK_PATTERNS) { - if (isToolingPluginEnabled(plugin, rootDependencies)) { - for (const pattern of plugin.alwaysUsed) { - if (!pattern.startsWith("**/")) { - rootActivatedGlobalPatterns.push(`**/${pattern}`); - } - } - } - } - - if (rootActivatedGlobalPatterns.length > 0) { - const globalAlwaysUsedFiles = fg.sync([...new Set(rootActivatedGlobalPatterns)], { - cwd: rootDir, - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - dot: true, - }); - allAlwaysUsed.push(...globalAlwaysUsedFiles); - } - - return { entryFiles: allEntries, alwaysUsedFiles: allAlwaysUsed }; -}; diff --git a/packages/deslop-js/src/collect/launch-worker.ts b/packages/deslop-js/src/collect/launch-worker.ts deleted file mode 100644 index de4b84e106..0000000000 --- a/packages/deslop-js/src/collect/launch-worker.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Worker } from "node:worker_threads"; -import { fileURLToPath } from "node:url"; - -/** - * Launches a worker module that sits next to `fromModuleUrl`: the TypeScript - * source (via tsx) when the caller itself runs from source — tests and direct - * `tsx` execution — and the built `.mjs` sibling when running from dist. - */ -export const launchSiblingWorker = (fromModuleUrl: string, workerBaseName: string): Worker => { - const isTypeScriptSource = fromModuleUrl.endsWith(".ts"); - const workerPath = fileURLToPath( - new URL( - isTypeScriptSource ? `./${workerBaseName}.ts` : `./${workerBaseName}.mjs`, - fromModuleUrl, - ), - ); - return new Worker(workerPath, { - ...(isTypeScriptSource ? { execArgv: ["--import", "tsx"] } : {}), - }); -}; diff --git a/packages/deslop-js/src/collect/parallel-parse.ts b/packages/deslop-js/src/collect/parallel-parse.ts deleted file mode 100644 index 3286e115aa..0000000000 --- a/packages/deslop-js/src/collect/parallel-parse.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { Worker } from "node:worker_threads"; -import type { SourceFile } from "../types.js"; -import type { ParsedSource } from "./parse.js"; -import { parseSourceFile } from "./parse.js"; -import { DeslopError, type DeslopErrorJson, ParseError } from "../errors.js"; -import { PARALLEL_PARSE_FILE_THRESHOLD } from "../constants.js"; -import { resolveAvailableConcurrency } from "../utils/resolve-available-concurrency.js"; -import { launchSiblingWorker } from "./launch-worker.js"; - -interface ParseResultMessage { - readonly type: "result"; - readonly fileIndex: number; - readonly filePath: string; - readonly parsed: { - readonly imports: ParsedSource["imports"]; - readonly exports: ParsedSource["exports"]; - readonly memberAccesses: ParsedSource["memberAccesses"]; - readonly wholeObjectUses: string[]; - readonly localIdentifierReferences: string[]; - readonly topLevelImportReferences: string[]; - readonly referencedFilenames: string[]; - readonly redundantTypePatterns: ParsedSource["redundantTypePatterns"]; - readonly identityWrappers: ParsedSource["identityWrappers"]; - readonly typeDefinitionHashes: ParsedSource["typeDefinitionHashes"]; - readonly inlineTypeLiterals: ParsedSource["inlineTypeLiterals"]; - readonly simplifiableFunctions: ParsedSource["simplifiableFunctions"]; - readonly simplifiableExpressions: ParsedSource["simplifiableExpressions"]; - readonly duplicateConstantCandidates: ParsedSource["duplicateConstantCandidates"]; - readonly errors: DeslopErrorJson[]; - }; -} - -interface ParseErrorMessage { - readonly type: "error"; - readonly fileIndex: number; - readonly filePath: string; - readonly errorMessage: string; -} - -type WorkerResponse = ParseResultMessage | ParseErrorMessage | { readonly type: "ready" }; - -const deserializeErrors = (serializedErrors: DeslopErrorJson[]): DeslopError[] => - serializedErrors.map( - (errorJson) => - new DeslopError({ - code: errorJson.code, - module: errorJson.module, - severity: errorJson.severity, - message: errorJson.message, - path: errorJson.path, - detail: errorJson.detail, - }), - ); - -const deserializeParsedSource = (serialized: ParseResultMessage["parsed"]): ParsedSource => ({ - imports: serialized.imports, - exports: serialized.exports, - memberAccesses: serialized.memberAccesses, - wholeObjectUses: serialized.wholeObjectUses, - localIdentifierReferences: serialized.localIdentifierReferences, - topLevelImportReferences: serialized.topLevelImportReferences, - referencedFilenames: serialized.referencedFilenames, - redundantTypePatterns: serialized.redundantTypePatterns, - identityWrappers: serialized.identityWrappers, - typeDefinitionHashes: serialized.typeDefinitionHashes, - inlineTypeLiterals: serialized.inlineTypeLiterals, - simplifiableFunctions: serialized.simplifiableFunctions, - simplifiableExpressions: serialized.simplifiableExpressions, - duplicateConstantCandidates: serialized.duplicateConstantCandidates, - errors: deserializeErrors(serialized.errors), -}); - -const waitForReady = (worker: Worker): Promise<void> => - new Promise((resolve, reject) => { - const onMessage = (message: WorkerResponse): void => { - if (message.type === "ready") { - worker.off("message", onMessage); - worker.off("error", onError); - resolve(); - } - }; - const onError = (error: Error): void => { - worker.off("message", onMessage); - worker.off("error", onError); - reject(error); - }; - worker.on("message", onMessage); - worker.on("error", onError); - }); - -const parseFilesWithWorkerPool = async ( - files: ReadonlyArray<SourceFile>, - workerCount: number, -): Promise<ParsedSource[]> => { - const results: ParsedSource[] = new Array(files.length); - const workers: Worker[] = []; - - try { - for (let workerIndex = 0; workerIndex < workerCount; workerIndex++) { - workers.push(launchSiblingWorker(import.meta.url, "parse-worker")); - } - await Promise.all(workers.map(waitForReady)); - } catch { - for (const worker of workers) worker.terminate(); - return files.map((file) => parseSourceFile(file.path)); - } - - let nextFileIndex = 0; - let completedCount = 0; - - return new Promise((resolve, reject) => { - const dispatchNext = (worker: Worker): void => { - if (nextFileIndex >= files.length) return; - const fileIndex = nextFileIndex; - nextFileIndex += 1; - worker.postMessage({ - type: "parse", - filePath: files[fileIndex].path, - fileIndex, - }); - }; - - const onWorkerMessage = (worker: Worker) => (message: WorkerResponse) => { - if (message.type === "result") { - results[message.fileIndex] = deserializeParsedSource(message.parsed); - completedCount += 1; - if (completedCount === files.length) { - cleanup(); - resolve(results); - } else { - dispatchNext(worker); - } - } else if (message.type === "error") { - results[message.fileIndex] = { - imports: [], - exports: [], - memberAccesses: [], - wholeObjectUses: [], - localIdentifierReferences: [], - topLevelImportReferences: [], - referencedFilenames: [], - redundantTypePatterns: [], - identityWrappers: [], - typeDefinitionHashes: [], - inlineTypeLiterals: [], - simplifiableFunctions: [], - simplifiableExpressions: [], - duplicateConstantCandidates: [], - errors: [ - new ParseError({ - code: "parse-failed", - message: `Worker parse failed: ${message.errorMessage}`, - path: message.filePath, - }), - ], - }; - completedCount += 1; - if (completedCount === files.length) { - cleanup(); - resolve(results); - } else { - dispatchNext(worker); - } - } - }; - - const cleanup = (): void => { - for (const worker of workers) { - worker.terminate(); - } - }; - - for (const worker of workers) { - worker.on("message", onWorkerMessage(worker)); - worker.on("error", (error) => { - cleanup(); - reject(error); - }); - } - - for (const worker of workers) { - dispatchNext(worker); - } - }); -}; - -export const parseFilesInParallel = async ( - files: ReadonlyArray<SourceFile>, -): Promise<ParsedSource[]> => { - if (files.length <= PARALLEL_PARSE_FILE_THRESHOLD) { - return files.map((file) => parseSourceFile(file.path)); - } - - const concurrency = resolveAvailableConcurrency(); - if (concurrency <= 1) { - return files.map((file) => parseSourceFile(file.path)); - } - - try { - return await parseFilesWithWorkerPool(files, concurrency); - } catch { - return files.map((file) => parseSourceFile(file.path)); - } -}; diff --git a/packages/deslop-js/src/collect/parse-worker.ts b/packages/deslop-js/src/collect/parse-worker.ts deleted file mode 100644 index 2e797eaeb5..0000000000 --- a/packages/deslop-js/src/collect/parse-worker.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { parentPort } from "node:worker_threads"; -import { parseSourceFile } from "./parse.js"; -import type { DeslopErrorJson } from "../errors.js"; - -interface ParseTaskMessage { - readonly type: "parse"; - readonly filePath: string; - readonly fileIndex: number; -} - -type WorkerMessage = ParseTaskMessage; - -interface SerializedParsedSource { - readonly imports: ReturnType<typeof parseSourceFile>["imports"]; - readonly exports: ReturnType<typeof parseSourceFile>["exports"]; - readonly memberAccesses: ReturnType<typeof parseSourceFile>["memberAccesses"]; - readonly wholeObjectUses: string[]; - readonly localIdentifierReferences: string[]; - readonly topLevelImportReferences: string[]; - readonly referencedFilenames: string[]; - readonly redundantTypePatterns: ReturnType<typeof parseSourceFile>["redundantTypePatterns"]; - readonly identityWrappers: ReturnType<typeof parseSourceFile>["identityWrappers"]; - readonly typeDefinitionHashes: ReturnType<typeof parseSourceFile>["typeDefinitionHashes"]; - readonly inlineTypeLiterals: ReturnType<typeof parseSourceFile>["inlineTypeLiterals"]; - readonly simplifiableFunctions: ReturnType<typeof parseSourceFile>["simplifiableFunctions"]; - readonly simplifiableExpressions: ReturnType<typeof parseSourceFile>["simplifiableExpressions"]; - readonly duplicateConstantCandidates: ReturnType< - typeof parseSourceFile - >["duplicateConstantCandidates"]; - readonly errors: DeslopErrorJson[]; -} - -interface ParseResultMessage { - readonly type: "result"; - readonly fileIndex: number; - readonly filePath: string; - readonly parsed: SerializedParsedSource; -} - -interface ParseErrorMessage { - readonly type: "error"; - readonly fileIndex: number; - readonly filePath: string; - readonly errorMessage: string; -} - -const port = parentPort!; - -port.on("message", (message: WorkerMessage) => { - if (message.type === "parse") { - try { - const parsed = parseSourceFile(message.filePath); - const response: ParseResultMessage = { - type: "result", - fileIndex: message.fileIndex, - filePath: message.filePath, - parsed: { - imports: parsed.imports, - exports: parsed.exports, - memberAccesses: parsed.memberAccesses, - wholeObjectUses: parsed.wholeObjectUses, - localIdentifierReferences: parsed.localIdentifierReferences, - topLevelImportReferences: parsed.topLevelImportReferences, - referencedFilenames: parsed.referencedFilenames, - redundantTypePatterns: parsed.redundantTypePatterns, - identityWrappers: parsed.identityWrappers, - typeDefinitionHashes: parsed.typeDefinitionHashes, - inlineTypeLiterals: parsed.inlineTypeLiterals, - simplifiableFunctions: parsed.simplifiableFunctions, - simplifiableExpressions: parsed.simplifiableExpressions, - duplicateConstantCandidates: parsed.duplicateConstantCandidates, - errors: parsed.errors.map((deslopError) => deslopError.toJSON()), - }, - }; - port.postMessage(response); - } catch (taskError) { - const response: ParseErrorMessage = { - type: "error", - fileIndex: message.fileIndex, - filePath: message.filePath, - errorMessage: taskError instanceof Error ? taskError.message : String(taskError), - }; - port.postMessage(response); - } - } -}); - -port.postMessage({ type: "ready" }); diff --git a/packages/deslop-js/src/collect/parse.ts b/packages/deslop-js/src/collect/parse.ts deleted file mode 100644 index 54ce9f07b8..0000000000 --- a/packages/deslop-js/src/collect/parse.ts +++ /dev/null @@ -1,1938 +0,0 @@ -import { parseSync } from "oxc-parser"; -import { readFileSync, statSync } from "node:fs"; -import { - BINARY_DETECTION_NULL_BYTE_THRESHOLD, - BINARY_DETECTION_SAMPLE_BYTES, - MAX_PARSE_FILE_SIZE_BYTES, - MINIFIED_DETECTION_AVG_LINE_LENGTH_THRESHOLD, - MINIFIED_DETECTION_MIN_BYTES, -} from "../constants.js"; -import { type DeslopError, FileReadError, ParseError, describeUnknownError } from "../errors.js"; -import type { - Statement, - ImportDeclaration, - ExportNamedDeclaration, - ExportDefaultDeclaration, - ExportAllDeclaration, - Declaration, - VariableDeclaration, - BindingPattern, - ModuleExportName, - ModuleDeclaration, -} from "@oxc-project/types"; -import type { - ImportReference, - ExportReference, - ImportBinding, - MemberAccess, - SourceModuleAnalysis, - SourceModuleDuplicateConstantCandidate, - SourceModuleIdentityWrapper, - SourceModuleInlineTypeLiteral, - SourceModuleRedundantTypePattern, - SourceModuleSimplifiableExpression, - SourceModuleSimplifiableFunction, - SourceModuleTypeDefinitionHash, -} from "../types.js"; -import { getLineFromOffset, getColumnFromOffset } from "../utils/line-column.js"; -import { extractDefaultExportLocalName } from "../utils/extract-default-export-local-name.js"; -import { - detectRedundantTypePatternForTypeAnnotation, - detectRedundantInterfaceDeclaration, -} from "../utils/detect-redundant-type-pattern.js"; -import { detectIdentityWrapperFromInitializer } from "../utils/detect-identity-wrapper.js"; -import { normalizeTypeAstHash } from "../utils/normalize-type-hash.js"; -import { collectInlineTypeLiterals } from "../utils/collect-inline-type-literals.js"; -import { collectSimplifiableFunctions } from "../utils/collect-simplifiable-functions.js"; -import { collectSimplifiableExpressions } from "../utils/collect-simplifiable-expressions.js"; -import { collectDuplicateConstantCandidates } from "../utils/collect-duplicate-constants.js"; -import { getIdentifierName } from "../utils/oxc-ast-node.js"; - -export interface ParsedSource extends SourceModuleAnalysis { - errors: DeslopError[]; -} - -const extractMdxImportsExports = (sourceText: string): string => { - const statements: string[] = []; - let isInMultiline = false; - let braceDepth = 0; - - for (const line of sourceText.split("\n")) { - const trimmedLine = line.trim(); - if (isInMultiline) { - statements.push(line); - for (const character of trimmedLine) { - if (character === "{") braceDepth++; - if (character === "}") braceDepth--; - } - const hasFromClause = - trimmedLine.includes(" from ") || - trimmedLine.includes(" from'") || - trimmedLine.includes(' from"'); - if (braceDepth <= 0 || trimmedLine.endsWith(";") || hasFromClause) { - isInMultiline = false; - braceDepth = 0; - } - } else if ( - trimmedLine.startsWith("import ") || - trimmedLine.startsWith("import{") || - trimmedLine.startsWith("export ") || - trimmedLine.startsWith("export{") - ) { - statements.push(line); - for (const character of trimmedLine) { - if (character === "{") braceDepth++; - if (character === "}") braceDepth--; - } - if (braceDepth > 0 && !trimmedLine.includes(" from ")) { - isInMultiline = true; - } - } - } - - return statements.join("\n"); -}; - -const ASTRO_FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---/; -const ASTRO_SCRIPT_TAG_PATTERN = - /<script\b([^>]*?)\/>|<script\b([^>]*)>([\s\S]*?)<\/script\b[^>]*>/gi; -const ASTRO_SCRIPT_SRC_ATTRIBUTE_PATTERN = /\bsrc\s*=\s*["']([^"']+)["']/i; - -const extractAstroSources = (sourceText: string): string => { - const sections: string[] = []; - const frontmatterMatch = sourceText.match(ASTRO_FRONTMATTER_PATTERN); - if (frontmatterMatch) { - sections.push(frontmatterMatch[1]); - } - ASTRO_SCRIPT_TAG_PATTERN.lastIndex = 0; - let scriptMatch: RegExpExecArray | null; - while ((scriptMatch = ASTRO_SCRIPT_TAG_PATTERN.exec(sourceText)) !== null) { - const selfClosingAttributes = scriptMatch[1]; - const pairedAttributes = scriptMatch[2]; - const attributes = selfClosingAttributes ?? pairedAttributes ?? ""; - const body = selfClosingAttributes === undefined ? (scriptMatch[3] ?? "") : ""; - const srcMatch = attributes.match(ASTRO_SCRIPT_SRC_ATTRIBUTE_PATTERN); - if (srcMatch) { - sections.push(`import ${JSON.stringify(srcMatch[1])};`); - } - if (body) { - sections.push(body); - } - } - return sections.join("\n"); -}; - -const VUE_SCRIPT_PATTERN = - /<script[^>]*(?:lang=["'](?:ts|tsx)["'][^>]*)?>([\s\S]*?)<\/script\b[^>]*>/gi; - -const extractVueScriptContent = (sourceText: string): string => { - const scriptBlocks: string[] = []; - let scriptMatch: RegExpExecArray | null; - VUE_SCRIPT_PATTERN.lastIndex = 0; - while ((scriptMatch = VUE_SCRIPT_PATTERN.exec(sourceText)) !== null) { - if (scriptMatch[1]) { - scriptBlocks.push(scriptMatch[1]); - } - } - return scriptBlocks.join("\n"); -}; - -const SVELTE_SCRIPT_PATTERN = /<script[^>]*>([\s\S]*?)<\/script\b[^>]*>/gi; - -const extractSvelteScriptContent = (sourceText: string): string => { - const scriptBlocks: string[] = []; - let scriptMatch: RegExpExecArray | null; - SVELTE_SCRIPT_PATTERN.lastIndex = 0; - while ((scriptMatch = SVELTE_SCRIPT_PATTERN.exec(sourceText)) !== null) { - if (scriptMatch[1]) { - scriptBlocks.push(scriptMatch[1]); - } - } - return scriptBlocks.join("\n"); -}; - -const getModuleExportNameValue = (exportName: ModuleExportName): string => { - if (exportName.type === "Identifier") return exportName.name; - if (exportName.type === "Literal") return exportName.value; - return "default"; -}; - -const CSS_EXTENSIONS = [".css", ".scss", ".less", ".sass"]; - -const CSS_IMPORT_PATTERN = /@import\s+(?:url\()?['"]([^'"]+)['"]\)?/g; -const SCSS_USE_FORWARD_PATTERN = /@(?:use|forward)\s+['"]([^'"]+)['"]/g; -const TAILWIND_PLUGIN_REFERENCE_PATTERN = /@(?:plugin|reference|config)\s+['"]([^'"]+)['"]/g; - -const parseCssImports = (filePath: string): ParsedSource => { - const sourceText = readFileSync(filePath, "utf-8"); - const imports: ImportReference[] = []; - - const patterns = [ - CSS_IMPORT_PATTERN, - SCSS_USE_FORWARD_PATTERN, - TAILWIND_PLUGIN_REFERENCE_PATTERN, - ]; - for (const pattern of patterns) { - let match: RegExpExecArray | null; - pattern.lastIndex = 0; - while ((match = pattern.exec(sourceText)) !== null) { - const specifier = match[1]; - if (specifier && !specifier.startsWith("http")) { - imports.push({ - specifier, - importedNames: [], - isTypeOnly: false, - isDynamic: false, - isSideEffect: true, - line: sourceText.substring(0, match.index).split("\n").length, - column: 0, - }); - } - } - } - - return { - imports, - exports: [], - memberAccesses: [], - wholeObjectUses: [], - localIdentifierReferences: [], - topLevelImportReferences: [], - referencedFilenames: [], - redundantTypePatterns: [], - identityWrappers: [], - typeDefinitionHashes: [], - inlineTypeLiterals: [], - simplifiableFunctions: [], - simplifiableExpressions: [], - duplicateConstantCandidates: [], - errors: [], - }; -}; - -const NON_JS_EXTENSIONS = [".graphql", ".gql"]; - -const collectLocalIdentifierReferences = (statements: Statement[]): string[] => { - const references: string[] = []; - const seenNames = new Set<string>(); - - const visitNode = (node: unknown): void => { - if (!node || typeof node !== "object") return; - - const record = node as Record<string, unknown>; - if (record.type === "Identifier" && typeof record.name === "string") { - if (!seenNames.has(record.name)) { - seenNames.add(record.name); - references.push(record.name); - } - return; - } - - for (const value of Object.values(record)) { - if (Array.isArray(value)) { - for (const innerValue of value) visitNode(innerValue); - } else if (value && typeof value === "object") { - visitNode(value); - } - } - }; - - // Exported declarations are visited through their VALUE side only - // (initializers, function/class bodies) — never their binding names — - // so a same-file call to another exported symbol counts as a local - // reference without every export marking itself referenced. - const visitExportedDeclarationValues = (declaration: unknown): void => { - if (!declaration || typeof declaration !== "object") return; - const record = declaration as Record<string, unknown>; - if (record.type === "VariableDeclaration" && Array.isArray(record.declarations)) { - for (const declarator of record.declarations) { - if (declarator && typeof declarator === "object") { - visitNode((declarator as Record<string, unknown>).init); - } - } - return; - } - if (record.type === "FunctionDeclaration" || record.type === "ClassDeclaration") { - visitNode(record.params); - visitNode(record.superClass); - visitNode(record.body); - return; - } - if (typeof record.type === "string" && !record.type.startsWith("TS")) { - visitNode(declaration); - } - }; - - for (const statement of statements) { - if (statement.type === "ImportDeclaration" || statement.type === "ExportAllDeclaration") { - continue; - } - if (statement.type === "ExportNamedDeclaration") { - visitExportedDeclarationValues((statement as { declaration?: unknown }).declaration); - continue; - } - if (statement.type === "ExportDefaultDeclaration") { - visitExportedDeclarationValues((statement as { declaration?: unknown }).declaration); - continue; - } - visitNode(statement); - } - - return references; -}; - -// TS wrapper expressions whose inner `.expression` is still a VALUE evaluated -// at runtime; every other `TS*` node is an erased type position. -const TS_VALUE_WRAPPER_NODE_TYPES = new Set([ - "TSAsExpression", - "TSSatisfiesExpression", - "TSNonNullExpression", - "TSInstantiationExpression", - "TSTypeAssertion", -]); - -// TS declarations that survive emit and evaluate at module init. -const TS_RUNTIME_DECLARATION_NODE_TYPES = new Set([ - "TSEnumDeclaration", - "TSModuleDeclaration", - "TSExportAssignment", -]); - -const FUNCTION_NODE_TYPES = new Set([ - "FunctionDeclaration", - "FunctionExpression", - "ArrowFunctionExpression", -]); - -const collectStaticImportLocalNames = (imports: ImportReference[]): Set<string> => { - const localNames = new Set<string>(); - for (const importInfo of imports) { - if (importInfo.isDynamic || importInfo.isTypeOnly) continue; - for (const binding of importInfo.importedNames) { - if (binding.isTypeOnly) continue; - const localName = binding.alias ?? binding.name; - if (localName && localName !== "*") localNames.add(localName); - } - } - return localNames; -}; - -// Records which static import bindings are dereferenced in code that runs at -// MODULE INIT time: top-level statements, IIFE bodies, class `extends` / -// decorators / static members — but not function bodies, method bodies, or -// erased TS type positions, all of which run (or vanish) after every module -// in a cycle has finished initializing. Cycle detection uses this to keep the -// documented initialization-order hazard firing while suppressing cycles whose -// back edges are only touched lazily. -const collectTopLevelImportReferences = ( - bodyNodes: Array<Statement | ModuleDeclaration>, - importLocalNames: Set<string>, -): string[] => { - const referencedNames = new Set<string>(); - if (importLocalNames.size === 0) return []; - - const visitClassBody = (classBody: WalkableNode): void => { - const bodyElements = Array.isArray(classBody.body) ? classBody.body.filter(isWalkableNode) : []; - for (const element of bodyElements) { - if (element.type === "StaticBlock") { - visitValueNode(element.body); - continue; - } - const isComputedKey = Boolean(element.computed); - if (isComputedKey) visitValueNode(element.key); - const isStatic = Boolean(element.static); - if (element.type === "PropertyDefinition" && isStatic) { - visitValueNode(element.value); - } - visitValueNode(element.decorators); - } - }; - - const visitValueNode = (node: unknown): void => { - if (Array.isArray(node)) { - for (const element of node) visitValueNode(element); - return; - } - if (!isWalkableNode(node)) return; - - if (node.type === "Identifier" || node.type === "JSXIdentifier") { - if (typeof node.name === "string" && importLocalNames.has(node.name)) { - const identifierName = node.name; - referencedNames.add(identifierName); - } - return; - } - - if (node.type.startsWith("TS")) { - if (TS_VALUE_WRAPPER_NODE_TYPES.has(node.type)) { - visitValueNode(node.expression); - return; - } - if (!TS_RUNTIME_DECLARATION_NODE_TYPES.has(node.type)) return; - } - - if (FUNCTION_NODE_TYPES.has(node.type)) return; - - if (node.type === "ClassDeclaration" || node.type === "ClassExpression") { - visitValueNode(node.superClass); - visitValueNode(node.decorators); - if (isWalkableNode(node.body)) visitClassBody(node.body); - return; - } - - if (node.type === "CallExpression" || node.type === "NewExpression") { - if (isWalkableNode(node.callee) && FUNCTION_NODE_TYPES.has(node.callee.type)) { - visitValueNode(node.callee.body); - } - } - - if (node.type === "MemberExpression" || node.type === "JSXMemberExpression") { - visitValueNode(node.object); - if (node.computed) { - visitValueNode(node.property); - } - return; - } - - if (node.type === "Property") { - if (node.computed) { - visitValueNode(node.key); - } - visitValueNode(node.value); - return; - } - - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const element of value) visitValueNode(element); - } else if (value && typeof value === "object") { - visitValueNode(value); - } - } - }; - - for (const statement of bodyNodes) { - if (statement.type === "ImportDeclaration" || statement.type === "ExportAllDeclaration") { - continue; - } - if ( - statement.type === "ExportNamedDeclaration" || - statement.type === "ExportDefaultDeclaration" - ) { - visitValueNode((statement as { declaration?: unknown }).declaration); - continue; - } - visitValueNode(statement); - } - - return [...referencedNames]; -}; - -const createEmptyParsedSource = (): ParsedSource => ({ - imports: [], - exports: [], - memberAccesses: [], - wholeObjectUses: [], - localIdentifierReferences: [], - topLevelImportReferences: [], - referencedFilenames: [], - redundantTypePatterns: [], - identityWrappers: [], - typeDefinitionHashes: [], - inlineTypeLiterals: [], - simplifiableFunctions: [], - simplifiableExpressions: [], - duplicateConstantCandidates: [], - errors: [], -}); - -const stripByteOrderMark = (sourceText: string): string => { - if (sourceText.charCodeAt(0) === 0xfeff) return sourceText.slice(1); - return sourceText; -}; - -const looksLikeBinaryContent = (sourceText: string): boolean => { - const sampleLength = Math.min(sourceText.length, BINARY_DETECTION_SAMPLE_BYTES); - let nullByteCount = 0; - for (let scanIndex = 0; scanIndex < sampleLength; scanIndex++) { - if (sourceText.charCodeAt(scanIndex) === 0) nullByteCount++; - if (nullByteCount > BINARY_DETECTION_NULL_BYTE_THRESHOLD) return true; - } - return false; -}; - -const looksLikeMinifiedSource = (sourceText: string): boolean => { - if (sourceText.length < MINIFIED_DETECTION_MIN_BYTES) return false; - let newlineCount = 0; - for (let scanIndex = 0; scanIndex < sourceText.length; scanIndex++) { - if (sourceText.charCodeAt(scanIndex) === 10) newlineCount++; - } - const averageLineLength = sourceText.length / (newlineCount + 1); - return averageLineLength > MINIFIED_DETECTION_AVG_LINE_LENGTH_THRESHOLD; -}; - -const safeReadSourceFile = (filePath: string, errors: DeslopError[]): string | undefined => { - try { - const stats = statSync(filePath); - if (stats.size === 0) { - errors.push( - new FileReadError({ - code: "file-empty", - severity: "info", - message: "file is empty — nothing to analyze", - path: filePath, - }), - ); - return undefined; - } - if (stats.size > MAX_PARSE_FILE_SIZE_BYTES) { - errors.push( - new FileReadError({ - code: "file-too-large", - message: `file size ${stats.size}B exceeds MAX_PARSE_FILE_SIZE_BYTES (${MAX_PARSE_FILE_SIZE_BYTES})`, - path: filePath, - }), - ); - return undefined; - } - } catch (statError) { - errors.push( - new FileReadError({ - code: "file-read-failed", - message: "could not stat source file", - path: filePath, - detail: describeUnknownError(statError), - }), - ); - return undefined; - } - try { - const rawSourceText = readFileSync(filePath, "utf-8"); - const sourceText = stripByteOrderMark(rawSourceText); - if (looksLikeBinaryContent(sourceText)) { - errors.push( - new FileReadError({ - code: "file-binary", - severity: "info", - message: "file appears to be binary — skipping", - path: filePath, - }), - ); - return undefined; - } - if (looksLikeMinifiedSource(sourceText)) { - errors.push( - new FileReadError({ - code: "file-minified", - severity: "info", - message: "file appears to be a minified/bundled artifact — skipping redundancy analysis", - path: filePath, - }), - ); - return undefined; - } - return sourceText; - } catch (readError) { - errors.push( - new FileReadError({ - code: "file-read-failed", - message: "could not read source file", - path: filePath, - detail: describeUnknownError(readError), - }), - ); - return undefined; - } -}; - -export const parseSourceFile = (filePath: string): ParsedSource => { - const isCss = CSS_EXTENSIONS.some((ext) => filePath.endsWith(ext)); - if (isCss) { - try { - return parseCssImports(filePath); - } catch (cssError) { - return { - ...createEmptyParsedSource(), - errors: [ - new ParseError({ - code: "parse-failed", - message: "CSS import parsing crashed", - path: filePath, - detail: describeUnknownError(cssError), - }), - ], - }; - } - } - - const isNonJsFile = NON_JS_EXTENSIONS.some((ext) => filePath.endsWith(ext)); - if (isNonJsFile) { - return createEmptyParsedSource(); - } - - const earlyErrors: DeslopError[] = []; - const sourceText = safeReadSourceFile(filePath, earlyErrors); - if (sourceText === undefined) { - return { ...createEmptyParsedSource(), errors: earlyErrors }; - } - const imports: ImportReference[] = []; - const exports: ExportReference[] = []; - - const isMdx = filePath.endsWith(".mdx"); - const isAstro = filePath.endsWith(".astro"); - const isVue = filePath.endsWith(".vue"); - const isSvelte = filePath.endsWith(".svelte"); - const isPreprocessed = isMdx || isAstro || isVue || isSvelte; - const textToParse = isMdx - ? extractMdxImportsExports(sourceText) - : isAstro - ? extractAstroSources(sourceText) - : isVue - ? extractVueScriptContent(sourceText) - : isSvelte - ? extractSvelteScriptContent(sourceText) - : sourceText; - const parseFileName = - isMdx || isAstro || isVue || isSvelte - ? filePath.replace(/\.(mdx|astro|vue|svelte)$/, ".tsx") - : filePath; - - let result: ReturnType<typeof parseSync>; - try { - result = parseSync(parseFileName, textToParse); - } catch (parseError) { - return { - ...createEmptyParsedSource(), - errors: [ - ...earlyErrors, - new ParseError({ - code: "parse-failed", - message: "oxc-parser threw during initial parse", - path: filePath, - detail: describeUnknownError(parseError), - }), - ], - }; - } - - const isPlainJsFile = - parseFileName.endsWith(".js") || - parseFileName.endsWith(".mjs") || - parseFileName.endsWith(".cjs"); - - if (isPlainJsFile && result.errors.length > 0) { - try { - const jsxFileName = parseFileName.replace(/\.(m?js|cjs)$/, ".jsx"); - const jsxResult = parseSync(jsxFileName, textToParse); - if (jsxResult.errors.length === 0) { - result = jsxResult; - } else { - const tsxFileName = parseFileName.replace(/\.(m?js|cjs)$/, ".tsx"); - const tsxResult = parseSync(tsxFileName, textToParse); - if (tsxResult.errors.length === 0) { - result = tsxResult; - } - } - } catch { - // fall through with the existing (error-laden) result - } - } - - if (result.errors.length > 0 && !isPreprocessed) { - return { - ...createEmptyParsedSource(), - imports, - exports, - referencedFilenames: extractReferencedFilenames(sourceText), - errors: [ - ...earlyErrors, - new ParseError({ - code: "parse-recovered", - severity: "info", - message: `oxc-parser reported ${result.errors.length} syntax issue(s); skipping deep analysis for this file`, - path: filePath, - }), - ], - }; - } - - if (result.errors.length > 0) { - earlyErrors.push( - new ParseError({ - code: "parse-recovered-partial", - severity: "info", - message: `oxc-parser reported ${result.errors.length} syntax issue(s) in extracted ${isAstro ? "Astro" : isVue ? "Vue" : isSvelte ? "Svelte" : "MDX"} sources; continuing with partial AST`, - path: filePath, - }), - ); - } - - const program = result.program; - if (!program?.body) { - return { - ...createEmptyParsedSource(), - imports, - exports, - referencedFilenames: extractReferencedFilenames(sourceText), - errors: [ - ...earlyErrors, - new ParseError({ - code: "parse-failed", - message: "oxc-parser returned no program body", - path: filePath, - }), - ], - }; - } - - const detectorErrors: DeslopError[] = []; - - const safeWalk = <ResultType>( - walkerName: string, - walker: () => ResultType, - fallback: ResultType, - ): ResultType => { - try { - return walker(); - } catch (walkError) { - detectorErrors.push( - new ParseError({ - code: "ast-walk-failed", - message: `${walkerName} threw during AST traversal`, - path: filePath, - detail: describeUnknownError(walkError), - }), - ); - return fallback; - } - }; - - safeWalk( - "extractImportsAndExports", - () => { - for (const node of program.body) { - switch (node.type) { - case "ImportDeclaration": - extractImportDeclaration(node, sourceText, imports); - break; - case "ExportNamedDeclaration": - extractNamedExportDeclaration(node, sourceText, exports); - break; - case "ExportDefaultDeclaration": - extractDefaultExportDeclaration(node, sourceText, exports); - break; - case "ExportAllDeclaration": - extractExportAllDeclaration(node, sourceText, exports); - break; - } - } - return undefined; - }, - undefined, - ); - - safeWalk( - "collectDynamicImports", - () => { - collectDynamicImports(program.body, sourceText, imports); - return undefined; - }, - undefined, - ); - - const namespaceLocalNames = collectNamespaceLocalNames(imports); - const memberAccesses: MemberAccess[] = []; - const wholeObjectUses: string[] = []; - if (namespaceLocalNames.size > 0) { - safeWalk( - "collectMemberAccesses", - () => { - collectMemberAccesses(program.body, namespaceLocalNames, memberAccesses, wholeObjectUses); - return undefined; - }, - undefined, - ); - } - - const localIdentifierReferences = safeWalk( - "collectLocalIdentifierReferences", - () => collectLocalIdentifierReferences(program.body), - [], - ); - - const topLevelImportReferences = safeWalk( - "collectTopLevelImportReferences", - () => collectTopLevelImportReferences(program.body, collectStaticImportLocalNames(imports)), - [], - ); - - const redundantTypePatterns: SourceModuleRedundantTypePattern[] = []; - const identityWrappers: SourceModuleIdentityWrapper[] = []; - const typeDefinitionHashes: SourceModuleTypeDefinitionHash[] = []; - safeWalk( - "collectDryPatterns", - () => { - collectDryPatterns( - program.body, - sourceText, - redundantTypePatterns, - identityWrappers, - typeDefinitionHashes, - ); - return undefined; - }, - undefined, - ); - - const inlineTypeCaptures = safeWalk( - "collectInlineTypeLiterals", - () => collectInlineTypeLiterals(program.body), - [], - ); - const inlineTypeLiterals: SourceModuleInlineTypeLiteral[] = inlineTypeCaptures.map((capture) => ({ - structuralHash: capture.structuralHash, - memberCount: capture.memberCount, - preview: capture.preview, - context: capture.context, - nearestName: capture.nearestName, - line: getLineFromOffset(sourceText, capture.startOffset), - column: getColumnFromOffset(sourceText, capture.startOffset), - })); - - const simplifiableCaptures = safeWalk( - "collectSimplifiableFunctions", - () => collectSimplifiableFunctions(program.body), - [], - ); - const simplifiableFunctions: SourceModuleSimplifiableFunction[] = simplifiableCaptures.map( - (capture) => ({ - kind: capture.kind, - functionName: capture.functionName, - line: getLineFromOffset(sourceText, capture.startOffset), - column: getColumnFromOffset(sourceText, capture.startOffset), - reason: capture.reason, - suggestion: capture.suggestion, - }), - ); - - const expressionCaptures = safeWalk( - "collectSimplifiableExpressions", - () => collectSimplifiableExpressions(program.body), - [], - ); - const simplifiableExpressions: SourceModuleSimplifiableExpression[] = expressionCaptures.map( - (capture) => ({ - kind: capture.kind, - snippet: capture.snippet, - line: getLineFromOffset(sourceText, capture.startOffset), - column: getColumnFromOffset(sourceText, capture.startOffset), - reason: capture.reason, - suggestion: capture.suggestion, - }), - ); - - const constantCaptures = safeWalk( - "collectDuplicateConstantCandidates", - () => collectDuplicateConstantCandidates(program.body), - [], - ); - const duplicateConstantCandidates: SourceModuleDuplicateConstantCandidate[] = - constantCaptures.map((capture) => ({ - constantName: capture.constantName, - literalHash: capture.literalHash, - literalPreview: capture.literalPreview, - line: getLineFromOffset(sourceText, capture.startOffset), - column: getColumnFromOffset(sourceText, capture.startOffset), - })); - - const referencedFilenames = extractReferencedFilenames(sourceText, program.body); - - return { - imports, - exports, - memberAccesses, - wholeObjectUses, - localIdentifierReferences, - topLevelImportReferences, - referencedFilenames, - redundantTypePatterns, - identityWrappers, - typeDefinitionHashes, - inlineTypeLiterals, - simplifiableFunctions, - simplifiableExpressions, - duplicateConstantCandidates, - errors: [...earlyErrors, ...detectorErrors], - }; -}; - -const REFERENCED_FILENAME_LITERAL_PATTERN = - /(?<![./@\w-])(?:["'`])([a-z][\w-]*\.(?:ts|tsx|js|jsx|mts|mjs|cts|cjs))(?:["'`])/g; -const REFERENCED_MODULE_PATH_PATTERN = /^[a-zA-Z0-9_@-][a-zA-Z0-9_@.-]*(?:\/[a-zA-Z0-9_@.-]+)+$/; - -const extractReferencedFilenames = ( - sourceText: string, - bodyNodes: Array<Statement | ModuleDeclaration> = [], -): string[] => { - const captured = new Set<string>(); - REFERENCED_FILENAME_LITERAL_PATTERN.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = REFERENCED_FILENAME_LITERAL_PATTERN.exec(sourceText)) !== null) { - captured.add(match[1]); - } - - const visitNode = (node: WalkableNode): void => { - if (node.type === "ImportExpression") { - const sourceExpression = node.source; - if (isWalkableNode(sourceExpression) && sourceExpression.type === "Literal") { - const literalValue = sourceExpression.value; - if (typeof literalValue === "string" && REFERENCED_MODULE_PATH_PATTERN.test(literalValue)) { - captured.add(literalValue); - } - } - } - - if (node.type === "CallExpression" || node.type === "NewExpression") { - const callArguments = node.arguments; - for (const callArgument of Array.isArray(callArguments) ? callArguments : []) { - if (!isWalkableNode(callArgument) || callArgument.type !== "Literal") continue; - const literalValue = callArgument.value; - if (typeof literalValue === "string" && REFERENCED_MODULE_PATH_PATTERN.test(literalValue)) { - captured.add(literalValue); - } - } - } - - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const element of value) { - if (isWalkableNode(element)) visitNode(element); - } - } else if (isWalkableNode(value)) { - visitNode(value); - } - } - }; - - for (const bodyNode of bodyNodes) { - if (isWalkableNode(bodyNode)) visitNode(bodyNode); - } - return [...captured]; -}; - -const collectDryPatterns = ( - bodyNodes: Array<Statement | ModuleDeclaration>, - sourceText: string, - redundantTypePatterns: SourceModuleRedundantTypePattern[], - identityWrappers: SourceModuleIdentityWrapper[], - typeDefinitionHashes: SourceModuleTypeDefinitionHash[], -): void => { - for (const statement of bodyNodes) { - inspectStatement( - statement, - sourceText, - redundantTypePatterns, - identityWrappers, - typeDefinitionHashes, - ); - } -}; - -const inspectStatement = ( - statementNode: Statement | ModuleDeclaration, - sourceText: string, - redundantTypePatterns: SourceModuleRedundantTypePattern[], - identityWrappers: SourceModuleIdentityWrapper[], - typeDefinitionHashes: SourceModuleTypeDefinitionHash[], -): void => { - let declarationOfInterest: unknown = statementNode; - if ( - statementNode.type === "ExportNamedDeclaration" && - (statementNode as { declaration?: unknown }).declaration - ) { - declarationOfInterest = (statementNode as { declaration?: unknown }).declaration; - } - - if (declarationOfInterest && typeof declarationOfInterest === "object") { - const declarationNode = declarationOfInterest as { - type?: string; - id?: { name?: string }; - typeAnnotation?: unknown; - declarations?: Array<{ id?: { name?: string }; init?: unknown; start?: number }>; - start?: number; - }; - - if (declarationNode.type === "TSTypeAliasDeclaration") { - const typeAliasName = declarationNode.id?.name; - const typeAnnotation = declarationNode.typeAnnotation; - const startOffset = declarationNode.start ?? 0; - if (typeAliasName && typeAnnotation) { - const redundantPattern = detectRedundantTypePatternForTypeAnnotation(typeAnnotation); - if (redundantPattern) { - redundantTypePatterns.push({ - typeName: typeAliasName, - kind: redundantPattern.kind, - line: getLineFromOffset(sourceText, startOffset), - column: getColumnFromOffset(sourceText, startOffset), - reason: redundantPattern.reason, - suggestion: redundantPattern.suggestion, - }); - } - typeDefinitionHashes.push({ - typeName: typeAliasName, - structuralHash: `alias:${normalizeTypeAstHash(typeAnnotation)}`, - line: getLineFromOffset(sourceText, startOffset), - column: getColumnFromOffset(sourceText, startOffset), - }); - } - } else if (declarationNode.type === "TSInterfaceDeclaration") { - const interfaceName = declarationNode.id?.name; - const startOffset = declarationNode.start ?? 0; - if (interfaceName) { - const redundantPattern = detectRedundantInterfaceDeclaration(declarationNode); - if (redundantPattern) { - redundantTypePatterns.push({ - typeName: interfaceName, - kind: redundantPattern.kind, - line: getLineFromOffset(sourceText, startOffset), - column: getColumnFromOffset(sourceText, startOffset), - reason: redundantPattern.reason, - suggestion: redundantPattern.suggestion, - }); - } - const declarationCopy = { ...declarationNode, id: undefined }; - typeDefinitionHashes.push({ - typeName: interfaceName, - structuralHash: `interface:${normalizeTypeAstHash(declarationCopy)}`, - line: getLineFromOffset(sourceText, startOffset), - column: getColumnFromOffset(sourceText, startOffset), - }); - } - } else if (declarationNode.type === "VariableDeclaration") { - for (const declarator of declarationNode.declarations ?? []) { - const wrapperName = declarator.id?.name; - const initializerNode = declarator.init; - const startOffset = declarator.start ?? declarationNode.start ?? 0; - if (!wrapperName || !initializerNode) continue; - const wrapperDetection = detectIdentityWrapperFromInitializer(initializerNode, wrapperName); - if (wrapperDetection) { - identityWrappers.push({ - wrapperName, - wrappedExpression: wrapperDetection.wrappedExpression, - line: getLineFromOffset(sourceText, startOffset), - column: getColumnFromOffset(sourceText, startOffset), - }); - } - } - } - } -}; - -const WHOLE_OBJECT_FUNCTION_NAMES = new Set([ - "keys", - "values", - "entries", - "assign", - "freeze", - "getOwnPropertyNames", - "getOwnPropertyDescriptors", -]); - -const collectNamespaceLocalNames = (imports: ImportReference[]): Set<string> => { - const namespaceNames = new Set<string>(); - for (const importInfo of imports) { - for (const importedName of importInfo.importedNames) { - if (importedName.isNamespace && importedName.alias) { - namespaceNames.add(importedName.alias); - } - } - } - return namespaceNames; -}; - -const collectMemberAccesses = ( - bodyNodes: Array<Statement | ModuleDeclaration>, - namespaceLocalNames: Set<string>, - memberAccesses: MemberAccess[], - wholeObjectUses: string[], -): void => { - const walkForMemberAccesses = (node: WalkableNode): void => { - if (node.type === "MemberExpression" && !node.computed) { - const objectName = getIdentifierName(node.object); - const memberName = getIdentifierName(node.property); - if (objectName && memberName && namespaceLocalNames.has(objectName)) { - memberAccesses.push({ objectName, memberName }); - } - } - - if (node.type === "MemberExpression" && Boolean(node.computed)) { - const objectName = getIdentifierName(node.object); - if (objectName && namespaceLocalNames.has(objectName)) { - const expressionNode = node.expression; - if ( - isWalkableNode(expressionNode) && - expressionNode.type === "Literal" && - typeof expressionNode.value === "string" - ) { - memberAccesses.push({ objectName, memberName: expressionNode.value }); - } else { - wholeObjectUses.push(objectName); - } - } - } - - // `<S.Custom />` — a JSX element whose name is a member of a namespace - // import. The name node is a `JSXMemberExpression`, not a `MemberExpression`, - // so it would otherwise be missed and the export reported unused (#875). - if (node.type === "JSXMemberExpression") { - const objectNode = isWalkableNode(node.object) ? node.object : undefined; - const propertyNode = isWalkableNode(node.property) ? node.property : undefined; - if ( - objectNode?.type === "JSXIdentifier" && - typeof objectNode.name === "string" && - namespaceLocalNames.has(objectNode.name) && - typeof propertyNode?.name === "string" - ) { - memberAccesses.push({ - objectName: objectNode.name, - memberName: propertyNode.name, - }); - } - } - - if (node.type === "SpreadElement") { - const spreadArgumentName = getIdentifierName(node.argument); - if (spreadArgumentName && namespaceLocalNames.has(spreadArgumentName)) { - wholeObjectUses.push(spreadArgumentName); - } - } - - // `const { a, b } = ns` — destructuring a namespace import reads those - // members without a MemberExpression, so it would otherwise be invisible - // to the usage map and the destructured exports reported unused (#875). - if (node.type === "VariableDeclarator") { - const namespaceName = getIdentifierName(node.init); - if ( - namespaceName && - namespaceLocalNames.has(namespaceName) && - isWalkableNode(node.id) && - node.id.type === "ObjectPattern" && - Array.isArray(node.id.properties) - ) { - for (const property of node.id.properties.filter(isWalkableNode)) { - if (property.type === "RestElement") { - wholeObjectUses.push(namespaceName); - continue; - } - if (property.computed) { - wholeObjectUses.push(namespaceName); - } else if (isWalkableNode(property.key)) { - const propertyName = getIdentifierName(property.key); - if (propertyName) { - memberAccesses.push({ objectName: namespaceName, memberName: propertyName }); - } else if (property.key.type === "Literal" && typeof property.key.value === "string") { - memberAccesses.push({ objectName: namespaceName, memberName: property.key.value }); - } - } - } - } - } - - if (node.type === "ForInStatement") { - const rightName = getIdentifierName(node.right); - if (rightName && namespaceLocalNames.has(rightName)) { - wholeObjectUses.push(rightName); - } - } - - if (node.type === "CallExpression") { - const calleeMember = isWalkableNode(node.callee) ? node.callee : undefined; - if (calleeMember?.type === "MemberExpression" && !calleeMember.computed) { - const calleeObjectName = getIdentifierName(calleeMember.object); - const calleePropertyName = getIdentifierName(calleeMember.property); - if ( - calleeObjectName === "Object" && - calleePropertyName && - WHOLE_OBJECT_FUNCTION_NAMES.has(calleePropertyName) && - Array.isArray(node.arguments) - ) { - const firstArgumentName = getIdentifierName(node.arguments[0]); - if (firstArgumentName && namespaceLocalNames.has(firstArgumentName)) { - wholeObjectUses.push(firstArgumentName); - } - } - } - } - - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const element of value) { - if (isWalkableNode(element)) walkForMemberAccesses(element); - } - } else if (isWalkableNode(value)) { - walkForMemberAccesses(value); - } - } - }; - - for (const topLevelNode of bodyNodes) { - if (isWalkableNode(topLevelNode)) walkForMemberAccesses(topLevelNode); - } -}; - -const extractImportDeclaration = ( - node: ImportDeclaration, - sourceText: string, - imports: ImportReference[], -): void => { - const specifier = node.source.value; - if (!specifier) return; - - const isTypeOnly = node.importKind === "type"; - const importedNames: ImportBinding[] = []; - - for (const specifierNode of node.specifiers) { - switch (specifierNode.type) { - case "ImportDefaultSpecifier": { - importedNames.push({ - name: "default", - alias: specifierNode.local.name, - isNamespace: false, - isDefault: true, - isTypeOnly, - }); - break; - } - case "ImportNamespaceSpecifier": { - importedNames.push({ - name: "*", - alias: specifierNode.local.name, - isNamespace: true, - isDefault: false, - isTypeOnly, - }); - break; - } - case "ImportSpecifier": { - const importedName = getModuleExportNameValue(specifierNode.imported); - const localName = specifierNode.local.name; - const isSelfAlias = - localName === importedName && - specifierNode.imported.type === "Identifier" && - specifierNode.imported.start !== specifierNode.local.start; - - importedNames.push({ - name: importedName, - alias: localName !== importedName ? localName : undefined, - isNamespace: false, - isDefault: importedName === "default", - isTypeOnly: isTypeOnly || specifierNode.importKind === "type", - isRedundantAlias: isSelfAlias || undefined, - }); - break; - } - } - } - - const isSideEffectImport = importedNames.length === 0; - - if (isSideEffectImport) { - importedNames.push({ - name: "*", - alias: undefined, - isNamespace: false, - isDefault: false, - isTypeOnly: false, - }); - } - - imports.push({ - specifier, - importedNames, - isTypeOnly, - isDynamic: false, - isSideEffect: isSideEffectImport, - line: getLineFromOffset(sourceText, node.start), - column: getColumnFromOffset(sourceText, node.start), - }); -}; - -const extractNamedExportDeclaration = ( - node: ExportNamedDeclaration, - sourceText: string, - exports: ExportReference[], -): void => { - const isTypeOnly = node.exportKind === "type"; - const reExportSource = node.source?.value; - - if (node.declaration) { - extractDeclarationNames(node.declaration, isTypeOnly, sourceText, exports, node.start); - } - - for (const specifierNode of node.specifiers) { - const exportedName = getModuleExportNameValue(specifierNode.exported); - const localName = getModuleExportNameValue(specifierNode.local); - const isSelfAlias = - exportedName === localName && - specifierNode.exported.type === "Identifier" && - specifierNode.local.type === "Identifier" && - specifierNode.exported.start !== specifierNode.local.start; - - exports.push({ - name: exportedName, - isDefault: exportedName === "default", - isTypeOnly: isTypeOnly || specifierNode.exportKind === "type", - isReExport: reExportSource !== undefined, - isSynthetic: false, - reExportSource, - reExportOriginalName: reExportSource !== undefined ? localName : undefined, - isNamespaceReExport: false, - line: getLineFromOffset(sourceText, specifierNode.start ?? node.start), - column: getColumnFromOffset(sourceText, specifierNode.start ?? node.start), - isRedundantAlias: isSelfAlias || undefined, - }); - } -}; - -const extractDefaultExportDeclaration = ( - node: ExportDefaultDeclaration, - sourceText: string, - exports: ExportReference[], -): void => { - const defaultExportLocalName = extractDefaultExportLocalName(node.declaration); - - exports.push({ - name: "default", - isDefault: true, - isTypeOnly: false, - isReExport: false, - isSynthetic: false, - reExportSource: undefined, - reExportOriginalName: undefined, - isNamespaceReExport: false, - line: getLineFromOffset(sourceText, node.start), - column: getColumnFromOffset(sourceText, node.start), - defaultExportLocalName, - }); -}; - -const extractExportAllDeclaration = ( - node: ExportAllDeclaration, - sourceText: string, - exports: ExportReference[], -): void => { - const reExportSource = node.source.value; - if (!reExportSource) return; - - const exportedName = node.exported ? getModuleExportNameValue(node.exported) : undefined; - - exports.push({ - name: exportedName ?? "*", - isDefault: false, - isTypeOnly: node.exportKind === "type", - isReExport: true, - isSynthetic: false, - reExportSource, - reExportOriginalName: "*", - isNamespaceReExport: !exportedName, - line: getLineFromOffset(sourceText, node.start), - column: getColumnFromOffset(sourceText, node.start), - }); -}; - -const extractDeclarationNames = ( - declaration: Declaration, - isTypeOnly: boolean, - sourceText: string, - exports: ExportReference[], - fallbackStart: number, -): void => { - const declarationType = declaration.type; - - if ( - declarationType === "FunctionDeclaration" || - declarationType === "ClassDeclaration" || - declarationType === "TSEnumDeclaration" - ) { - const declarationWithId = declaration as { id: { name: string } | null; start: number }; - const declarationName = declarationWithId.id?.name; - if (declarationName) { - exports.push({ - name: declarationName, - isDefault: false, - isTypeOnly, - isReExport: false, - isSynthetic: false, - reExportSource: undefined, - reExportOriginalName: undefined, - isNamespaceReExport: false, - line: getLineFromOffset(sourceText, declaration.start ?? fallbackStart), - column: getColumnFromOffset(sourceText, declaration.start ?? fallbackStart), - }); - } - return; - } - - if ( - declarationType === "TSTypeAliasDeclaration" || - declarationType === "TSInterfaceDeclaration" - ) { - const typeDeclaration = declaration as { id: { name: string }; start: number }; - const declarationName = typeDeclaration.id.name; - if (declarationName) { - exports.push({ - name: declarationName, - isDefault: false, - isTypeOnly: true, - isReExport: false, - isSynthetic: false, - reExportSource: undefined, - reExportOriginalName: undefined, - isNamespaceReExport: false, - line: getLineFromOffset(sourceText, declaration.start ?? fallbackStart), - column: getColumnFromOffset(sourceText, declaration.start ?? fallbackStart), - }); - } - return; - } - - if (declarationType === "VariableDeclaration") { - const variableDeclaration = declaration as VariableDeclaration; - for (const declarator of variableDeclaration.declarations) { - const bindingNames = extractBindingPatternNames(declarator.id); - for (const bindingName of bindingNames) { - exports.push({ - name: bindingName, - isDefault: false, - isTypeOnly, - isReExport: false, - isSynthetic: false, - reExportSource: undefined, - reExportOriginalName: undefined, - isNamespaceReExport: false, - line: getLineFromOffset(sourceText, declarator.start ?? fallbackStart), - column: getColumnFromOffset(sourceText, declarator.start ?? fallbackStart), - }); - } - } - } -}; - -const extractBindingPatternNames = (pattern: BindingPattern): string[] => { - if (!pattern) return []; - - if (pattern.type === "Identifier") { - return pattern.name ? [pattern.name] : []; - } - - if (pattern.type === "ObjectPattern") { - const names: string[] = []; - for (const property of pattern.properties) { - if (property.type === "RestElement") { - names.push(...extractBindingPatternNames(property.argument)); - } else { - names.push(...extractBindingPatternNames(property.value)); - } - } - return names; - } - - if (pattern.type === "ArrayPattern") { - const names: string[] = []; - for (const element of pattern.elements) { - if (!element) continue; - if (element.type === "RestElement") { - names.push(...extractBindingPatternNames(element.argument)); - } else { - names.push(...extractBindingPatternNames(element)); - } - } - return names; - } - - if (pattern.type === "AssignmentPattern") { - return extractBindingPatternNames(pattern.left); - } - - return []; -}; - -const createNamespaceImportBinding = (): ImportBinding => ({ - name: "*", - alias: undefined, - isNamespace: true, - isDefault: false, - isTypeOnly: false, -}); - -interface WalkableNode { - type: string; - start: number; - end: number; - [key: string]: unknown; -} - -const isObjectRecord = (value: unknown): value is Record<string, unknown> => - value !== null && typeof value === "object"; - -const isWalkableNode = (value: unknown): value is WalkableNode => - isObjectRecord(value) && typeof value.type === "string"; - -const getTemplateCookedValues = (expression: WalkableNode): string[] | undefined => { - if (!Array.isArray(expression.quasis)) return undefined; - const cookedValues: string[] = []; - for (const quasi of expression.quasis) { - if ( - !isObjectRecord(quasi) || - !isObjectRecord(quasi.value) || - typeof quasi.value.cooked !== "string" - ) { - return undefined; - } - cookedValues.push(quasi.value.cooked); - } - return cookedValues; -}; - -const extractStringLiteralFromArgument = (callArguments: unknown): string | undefined => { - if (!Array.isArray(callArguments)) return undefined; - const firstArgument = callArguments[0]; - if (!isWalkableNode(firstArgument)) return undefined; - if (firstArgument.type === "SpreadElement") return undefined; - if (firstArgument.type !== "Literal") return undefined; - const literalValue = firstArgument.value; - return typeof literalValue === "string" ? literalValue : undefined; -}; - -const extractGlobPatterns = (callArguments: unknown): string[] => { - if (!Array.isArray(callArguments)) return []; - const firstArgument = callArguments[0]; - if (!isWalkableNode(firstArgument) || firstArgument.type === "SpreadElement") return []; - - if (firstArgument.type === "Literal") { - const literalValue = firstArgument.value; - if ( - typeof literalValue === "string" && - (literalValue.startsWith("./") || literalValue.startsWith("../")) - ) { - return [literalValue]; - } - return []; - } - - if (firstArgument.type === "ArrayExpression") { - if (!Array.isArray(firstArgument.elements)) return []; - return firstArgument.elements.flatMap((element) => { - if ( - !isWalkableNode(element) || - element.type !== "Literal" || - typeof element.value !== "string" || - (!element.value.startsWith("./") && !element.value.startsWith("../")) - ) { - return []; - } - return [element.value]; - }); - } - - return []; -}; - -interface RegexMetadata { - pattern: string; -} - -const isRegexMetadata = (value: unknown): value is RegexMetadata => - value !== null && - typeof value === "object" && - "pattern" in value && - typeof value.pattern === "string"; - -const extractRegexGlobSuffix = (callArguments: unknown): string | undefined => { - if (!Array.isArray(callArguments)) return undefined; - const thirdArgument = callArguments[2]; - if (!isWalkableNode(thirdArgument) || thirdArgument.type === "SpreadElement") return undefined; - if (thirdArgument.type !== "Literal") return undefined; - if (!isRegexMetadata(thirdArgument.regex)) return undefined; - const pattern = thirdArgument.regex.pattern; - const extensionMatch = pattern.match(/^\\\.([\w|]+)\$$/); - if (extensionMatch) { - const extensions = extensionMatch[1].split("|"); - if (extensions.length === 1) return `*.${extensions[0]}`; - return `*.{${extensions.join(",")}}`; - } - return undefined; -}; - -const hasMockFactoryArgument = (callArguments: unknown): boolean => { - if (!Array.isArray(callArguments)) return false; - const secondArgument = callArguments[1]; - if (!isWalkableNode(secondArgument)) return false; - if (secondArgument.type === "SpreadElement") return false; - return ( - secondArgument.type === "ArrowFunctionExpression" || - secondArgument.type === "FunctionExpression" - ); -}; - -const synthesizeAutoMockSibling = (mockSource: string): string | undefined => { - if ( - !mockSource || - mockSource.includes("://") || - mockSource.startsWith("data:") || - mockSource.split("/").some((segment) => segment === "__mocks__") - ) { - return undefined; - } - const lastSlashIndex = mockSource.lastIndexOf("/"); - if (lastSlashIndex === -1) return undefined; - const directory = mockSource.slice(0, lastSlashIndex); - const fileName = mockSource.slice(lastSlashIndex + 1); - if (!fileName) return undefined; - return `${directory}/__mocks__/${fileName}`; -}; - -const collectDynamicImports = ( - bodyNodes: Array<Statement | ModuleDeclaration>, - sourceText: string, - imports: ImportReference[], -): void => { - const walkNode = (node: WalkableNode): void => { - if (node.type === "ImportExpression") { - const sourceExpression = isWalkableNode(node.source) ? node.source : undefined; - if (!sourceExpression) return; - if (sourceExpression.type === "Literal") { - if (typeof sourceExpression.value === "string" && sourceExpression.value) { - imports.push({ - specifier: sourceExpression.value, - importedNames: [createNamespaceImportBinding()], - isTypeOnly: false, - isDynamic: true, - isSideEffect: false, - line: getLineFromOffset(sourceText, node.start), - column: getColumnFromOffset(sourceText, node.start), - }); - } - } else if (sourceExpression.type === "TemplateLiteral") { - const cookedValues = getTemplateCookedValues(sourceExpression); - if (cookedValues && cookedValues.length >= 2) { - const globPattern = cookedValues.join("*"); - if (globPattern.startsWith("./") || globPattern.startsWith("../")) { - imports.push({ - specifier: globPattern, - importedNames: [createNamespaceImportBinding()], - isTypeOnly: false, - isDynamic: true, - isSideEffect: false, - isGlob: true, - line: getLineFromOffset(sourceText, node.start), - column: getColumnFromOffset(sourceText, node.start), - }); - } - } - } - return; - } - - if (node.type === "CallExpression") { - const callee = isWalkableNode(node.callee) ? node.callee : undefined; - if (getIdentifierName(callee) === "require") { - const requireSpecifier = extractStringLiteralFromArgument(node.arguments); - if (requireSpecifier) { - imports.push({ - specifier: requireSpecifier, - importedNames: [createNamespaceImportBinding()], - isTypeOnly: false, - isDynamic: true, - isSideEffect: false, - line: getLineFromOffset(sourceText, node.start), - column: getColumnFromOffset(sourceText, node.start), - }); - } - } - - if (callee?.type === "MemberExpression" && !callee.computed) { - const objectName = getIdentifierName(callee.object); - const propertyName = getIdentifierName(callee.property); - - if (objectName === "require" && propertyName === "resolve") { - const resolveSpecifier = extractStringLiteralFromArgument(node.arguments); - if (resolveSpecifier) { - imports.push({ - specifier: resolveSpecifier, - importedNames: [createNamespaceImportBinding()], - isTypeOnly: false, - isDynamic: true, - isSideEffect: false, - line: getLineFromOffset(sourceText, node.start), - column: getColumnFromOffset(sourceText, node.start), - }); - } - } - - if ((objectName === "vi" || objectName === "jest") && propertyName === "mock") { - const mockSpecifier = extractStringLiteralFromArgument(node.arguments); - if (mockSpecifier) { - imports.push({ - specifier: mockSpecifier, - importedNames: [createNamespaceImportBinding()], - isTypeOnly: false, - isDynamic: true, - isSideEffect: true, - line: getLineFromOffset(sourceText, node.start), - column: getColumnFromOffset(sourceText, node.start), - }); - - const hasFactoryArgument = hasMockFactoryArgument(node.arguments); - const autoMockSibling = synthesizeAutoMockSibling(mockSpecifier); - if (!hasFactoryArgument && autoMockSibling) { - imports.push({ - specifier: autoMockSibling, - importedNames: [createNamespaceImportBinding()], - isTypeOnly: false, - isDynamic: true, - isSideEffect: true, - line: getLineFromOffset(sourceText, node.start), - column: getColumnFromOffset(sourceText, node.start), - }); - } - } - } - if ( - isWalkableNode(callee.object) && - callee.object.type === "MetaProperty" && - propertyName === "glob" - ) { - const globPatterns = extractGlobPatterns(node.arguments); - for (const globPattern of globPatterns) { - imports.push({ - specifier: globPattern, - importedNames: [createNamespaceImportBinding()], - isTypeOnly: false, - isDynamic: true, - isSideEffect: false, - isGlob: true, - line: getLineFromOffset(sourceText, node.start), - column: getColumnFromOffset(sourceText, node.start), - }); - } - } - - if (objectName === "require" && propertyName === "context") { - const directoryArgument = extractStringLiteralFromArgument(node.arguments); - if ( - directoryArgument && - (directoryArgument.startsWith("./") || directoryArgument.startsWith("../")) - ) { - const hasRegexArgument = - Array.isArray(node.arguments) && - node.arguments.length >= 3 && - isWalkableNode(node.arguments[2]) && - node.arguments[2].type !== "SpreadElement"; - const regexSuffix = extractRegexGlobSuffix(node.arguments); - const canResolveFilter = !hasRegexArgument || Boolean(regexSuffix); - if (canResolveFilter) { - const isRecursive = - Array.isArray(node.arguments) && - isWalkableNode(node.arguments[1]) && - node.arguments[1].type === "Literal" && - node.arguments[1].value === true; - const contextGlobPrefix = isRecursive - ? `${directoryArgument}/**/` - : `${directoryArgument}/`; - const contextGlobPattern = regexSuffix - ? `${contextGlobPrefix}${regexSuffix}` - : `${contextGlobPrefix}*`; - imports.push({ - specifier: contextGlobPattern, - importedNames: [createNamespaceImportBinding()], - isTypeOnly: false, - isDynamic: true, - isSideEffect: false, - isGlob: true, - line: getLineFromOffset(sourceText, node.start), - column: getColumnFromOffset(sourceText, node.start), - }); - } - } - } - } - } - - if (node.type === "NewExpression") { - const calleeName = getIdentifierName(node.callee); - if (calleeName === "URL" && Array.isArray(node.arguments) && node.arguments.length >= 2) { - const secondArgument = isWalkableNode(node.arguments[1]) ? node.arguments[1] : undefined; - const isImportMetaUrl = - secondArgument?.type === "MemberExpression" && - isWalkableNode(secondArgument.object) && - secondArgument.object.type === "MetaProperty" && - getIdentifierName(secondArgument.property) === "url"; - if (isImportMetaUrl) { - const urlSpecifier = extractStringLiteralFromArgument(node.arguments); - if (urlSpecifier) { - imports.push({ - specifier: urlSpecifier, - importedNames: [createNamespaceImportBinding()], - isTypeOnly: false, - isDynamic: true, - isSideEffect: true, - line: getLineFromOffset(sourceText, node.start), - column: getColumnFromOffset(sourceText, node.start), - }); - } - } - } - } - - if (node.type === "Decorator") { - const expression = isWalkableNode(node.expression) ? node.expression : undefined; - if ( - expression?.type === "CallExpression" && - getIdentifierName(expression.callee) === "Component" - ) { - const objectArgument = Array.isArray(expression.arguments) - ? expression.arguments[0] - : undefined; - if (isWalkableNode(objectArgument) && objectArgument.type === "ObjectExpression") { - const objectProperties = Array.isArray(objectArgument.properties) - ? objectArgument.properties.filter(isWalkableNode) - : []; - for (const property of objectProperties) { - if (property.type !== "ObjectProperty" && property.type !== "Property") continue; - const propertyKey = isWalkableNode(property.key) ? property.key : undefined; - const propertyName = getIdentifierName(propertyKey) ?? propertyKey?.value; - const propertyValue = isWalkableNode(property.value) ? property.value : undefined; - if ( - propertyName === "templateUrl" && - propertyValue?.type === "Literal" && - typeof propertyValue.value === "string" && - propertyValue.value - ) { - const templatePath = propertyValue.value; - imports.push({ - specifier: templatePath.startsWith(".") ? templatePath : `./${templatePath}`, - importedNames: [], - isTypeOnly: false, - isDynamic: false, - isSideEffect: true, - line: getLineFromOffset(sourceText, property.start), - column: getColumnFromOffset(sourceText, property.start), - }); - } - if ((propertyName === "styleUrl" || propertyName === "styleUrls") && propertyValue) { - const styleUrlValues: string[] = []; - if (propertyValue.type === "Literal" && typeof propertyValue.value === "string") { - styleUrlValues.push(propertyValue.value); - } else if ( - propertyValue.type === "ArrayExpression" && - Array.isArray(propertyValue.elements) - ) { - for (const element of propertyValue.elements) { - if ( - isWalkableNode(element) && - element.type === "Literal" && - typeof element.value === "string" - ) { - styleUrlValues.push(element.value); - } - } - } - for (const styleUrl of styleUrlValues) { - imports.push({ - specifier: styleUrl.startsWith(".") ? styleUrl : `./${styleUrl}`, - importedNames: [], - isTypeOnly: false, - isDynamic: false, - isSideEffect: true, - line: getLineFromOffset(sourceText, property.start), - column: getColumnFromOffset(sourceText, property.start), - }); - } - } - } - } - } - } - - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const element of value) { - if (isWalkableNode(element)) walkNode(element); - } - } else if (isWalkableNode(value)) { - walkNode(value); - } - } - }; - - for (const topLevelNode of bodyNodes) { - if (isWalkableNode(topLevelNode)) walkNode(topLevelNode); - } -}; - -const ROUTE_CALL_FILE_ARG_INDEX: Record<string, number> = { - route: 1, - layout: 0, - index: 0, -}; - -const extractStringFromExpression = (expression: WalkableNode): string | undefined => { - if (expression.type === "Literal") { - const literalValue = expression.value; - return typeof literalValue === "string" ? literalValue : undefined; - } - if (expression.type === "TemplateLiteral") { - const cookedValues = getTemplateCookedValues(expression); - if (Array.isArray(expression.expressions) && expression.expressions.length === 0) { - return cookedValues?.length === 1 ? cookedValues[0] : undefined; - } - } - return undefined; -}; - -export const extractReactRouterRouteModuleEntries = (routesFilePath: string): string[] => { - const sourceText = readFileSync(routesFilePath, "utf-8"); - const result = parseSync(routesFilePath, sourceText); - - if (result.errors.length > 0 || !result.program?.body) { - return []; - } - - const modulePaths: string[] = []; - - const walkForRouteCalls = (node: WalkableNode): void => { - if (node.type === "CallExpression") { - const calleeName = getIdentifierName(node.callee); - if (calleeName) { - const fileArgumentIndex = ROUTE_CALL_FILE_ARG_INDEX[calleeName]; - - if (fileArgumentIndex !== undefined && Array.isArray(node.arguments)) { - const fileArgument = node.arguments[fileArgumentIndex]; - if (isWalkableNode(fileArgument) && fileArgument.type !== "SpreadElement") { - const filePath = extractStringFromExpression(fileArgument); - if (filePath) { - modulePaths.push(filePath); - } - } - } - } - } - - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const element of value) { - if (isWalkableNode(element)) walkForRouteCalls(element); - } - } else if (isWalkableNode(value)) { - walkForRouteCalls(value); - } - } - }; - - for (const topLevelNode of result.program.body) { - if (isWalkableNode(topLevelNode)) walkForRouteCalls(topLevelNode); - } - - return modulePaths; -}; diff --git a/packages/deslop-js/src/collect/sibling-workspace-import-entries.ts b/packages/deslop-js/src/collect/sibling-workspace-import-entries.ts deleted file mode 100644 index 43d94f96a2..0000000000 --- a/packages/deslop-js/src/collect/sibling-workspace-import-entries.ts +++ /dev/null @@ -1,83 +0,0 @@ -import fg from "fast-glob"; -import { join } from "node:path"; -import { readFileSync } from "node:fs"; -import { findMonorepoRoot } from "../utils/find-monorepo-root.js"; -import { resolveWorkspaces } from "./workspaces.js"; -import { resolveWorkspaceSubpath, trySourceFallback } from "../resolver/resolve.js"; - -const IMPORT_SPECIFIER_PATTERN = - /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|\bimport\s+)["']([^"'\n]+)["']/g; - -const SIBLING_SOURCE_GLOB = "**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}"; - -const SIBLING_IGNORE_PATTERNS = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.git/**"]; - -const readPackageName = (directory: string): string | undefined => { - try { - const content = readFileSync(join(directory, "package.json"), "utf-8"); - const packageJson = JSON.parse(content); - return typeof packageJson.name === "string" ? packageJson.name : undefined; - } catch { - return undefined; - } -}; - -const extractImportSpecifiers = (sourceText: string): string[] => { - const specifiers: string[] = []; - for (const specifierMatch of sourceText.matchAll(IMPORT_SPECIFIER_PATTERN)) { - specifiers.push(specifierMatch[1]); - } - return specifiers; -}; - -export const extractSiblingWorkspaceImportEntries = (absoluteRoot: string): string[] => { - const monorepoRoot = findMonorepoRoot(absoluteRoot); - if (!monorepoRoot || monorepoRoot === absoluteRoot) return []; - - const packageName = readPackageName(absoluteRoot); - if (!packageName) return []; - - const siblingDirectories = resolveWorkspaces(monorepoRoot) - .packages.map((workspacePackage) => workspacePackage.directory) - .filter( - (workspaceDirectory) => - workspaceDirectory !== absoluteRoot && - !workspaceDirectory.startsWith(`${absoluteRoot}/`) && - !absoluteRoot.startsWith(`${workspaceDirectory}/`), - ); - if (siblingDirectories.length === 0) return []; - - const importedEntries: string[] = []; - for (const siblingDirectory of siblingDirectories) { - const siblingSourceFiles = fg.sync(SIBLING_SOURCE_GLOB, { - cwd: siblingDirectory, - absolute: true, - onlyFiles: true, - ignore: SIBLING_IGNORE_PATTERNS, - }); - - for (const siblingSourceFile of siblingSourceFiles) { - let sourceText: string; - try { - sourceText = readFileSync(siblingSourceFile, "utf-8"); - } catch { - continue; - } - if (!sourceText.includes(packageName)) continue; - - for (const importSpecifier of extractImportSpecifiers(sourceText)) { - if (importSpecifier !== packageName && !importSpecifier.startsWith(`${packageName}/`)) { - continue; - } - const subpath = importSpecifier.slice(packageName.length + 1); - if (!subpath) continue; - const resolvedEntry = resolveWorkspaceSubpath(absoluteRoot, subpath); - if (resolvedEntry) { - importedEntries.push(trySourceFallback(resolvedEntry) ?? resolvedEntry); - } - } - } - } - - return [...new Set(importedEntries)]; -}; diff --git a/packages/deslop-js/src/config.ts b/packages/deslop-js/src/config.ts deleted file mode 100644 index 7c5b3acd0a..0000000000 --- a/packages/deslop-js/src/config.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { resolve } from "node:path"; -import { - DEFAULT_COGNITIVE_THRESHOLD, - DEFAULT_CYCLOMATIC_THRESHOLD, - DEFAULT_DUPLICATE_BLOCK_MIN_LINES, - DEFAULT_DUPLICATE_BLOCK_MIN_OCCURRENCES, - DEFAULT_DUPLICATE_BLOCK_MIN_TOKENS, - DEFAULT_ENTRY_GLOBS, - DEFAULT_EXTENSIONS, - DEFAULT_FUNCTION_LINE_THRESHOLD, - DEFAULT_PARAM_COUNT_THRESHOLD, - DEFAULT_SEMANTIC_DECORATOR_ALLOWLIST, -} from "./constants.js"; -import type { DeslopConfig } from "./types.js"; - -const fillSemanticConfig = ( - semanticOverrides: Partial<DeslopConfig["semantic"]> | undefined, -): DeslopConfig["semantic"] => { - const overrides = semanticOverrides ?? {}; - return { - enabled: overrides.enabled ?? true, - reportUnusedTypes: overrides.reportUnusedTypes ?? true, - reportUnusedEnumMembers: overrides.reportUnusedEnumMembers ?? true, - reportUnusedClassMembers: overrides.reportUnusedClassMembers ?? false, - reportRedundantVariableAliases: overrides.reportRedundantVariableAliases ?? true, - reportMisclassifiedDependencies: overrides.reportMisclassifiedDependencies ?? true, - reportRoundTripAliases: overrides.reportRoundTripAliases ?? true, - decoratorAllowlist: overrides.decoratorAllowlist ?? DEFAULT_SEMANTIC_DECORATOR_ALLOWLIST, - }; -}; - -const fillDuplicateBlocksConfig = ( - duplicateBlocksOverrides: Partial<DeslopConfig["duplicateBlocks"]> | undefined, -): DeslopConfig["duplicateBlocks"] => { - const overrides = duplicateBlocksOverrides ?? {}; - return { - enabled: overrides.enabled ?? true, - mode: overrides.mode ?? "semantic", - minTokens: overrides.minTokens ?? DEFAULT_DUPLICATE_BLOCK_MIN_TOKENS, - minLines: overrides.minLines ?? DEFAULT_DUPLICATE_BLOCK_MIN_LINES, - minOccurrences: overrides.minOccurrences ?? DEFAULT_DUPLICATE_BLOCK_MIN_OCCURRENCES, - skipLocal: overrides.skipLocal ?? false, - }; -}; - -const fillFeatureFlagsConfig = ( - featureFlagOverrides: Partial<DeslopConfig["featureFlags"]> | undefined, -): DeslopConfig["featureFlags"] => { - const overrides = featureFlagOverrides ?? {}; - return { - enabled: overrides.enabled ?? true, - extraEnvPrefixes: overrides.extraEnvPrefixes ?? [], - extraSdkFunctionNames: overrides.extraSdkFunctionNames ?? [], - detectConfigObjects: overrides.detectConfigObjects ?? false, - }; -}; - -const fillComplexityConfig = ( - complexityOverrides: Partial<DeslopConfig["complexity"]> | undefined, -): DeslopConfig["complexity"] => { - const overrides = complexityOverrides ?? {}; - return { - enabled: overrides.enabled ?? true, - cyclomaticThreshold: overrides.cyclomaticThreshold ?? DEFAULT_CYCLOMATIC_THRESHOLD, - cognitiveThreshold: overrides.cognitiveThreshold ?? DEFAULT_COGNITIVE_THRESHOLD, - paramCountThreshold: overrides.paramCountThreshold ?? DEFAULT_PARAM_COUNT_THRESHOLD, - functionLineThreshold: overrides.functionLineThreshold ?? DEFAULT_FUNCTION_LINE_THRESHOLD, - }; -}; - -export const defineConfig = ( - options: Partial<DeslopConfig> & { rootDir: string }, -): DeslopConfig => ({ - rootDir: resolve(options.rootDir), - entryPatterns: options.entryPatterns ?? DEFAULT_ENTRY_GLOBS, - ignorePatterns: options.ignorePatterns ?? [], - includeExtensions: options.includeExtensions ?? DEFAULT_EXTENSIONS, - tsConfigPath: options.tsConfigPath, - paths: options.paths, - incrementalCachePath: options.incrementalCachePath, - reportTypes: options.reportTypes ?? false, - includeEntryExports: options.includeEntryExports ?? false, - reportRedundancy: options.reportRedundancy ?? true, - reportCodeQuality: options.reportCodeQuality ?? true, - semantic: fillSemanticConfig(options.semantic), - duplicateBlocks: fillDuplicateBlocksConfig(options.duplicateBlocks), - featureFlags: fillFeatureFlagsConfig(options.featureFlags), - complexity: fillComplexityConfig(options.complexity), -}); diff --git a/packages/deslop-js/src/constants.ts b/packages/deslop-js/src/constants.ts deleted file mode 100644 index 527d00f629..0000000000 --- a/packages/deslop-js/src/constants.ts +++ /dev/null @@ -1,438 +0,0 @@ -export const DEFAULT_EXTENSIONS = [ - ".ts", - ".tsx", - ".js", - ".jsx", - ".mts", - ".mjs", - ".cts", - ".cjs", - ".mdx", - ".astro", - ".graphql", - ".gql", - ".css", - ".scss", - ".vue", - ".svelte", -]; - -export const STANDALONE_PROJECT_LOCKFILES = [ - "package-lock.json", - "yarn.lock", - "pnpm-lock.yaml", - "bun.lockb", -]; - -export const MONOREPO_ROOT_MARKERS = [ - "pnpm-workspace.yaml", - "pnpm-workspace.yml", - "lerna.json", - "nx.json", - "turbo.json", - "rush.json", -]; - -export const LOCKFILE_MARKERS = [ - "pnpm-lock.yaml", - "yarn.lock", - "package-lock.json", - "bun.lockb", - "bun.lock", -]; - -// Every non-source file name the analysis reads, assembled from the same -// constants the readers consume (plus the names read individually by -// `collect/workspaces.ts`, `collect/entries.ts`, -// `collect/expo-config-plugin-entries.ts`, and — via `git check-ignore` — -// `utils/collect-git-ignored-paths.ts`). Exported through -// `deslop-js/analyzed-inputs` so external result caches can fingerprint -// exactly the files a pass depends on; extend this list whenever a reader -// starts consuming a new manifest name. -export const ANALYZED_MANIFEST_FILENAMES = [ - ...new Set([ - "package.json", - "pnpm-workspace.yaml", - "lerna.json", - "app.json", - "ng-package.json", - ".gitignore", - ...STANDALONE_PROJECT_LOCKFILES, - ...MONOREPO_ROOT_MARKERS, - ...LOCKFILE_MARKERS, - ]), -]; - -export const HIDDEN_DIRECTORY_ALLOWLIST = [ - ".storybook", - ".vitepress", - ".well-known", - ".changeset", - ".github", - ".client", - ".server", -]; - -export const OUTPUT_DIRECTORIES = ["dist", "build", "out", "esm", "cjs"]; - -export const SOURCE_EXTENSIONS = ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"]; - -export const DEFAULT_EXCLUSIONS = [ - "**/node_modules/**", - "**/.git/**", - "**/coverage/**", - "**/*.min.js", - "**/*.min.mjs", - "**/mockServiceWorker.js", -]; - -export const SCRIPT_FILE_PATTERN = - /(?:^|\s)(?:node|tsx|ts-node|tsc|npx|bun|esr|esno|jiti|babel-node|zx)\s+(?:\S+\s+)*?([\w./@-]+\.(?:ts|tsx|js|jsx|mts|mjs|cts|cjs))(?:\s|$)/; - -export const SCRIPT_EXTENSIONLESS_FILE_PATTERN = - /(?:^|\s)(?:node|tsx|ts-node|bun|esr|esno|jiti|babel-node|zx)\s+(?:\S+\s+)*?((?:[./]|[\w@][\w@-]*\/)[\w./@-]+)(?:\s|$)/; - -export const SCRIPT_CONFIG_FILE_PATTERN = - /--config\s+([\w./@-]+\.(?:ts|tsx|js|jsx|mts|mjs|cts|cjs))/; - -export const DEFAULT_ENTRY_GLOBS = [ - "src/index.{ts,tsx,js,jsx}", - "src/main.{ts,tsx,js,jsx}", - "index.{ts,tsx,js,jsx}", - "main.{ts,tsx,js,jsx}", -]; - -export const EXPO_CONFIG_SCAN_MAX_DEPTH = 6; - -export const KNOWN_CONFIG_PREFIXES = [ - "babel.config.", - "rollup.config.", - "webpack.config.", - "postcss.config.", - "stencil.config.", - "remotion.config.", - "metro.config.", - "tsup.config.", - "tsdown.config.", - "unbuild.config.", - "esbuild.config.", - "swc.config.", - "turbo.", - "jest.config.", - "jest.setup.", - "vitest.config.", - "vitest.ci.config.", - "vitest.setup.", - "vitest.workspace.", - "playwright.config.", - "cypress.config.", - "karma.conf.", - "eslint.config.", - "prettier.config.", - "stylelint.config.", - "lint-staged.config.", - "commitlint.config.", - "next.config.", - "next-sitemap.config.", - "nuxt.config.", - "astro.config.", - "sanity.config.", - "vite.config.", - "tailwind.config.", - "drizzle.config.", - "knexfile.", - "sentry.client.config.", - "sentry.server.config.", - "sentry.edge.config.", - "react-router.config.", - "typedoc.", - "deslop.config.", - "i18next-parser.config.", - "codegen.config.", - "graphql.config.", - "npmpackagejsonlint.config.", - "release-it.", - "release.config.", - "contentlayer.config.", - "rspack.config.", - "rsbuild.config.", - "module-federation.config.", - "vercel.", - "next-env.d.", - "env.d.", - "vite-env.d.", -]; - -export const IMPLICIT_DEPENDENCIES = new Set([ - "typescript", - "@types/node", - "@types/react", - "@types/react-dom", - "eslint", - "prettier", - "husky", - "lint-staged", - "tslib", - "@babel/core", - "@babel/runtime", - "babel-core", - "babel-jest", - "babel-loader", - "postcss", - "cross-env", - "sass", - "node-sass", - "less", - "oxlint", - "biome", - "@biomejs/biome", - "patch-package", - "simple-git-hooks", - "lefthook", - "ts-node", - "ts-jest", - "tsx", - "jsdom", - "rimraf", - "concurrently", - "npm-run-all", - "npm-run-all2", - "dotenv-cli", - "webpack", - "rollup", - "terser", - "autoprefixer", - "tailwindcss", - "react-test-renderer", - "esbuild", - "typedoc", - "commitizen", - "cz-conventional-changelog", -]); - -export const BUILTIN_MODULES = new Set([ - "assert", - "async_hooks", - "buffer", - "child_process", - "cluster", - "console", - "constants", - "crypto", - "dgram", - "diagnostics_channel", - "dns", - "domain", - "events", - "fs", - "http", - "http2", - "https", - "inspector", - "module", - "net", - "os", - "path", - "perf_hooks", - "process", - "punycode", - "querystring", - "readline", - "repl", - "stream", - "string_decoder", - "sys", - "timers", - "tls", - "trace_events", - "tty", - "url", - "util", - "v8", - "vm", - "wasi", - "worker_threads", - "zlib", -]); - -export const PLATFORM_SUFFIXES = [ - ".web", - ".react-native", - ".native", - ".ios", - ".android", - ".desktop", - ".windows", - ".macos", - ".any", - ".react-server", - ".server", - ".client", -]; - -export const REACT_NATIVE_PLATFORM_EXTENSIONS = [ - ".web.ts", - ".web.tsx", - ".web.js", - ".web.jsx", - ".native.ts", - ".native.tsx", - ".native.js", - ".native.jsx", - ".ios.ts", - ".ios.tsx", - ".ios.js", - ".ios.jsx", - ".android.ts", - ".android.tsx", - ".android.js", - ".android.jsx", -]; - -export const RESOLVER_EXTENSIONS = [ - ...DEFAULT_EXTENSIONS, - ".d.ts", - ".d.mts", - ".d.cts", - ".json", - ".node", - ".css", - ".scss", - ".less", - ".svg", - ".png", - ".jpg", - ".graphql", - ".gql", -]; - -export const SHALLOW_WORKSPACE_MAX_DEPTH = 2; - -export const MAX_CYCLES_PER_SCC = 20; - -export const MAX_TOTAL_CYCLES = 200; - -export const MAX_SCC_SIZE_FOR_ENUMERATION = 50; - -export const SEMANTIC_MAX_PROGRAM_FILES = 5000; - -export const MAX_PARSE_FILE_SIZE_BYTES = 2_000_000; - -export const MAX_AST_WALK_DEPTH = 200; - -export const MAX_TYPE_REFERENCE_WALK_DEPTH = 6; - -export const MAX_EXPRESSION_DETECTOR_WALK_DEPTH = 100; - -export const MAX_FUNCTION_BODY_INSPECT_DEPTH = 30; - -export const MAX_TYPE_CONTEXT_PARENT_WALK = 12; - -export const MAX_ANALYSIS_ERRORS = 5000; - -export const MAX_ERROR_DETAIL_LENGTH = 1000; - -// Bumped to 2: per-file entries carry a content-hash repair witness (`h`). -export const SUMMARY_CACHE_SCHEMA_VERSION = 4; - -export const SUMMARY_CACHE_MAX_BYTES = 256 * 1024 * 1024; - -export const BINARY_DETECTION_SAMPLE_BYTES = 2048; - -export const BINARY_DETECTION_NULL_BYTE_THRESHOLD = 4; - -export const MINIFIED_DETECTION_MIN_BYTES = 5000; - -export const MINIFIED_DETECTION_AVG_LINE_LENGTH_THRESHOLD = 500; - -export const MIN_FILES_FOR_DUPLICATE_CONSTANT = 3; - -export const MIN_PROPERTIES_FOR_INLINE_TYPE_LITERAL = 3; - -/** - * Strings shorter than this are mostly noise (`""`, `"id"`, `"name"`, - * single-word config keys) and trigger many cross-file coincidental matches - * that aren't real DRY violations. 8 chars roughly excludes single common - * words but still catches URLs, error codes, and identifiers worth extracting. - * Tuned for low FP rate, not corpus-tuned to a specific metric target. - */ -export const MIN_STRING_LITERAL_LENGTH_FOR_DUPLICATE = 8; - -/** - * Numeric literals below 1000 are dominated by indices, counters, small - * ranges, ports, percentages, and array sizes that coincide by accident - * (every `MAX_RETRIES = 3` is not a duplicate of every `LIMIT = 3`). - * 1000 admits real shared constants (timeouts in ms, byte sizes, polling - * intervals) without producing the noise floor that smaller magnitudes do. - * NOTE: even at 1000, the rule still produces medium-confidence false - * positives when constants share a value coincidentally with different - * names (e.g. `STEP_DELAY_MS` vs `MINIMUM_TOKENS`); the report explicitly - * downgrades those to `confidence: "medium"`. - */ -export const MIN_NUMERIC_LITERAL_MAGNITUDE_FOR_DUPLICATE = 1000; - -export const INLINE_TYPE_PREVIEW_KEYS = 4; - -export const SIMPLIFIABLE_EXPRESSION_MEMBER_ACCESS_DEPTH = 6; - -export const DUPLICATE_INLINE_TYPE_HIGH_MEMBER_COUNT = 5; - -export const SEMANTIC_TRACE_MAX_ENTRIES = 5; - -export const DEFAULT_DUPLICATE_BLOCK_MIN_TOKENS = 50; - -export const DEFAULT_DUPLICATE_BLOCK_MIN_LINES = 5; - -export const DEFAULT_DUPLICATE_BLOCK_MIN_OCCURRENCES = 2; - -export const DUPLICATE_BLOCK_MODULE_EXTRACTION_THRESHOLD_LINES = 50; - -export const SHADOWED_DIRECTORY_MIN_CLUSTERS = 3; - -export const DEFAULT_SEMANTIC_DECORATOR_ALLOWLIST = [ - "Component", - "Injectable", - "NgModule", - "Pipe", - "Directive", - "Controller", - "Module", - "Resolver", - "Query", - "Mutation", - "Get", - "Post", - "Put", - "Patch", - "Delete", - "Head", - "Options", - "All", - "Sse", - "WebSocketGateway", - "SubscribeMessage", -]; - -export const DEFAULT_SEMANTIC_TSCONFIG_NAMES = [ - "tsconfig.json", - "tsconfig.app.json", - "tsconfig.build.json", - "tsconfig.src.json", - "jsconfig.json", -]; - -export const DEFAULT_CYCLOMATIC_THRESHOLD = 10; - -export const DEFAULT_COGNITIVE_THRESHOLD = 15; - -export const DEFAULT_PARAM_COUNT_THRESHOLD = 5; - -export const DEFAULT_FUNCTION_LINE_THRESHOLD = 80; - -export const PARALLEL_PARSE_FILE_THRESHOLD = 50; - -export const MIN_PARSE_CONCURRENCY = 1; - -export const MAX_PARSE_CONCURRENCY = 16; - -export const GIT_CHECK_IGNORE_MAX_BUFFER_BYTES = 10 * 1024 * 1024; diff --git a/packages/deslop-js/src/duplicate-blocks/clusters.ts b/packages/deslop-js/src/duplicate-blocks/clusters.ts deleted file mode 100644 index 7f4e79e044..0000000000 --- a/packages/deslop-js/src/duplicate-blocks/clusters.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { DUPLICATE_BLOCK_MODULE_EXTRACTION_THRESHOLD_LINES } from "../constants.js"; -import type { - DuplicateBlock, - DuplicateBlockCluster, - DuplicateBlockRefactoringHint, -} from "../types.js"; - -interface ClusterBucket { - files: string[]; - blocks: DuplicateBlock[]; -} - -const baseName = (filePath: string): string => { - const trailingSlashIndex = filePath.lastIndexOf("/"); - return trailingSlashIndex === -1 ? filePath : filePath.slice(trailingSlashIndex + 1); -}; - -const buildSuggestions = ( - files: string[], - blocks: DuplicateBlock[], - totalDuplicatedLines: number, -): DuplicateBlockRefactoringHint[] => { - const fileBaseNames = files.map((filePath) => baseName(filePath)); - const isCrossFile = files.length >= 2; - - if (isCrossFile && totalDuplicatedLines >= DUPLICATE_BLOCK_MODULE_EXTRACTION_THRESHOLD_LINES) { - const estimatedSavings = blocks.reduce( - (runningSum, block) => runningSum + block.lineCount * Math.max(0, block.instances.length - 1), - 0, - ); - return [ - { - kind: "extract-module", - description: `Extract ${blocks.length} shared duplicate block${ - blocks.length === 1 ? "" : "s" - } (${totalDuplicatedLines} lines) from ${fileBaseNames.join(", ")} into a shared module`, - estimatedSavings, - }, - ]; - } - - return blocks.map((block) => ({ - kind: "extract-function", - description: `Extract shared function (${block.lineCount} lines) from ${fileBaseNames.join(", ")}`, - estimatedSavings: block.lineCount * Math.max(0, block.instances.length - 1), - })); -}; - -export const groupDuplicateBlocksIntoClusters = ( - duplicateBlocks: DuplicateBlock[], -): DuplicateBlockCluster[] => { - if (duplicateBlocks.length === 0) return []; - - const fileSetKeyToBucket = new Map<string, ClusterBucket>(); - for (const block of duplicateBlocks) { - const sortedFiles = [...new Set(block.instances.map((instance) => instance.path))].sort(); - const fileSetKey = sortedFiles.join("|"); - const existing = fileSetKeyToBucket.get(fileSetKey); - if (existing) { - existing.blocks.push(block); - } else { - fileSetKeyToBucket.set(fileSetKey, { files: sortedFiles, blocks: [block] }); - } - } - - const clusters: DuplicateBlockCluster[] = []; - for (const bucket of fileSetKeyToBucket.values()) { - const totalDuplicatedLines = bucket.blocks.reduce( - (runningSum, block) => runningSum + block.lineCount, - 0, - ); - const totalDuplicatedTokens = bucket.blocks.reduce( - (runningSum, block) => runningSum + block.tokenCount, - 0, - ); - clusters.push({ - files: bucket.files, - groups: bucket.blocks, - totalDuplicatedLines, - totalDuplicatedTokens, - suggestions: buildSuggestions(bucket.files, bucket.blocks, totalDuplicatedLines), - }); - } - - clusters.sort((leftCluster, rightCluster) => { - if (leftCluster.totalDuplicatedLines !== rightCluster.totalDuplicatedLines) { - return rightCluster.totalDuplicatedLines - leftCluster.totalDuplicatedLines; - } - return rightCluster.groups.length - leftCluster.groups.length; - }); - return clusters; -}; diff --git a/packages/deslop-js/src/duplicate-blocks/concatenate.ts b/packages/deslop-js/src/duplicate-blocks/concatenate.ts deleted file mode 100644 index 995a231595..0000000000 --- a/packages/deslop-js/src/duplicate-blocks/concatenate.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { HashedToken } from "./token-types.js"; - -export interface ConcatenationResult { - tokenSequence: number[]; - fileOf: number[]; - fileOffsets: number[]; -} - -const SENTINEL_FILE_INDEX = Number.MAX_SAFE_INTEGER; - -/** - * Rank-reduce token hashes to dense 0..K-1 integers and concatenate every - * file's reduced sequence with a unique negative sentinel between files. Dense - * ranks shrink the suffix-array's bucket counters from ~4 billion to a few - * thousand (the standard prefix-doubling speedup), and negative sentinels - * guarantee no real-token suffix can match across a file boundary. - */ -export const rankReduceAndConcatenate = ( - filesHashedTokens: HashedToken[][], -): ConcatenationResult => { - const uniqueHashes = new Set<number>(); - for (const fileTokens of filesHashedTokens) { - for (const hashedToken of fileTokens) uniqueHashes.add(hashedToken.hash); - } - const sortedUniqueHashes = [...uniqueHashes].sort((leftHash, rightHash) => leftHash - rightHash); - const hashToRank = new Map<number, number>(); - for (let rankIndex = 0; rankIndex < sortedUniqueHashes.length; rankIndex++) { - hashToRank.set(sortedUniqueHashes[rankIndex], rankIndex + 1); - } - - const totalTokens = filesHashedTokens.reduce( - (runningSum, fileTokens) => runningSum + fileTokens.length, - 0, - ); - const sentinelCount = Math.max(0, filesHashedTokens.length - 1); - const sequenceLength = totalTokens + sentinelCount; - - const tokenSequence: number[] = new Array(sequenceLength); - const fileOf: number[] = new Array(sequenceLength); - const fileOffsets: number[] = new Array(filesHashedTokens.length); - - let writeCursor = 0; - let nextSentinelValue = -1; - - for (let fileIndex = 0; fileIndex < filesHashedTokens.length; fileIndex++) { - fileOffsets[fileIndex] = writeCursor; - const fileTokens = filesHashedTokens[fileIndex]; - for (const hashedToken of fileTokens) { - tokenSequence[writeCursor] = hashToRank.get(hashedToken.hash) ?? 0; - fileOf[writeCursor] = fileIndex; - writeCursor++; - } - if (fileIndex < filesHashedTokens.length - 1) { - tokenSequence[writeCursor] = nextSentinelValue; - fileOf[writeCursor] = SENTINEL_FILE_INDEX; - writeCursor++; - nextSentinelValue--; - } - } - - return { tokenSequence, fileOf, fileOffsets }; -}; - -export const SENTINEL_FILE_MARKER = SENTINEL_FILE_INDEX; diff --git a/packages/deslop-js/src/duplicate-blocks/extract.ts b/packages/deslop-js/src/duplicate-blocks/extract.ts deleted file mode 100644 index 8363ee9dcc..0000000000 --- a/packages/deslop-js/src/duplicate-blocks/extract.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { SENTINEL_FILE_MARKER } from "./concatenate.js"; - -export interface RawDuplicateBlockOccurrence { - fileIndex: number; - tokenOffsetWithinFile: number; -} - -export interface RawDuplicateBlock { - instances: RawDuplicateBlockOccurrence[]; - tokenLength: number; -} - -interface MonotoneStackEntry { - lcpValue: number; - startIndex: number; -} - -const buildRawBlock = ( - suffixArray: number[], - fileOf: number[], - fileOffsets: number[], - filesTokenCounts: number[], - intervalBegin: number, - intervalEnd: number, - tokenLength: number, -): RawDuplicateBlock | undefined => { - const candidateInstances: RawDuplicateBlockOccurrence[] = []; - for (let suffixIndex = intervalBegin; suffixIndex < intervalEnd; suffixIndex++) { - const startPosition = suffixArray[suffixIndex]; - const fileIndex = fileOf[startPosition]; - if (fileIndex === SENTINEL_FILE_MARKER) continue; - const tokenOffsetWithinFile = startPosition - fileOffsets[fileIndex]; - if (tokenOffsetWithinFile + tokenLength > filesTokenCounts[fileIndex]) continue; - candidateInstances.push({ fileIndex, tokenOffsetWithinFile }); - } - - if (candidateInstances.length < 2) return undefined; - - candidateInstances.sort((leftInstance, rightInstance) => { - if (leftInstance.fileIndex !== rightInstance.fileIndex) { - return leftInstance.fileIndex - rightInstance.fileIndex; - } - return leftInstance.tokenOffsetWithinFile - rightInstance.tokenOffsetWithinFile; - }); - - const dedupedInstances: RawDuplicateBlockOccurrence[] = []; - for (const instance of candidateInstances) { - const lastInstance = dedupedInstances[dedupedInstances.length - 1]; - const isOverlappingInSameFile = - lastInstance !== undefined && - lastInstance.fileIndex === instance.fileIndex && - instance.tokenOffsetWithinFile < lastInstance.tokenOffsetWithinFile + tokenLength; - if (isOverlappingInSameFile) continue; - dedupedInstances.push(instance); - } - - if (dedupedInstances.length < 2) return undefined; - return { instances: dedupedInstances, tokenLength }; -}; - -/** - * Walks `lcpArray` with a monotone stack to materialize every maximal - * interval `[i, j]` whose minimum LCP is >= `minTokens`. Within-file - * overlapping occurrences are dropped (keep the earliest non-overlapping - * prefix), and any block left with fewer than two occurrences is discarded. - */ -export const extractRawDuplicateBlocks = ( - suffixArray: number[], - lcpArray: number[], - fileOf: number[], - fileOffsets: number[], - filesTokenCounts: number[], - minTokens: number, -): RawDuplicateBlock[] => { - const sequenceLength = suffixArray.length; - if (sequenceLength < 2) return []; - - const rawBlocks: RawDuplicateBlock[] = []; - const monotoneStack: MonotoneStackEntry[] = []; - - for (let scanIndex = 1; scanIndex <= sequenceLength; scanIndex++) { - const currentLcp = scanIndex < sequenceLength ? lcpArray[scanIndex] : 0; - let intervalStart = scanIndex; - - while ( - monotoneStack.length > 0 && - monotoneStack[monotoneStack.length - 1].lcpValue > currentLcp - ) { - const popped = monotoneStack.pop()!; - intervalStart = popped.startIndex; - if (popped.lcpValue >= minTokens) { - const candidate = buildRawBlock( - suffixArray, - fileOf, - fileOffsets, - filesTokenCounts, - intervalStart - 1, - scanIndex, - popped.lcpValue, - ); - if (candidate) rawBlocks.push(candidate); - } - } - - if (scanIndex < sequenceLength) { - monotoneStack.push({ lcpValue: currentLcp, startIndex: intervalStart }); - } - } - - return rawBlocks; -}; diff --git a/packages/deslop-js/src/duplicate-blocks/index.ts b/packages/deslop-js/src/duplicate-blocks/index.ts deleted file mode 100644 index ae6ae63d62..0000000000 --- a/packages/deslop-js/src/duplicate-blocks/index.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { readFileSync, statSync } from "node:fs"; -import { dirname } from "node:path"; -import { parseSync } from "oxc-parser"; -import { - MAX_PARSE_FILE_SIZE_BYTES, - BINARY_DETECTION_NULL_BYTE_THRESHOLD, - BINARY_DETECTION_SAMPLE_BYTES, - MINIFIED_DETECTION_AVG_LINE_LENGTH_THRESHOLD, - MINIFIED_DETECTION_MIN_BYTES, -} from "../constants.js"; -import type { - DuplicateBlock, - DuplicateBlockCluster, - DuplicateBlockOccurrence, - DuplicateBlocksConfig, - DependencyGraph, - ShadowedDirectoryPair, -} from "../types.js"; -import { computeLineStarts } from "../utils/compute-line-starts.js"; -import { offsetToLineColumn } from "../utils/offset-to-line-column.js"; -import { rankReduceAndConcatenate } from "./concatenate.js"; -import { extractRawDuplicateBlocks, type RawDuplicateBlock } from "./extract.js"; -import { groupDuplicateBlocksIntoClusters } from "./clusters.js"; -import { detectShadowedDirectoryPairs } from "./shadowed-directory-pairs.js"; -import { normalizeAndHashTokens } from "./normalize.js"; -import { buildLcpArray, buildSuffixArray } from "./suffix-array.js"; -import type { SourceToken } from "./token-types.js"; -import { tokenizeAst } from "./token-visitor.js"; - -interface TokenizedFile { - path: string; - sourceTokens: SourceToken[]; - /** 1-based byte offsets at line starts for line/column reconstruction. */ - lineStarts: number[]; - lineCount: number; -} - -const isBinaryFile = (sourceText: string): boolean => { - const sampleEnd = Math.min(sourceText.length, BINARY_DETECTION_SAMPLE_BYTES); - let nullByteCount = 0; - for (let charIndex = 0; charIndex < sampleEnd; charIndex++) { - if (sourceText.charCodeAt(charIndex) === 0) { - nullByteCount++; - if (nullByteCount >= BINARY_DETECTION_NULL_BYTE_THRESHOLD) return true; - } - } - return false; -}; - -const isMinifiedSource = (sourceText: string): boolean => { - if (sourceText.length < MINIFIED_DETECTION_MIN_BYTES) return false; - const lineCount = (sourceText.match(/\n/g)?.length ?? 0) + 1; - return sourceText.length / lineCount > MINIFIED_DETECTION_AVG_LINE_LENGTH_THRESHOLD; -}; - -const tokenizeFile = (filePath: string): TokenizedFile | undefined => { - let sourceStat: ReturnType<typeof statSync>; - try { - sourceStat = statSync(filePath); - } catch { - return undefined; - } - if (sourceStat.size > MAX_PARSE_FILE_SIZE_BYTES) return undefined; - - let sourceText: string; - try { - sourceText = readFileSync(filePath, "utf-8"); - } catch { - return undefined; - } - if (sourceText.length === 0) return undefined; - if (isBinaryFile(sourceText)) return undefined; - if (isMinifiedSource(sourceText)) return undefined; - - let parseResult: ReturnType<typeof parseSync>; - try { - parseResult = parseSync(filePath, sourceText); - } catch { - return undefined; - } - const sourceTokens = tokenizeAst(parseResult.program); - if (sourceTokens.length === 0) return undefined; - - const lineStarts = computeLineStarts(sourceText); - return { - path: filePath, - sourceTokens, - lineStarts, - lineCount: lineStarts.length, - }; -}; - -const buildCloneInstance = ( - rawInstance: { fileIndex: number; tokenOffsetWithinFile: number }, - tokenLength: number, - tokenizedFiles: TokenizedFile[], -): DuplicateBlockOccurrence => { - const file = tokenizedFiles[rawInstance.fileIndex]; - const firstToken = file.sourceTokens[rawInstance.tokenOffsetWithinFile]; - const lastToken = file.sourceTokens[rawInstance.tokenOffsetWithinFile + tokenLength - 1]; - const startSpan = offsetToLineColumn(firstToken.start, file.lineStarts); - const endSpan = offsetToLineColumn(lastToken.end, file.lineStarts); - return { - path: file.path, - startLine: startSpan.line, - endLine: endSpan.line, - startColumn: startSpan.column, - endColumn: endSpan.column, - }; -}; - -const filterRawBlocksToReportableDuplicates = ( - rawBlocks: RawDuplicateBlock[], - tokenizedFiles: TokenizedFile[], - config: DuplicateBlocksConfig, -): DuplicateBlock[] => { - const duplicateBlocks: DuplicateBlock[] = []; - for (const rawBlock of rawBlocks) { - const instances = rawBlock.instances.map((rawInstance) => - buildCloneInstance(rawInstance, rawBlock.tokenLength, tokenizedFiles), - ); - - let lineCount = 0; - for (const instance of instances) { - const instanceLineCount = instance.endLine - instance.startLine + 1; - if (instanceLineCount > lineCount) lineCount = instanceLineCount; - } - if (lineCount < config.minLines) continue; - if (instances.length < config.minOccurrences) continue; - - if (config.skipLocal) { - const distinctDirectories = new Set(instances.map((instance) => dirname(instance.path))); - if (distinctDirectories.size < 2) continue; - } - - const distinctFiles = new Set(instances.map((instance) => instance.path)); - const confidence = distinctFiles.size >= 2 ? "high" : "medium"; - - duplicateBlocks.push({ - instances, - tokenCount: rawBlock.tokenLength, - lineCount, - confidence, - reason: - distinctFiles.size >= 2 - ? `${instances.length} occurrences spanning ${distinctFiles.size} files (≥${rawBlock.tokenLength} tokens, ${lineCount} lines)` - : `${instances.length} occurrences within a single file (≥${rawBlock.tokenLength} tokens, ${lineCount} lines)`, - }); - } - - const maximalBlocks = dropBlocksSubsumedByLongerSibling(duplicateBlocks); - - maximalBlocks.sort((firstClone, secondClone) => { - if (firstClone.lineCount !== secondClone.lineCount) { - return secondClone.lineCount - firstClone.lineCount; - } - return secondClone.tokenCount - firstClone.tokenCount; - }); - return maximalBlocks; -}; - -/** - * The suffix-array + LCP-interval scan emits one block per LCP interval, but - * nested intervals routinely yield the same set of source spans at multiple - * lengths (the same maximal repeat reported at L, L-1, L-2, …). Drop any - * block whose every instance is spatially contained inside some other block's - * matching instance — that other block is strictly more informative. - * - * O(N²) worst-case, but N here is post-filter blocks (typically <1000 even on - * large monorepos), and the early-exit on instance-count mismatch keeps it - * tight in practice. - */ -const dropBlocksSubsumedByLongerSibling = (blocks: DuplicateBlock[]): DuplicateBlock[] => { - const sorted = [...blocks].sort((firstBlock, secondBlock) => { - if (firstBlock.tokenCount !== secondBlock.tokenCount) { - return secondBlock.tokenCount - firstBlock.tokenCount; - } - return secondBlock.lineCount - firstBlock.lineCount; - }); - - const survivors: DuplicateBlock[] = []; - for (const candidate of sorted) { - let subsumed = false; - for (const survivor of survivors) { - if (survivor.instances.length !== candidate.instances.length) continue; - if (allInstancesContainedIn(candidate, survivor)) { - subsumed = true; - break; - } - } - if (!subsumed) survivors.push(candidate); - } - return survivors; -}; - -const allInstancesContainedIn = (candidate: DuplicateBlock, longer: DuplicateBlock): boolean => { - for (const candidateInstance of candidate.instances) { - let matched = false; - for (const longerInstance of longer.instances) { - if ( - candidateInstance.path === longerInstance.path && - isSpanContained(candidateInstance, longerInstance) - ) { - matched = true; - break; - } - } - if (!matched) return false; - } - return true; -}; - -const isSpanContained = ( - inner: { startLine: number; startColumn: number; endLine: number; endColumn: number }, - outer: { startLine: number; startColumn: number; endLine: number; endColumn: number }, -): boolean => { - const innerStartsAfterOuter = - inner.startLine > outer.startLine || - (inner.startLine === outer.startLine && inner.startColumn >= outer.startColumn); - const innerEndsBeforeOuter = - inner.endLine < outer.endLine || - (inner.endLine === outer.endLine && inner.endColumn <= outer.endColumn); - return innerStartsAfterOuter && innerEndsBeforeOuter; -}; - -export interface DuplicateBlocksResult { - duplicateBlocks: DuplicateBlock[]; - duplicateBlockClusters: DuplicateBlockCluster[]; - shadowedDirectoryPairs: ShadowedDirectoryPair[]; -} - -/** - * Token-based duplicate block detector. - * - * Pipeline: - * 1. Tokenize each file with the AST visitor in `token-visitor.ts` - * 2. Hash + normalize tokens with the chosen detection mode - * 3. Concatenate every file's hashed tokens with unique negative sentinels - * 4. Build a suffix array (prefix doubling + radix sort) and LCP array - * 5. Stack-based LCP-interval scan extracts maximal duplicate blocks - * 6. Filter on min-tokens / min-lines / min-occurrences / skip-local - * 7. Group clones into families; collapse N two-file families with matching - * basenames into a `ShadowedDirectoryPair` finding - * - * Returns empty arrays when `config.enabled` is false. - */ -export const detectDuplicateBlocks = ( - graph: DependencyGraph, - config: DuplicateBlocksConfig | undefined, - rootDir: string, -): DuplicateBlocksResult => { - if (!config || !config.enabled) { - return { duplicateBlocks: [], duplicateBlockClusters: [], shadowedDirectoryPairs: [] }; - } - - const tokenizedFiles: TokenizedFile[] = []; - for (const module of graph.modules) { - if (module.isDeclarationFile) continue; - if (module.isConfigFile) continue; - const tokenizedFile = tokenizeFile(module.fileId.path); - if (!tokenizedFile) continue; - tokenizedFiles.push(tokenizedFile); - } - if (tokenizedFiles.length === 0) { - return { duplicateBlocks: [], duplicateBlockClusters: [], shadowedDirectoryPairs: [] }; - } - - const filesHashedTokens = tokenizedFiles.map((file) => - normalizeAndHashTokens(file.sourceTokens, config.mode), - ); - const filesTokenCounts = filesHashedTokens.map((fileTokens) => fileTokens.length); - - const filesHaveEnoughTokens = filesTokenCounts.some((count) => count >= config.minTokens); - if (!filesHaveEnoughTokens) { - return { duplicateBlocks: [], duplicateBlockClusters: [], shadowedDirectoryPairs: [] }; - } - - const concatenation = rankReduceAndConcatenate(filesHashedTokens); - if (concatenation.tokenSequence.length === 0) { - return { duplicateBlocks: [], duplicateBlockClusters: [], shadowedDirectoryPairs: [] }; - } - - const suffixArray = buildSuffixArray(concatenation.tokenSequence); - const lcpArray = buildLcpArray(concatenation.tokenSequence, suffixArray); - const rawDuplicateBlocks = extractRawDuplicateBlocks( - suffixArray, - lcpArray, - concatenation.fileOf, - concatenation.fileOffsets, - filesTokenCounts, - config.minTokens, - ); - - const duplicateBlocks = filterRawBlocksToReportableDuplicates( - rawDuplicateBlocks, - tokenizedFiles, - config, - ); - const duplicateBlockClusters = groupDuplicateBlocksIntoClusters(duplicateBlocks); - const shadowedDirectoryPairs = detectShadowedDirectoryPairs(duplicateBlockClusters, rootDir); - - return { duplicateBlocks, duplicateBlockClusters, shadowedDirectoryPairs }; -}; diff --git a/packages/deslop-js/src/duplicate-blocks/normalize.ts b/packages/deslop-js/src/duplicate-blocks/normalize.ts deleted file mode 100644 index fca32adf0f..0000000000 --- a/packages/deslop-js/src/duplicate-blocks/normalize.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { DuplicateBlockDetectionMode } from "../types.js"; -import type { HashedToken, SourceToken } from "./token-types.js"; - -/** - * 32-bit FNV-1a. Collisions are tolerable: ties are broken back to the - * original (path, offset) tuples downstream, so a rare collision inflates a - * duplicate block with one extra spurious instance at worst. - */ -const FNV_OFFSET_BASIS = 0x811c9dc5; -const FNV_PRIME = 0x01000193; - -const hashString = (input: string): number => { - let hash = FNV_OFFSET_BASIS; - for (let charIndex = 0; charIndex < input.length; charIndex++) { - hash ^= input.charCodeAt(charIndex); - hash = Math.imul(hash, FNV_PRIME); - } - return hash >>> 0; -}; - -interface ResolvedNormalization { - ignoreIdentifiers: boolean; - ignoreStringValues: boolean; - ignoreNumericValues: boolean; -} - -const resolveNormalization = (mode: DuplicateBlockDetectionMode): ResolvedNormalization => { - if (mode === "strict") { - return { ignoreIdentifiers: false, ignoreStringValues: false, ignoreNumericValues: false }; - } - return { ignoreIdentifiers: true, ignoreStringValues: true, ignoreNumericValues: true }; -}; - -const hashSourceToken = ( - sourceToken: SourceToken, - normalization: ResolvedNormalization, -): number => { - switch (sourceToken.kind) { - case "node-enter": - return hashString(`n:${sourceToken.payload}`); - case "identifier": - return normalization.ignoreIdentifiers - ? hashString("id:*") - : hashString(`id:${sourceToken.payload}`); - case "string-literal": - return normalization.ignoreStringValues - ? hashString("s:*") - : hashString(`s:${sourceToken.payload}`); - case "numeric-literal": - return normalization.ignoreNumericValues - ? hashString("num:*") - : hashString(`num:${sourceToken.payload}`); - case "boolean-literal": - return hashString(`b:${sourceToken.payload}`); - case "null-literal": - return hashString("null"); - case "template-literal": - return hashString("tpl"); - case "regexp-literal": - return hashString("re"); - default: - return hashString("?"); - } -}; - -export const normalizeAndHashTokens = ( - tokens: SourceToken[], - mode: DuplicateBlockDetectionMode, -): HashedToken[] => { - const normalization = resolveNormalization(mode); - const hashedTokens: HashedToken[] = new Array(tokens.length); - for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex++) { - hashedTokens[tokenIndex] = { - hash: hashSourceToken(tokens[tokenIndex], normalization), - originalIndex: tokenIndex, - }; - } - return hashedTokens; -}; diff --git a/packages/deslop-js/src/duplicate-blocks/shadowed-directory-pairs.ts b/packages/deslop-js/src/duplicate-blocks/shadowed-directory-pairs.ts deleted file mode 100644 index 9f7ec13aad..0000000000 --- a/packages/deslop-js/src/duplicate-blocks/shadowed-directory-pairs.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { SHADOWED_DIRECTORY_MIN_CLUSTERS } from "../constants.js"; -import type { DuplicateBlockCluster, ShadowedDirectoryPair } from "../types.js"; - -interface DirectoryAndFile { - directory: string; - baseName: string; -} - -interface PairEntry { - baseName: string; - duplicatedLines: number; -} - -const splitDirectoryAndFile = (filePath: string): DirectoryAndFile => { - const trailingSlashIndex = filePath.lastIndexOf("/"); - if (trailingSlashIndex === -1) return { directory: "", baseName: filePath }; - return { - directory: filePath.slice(0, trailingSlashIndex + 1), - baseName: filePath.slice(trailingSlashIndex + 1), - }; -}; - -const toRelative = (filePath: string, rootDir: string): string => { - if (filePath.startsWith(rootDir + "/")) return filePath.slice(rootDir.length + 1); - if (filePath === rootDir) return ""; - return filePath; -}; - -/** - * Collapse N two-file duplicate-block clusters that share the same - * `(directoryA, directoryB)` and matching basenames into a single - * `ShadowedDirectoryPair` finding — the directories themselves drifted - * (e.g. `src/` vs `deno/lib/`, a fork, a copy-paste of a route tree). - */ -export const detectShadowedDirectoryPairs = ( - duplicateBlockClusters: DuplicateBlockCluster[], - rootDir: string, -): ShadowedDirectoryPair[] => { - const directoryPairBuckets = new Map<string, PairEntry[]>(); - - for (const cluster of duplicateBlockClusters) { - if (cluster.files.length !== 2) continue; - const [firstFile, secondFile] = cluster.files; - const firstSplit = splitDirectoryAndFile(toRelative(firstFile, rootDir)); - const secondSplit = splitDirectoryAndFile(toRelative(secondFile, rootDir)); - if (firstSplit.baseName !== secondSplit.baseName) continue; - - const [smallerDirectory, largerDirectory] = - firstSplit.directory <= secondSplit.directory - ? [firstSplit.directory, secondSplit.directory] - : [secondSplit.directory, firstSplit.directory]; - const pairKey = `${smallerDirectory}::${largerDirectory}`; - const entry: PairEntry = { - baseName: firstSplit.baseName, - duplicatedLines: cluster.totalDuplicatedLines, - }; - const existing = directoryPairBuckets.get(pairKey); - if (existing) existing.push(entry); - else directoryPairBuckets.set(pairKey, [entry]); - } - - const shadowedDirectoryPairs: ShadowedDirectoryPair[] = []; - for (const [pairKey, entries] of directoryPairBuckets) { - if (entries.length < SHADOWED_DIRECTORY_MIN_CLUSTERS) continue; - const [directoryA, directoryB] = pairKey.split("::"); - const sharedBaseNames = [...new Set(entries.map((entry) => entry.baseName))].sort(); - const totalDuplicatedLines = entries.reduce( - (runningSum, entry) => runningSum + entry.duplicatedLines, - 0, - ); - shadowedDirectoryPairs.push({ - directoryA, - directoryB, - sharedFiles: sharedBaseNames, - totalDuplicatedLines, - }); - } - - shadowedDirectoryPairs.sort( - (leftPair, rightPair) => rightPair.totalDuplicatedLines - leftPair.totalDuplicatedLines, - ); - return shadowedDirectoryPairs; -}; diff --git a/packages/deslop-js/src/duplicate-blocks/suffix-array.ts b/packages/deslop-js/src/duplicate-blocks/suffix-array.ts deleted file mode 100644 index 4273f035ab..0000000000 --- a/packages/deslop-js/src/duplicate-blocks/suffix-array.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Prefix-doubling suffix array with two-pass radix sort, O(N log N). - * - * Negative values in `tokenSequence` (file-separator sentinels emitted by - * `rankReduceAndConcatenate`) are shifted up so all ranks are >= 0. The - * shift preserves the property that sentinels sort before all real ranks, - * which is what stops cross-file suffix matches. - */ -export const buildSuffixArray = (tokenSequence: number[]): number[] => { - const sequenceLength = tokenSequence.length; - if (sequenceLength === 0) return []; - - let minimumValue = 0; - for (let scanIndex = 0; scanIndex < sequenceLength; scanIndex++) { - if (tokenSequence[scanIndex] < minimumValue) minimumValue = tokenSequence[scanIndex]; - } - - let currentRanks: number[] = new Array(sequenceLength); - for (let scanIndex = 0; scanIndex < sequenceLength; scanIndex++) { - currentRanks[scanIndex] = tokenSequence[scanIndex] - minimumValue; - } - let suffixArray: number[] = new Array(sequenceLength); - for (let positionIndex = 0; positionIndex < sequenceLength; positionIndex++) { - suffixArray[positionIndex] = positionIndex; - } - let nextRanks: number[] = new Array(sequenceLength); - let scratchSuffixArray: number[] = new Array(sequenceLength); - - let maximumRank = 0; - for (let scanIndex = 0; scanIndex < sequenceLength; scanIndex++) { - if (currentRanks[scanIndex] > maximumRank) maximumRank = currentRanks[scanIndex]; - } - - let stride = 1; - while (stride < sequenceLength) { - const bucketCount = maximumRank + 2; - const buckets: number[] = new Array(bucketCount + 1).fill(0); - - for (let suffixIndex = 0; suffixIndex < sequenceLength; suffixIndex++) { - const startPosition = suffixArray[suffixIndex]; - const secondaryKey = - startPosition + stride < sequenceLength ? currentRanks[startPosition + stride] + 1 : 0; - buckets[secondaryKey]++; - } - let prefixSum = 0; - for (let bucketIndex = 0; bucketIndex < buckets.length; bucketIndex++) { - const bucketCountValue = buckets[bucketIndex]; - buckets[bucketIndex] = prefixSum; - prefixSum += bucketCountValue; - } - for (let suffixIndex = 0; suffixIndex < sequenceLength; suffixIndex++) { - const startPosition = suffixArray[suffixIndex]; - const secondaryKey = - startPosition + stride < sequenceLength ? currentRanks[startPosition + stride] + 1 : 0; - scratchSuffixArray[buckets[secondaryKey]] = startPosition; - buckets[secondaryKey]++; - } - - for (let bucketIndex = 0; bucketIndex < buckets.length; bucketIndex++) buckets[bucketIndex] = 0; - for (let suffixIndex = 0; suffixIndex < sequenceLength; suffixIndex++) { - const startPosition = scratchSuffixArray[suffixIndex]; - buckets[currentRanks[startPosition]]++; - } - prefixSum = 0; - for (let bucketIndex = 0; bucketIndex < buckets.length; bucketIndex++) { - const bucketCountValue = buckets[bucketIndex]; - buckets[bucketIndex] = prefixSum; - prefixSum += bucketCountValue; - } - for (let suffixIndex = 0; suffixIndex < sequenceLength; suffixIndex++) { - const startPosition = scratchSuffixArray[suffixIndex]; - suffixArray[buckets[currentRanks[startPosition]]] = startPosition; - buckets[currentRanks[startPosition]]++; - } - - nextRanks[suffixArray[0]] = 0; - for (let suffixIndex = 1; suffixIndex < sequenceLength; suffixIndex++) { - const previousStart = suffixArray[suffixIndex - 1]; - const currentStart = suffixArray[suffixIndex]; - const previousSecondary = - previousStart + stride < sequenceLength ? currentRanks[previousStart + stride] : -1; - const currentSecondary = - currentStart + stride < sequenceLength ? currentRanks[currentStart + stride] : -1; - const isSameBucket = - currentRanks[previousStart] === currentRanks[currentStart] && - previousSecondary === currentSecondary; - nextRanks[currentStart] = nextRanks[previousStart] + (isSameBucket ? 0 : 1); - } - - const newMaximumRank = nextRanks[suffixArray[sequenceLength - 1]]; - [currentRanks, nextRanks] = [nextRanks, currentRanks]; - if (newMaximumRank === sequenceLength - 1) break; - maximumRank = newMaximumRank; - stride *= 2; - } - - return suffixArray; -}; - -/** - * Kasai's O(N) longest-common-prefix array. The `>= 0` check inside the inner - * loop is the only non-textbook bit: it prevents a real-token LCP from - * accidentally crossing a sentinel boundary (sentinels are negative). - */ -export const buildLcpArray = (tokenSequence: number[], suffixArray: number[]): number[] => { - const sequenceLength = tokenSequence.length; - const inverseSuffixArray: number[] = new Array(sequenceLength); - for (let arrayIndex = 0; arrayIndex < sequenceLength; arrayIndex++) { - inverseSuffixArray[suffixArray[arrayIndex]] = arrayIndex; - } - - const lcpArray: number[] = new Array(sequenceLength).fill(0); - let runningLcp = 0; - for (let positionIndex = 0; positionIndex < sequenceLength; positionIndex++) { - if (inverseSuffixArray[positionIndex] === 0) { - runningLcp = 0; - continue; - } - const previousStart = suffixArray[inverseSuffixArray[positionIndex] - 1]; - while ( - positionIndex + runningLcp < sequenceLength && - previousStart + runningLcp < sequenceLength && - tokenSequence[positionIndex + runningLcp] === tokenSequence[previousStart + runningLcp] && - tokenSequence[positionIndex + runningLcp] >= 0 - ) { - runningLcp++; - } - lcpArray[inverseSuffixArray[positionIndex]] = runningLcp; - if (runningLcp > 0) runningLcp--; - } - - return lcpArray; -}; diff --git a/packages/deslop-js/src/duplicate-blocks/token-types.ts b/packages/deslop-js/src/duplicate-blocks/token-types.ts deleted file mode 100644 index 856f9a7fbd..0000000000 --- a/packages/deslop-js/src/duplicate-blocks/token-types.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type SourceTokenKind = - | "node-enter" - | "identifier" - | "string-literal" - | "numeric-literal" - | "boolean-literal" - | "null-literal" - | "template-literal" - | "regexp-literal"; - -export interface SourceToken { - kind: SourceTokenKind; - payload: string; - start: number; - end: number; -} - -export interface HashedToken { - hash: number; - originalIndex: number; -} diff --git a/packages/deslop-js/src/duplicate-blocks/token-visitor.ts b/packages/deslop-js/src/duplicate-blocks/token-visitor.ts deleted file mode 100644 index 038ed001cd..0000000000 --- a/packages/deslop-js/src/duplicate-blocks/token-visitor.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { SourceToken } from "./token-types.js"; -import { isAstNode } from "../utils/is-ast-node.js"; - -const NODES_DROPPED_FROM_TOKEN_STREAM = new Set<string>([ - "ImportDeclaration", - "ExportAllDeclaration", - "TSTypeAnnotation", - "TSTypeAliasDeclaration", - "TSInterfaceDeclaration", - "TSTypeParameterDeclaration", - "TSTypeParameterInstantiation", - "TSTypeReference", - "TSAnyKeyword", - "TSUnknownKeyword", - "TSStringKeyword", - "TSNumberKeyword", - "TSBooleanKeyword", - "TSVoidKeyword", - "TSUndefinedKeyword", - "TSNullKeyword", - "TSNeverKeyword", - "TSUnionType", - "TSIntersectionType", - "TSLiteralType", - "TSArrayType", - "TSTupleType", - "TSTypeLiteral", - "TSPropertySignature", - "TSMethodSignature", - "TSCallSignatureDeclaration", - "TSConstructSignatureDeclaration", - "TSIndexSignature", - "TSConditionalType", - "TSMappedType", - "TSInferType", - "TSImportType", - "TSQualifiedName", - "TSTypeOperator", - "TSTypePredicate", - "TSFunctionType", - "TSConstructorType", -]); - -const visitChildrenRaw = (node: unknown, visit: (child: unknown) => void): void => { - if (!isAstNode(node)) return; - for (const key of Object.keys(node)) { - if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") { - continue; - } - const value = node[key]; - if (Array.isArray(value)) { - for (const item of value) visit(item); - } else if (value !== null && typeof value === "object") { - visit(value); - } - } -}; - -const safeNumberOrZero = (candidate: unknown): number => - typeof candidate === "number" ? candidate : 0; - -/** - * Walk an oxc AST and emit a flat token stream suitable for suffix-array-based - * duplicate-block detection. Two structurally-identical regions of code produce the same - * token sequence (modulo identifier/literal-value normalization, applied later - * in `normalize.ts`). - * - * Implementation note: rather than a hand-written keyword/operator lexer-style - * visitor, we walk the AST generically and emit one `node-enter` token per - * visited node. This trades a slightly different token-density profile for - * less code. AST-shape tokens still distinguish - * `function add(a, b) { return a + b }` from `const add = (a, b) => a + b`. - * Identifiers and value literals get dedicated tokens so semantic-mode - * normalization can blind them. - * - * Imports and type-only constructs are dropped to keep import-block boilerplate - * and ambient type declarations from inflating the noise floor. - */ -export const tokenizeAst = (program: unknown): SourceToken[] => { - const tokens: SourceToken[] = []; - - const visit = (node: unknown): void => { - if (!isAstNode(node)) return; - const nodeType = node.type; - if (NODES_DROPPED_FROM_TOKEN_STREAM.has(nodeType)) return; - - const start = safeNumberOrZero(node.start); - const end = safeNumberOrZero(node.end); - - if (nodeType === "Identifier" || nodeType === "PrivateIdentifier") { - const identifierName = node.name; - tokens.push({ - kind: "identifier", - payload: typeof identifierName === "string" ? identifierName : "", - start, - end, - }); - return; - } - - if (nodeType === "Literal") { - const literalValue = node.value; - if (typeof literalValue === "string") { - tokens.push({ kind: "string-literal", payload: literalValue, start, end }); - } else if (typeof literalValue === "number") { - tokens.push({ kind: "numeric-literal", payload: String(literalValue), start, end }); - } else if (typeof literalValue === "boolean") { - tokens.push({ - kind: "boolean-literal", - payload: literalValue ? "true" : "false", - start, - end, - }); - } else if (literalValue === null) { - tokens.push({ kind: "null-literal", payload: "null", start, end }); - } else if (node.regex) { - tokens.push({ kind: "regexp-literal", payload: "regex", start, end }); - } else { - tokens.push({ kind: "node-enter", payload: nodeType, start, end }); - } - return; - } - - if (nodeType === "TemplateLiteral") { - tokens.push({ kind: "template-literal", payload: "tpl", start, end }); - visitChildrenRaw(node, visit); - return; - } - - tokens.push({ kind: "node-enter", payload: nodeType, start, end }); - visitChildrenRaw(node, visit); - }; - - visit(program); - return tokens; -}; diff --git a/packages/deslop-js/src/errors.ts b/packages/deslop-js/src/errors.ts deleted file mode 100644 index 25527ed017..0000000000 --- a/packages/deslop-js/src/errors.ts +++ /dev/null @@ -1,226 +0,0 @@ -export type DeslopErrorCode = - | "file-read-failed" - | "file-too-large" - | "file-empty" - | "file-binary" - | "file-minified" - | "parse-failed" - | "parse-recovered" - | "parse-recovered-partial" - | "ast-walk-failed" - | "ast-walk-depth-exceeded" - | "tsconfig-not-found" - | "tsconfig-parse-failed" - | "ts-program-creation-failed" - | "ts-program-too-large" - | "ts-not-loadable" - | "package-json-not-found" - | "package-json-parse-failed" - | "workspace-discovery-failed" - | "gitignore-check-failed" - | "resolver-init-failed" - | "monorepo-discovery-failed" - | "detector-failed" - | "config-invalid" - | "system-out-of-memory" - | "unknown"; - -export type DeslopErrorModule = - | "collect" - | "parse" - | "linker" - | "resolver" - | "report" - | "semantic" - | "config"; - -export type DeslopErrorSeverity = "fatal" | "warning" | "info"; - -export interface DeslopErrorInput { - code: DeslopErrorCode; - module: DeslopErrorModule; - message: string; - severity?: DeslopErrorSeverity; - path?: string; - detail?: string; -} - -export interface DeslopErrorFromCaughtInput extends Omit<DeslopErrorInput, "detail"> { - caught: unknown; -} - -export interface DeslopErrorJson { - name: string; - code: DeslopErrorCode; - module: DeslopErrorModule; - severity: DeslopErrorSeverity; - message: string; - path?: string; - detail?: string; -} - -import { MAX_ERROR_DETAIL_LENGTH } from "./constants.js"; - -const truncateDetail = (text: string): string => { - if (text.length <= MAX_ERROR_DETAIL_LENGTH) return text; - return `${text.slice(0, MAX_ERROR_DETAIL_LENGTH)}… [truncated ${text.length - MAX_ERROR_DETAIL_LENGTH} chars]`; -}; - -export const describeUnknownError = (caughtValue: unknown): string => { - let rawText: string; - if (caughtValue instanceof Error) { - rawText = caughtValue.message || caughtValue.name || "unknown error"; - } else if (typeof caughtValue === "string") { - rawText = caughtValue; - } else { - try { - rawText = JSON.stringify(caughtValue); - } catch { - rawText = String(caughtValue); - } - } - return truncateDetail(rawText ?? ""); -}; - -export class DeslopError extends Error { - readonly code: DeslopErrorCode; - readonly module: DeslopErrorModule; - readonly severity: DeslopErrorSeverity; - readonly path?: string; - readonly detail?: string; - - constructor(input: DeslopErrorInput) { - super(input.message); - this.name = "DeslopError"; - this.code = input.code; - this.module = input.module; - this.severity = input.severity ?? "warning"; - if (input.path !== undefined) this.path = input.path; - if (input.detail !== undefined) this.detail = input.detail; - } - - toJSON(): DeslopErrorJson { - const payload: DeslopErrorJson = { - name: this.name, - code: this.code, - module: this.module, - severity: this.severity, - message: this.message, - }; - if (this.path !== undefined) payload.path = this.path; - if (this.detail !== undefined) payload.detail = this.detail; - return payload; - } - - static fromCaught(input: DeslopErrorFromCaughtInput): DeslopError { - return new DeslopError({ - code: input.code, - module: input.module, - severity: input.severity, - message: input.message, - path: input.path, - detail: describeUnknownError(input.caught), - }); - } -} - -export class ConfigError extends DeslopError { - constructor(input: Omit<DeslopErrorInput, "module" | "code"> & { code?: "config-invalid" }) { - super({ - ...input, - code: input.code ?? "config-invalid", - module: "config", - severity: input.severity ?? "fatal", - }); - this.name = "ConfigError"; - } -} - -export class FileReadError extends DeslopError { - constructor( - input: Omit<DeslopErrorInput, "module" | "code"> & { - code: "file-read-failed" | "file-too-large" | "file-empty" | "file-binary" | "file-minified"; - }, - ) { - super({ ...input, module: "parse" }); - this.name = "FileReadError"; - } -} - -export class ParseError extends DeslopError { - constructor( - input: Omit<DeslopErrorInput, "module" | "code"> & { - code: - | "parse-failed" - | "parse-recovered" - | "parse-recovered-partial" - | "ast-walk-failed" - | "ast-walk-depth-exceeded"; - }, - ) { - super({ ...input, module: "parse" }); - this.name = "ParseError"; - } -} - -export class TypeScriptError extends DeslopError { - constructor( - input: Omit<DeslopErrorInput, "module" | "code"> & { - code: - | "tsconfig-not-found" - | "tsconfig-parse-failed" - | "ts-program-creation-failed" - | "ts-program-too-large" - | "ts-not-loadable"; - }, - ) { - super({ ...input, module: "semantic" }); - this.name = "TypeScriptError"; - } -} - -export class WorkspaceError extends DeslopError { - constructor( - input: Omit<DeslopErrorInput, "module" | "code"> & { - code: - | "workspace-discovery-failed" - | "monorepo-discovery-failed" - | "package-json-not-found" - | "package-json-parse-failed" - | "gitignore-check-failed"; - }, - ) { - super({ ...input, module: "collect" }); - this.name = "WorkspaceError"; - } -} - -export class ResolverError extends DeslopError { - constructor( - input: Omit<DeslopErrorInput, "module" | "code"> & { code?: "resolver-init-failed" }, - ) { - super({ - ...input, - code: input.code ?? "resolver-init-failed", - module: "resolver", - severity: input.severity ?? "fatal", - }); - this.name = "ResolverError"; - } -} - -export class DetectorError extends DeslopError { - constructor( - input: Omit<DeslopErrorInput, "module" | "code"> & { - module?: DeslopErrorModule; - code?: "detector-failed"; - }, - ) { - super({ - ...input, - code: input.code ?? "detector-failed", - module: input.module ?? "report", - }); - this.name = "DetectorError"; - } -} diff --git a/packages/deslop-js/src/index.ts b/packages/deslop-js/src/index.ts deleted file mode 100644 index 4e794c0835..0000000000 --- a/packages/deslop-js/src/index.ts +++ /dev/null @@ -1,486 +0,0 @@ -import { resolve } from "node:path"; -import { existsSync, readFileSync } from "node:fs"; -import type { DeslopConfig, DeslopError, ScanResult } from "./types.js"; -import { - ConfigError, - DetectorError, - ResolverError, - WorkspaceError, - describeUnknownError, -} from "./errors.js"; -import { OUTPUT_DIRECTORIES } from "./constants.js"; -import { collectSourceFiles, resolveEntries, getFrameworkExclusions } from "./collect/entries.js"; -import { resolveWorkspaces } from "./collect/workspaces.js"; -import { parseFilesInParallel } from "./collect/parallel-parse.js"; -import { createResolver } from "./resolver/resolve.js"; -import { buildDependencyGraph } from "./linker/build.js"; -import { buildModuleLinkInputs } from "./linker/build-module-link-inputs.js"; -import { markFilenameRegistryEntries } from "./linker/mark-filename-registry-entries.js"; -import { traceReachability } from "./linker/reachability.js"; -import { resolveReExportChains } from "./linker/re-exports.js"; -import { generateReport } from "./report/generate.js"; -import { resolveEntriesInWorker } from "./collect/entries-in-worker.js"; -import { loadSummaryCache } from "./summary-cache.js"; -import { findMonorepoRoot } from "./utils/find-monorepo-root.js"; -import { collectGitIgnoredPaths } from "./utils/collect-git-ignored-paths.js"; - -export { defineConfig } from "./config.js"; - -const REACT_NATIVE_ENABLERS = ["react-native", "expo"]; - -const detectReactNative = ( - rootDir: string, - workspacePackages: Array<{ directory: string }>, -): boolean => { - const directoriesToCheck = [ - rootDir, - ...workspacePackages.map((workspacePackage) => workspacePackage.directory), - ]; - for (const directory of directoriesToCheck) { - const packageJsonPath = resolve(directory, "package.json"); - if (!existsSync(packageJsonPath)) continue; - try { - const content = readFileSync(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(content); - const allDependencies = { - ...packageJson.dependencies, - ...packageJson.devDependencies, - ...packageJson.optionalDependencies, - }; - if (REACT_NATIVE_ENABLERS.some((enabler) => enabler in allDependencies)) return true; - } catch { - continue; - } - } - return false; -}; - -export type { - ScanResult, - DeslopConfig, - UnusedFile, - UnusedExport, - UnusedDependency, - SkippedDependency, - SkippedDependencyReason, - CircularDependency, - UnusedType, - UnusedTypeKind, - SemanticConfig, - SemanticConfidence, - MisclassifiedDependency, - DependencyDeclaredAs, - UnusedEnumMember, - UnusedClassMember, - ClassMemberKind, - RedundantAlias, - RedundantAliasKind, - DuplicateExport, - DuplicateExportOccurrence, - DuplicateImport, - DuplicateImportOccurrence, - RedundantTypePattern, - RedundantTypePatternKind, - IdentityWrapper, - DuplicateTypeDefinition, - DuplicateTypeDefinitionInstance, - DuplicateInlineType, - InlineTypeOccurrence, - InlineTypeContext, - SimplifiableFunction, - SimplifiableFunctionKind, - SimplifiableExpression, - SimplifiableExpressionKind, - DuplicateConstant, - DuplicateConstantOccurrence, - CrossFileDuplicateExport, - CrossFileDuplicateExportLocation, - DuplicateBlock, - DuplicateBlockOccurrence, - DuplicateBlockCluster, - DuplicateBlockRefactoringKind, - DuplicateBlockRefactoringHint, - DuplicateBlockDetectionMode, - DuplicateBlocksConfig, - ShadowedDirectoryPair, - ReExportCycle, - ReExportCycleKind, - FeatureFlag, - FeatureFlagKind, - FeatureFlagsConfig, - FunctionComplexity, - ComplexityConfig, - PrivateTypeLeak, - UnnecessaryAssertion, - UnnecessaryAssertionKind, - LazyImportAtTopLevel, - LazyImportKind, - CommonjsInEsm, - CommonjsInEsmKind, - TypeScriptEscapeHatch, - TypeScriptEscapeHatchKind, - DeslopError, - DeslopErrorCode, - DeslopErrorModule, - DeslopErrorSeverity, -} from "./types.js"; - -const buildEmptyScanResult = (errors: DeslopError[], elapsedMs: number): ScanResult => ({ - unusedFiles: [], - unusedExports: [], - unusedDependencies: [], - circularDependencies: [], - unusedTypes: [], - misclassifiedDependencies: [], - unusedEnumMembers: [], - unusedClassMembers: [], - redundantAliases: [], - duplicateExports: [], - duplicateImports: [], - redundantTypePatterns: [], - identityWrappers: [], - duplicateTypeDefinitions: [], - duplicateInlineTypes: [], - simplifiableFunctions: [], - simplifiableExpressions: [], - duplicateConstants: [], - crossFileDuplicateExports: [], - duplicateBlocks: [], - duplicateBlockClusters: [], - shadowedDirectoryPairs: [], - reExportCycles: [], - featureFlags: [], - complexFunctions: [], - privateTypeLeaks: [], - unnecessaryAssertions: [], - lazyImportsAtTopLevel: [], - commonjsInEsm: [], - typeScriptEscapeHatches: [], - analysisErrors: errors, - totalFiles: 0, - totalExports: 0, - analysisTimeMs: elapsedMs, -}); - -const validateConfig = (config: DeslopConfig): DeslopError | undefined => { - if (!config.rootDir || typeof config.rootDir !== "string") { - return new ConfigError({ message: "config.rootDir must be a non-empty string" }); - } - if (!existsSync(config.rootDir)) { - return new ConfigError({ - message: `config.rootDir does not exist: ${config.rootDir}`, - path: config.rootDir, - }); - } - return undefined; -}; - -export const analyze = async (config: DeslopConfig): Promise<ScanResult> => { - const pipelineStartTime = performance.now(); - const setupErrors: DeslopError[] = []; - - const configValidationError = validateConfig(config); - if (configValidationError) { - return buildEmptyScanResult([configValidationError], performance.now() - pipelineStartTime); - } - - let workspaceDiscovery: ReturnType<typeof resolveWorkspaces>; - try { - workspaceDiscovery = resolveWorkspaces(resolve(config.rootDir)); - } catch (workspaceError) { - setupErrors.push( - new WorkspaceError({ - code: "workspace-discovery-failed", - message: "resolveWorkspaces threw — falling back to single-package mode", - path: config.rootDir, - detail: describeUnknownError(workspaceError), - }), - ); - workspaceDiscovery = { - packages: [], - excludedDirectories: [], - hasRootLevelWorkspacePatterns: false, - }; - } - const workspacePackages = [...workspaceDiscovery.packages]; - - let monorepoRoot: string | undefined; - try { - monorepoRoot = findMonorepoRoot(config.rootDir); - } catch (monorepoError) { - setupErrors.push( - new WorkspaceError({ - code: "monorepo-discovery-failed", - message: "findMonorepoRoot threw", - path: config.rootDir, - detail: describeUnknownError(monorepoError), - }), - ); - monorepoRoot = undefined; - } - if (monorepoRoot) { - try { - const monorepoWorkspaces = resolveWorkspaces(monorepoRoot); - const existingDirectories = new Set( - workspacePackages.map((workspacePackage) => workspacePackage.directory), - ); - for (const monorepoPackage of monorepoWorkspaces.packages) { - if (!existingDirectories.has(monorepoPackage.directory)) { - workspacePackages.push(monorepoPackage); - } - } - } catch (monorepoWorkspaceError) { - setupErrors.push( - new WorkspaceError({ - code: "workspace-discovery-failed", - message: "resolveWorkspaces threw on monorepo root", - path: monorepoRoot, - detail: describeUnknownError(monorepoWorkspaceError), - }), - ); - } - } - - let frameworkIgnorePatterns: string[] = []; - try { - frameworkIgnorePatterns = getFrameworkExclusions(config.rootDir); - } catch (frameworkError) { - setupErrors.push( - new WorkspaceError({ - code: "workspace-discovery-failed", - message: "getFrameworkExclusions failed — proceeding without framework exclusion patterns", - path: config.rootDir, - detail: describeUnknownError(frameworkError), - }), - ); - } - - const absoluteRoot = resolve(config.rootDir); - const outputDirectoryExclusions = OUTPUT_DIRECTORIES.flatMap((outputDirectory) => [ - `${absoluteRoot}/${outputDirectory}/**`, - `${absoluteRoot}/**/${outputDirectory}/**`, - ]); - - const allExclusionPatterns = [ - ...workspaceDiscovery.excludedDirectories.map((directory) => `${directory}/**`), - ...frameworkIgnorePatterns, - ...outputDirectoryExclusions, - ]; - - const configWithExclusions = - allExclusionPatterns.length > 0 - ? { - ...config, - ignorePatterns: [...config.ignorePatterns, ...allExclusionPatterns], - } - : config; - - // Entry resolution always runs live — it reads config/doc/sibling-source - // CONTENT that no name-based fingerprint can validate — but its result is - // not needed until graph assembly. Uncached, it overlaps collection and the - // parse pool on the main thread's awaits; with the incremental cache a warm - // run has no parse window left to hide its mostly-synchronous work, so it - // moves to a dedicated worker thread (spawned before the cache's tree walk, - // which would otherwise serialize ahead of it). - const entriesPromise = ( - configWithExclusions.incrementalCachePath - ? resolveEntriesInWorker(configWithExclusions) - : resolveEntries(configWithExclusions) - ).catch((entriesError: unknown): Awaited<ReturnType<typeof resolveEntries>> => { - setupErrors.push( - new WorkspaceError({ - code: "workspace-discovery-failed", - message: "resolveEntries failed — defaulting to empty entry set", - path: config.rootDir, - detail: describeUnknownError(entriesError), - }), - ); - return { productionEntries: [], testEntries: [], alwaysUsedFiles: [] }; - }); - - const summaryCache = loadSummaryCache(configWithExclusions); - - let files: Awaited<ReturnType<typeof collectSourceFiles>>; - const cachedFileList = summaryCache?.lookupFileList() ?? null; - if (cachedFileList !== null) { - files = cachedFileList; - } else { - try { - files = await collectSourceFiles(configWithExclusions); - summaryCache?.storeFileList(files); - } catch (collectError) { - setupErrors.push( - new WorkspaceError({ - code: "workspace-discovery-failed", - severity: "fatal", - message: "collectSourceFiles failed", - path: config.rootDir, - detail: describeUnknownError(collectError), - }), - ); - return buildEmptyScanResult(setupErrors, performance.now() - pipelineStartTime); - } - } - const gitIgnoreResult = collectGitIgnoredPaths( - resolve(config.rootDir), - files.map((file) => file.path), - ); - const gitIgnoredFileSet = gitIgnoreResult.ignoredPaths; - if (gitIgnoreResult.gitUnavailable) { - setupErrors.push( - new WorkspaceError({ - code: "gitignore-check-failed", - severity: "info", - message: "git unavailable — .gitignore filtering skipped", - path: config.rootDir, - }), - ); - } - - let hasReactNative = false; - try { - hasReactNative = detectReactNative(config.rootDir, workspacePackages); - } catch { - hasReactNative = false; - } - - let moduleResolver: ReturnType<typeof createResolver>; - try { - moduleResolver = createResolver( - config, - workspacePackages.map((workspacePackage) => ({ - name: workspacePackage.name, - directory: workspacePackage.directory, - })), - { hasReactNative, monorepoRoot }, - ); - } catch (resolverError) { - setupErrors.push( - new ResolverError({ - message: "createResolver failed", - path: config.rootDir, - detail: describeUnknownError(resolverError), - }), - ); - return buildEmptyScanResult(setupErrors, performance.now() - pipelineStartTime); - } - const resolveModuleThroughCache = ( - specifier: string, - fromFile: string, - ): ReturnType<typeof moduleResolver.resolveModule> => { - if (summaryCache === null) return moduleResolver.resolveModule(specifier, fromFile); - const cachedResolution = summaryCache.lookupResolution(specifier, fromFile); - if (cachedResolution !== null) return cachedResolution; - const resolved = moduleResolver.resolveModule(specifier, fromFile); - summaryCache.storeResolution(specifier, fromFile, resolved); - return resolved; - }; - - let parsedModules: Awaited<ReturnType<typeof parseFilesInParallel>>; - let summaryMissCount = 0; - if (summaryCache === null) { - parsedModules = await parseFilesInParallel(files); - } else { - parsedModules = new Array(files.length); - const missedFiles: typeof files = []; - const missedPositions: number[] = []; - for (let fileIndex = 0; fileIndex < files.length; fileIndex++) { - const cachedSummary = summaryCache.lookupSummary(files[fileIndex].path); - if (cachedSummary !== null) { - parsedModules[fileIndex] = cachedSummary; - continue; - } - missedFiles.push(files[fileIndex]); - missedPositions.push(fileIndex); - } - summaryMissCount = missedFiles.length; - const parsedMissedModules = await parseFilesInParallel(missedFiles); - for (let missIndex = 0; missIndex < missedFiles.length; missIndex++) { - parsedModules[missedPositions[missIndex]] = parsedMissedModules[missIndex]; - summaryCache.storeSummary(missedFiles[missIndex].path, parsedMissedModules[missIndex]); - } - } - - const discoveredEntries = await entriesPromise; - const moduleLinkInputsResult = buildModuleLinkInputs({ - files, - parsedModules, - resolvedEntries: discoveredEntries, - gitIgnoredFilePaths: gitIgnoredFileSet, - resolveModule: resolveModuleThroughCache, - }); - setupErrors.push(...moduleLinkInputsResult.errors); - - let moduleGraph: ReturnType<typeof buildDependencyGraph>; - try { - moduleGraph = buildDependencyGraph(moduleLinkInputsResult.graphInputs); - } catch (graphError) { - setupErrors.push( - new DetectorError({ - module: "linker", - severity: "fatal", - message: "buildDependencyGraph threw", - detail: describeUnknownError(graphError), - }), - ); - return buildEmptyScanResult(setupErrors, performance.now() - pipelineStartTime); - } - - try { - resolveReExportChains(moduleGraph); - } catch (reExportError) { - setupErrors.push( - new DetectorError({ - module: "linker", - message: "resolveReExportChains threw — re-export propagation skipped", - detail: describeUnknownError(reExportError), - }), - ); - } - - markFilenameRegistryEntries(moduleGraph); - - try { - traceReachability(moduleGraph); - } catch (reachabilityError) { - setupErrors.push( - new DetectorError({ - module: "linker", - message: "traceReachability threw — every module marked reachable to avoid over-reporting", - detail: describeUnknownError(reachabilityError), - }), - ); - for (const module of moduleGraph.modules) module.isReachable = true; - } - - let analysisResult: ScanResult; - try { - analysisResult = generateReport(moduleGraph, config, summaryCache ?? undefined); - } catch (reportError) { - setupErrors.push( - new DetectorError({ - module: "report", - severity: "fatal", - message: "generateReport threw at the top level", - detail: describeUnknownError(reportError), - }), - ); - return buildEmptyScanResult(setupErrors, performance.now() - pipelineStartTime); - } - - summaryCache?.save(); - - if (summaryCache !== null) { - analysisResult.incrementalCacheStats = { - summaryHits: files.length - summaryMissCount, - summaryMisses: summaryMissCount, - }; - } - - if (setupErrors.length > 0) { - analysisResult.analysisErrors = [...setupErrors, ...analysisResult.analysisErrors]; - } - analysisResult.analysisTimeMs = performance.now() - pipelineStartTime; - - return analysisResult; -}; diff --git a/packages/deslop-js/src/linker/build-module-link-inputs.ts b/packages/deslop-js/src/linker/build-module-link-inputs.ts deleted file mode 100644 index 92d42cf13e..0000000000 --- a/packages/deslop-js/src/linker/build-module-link-inputs.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { dirname } from "node:path"; -import { existsSync } from "node:fs"; -import fg from "fast-glob"; -import type { DeslopError, ResolvedEntries, SourceFile } from "../types.js"; -import { ResolverError, WorkspaceError, describeUnknownError } from "../errors.js"; -import { parseSourceFile, type ParsedSource } from "../collect/parse.js"; -import type { ResolvedImport } from "../resolver/resolve.js"; -import type { ModuleLinkInput } from "./build.js"; - -interface BuildModuleLinkInputsOptions { - files: SourceFile[]; - parsedModules: ParsedSource[]; - resolvedEntries: ResolvedEntries; - gitIgnoredFilePaths: ReadonlySet<string>; - resolveModule: (specifier: string, fromFile: string) => ResolvedImport; -} - -interface ModuleLinkInputsResult { - graphInputs: ModuleLinkInput[]; - errors: DeslopError[]; -} - -interface ModuleResolutionContext { - errors: DeslopError[]; - resolveModule: (specifier: string, fromFile: string) => ResolvedImport; -} - -interface StyleDiscoveryContext extends ModuleResolutionContext { - discoveredFilePaths: Set<string>; - pendingStyleFilePaths: Set<string>; - styleFileQueue: string[]; -} - -const STYLE_EXTENSIONS = [".css", ".scss"]; - -const isStyleFile = (filePath: string): boolean => - STYLE_EXTENSIONS.some((extension) => filePath.endsWith(extension)); - -const unresolvedImport = (): ResolvedImport => ({ - resolvedPath: undefined, - isExternal: false, - packageName: undefined, -}); - -const resolveImport = ( - context: ModuleResolutionContext, - specifier: string, - fromFilePath: string, - failureMessage: string, -): ResolvedImport => { - try { - return context.resolveModule(specifier, fromFilePath); - } catch (resolveError) { - context.errors.push( - new ResolverError({ - severity: "warning", - message: failureMessage, - path: fromFilePath, - detail: describeUnknownError(resolveError), - }), - ); - return unresolvedImport(); - } -}; - -const expandImportGlob = ( - specifier: string, - fromFilePath: string, - errors: DeslopError[], -): string[] => { - try { - return fg.sync(specifier, { - cwd: dirname(fromFilePath), - absolute: true, - onlyFiles: true, - ignore: ["**/node_modules/**"], - }); - } catch (globError) { - errors.push( - new WorkspaceError({ - code: "workspace-discovery-failed", - message: `fast-glob threw on import glob "${specifier}"`, - path: fromFilePath, - detail: describeUnknownError(globError), - }), - ); - return []; - } -}; - -const collectSourceImports = ( - parsedModule: ParsedSource, - filePath: string, - context: ModuleResolutionContext, -): Map<string, ResolvedImport> => { - const resolvedImports = new Map<string, ResolvedImport>(); - for (const importInfo of parsedModule.imports) { - if (importInfo.isGlob) { - for (const expandedFilePath of expandImportGlob( - importInfo.specifier, - filePath, - context.errors, - )) { - resolvedImports.set(expandedFilePath, { - resolvedPath: expandedFilePath, - isExternal: false, - packageName: undefined, - }); - } - resolvedImports.set(importInfo.specifier, unresolvedImport()); - continue; - } - resolvedImports.set( - importInfo.specifier, - resolveImport( - context, - importInfo.specifier, - filePath, - `moduleResolver.resolveModule threw on specifier "${importInfo.specifier}"`, - ), - ); - } - return resolvedImports; -}; - -const collectReExportImports = ( - parsedModule: ParsedSource, - filePath: string, - resolvedImports: Map<string, ResolvedImport>, - context: ModuleResolutionContext, -): void => { - for (const exportInfo of parsedModule.exports) { - if ( - !exportInfo.isReExport || - !exportInfo.reExportSource || - resolvedImports.has(exportInfo.reExportSource) - ) { - continue; - } - resolvedImports.set( - exportInfo.reExportSource, - resolveImport( - context, - exportInfo.reExportSource, - filePath, - `moduleResolver.resolveModule threw on specifier "${exportInfo.reExportSource}"`, - ), - ); - } -}; - -const buildSourceModuleLinkInputs = ( - options: BuildModuleLinkInputsOptions, -): ModuleLinkInputsResult => { - const errors: DeslopError[] = []; - const productionEntryPaths = new Set(options.resolvedEntries.productionEntries); - const testEntryPaths = new Set(options.resolvedEntries.testEntries); - const alwaysUsedFilePaths = new Set(options.resolvedEntries.alwaysUsedFiles); - const graphInputs: ModuleLinkInput[] = []; - const resolutionContext: ModuleResolutionContext = { - errors, - resolveModule: options.resolveModule, - }; - - for (let fileIndex = 0; fileIndex < options.files.length; fileIndex++) { - const file = options.files[fileIndex]; - const parsedModule = options.parsedModules[fileIndex]; - const resolvedImports = collectSourceImports(parsedModule, file.path, resolutionContext); - collectReExportImports(parsedModule, file.path, resolvedImports, resolutionContext); - - graphInputs.push({ - fileId: file, - parsed: parsedModule, - resolvedImports, - isEntryPoint: - alwaysUsedFilePaths.has(file.path) || - productionEntryPaths.has(file.path) || - testEntryPaths.has(file.path), - isTestEntry: testEntryPaths.has(file.path), - isGitIgnored: options.gitIgnoredFilePaths.has(file.path), - }); - } - - return { graphInputs, errors }; -}; - -const findUndiscoveredStyleFilePath = ( - resolvedImport: ResolvedImport, - discoveredFilePaths: ReadonlySet<string>, -): string | undefined => { - const resolvedPath = resolvedImport.resolvedPath; - if ( - !resolvedPath || - discoveredFilePaths.has(resolvedPath) || - !isStyleFile(resolvedPath) || - !existsSync(resolvedPath) - ) { - return undefined; - } - return resolvedPath; -}; - -const collectPendingStyleFilePaths = ( - sourceGraphInputs: ModuleLinkInput[], - discoveredFilePaths: ReadonlySet<string>, -): Set<string> => { - const pendingStyleFilePaths = new Set<string>(); - for (const graphInput of sourceGraphInputs) { - for (const resolvedImport of graphInput.resolvedImports.values()) { - if (resolvedImport.isExternal) continue; - const styleFilePath = findUndiscoveredStyleFilePath(resolvedImport, discoveredFilePaths); - if (styleFilePath) pendingStyleFilePaths.add(styleFilePath); - } - } - return pendingStyleFilePaths; -}; - -const collectStyleImports = ( - parsedStyleModule: ParsedSource, - styleFilePath: string, - context: StyleDiscoveryContext, -): Map<string, ResolvedImport> => { - const resolvedStyleImports = new Map<string, ResolvedImport>(); - for (const importInfo of parsedStyleModule.imports) { - const resolvedImport = resolveImport( - context, - importInfo.specifier, - styleFilePath, - `moduleResolver.resolveModule threw on style import "${importInfo.specifier}"`, - ); - resolvedStyleImports.set(importInfo.specifier, resolvedImport); - - const importedStyleFilePath = findUndiscoveredStyleFilePath( - resolvedImport, - context.discoveredFilePaths, - ); - if (!importedStyleFilePath || context.pendingStyleFilePaths.has(importedStyleFilePath)) { - continue; - } - context.pendingStyleFilePaths.add(importedStyleFilePath); - context.styleFileQueue.push(importedStyleFilePath); - } - return resolvedStyleImports; -}; - -const buildStyleModuleLinkInputs = ( - options: BuildModuleLinkInputsOptions, - sourceGraphInputs: ModuleLinkInput[], -): ModuleLinkInputsResult => { - const errors: DeslopError[] = []; - const graphInputs: ModuleLinkInput[] = []; - const discoveredFilePaths = new Set(options.files.map((file) => file.path)); - const pendingStyleFilePaths = collectPendingStyleFilePaths( - sourceGraphInputs, - discoveredFilePaths, - ); - const styleFileQueue = [...pendingStyleFilePaths].sort(); - const discoveryContext: StyleDiscoveryContext = { - discoveredFilePaths, - errors, - pendingStyleFilePaths, - resolveModule: options.resolveModule, - styleFileQueue, - }; - let nextFileIndex = options.files.length; - for (let queueIndex = 0; queueIndex < styleFileQueue.length; queueIndex++) { - const styleFilePath = styleFileQueue[queueIndex]; - if (discoveredFilePaths.has(styleFilePath)) continue; - - const parsedStyleModule = parseSourceFile(styleFilePath); - const resolvedStyleImports = collectStyleImports( - parsedStyleModule, - styleFilePath, - discoveryContext, - ); - - graphInputs.push({ - fileId: { index: nextFileIndex, path: styleFilePath }, - parsed: parsedStyleModule, - resolvedImports: resolvedStyleImports, - isEntryPoint: false, - isTestEntry: false, - isGitIgnored: options.gitIgnoredFilePaths.has(styleFilePath), - }); - discoveredFilePaths.add(styleFilePath); - nextFileIndex++; - } - - return { graphInputs, errors }; -}; - -export const buildModuleLinkInputs = ( - options: BuildModuleLinkInputsOptions, -): ModuleLinkInputsResult => { - const sourceResult = buildSourceModuleLinkInputs(options); - const styleResult = buildStyleModuleLinkInputs(options, sourceResult.graphInputs); - return { - graphInputs: [...sourceResult.graphInputs, ...styleResult.graphInputs], - errors: [...sourceResult.errors, ...styleResult.errors], - }; -}; diff --git a/packages/deslop-js/src/linker/build.ts b/packages/deslop-js/src/linker/build.ts deleted file mode 100644 index 497aedec4b..0000000000 --- a/packages/deslop-js/src/linker/build.ts +++ /dev/null @@ -1,174 +0,0 @@ -import path from "node:path"; -import { minimatch } from "minimatch"; -import type { - SourceFile, - DependencyGraph, - SourceModule, - Edge, - LinkedSymbol, - ReExportMapping, -} from "../types.js"; -import type { ParsedSource } from "../collect/parse.js"; -import type { ResolvedImport } from "../resolver/resolve.js"; -import { isConfigFile } from "../utils/is-config-file.js"; -import { toPosixPath } from "../utils/to-posix-path.js"; - -export interface ModuleLinkInput { - fileId: SourceFile; - parsed: ParsedSource; - resolvedImports: Map<string, ResolvedImport>; - isEntryPoint: boolean; - isTestEntry: boolean; - isGitIgnored: boolean; -} - -export const buildDependencyGraph = (inputs: ModuleLinkInput[]): DependencyGraph => { - const normalizedInputs = inputs.map((input) => ({ - ...input, - fileId: { - ...input.fileId, - path: toPosixPath(input.fileId.path), - }, - })); - - const fileIdMap = new Map<string, number>(); - for (const input of normalizedInputs) { - fileIdMap.set(input.fileId.path, input.fileId.index); - } - - const modules: SourceModule[] = normalizedInputs.map((input) => ({ - fileId: input.fileId, - imports: input.parsed.imports, - exports: input.parsed.exports, - memberAccesses: input.parsed.memberAccesses, - wholeObjectUses: input.parsed.wholeObjectUses, - localIdentifierReferences: input.parsed.localIdentifierReferences, - topLevelImportReferences: input.parsed.topLevelImportReferences, - referencedFilenames: input.parsed.referencedFilenames, - redundantTypePatterns: input.parsed.redundantTypePatterns, - identityWrappers: input.parsed.identityWrappers, - typeDefinitionHashes: input.parsed.typeDefinitionHashes, - inlineTypeLiterals: input.parsed.inlineTypeLiterals, - simplifiableFunctions: input.parsed.simplifiableFunctions, - simplifiableExpressions: input.parsed.simplifiableExpressions, - duplicateConstantCandidates: input.parsed.duplicateConstantCandidates, - parseErrors: input.parsed.errors, - isEntryPoint: input.isEntryPoint, - isTestEntry: input.isTestEntry, - isReachable: false, - isDeclarationFile: - input.fileId.path.endsWith(".d.ts") || - input.fileId.path.endsWith(".d.mts") || - input.fileId.path.endsWith(".d.cts"), - isConfigFile: isConfigFile(input.fileId.path), - isGitIgnored: input.isGitIgnored, - })); - - const edges: Edge[] = []; - const reverseEdges = new Map<number, number[]>(); - - const addEdge = ( - sourceIndex: number, - targetIndex: number, - symbols: LinkedSymbol[], - isReExportEdge: boolean = false, - reExportedNames: string[] = [], - reExportMappings: ReExportMapping[] = [], - isDynamic: boolean = false, - ): void => { - edges.push({ - source: sourceIndex, - target: targetIndex, - importedSymbols: symbols, - isReExportEdge, - isDynamic, - reExportedNames, - reExportMappings, - }); - - const existingReverseEdges = reverseEdges.get(targetIndex); - if (existingReverseEdges) { - if (!existingReverseEdges.includes(sourceIndex)) { - existingReverseEdges.push(sourceIndex); - } - } else { - reverseEdges.set(targetIndex, [sourceIndex]); - } - }; - - for (const input of normalizedInputs) { - const sourceIndex = input.fileId.index; - - for (const importInfo of input.parsed.imports) { - if (importInfo.isGlob) { - const sourceDir = path.dirname(input.fileId.path); - const globPattern = importInfo.specifier; - for (const [filePath] of fileIdMap) { - const relativePath = toPosixPath(path.relative(sourceDir, filePath)); - const normalizedRelative = relativePath.startsWith(".") - ? relativePath - : `./${relativePath}`; - if (minimatch(normalizedRelative, globPattern)) { - const targetIndex = fileIdMap.get(filePath); - if (targetIndex !== undefined) { - addEdge(sourceIndex, targetIndex, [], false, [], [], true); - } - } - } - continue; - } - - const resolved = input.resolvedImports.get(importInfo.specifier); - if (!resolved?.resolvedPath) continue; - - const targetIndex = fileIdMap.get(toPosixPath(resolved.resolvedPath)); - if (targetIndex === undefined) continue; - - const importedSymbols: LinkedSymbol[] = importInfo.importedNames.map((importedName) => ({ - importedName: importedName.name, - localName: importedName.alias ?? importedName.name, - isTypeOnly: importedName.isTypeOnly, - isNamespace: importedName.isNamespace, - isDefault: importedName.isDefault, - })); - - addEdge(sourceIndex, targetIndex, importedSymbols, false, [], [], importInfo.isDynamic); - } - - const reExportsByTarget = new Map<number, { names: string[]; mappings: ReExportMapping[] }>(); - for (const exportInfo of input.parsed.exports) { - if (!exportInfo.isReExport || !exportInfo.reExportSource) continue; - - const resolved = input.resolvedImports.get(exportInfo.reExportSource); - if (!resolved?.resolvedPath) continue; - - const targetIndex = fileIdMap.get(toPosixPath(resolved.resolvedPath)); - if (targetIndex === undefined) continue; - - const exportedName = exportInfo.isNamespaceReExport ? "*" : exportInfo.name; - const originalName = exportInfo.isNamespaceReExport - ? "*" - : (exportInfo.reExportOriginalName ?? exportInfo.name); - - const existing = reExportsByTarget.get(targetIndex); - if (existing) { - existing.names.push(exportedName); - existing.mappings.push({ exportedName, originalName }); - } else { - reExportsByTarget.set(targetIndex, { - names: [exportedName], - mappings: [{ exportedName, originalName }], - }); - } - } - - for (const [ - targetIndex, - { names: reExportedNames, mappings: reExportMappings }, - ] of reExportsByTarget) { - addEdge(sourceIndex, targetIndex, [], true, reExportedNames, reExportMappings); - } - } - - return { modules, edges, reverseEdges, fileIdMap }; -}; diff --git a/packages/deslop-js/src/linker/reachability.ts b/packages/deslop-js/src/linker/reachability.ts deleted file mode 100644 index 8e75883424..0000000000 --- a/packages/deslop-js/src/linker/reachability.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { DependencyGraph, Edge } from "../types.js"; -import { PLATFORM_SUFFIXES } from "../constants.js"; - -const PLATFORM_DIRECTORY_NAMES = new Set([ - "web", - "native", - "ios", - "android", - "desktop", - "windows", - "macos", -]); - -const stripPlatformSuffix = (filePath: string): string | undefined => { - for (const suffix of PLATFORM_SUFFIXES) { - const extensionIndex = filePath.lastIndexOf("."); - if (extensionIndex === -1) continue; - - const withoutExtension = filePath.slice(0, extensionIndex); - if (withoutExtension.endsWith(suffix)) { - return withoutExtension.slice(0, -suffix.length) + filePath.slice(extensionIndex); - } - } - return undefined; -}; - -const stripPlatformDirectory = (filePath: string): string | undefined => { - const segments = filePath.split("/"); - for (let segmentIndex = segments.length - 2; segmentIndex >= 0; segmentIndex--) { - if (PLATFORM_DIRECTORY_NAMES.has(segments[segmentIndex])) { - const withoutPlatformDir = [ - ...segments.slice(0, segmentIndex), - ...segments.slice(segmentIndex + 1), - ].join("/"); - return withoutPlatformDir; - } - } - return undefined; -}; - -interface ReachabilityQueueItem { - moduleIndex: number; - demandedSymbols: Set<string> | "all"; -} - -export const traceReachability = (graph: DependencyGraph): void => { - const totalModules = graph.modules.length; - const visited = new Uint8Array(totalModules); - const consumedExportsPerModule = new Map<number, Set<string>>(); - const queue: ReachabilityQueueItem[] = []; - - const outgoingEdgesMap = new Map<number, Edge[]>(); - for (const edge of graph.edges) { - const existing = outgoingEdgesMap.get(edge.source); - if (existing) { - existing.push(edge); - } else { - outgoingEdgesMap.set(edge.source, [edge]); - } - } - - for (const module of graph.modules) { - if (module.isEntryPoint) { - const moduleIndex = module.fileId.index; - if (moduleIndex < totalModules) { - visited[moduleIndex] = 1; - queue.push({ moduleIndex, demandedSymbols: "all" }); - } - } - } - - const markConsumedExports = (targetModuleIndex: number, symbols: Set<string> | "all"): void => { - if (symbols === "all") { - consumedExportsPerModule.set(targetModuleIndex, new Set(["*"])); - return; - } - const existing = consumedExportsPerModule.get(targetModuleIndex); - if (existing && existing.has("*")) return; - if (existing) { - for (const symbol of symbols) { - existing.add(symbol); - } - } else { - consumedExportsPerModule.set(targetModuleIndex, new Set(symbols)); - } - }; - - let headPointer = 0; - while (headPointer < queue.length) { - const { moduleIndex: currentIndex } = queue[headPointer++]; - const outgoingEdges = outgoingEdgesMap.get(currentIndex); - if (!outgoingEdges) continue; - - for (const edge of outgoingEdges) { - const targetIndex = edge.target; - if (targetIndex >= totalModules) continue; - - if (edge.isReExportEdge) { - if (!visited[targetIndex]) { - visited[targetIndex] = 1; - markConsumedExports(targetIndex, "all"); - queue.push({ moduleIndex: targetIndex, demandedSymbols: "all" }); - } - } else { - const importSymbolNames = new Set<string>(); - let isNamespaceOrSideEffect = edge.importedSymbols.length === 0; - - for (const symbol of edge.importedSymbols) { - if (symbol.isNamespace) { - isNamespaceOrSideEffect = true; - break; - } - importSymbolNames.add(symbol.importedName); - if (symbol.isDefault) { - importSymbolNames.add("default"); - } - } - - const symbolDemand: Set<string> | "all" = isNamespaceOrSideEffect - ? "all" - : importSymbolNames; - - if (!visited[targetIndex]) { - visited[targetIndex] = 1; - markConsumedExports(targetIndex, symbolDemand); - queue.push({ moduleIndex: targetIndex, demandedSymbols: symbolDemand }); - } else { - const existingConsumed = consumedExportsPerModule.get(targetIndex); - if (symbolDemand !== "all" && existingConsumed && !existingConsumed.has("*")) { - let hasNewSymbols = false; - for (const symbol of symbolDemand) { - if (!existingConsumed.has(symbol)) { - hasNewSymbols = true; - break; - } - } - if (hasNewSymbols) { - markConsumedExports(targetIndex, symbolDemand); - queue.push({ moduleIndex: targetIndex, demandedSymbols: symbolDemand }); - } - } else if (symbolDemand === "all" && (!existingConsumed || !existingConsumed.has("*"))) { - markConsumedExports(targetIndex, "all"); - queue.push({ moduleIndex: targetIndex, demandedSymbols: "all" }); - } - } - } - } - } - - const platformSiblingGroups = new Map<string, number[]>(); - const addToSiblingGroup = (groupKey: string, moduleIndex: number): void => { - const existingSiblings = platformSiblingGroups.get(groupKey); - if (existingSiblings) { - existingSiblings.push(moduleIndex); - } else { - platformSiblingGroups.set(groupKey, [moduleIndex]); - } - }; - - for (let moduleIndex = 0; moduleIndex < totalModules; moduleIndex++) { - const modulePath = graph.modules[moduleIndex].fileId.path; - - const basePathFromSuffix = stripPlatformSuffix(modulePath); - if (basePathFromSuffix) { - addToSiblingGroup(basePathFromSuffix, moduleIndex); - } - - const basePathFromDirectory = stripPlatformDirectory(modulePath); - if (basePathFromDirectory) { - addToSiblingGroup("dir:" + basePathFromDirectory, moduleIndex); - } - } - - for (let moduleIndex = 0; moduleIndex < totalModules; moduleIndex++) { - const modulePath = graph.modules[moduleIndex].fileId.path; - if (platformSiblingGroups.has(modulePath)) { - platformSiblingGroups.get(modulePath)!.push(moduleIndex); - } - } - - const platformQueue: ReachabilityQueueItem[] = []; - for (const siblingIndices of platformSiblingGroups.values()) { - const hasReachableSibling = siblingIndices.some((index) => Boolean(visited[index])); - if (hasReachableSibling) { - for (const siblingIndex of siblingIndices) { - if (!visited[siblingIndex]) { - visited[siblingIndex] = 1; - platformQueue.push({ moduleIndex: siblingIndex, demandedSymbols: "all" }); - } - } - } - } - - let platformHeadPointer = 0; - while (platformHeadPointer < platformQueue.length) { - const { moduleIndex: currentIndex } = platformQueue[platformHeadPointer++]; - const outgoingEdges = outgoingEdgesMap.get(currentIndex); - if (!outgoingEdges) continue; - - for (const edge of outgoingEdges) { - if (edge.target < totalModules && !visited[edge.target]) { - visited[edge.target] = 1; - platformQueue.push({ moduleIndex: edge.target, demandedSymbols: "all" }); - } - } - } - - for (let moduleIndex = 0; moduleIndex < totalModules; moduleIndex++) { - graph.modules[moduleIndex].isReachable = Boolean(visited[moduleIndex]); - } -}; diff --git a/packages/deslop-js/src/report/complexity.ts b/packages/deslop-js/src/report/complexity.ts deleted file mode 100644 index 8d99749cf4..0000000000 --- a/packages/deslop-js/src/report/complexity.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { readFileSync } from "node:fs"; -import { parseSync } from "oxc-parser"; -import type { - ComplexityConfig, - DependencyGraph, - FunctionComplexity, - SemanticConfidence, -} from "../types.js"; -import { computeLineStarts } from "../utils/compute-line-starts.js"; -import { offsetToLineColumn } from "../utils/offset-to-line-column.js"; -import { isAstNode } from "../utils/is-ast-node.js"; - -interface FunctionFrame { - functionName: string; - startOffset: number; - endOffset: number; - cyclomaticComplexity: number; - cognitiveComplexity: number; - nestingLevel: number; - lastLogicalOperator: "&&" | "||" | "??" | undefined; - parameterCount: number; -} - -interface VisitState { - filePath: string; - lineStarts: number[]; - results: FunctionComplexity[]; - frameStack: FunctionFrame[]; - pendingFunctionName: string | undefined; -} - -const incrementCyclomatic = (state: VisitState): void => { - const topFrame = state.frameStack[state.frameStack.length - 1]; - if (topFrame) topFrame.cyclomaticComplexity++; -}; - -const incrementCognitiveWithNesting = (state: VisitState): void => { - const topFrame = state.frameStack[state.frameStack.length - 1]; - if (topFrame) topFrame.cognitiveComplexity += 1 + topFrame.nestingLevel; -}; - -const incrementCognitiveFlat = (state: VisitState): void => { - const topFrame = state.frameStack[state.frameStack.length - 1]; - if (topFrame) topFrame.cognitiveComplexity++; -}; - -const handleLogicalOperator = (operator: "&&" | "||" | "??", state: VisitState): void => { - const topFrame = state.frameStack[state.frameStack.length - 1]; - if (!topFrame) return; - if (topFrame.lastLogicalOperator === undefined) { - topFrame.cognitiveComplexity++; - topFrame.lastLogicalOperator = operator; - return; - } - if (topFrame.lastLogicalOperator === operator) return; - topFrame.cognitiveComplexity++; - topFrame.lastLogicalOperator = operator; -}; - -const resetLogicalOperator = (state: VisitState): void => { - const topFrame = state.frameStack[state.frameStack.length - 1]; - if (topFrame) topFrame.lastLogicalOperator = undefined; -}; - -const incrementNesting = (state: VisitState): void => { - const topFrame = state.frameStack[state.frameStack.length - 1]; - if (topFrame) topFrame.nestingLevel++; -}; - -const decrementNesting = (state: VisitState): void => { - const topFrame = state.frameStack[state.frameStack.length - 1]; - if (topFrame && topFrame.nestingLevel > 0) topFrame.nestingLevel--; -}; - -const countParameters = (parametersNode: unknown): number => { - if (!isAstNode(parametersNode)) return 0; - const params = parametersNode; - if (Array.isArray(params.params)) { - return params.params.length; - } - if (Array.isArray(params.items)) { - return params.items.length; - } - return 0; -}; - -const visitChildrenGeneric = (node: unknown, visitor: (child: unknown) => void): void => { - if (!isAstNode(node)) return; - for (const key of Object.keys(node)) { - if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") { - continue; - } - const value = node[key]; - if (Array.isArray(value)) { - for (const item of value) visitor(item); - } else if (value !== null && typeof value === "object") { - visitor(value); - } - } -}; - -const pushFunctionFrame = ( - functionName: string, - startOffset: number, - endOffset: number, - parameterCount: number, - state: VisitState, -): void => { - state.frameStack.push({ - functionName, - startOffset, - endOffset, - cyclomaticComplexity: 1, - cognitiveComplexity: 0, - nestingLevel: 0, - lastLogicalOperator: undefined, - parameterCount, - }); -}; - -const popFunctionFrame = (state: VisitState): void => { - const completedFrame = state.frameStack.pop(); - if (!completedFrame) return; - const { line, column } = offsetToLineColumn(completedFrame.startOffset, state.lineStarts); - const endLine = offsetToLineColumn(completedFrame.endOffset, state.lineStarts).line; - state.results.push({ - path: state.filePath, - functionName: completedFrame.functionName, - line, - column, - cyclomatic: completedFrame.cyclomaticComplexity, - cognitive: completedFrame.cognitiveComplexity, - lineCount: Math.max(1, endLine - line + 1), - paramCount: completedFrame.parameterCount, - confidence: "medium", - reason: "", - }); -}; - -const visitFunctionLike = (node: unknown, kind: "function" | "arrow", state: VisitState): void => { - if (!isAstNode(node)) return; - const functionName = - state.pendingFunctionName ?? - (() => { - const idNode = node.id; - const idName = isAstNode(idNode) ? idNode.name : undefined; - return typeof idName === "string" ? idName : kind === "arrow" ? "<arrow>" : "<anonymous>"; - })(); - state.pendingFunctionName = undefined; - - const isNested = state.frameStack.length > 0; - if (isNested) incrementNesting(state); - - const startOffset = node.start; - const endOffset = node.end; - const parameterCount = countParameters(node.params); - pushFunctionFrame( - functionName, - typeof startOffset === "number" ? startOffset : 0, - typeof endOffset === "number" ? endOffset : 0, - parameterCount, - state, - ); - - visitChildrenGeneric(node, (child) => visitNode(child, state)); - popFunctionFrame(state); - - if (isNested) decrementNesting(state); -}; - -const visitNode = (node: unknown, state: VisitState): void => { - if (!isAstNode(node)) return; - - switch (node.type) { - case "FunctionDeclaration": - case "FunctionExpression": - case "MethodDefinition": - if (node.type === "MethodDefinition") { - const keyNode = node.key; - const keyName = isAstNode(keyNode) ? (keyNode.name ?? keyNode.value) : undefined; - if (typeof keyName === "string") state.pendingFunctionName = keyName; - visitChildrenGeneric(node, (child) => visitNode(child, state)); - state.pendingFunctionName = undefined; - return; - } - visitFunctionLike(node, "function", state); - return; - - case "ArrowFunctionExpression": - visitFunctionLike(node, "arrow", state); - return; - - case "VariableDeclarator": { - const declaratorId = node.id; - const declaratorIdName = isAstNode(declaratorId) ? declaratorId.name : undefined; - if (typeof declaratorIdName === "string") state.pendingFunctionName = declaratorIdName; - visitChildrenGeneric(node, (child) => visitNode(child, state)); - state.pendingFunctionName = undefined; - return; - } - - case "PropertyDefinition": { - const keyNode = node.key; - const keyName = isAstNode(keyNode) ? keyNode.name : undefined; - if (typeof keyName === "string") state.pendingFunctionName = keyName; - visitChildrenGeneric(node, (child) => visitNode(child, state)); - state.pendingFunctionName = undefined; - return; - } - - case "IfStatement": - incrementCyclomatic(state); - incrementCognitiveWithNesting(state); - incrementNesting(state); - visitChildrenGeneric(node, (child) => visitNode(child, state)); - decrementNesting(state); - resetLogicalOperator(state); - return; - - case "ForStatement": - case "ForInStatement": - case "ForOfStatement": - case "WhileStatement": - case "DoWhileStatement": - incrementCyclomatic(state); - incrementCognitiveWithNesting(state); - incrementNesting(state); - visitChildrenGeneric(node, (child) => visitNode(child, state)); - decrementNesting(state); - return; - - case "SwitchCase": { - const testNode = node.test; - if (testNode !== null && testNode !== undefined) { - incrementCyclomatic(state); - incrementCognitiveFlat(state); - } - visitChildrenGeneric(node, (child) => visitNode(child, state)); - return; - } - - case "CatchClause": - incrementCyclomatic(state); - incrementCognitiveWithNesting(state); - incrementNesting(state); - visitChildrenGeneric(node, (child) => visitNode(child, state)); - decrementNesting(state); - return; - - case "ConditionalExpression": - incrementCyclomatic(state); - incrementCognitiveWithNesting(state); - visitChildrenGeneric(node, (child) => visitNode(child, state)); - return; - - case "LogicalExpression": { - const operator = node.operator; - if (operator === "&&" || operator === "||" || operator === "??") { - incrementCyclomatic(state); - handleLogicalOperator(operator, state); - } - visitChildrenGeneric(node, (child) => visitNode(child, state)); - return; - } - - case "AssignmentExpression": { - const operator = node.operator; - if (operator === "&&=" || operator === "||=" || operator === "??=") { - incrementCyclomatic(state); - } - visitChildrenGeneric(node, (child) => visitNode(child, state)); - return; - } - - case "ChainExpression": - incrementCyclomatic(state); - visitChildrenGeneric(node, (child) => visitNode(child, state)); - return; - - default: - visitChildrenGeneric(node, (child) => visitNode(child, state)); - } -}; - -const annotateConfidence = ( - finding: FunctionComplexity, - config: ComplexityConfig, -): { confidence: SemanticConfidence; reason: string } => { - const breaches: string[] = []; - if (finding.cyclomatic >= config.cyclomaticThreshold) { - breaches.push(`cyclomatic ${finding.cyclomatic} ≥ ${config.cyclomaticThreshold}`); - } - if (finding.cognitive >= config.cognitiveThreshold) { - breaches.push(`cognitive ${finding.cognitive} ≥ ${config.cognitiveThreshold}`); - } - if (finding.paramCount >= config.paramCountThreshold) { - breaches.push(`paramCount ${finding.paramCount} ≥ ${config.paramCountThreshold}`); - } - if (finding.lineCount >= config.functionLineThreshold) { - breaches.push(`lineCount ${finding.lineCount} ≥ ${config.functionLineThreshold}`); - } - const confidence: SemanticConfidence = breaches.length >= 2 ? "high" : "medium"; - return { - confidence, - reason: `${finding.functionName} breaches ${breaches.length} threshold${breaches.length === 1 ? "" : "s"}: ${breaches.join(", ")}`, - }; -}; - -/** - * Per-function cyclomatic + cognitive complexity. - * - * Cyclomatic (McCabe): 1 + decision points. Counts if/for/while/do/case/catch, - * the ?: ternary, &&, ||, ??, &&=/||=/??=, and ?. (optional chaining). - * - * Cognitive (SonarSource): structural increments with nesting penalty. - * Operator-sequence rule: a run of the same logical operator is +1 total; - * each operator change adds another +1. - * - * Returns only functions whose metrics breach at least one threshold from - * `config`. Threshold breach count tunes the `confidence` field. - */ -export const detectComplexHotspots = ( - graph: DependencyGraph, - config: ComplexityConfig | undefined, -): FunctionComplexity[] => { - if (!config?.enabled) return []; - - const hotspotFindings: FunctionComplexity[] = []; - - for (const module of graph.modules) { - if (module.isDeclarationFile) continue; - if (module.isConfigFile) continue; - - let sourceText: string; - try { - sourceText = readFileSync(module.fileId.path, "utf-8"); - } catch { - continue; - } - let parseResult: ReturnType<typeof parseSync>; - try { - parseResult = parseSync(module.fileId.path, sourceText); - } catch { - continue; - } - - const visitState: VisitState = { - filePath: module.fileId.path, - lineStarts: computeLineStarts(sourceText), - results: [], - frameStack: [], - pendingFunctionName: undefined, - }; - visitNode(parseResult.program, visitState); - - for (const result of visitState.results) { - const breachesAtLeastOneThreshold = - result.cyclomatic >= config.cyclomaticThreshold || - result.cognitive >= config.cognitiveThreshold || - result.paramCount >= config.paramCountThreshold || - result.lineCount >= config.functionLineThreshold; - if (!breachesAtLeastOneThreshold) continue; - const annotated = annotateConfidence(result, config); - hotspotFindings.push({ - ...result, - confidence: annotated.confidence, - reason: annotated.reason, - }); - } - } - - hotspotFindings.sort((leftFinding, rightFinding) => { - const leftScore = leftFinding.cyclomatic + leftFinding.cognitive; - const rightScore = rightFinding.cyclomatic + rightFinding.cognitive; - if (leftScore !== rightScore) return rightScore - leftScore; - if (leftFinding.path !== rightFinding.path) - return leftFinding.path.localeCompare(rightFinding.path); - return leftFinding.line - rightFinding.line; - }); - - return hotspotFindings; -}; diff --git a/packages/deslop-js/src/report/cross-file-duplicate-exports.ts b/packages/deslop-js/src/report/cross-file-duplicate-exports.ts deleted file mode 100644 index 0e9259f456..0000000000 --- a/packages/deslop-js/src/report/cross-file-duplicate-exports.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { - CrossFileDuplicateExport, - CrossFileDuplicateExportLocation, - DependencyGraph, -} from "../types.js"; - -interface ExportEntry { - moduleIndex: number; - path: string; - line: number; - column: number; - isTypeOnly: boolean; -} - -const buildReExportSourceSets = (graph: DependencyGraph): Map<number, Set<number>> => { - const reExportSources = new Map<number, Set<number>>(); - for (const edge of graph.edges) { - if (!edge.isReExportEdge) continue; - const existing = reExportSources.get(edge.source); - if (existing) { - existing.add(edge.target); - } else { - reExportSources.set(edge.source, new Set([edge.target])); - } - } - return reExportSources; -}; - -/** - * Two duplicate-export files "share a common importer" when there exists a - * third file that imports from both, OR one duplicate file imports another. - * This filters out coincidental duplicates among unrelated leaf modules - * (SvelteKit/Next.js route files, scripts in different parts of a monorepo, - * etc.) that happen to export the same name but can never be confused at any - * import site. - */ -const hasCommonImporter = (moduleIndices: number[], graph: DependencyGraph): boolean => { - if (moduleIndices.length <= 1) return false; - const duplicateModuleSet = new Set(moduleIndices); - - const importerOwner = new Map<number, number>(); - for (const moduleIndex of moduleIndices) { - const importers = graph.reverseEdges.get(moduleIndex) ?? []; - for (const importerIndex of importers) { - if (duplicateModuleSet.has(importerIndex)) return true; - const previousOwner = importerOwner.get(importerIndex); - if (previousOwner === undefined) { - importerOwner.set(importerIndex, moduleIndex); - } else if (previousOwner !== moduleIndex) { - return true; - } - } - } - return false; -}; - -/** - * Cross-file duplicate exports: the same exported name lives in 2+ files. - * - * Filters applied (to keep the rule actionable): - * - default exports are skipped (every module gets one and it's not actionable) - * - re-export chains are pruned: if module A re-exports `Foo` from module B, - * the (A, B) pair is one chain, not two real declarations - * - TypeScript value/type namespace split: `export const X` and `export type X` - * in the same file are distinct in TS's value/type namespaces; same name in a - * value file and a type file is not a true duplicate either - * - common-importer filter: only report duplicates where two of the duplicate - * files share an importer or one imports another, so unrelated route files in - * different parts of a repo don't get flagged - */ -export const detectCrossFileDuplicateExports = ( - graph: DependencyGraph, -): CrossFileDuplicateExport[] => { - const reExportSources = buildReExportSourceSets(graph); - const exportEntriesByName = new Map<string, ExportEntry[]>(); - - for (const module of graph.modules) { - if (!module.isReachable) continue; - if (module.isDeclarationFile) continue; - if (module.isEntryPoint) continue; - - for (const exportInfo of module.exports) { - if (exportInfo.isDefault) continue; - if (exportInfo.isSynthetic) continue; - if (exportInfo.name === "*") continue; - if (exportInfo.isReExport) continue; - - const entry: ExportEntry = { - moduleIndex: module.fileId.index, - path: module.fileId.path, - line: exportInfo.line, - column: exportInfo.column, - isTypeOnly: exportInfo.isTypeOnly, - }; - - const existing = exportEntriesByName.get(exportInfo.name); - if (existing) { - existing.push(entry); - } else { - exportEntriesByName.set(exportInfo.name, [entry]); - } - } - } - - const findings: CrossFileDuplicateExport[] = []; - const sortedEntries = [...exportEntriesByName.entries()].sort(([nameA], [nameB]) => - nameA.localeCompare(nameB), - ); - - for (const [name, entries] of sortedEntries) { - if (entries.length <= 1) continue; - - const hasValueExport = entries.some((entry) => !entry.isTypeOnly); - const hasTypeExport = entries.some((entry) => entry.isTypeOnly); - if (hasValueExport && hasTypeExport) { - const valueModuleIndices = new Set( - entries.filter((entry) => !entry.isTypeOnly).map((entry) => entry.moduleIndex), - ); - const typeModuleIndices = new Set( - entries.filter((entry) => entry.isTypeOnly).map((entry) => entry.moduleIndex), - ); - if (valueModuleIndices.size <= 1 && typeModuleIndices.size <= 1) continue; - } - - const moduleIndexSet = new Set(entries.map((entry) => entry.moduleIndex)); - const independentEntries = entries.filter((entry) => { - const sources = reExportSources.get(entry.moduleIndex); - if (!sources) return true; - for (const sourceIndex of sources) { - if (moduleIndexSet.has(sourceIndex)) return false; - } - return true; - }); - - if (independentEntries.length <= 1) continue; - - const independentModuleIndices = independentEntries.map((entry) => entry.moduleIndex); - if (!hasCommonImporter(independentModuleIndices, graph)) continue; - - const locations: CrossFileDuplicateExportLocation[] = independentEntries.map((entry) => ({ - path: entry.path, - line: entry.line, - column: entry.column, - isTypeOnly: entry.isTypeOnly, - })); - - findings.push({ - name, - locations, - confidence: "medium", - reason: `"${name}" is exported from ${locations.length} files that share a common importer — consumers may import the wrong one`, - }); - } - - return findings; -}; diff --git a/packages/deslop-js/src/report/dry-patterns.ts b/packages/deslop-js/src/report/dry-patterns.ts deleted file mode 100644 index b4816318c1..0000000000 --- a/packages/deslop-js/src/report/dry-patterns.ts +++ /dev/null @@ -1,322 +0,0 @@ -import type { - DependencyGraph, - DuplicateConstant, - DuplicateConstantOccurrence, - DuplicateImport, - DuplicateImportOccurrence, - DuplicateInlineType, - DuplicateTypeDefinition, - DuplicateTypeDefinitionInstance, - IdentityWrapper, - InlineTypeOccurrence, - RedundantTypePattern, - SimplifiableExpression, - SimplifiableFunction, -} from "../types.js"; -import { - DUPLICATE_INLINE_TYPE_HIGH_MEMBER_COUNT, - MIN_FILES_FOR_DUPLICATE_CONSTANT, -} from "../constants.js"; - -export const detectDuplicateImports = (graph: DependencyGraph): DuplicateImport[] => { - const findings: DuplicateImport[] = []; - - for (const module of graph.modules) { - if (module.isDeclarationFile) continue; - - const groupedByKindAndSpecifier = new Map<string, DuplicateImportOccurrence[]>(); - for (const importInfo of module.imports) { - if (importInfo.isSideEffect) continue; - if (importInfo.isDynamic) continue; - if (importInfo.isGlob) continue; - const occurrence: DuplicateImportOccurrence = { - line: importInfo.line, - column: importInfo.column, - importedNames: importInfo.importedNames.map((binding) => - binding.isNamespace ? `* as ${binding.alias ?? ""}` : (binding.alias ?? binding.name), - ), - isTypeOnly: importInfo.isTypeOnly, - }; - const groupKey = `${importInfo.isTypeOnly ? "type" : "value"}:${importInfo.specifier}`; - const existing = groupedByKindAndSpecifier.get(groupKey); - if (existing) { - existing.push(occurrence); - } else { - groupedByKindAndSpecifier.set(groupKey, [occurrence]); - } - } - - for (const [groupKey, occurrences] of groupedByKindAndSpecifier) { - if (occurrences.length < 2) continue; - const specifier = groupKey.slice(groupKey.indexOf(":") + 1); - const kindLabel = groupKey.startsWith("type:") ? "type-only " : ""; - findings.push({ - path: module.fileId.path, - specifier, - occurrences, - confidence: "high", - reason: `"${specifier}" is imported ${occurrences.length} times in this file as ${kindLabel}imports — merge into a single statement`, - }); - } - } - - return findings; -}; - -export const detectRedundantTypePatterns = (graph: DependencyGraph): RedundantTypePattern[] => { - const findings: RedundantTypePattern[] = []; - - for (const module of graph.modules) { - if (module.isDeclarationFile) continue; - for (const parsedPattern of module.redundantTypePatterns) { - findings.push({ - path: module.fileId.path, - typeName: parsedPattern.typeName, - kind: parsedPattern.kind, - line: parsedPattern.line, - column: parsedPattern.column, - confidence: "high", - reason: parsedPattern.reason, - suggestion: parsedPattern.suggestion, - }); - } - } - - return findings; -}; - -export const detectIdentityWrappers = (graph: DependencyGraph): IdentityWrapper[] => { - const findings: IdentityWrapper[] = []; - - for (const module of graph.modules) { - if (module.isDeclarationFile) continue; - for (const parsedWrapper of module.identityWrappers) { - findings.push({ - path: module.fileId.path, - wrapperName: parsedWrapper.wrapperName, - wrappedExpression: parsedWrapper.wrappedExpression, - line: parsedWrapper.line, - column: parsedWrapper.column, - confidence: "high", - reason: `\`${parsedWrapper.wrapperName}\` is a thin wrapper that forwards every argument to \`${parsedWrapper.wrappedExpression}\` unchanged`, - }); - } - } - - return findings; -}; - -export const detectDuplicateTypeDefinitions = ( - graph: DependencyGraph, -): DuplicateTypeDefinition[] => { - const hashToInstances = new Map<string, DuplicateTypeDefinitionInstance[]>(); - - for (const module of graph.modules) { - if (module.isDeclarationFile) continue; - for (const typeHash of module.typeDefinitionHashes) { - const instance: DuplicateTypeDefinitionInstance = { - path: module.fileId.path, - typeName: typeHash.typeName, - line: typeHash.line, - column: typeHash.column, - }; - const existing = hashToInstances.get(typeHash.structuralHash); - if (existing) { - existing.push(instance); - } else { - hashToInstances.set(typeHash.structuralHash, [instance]); - } - } - } - - const findings: DuplicateTypeDefinition[] = []; - for (const [structuralHash, instances] of hashToInstances) { - if (instances.length < 2) continue; - const uniquePaths = new Set(instances.map((instance) => instance.path)); - if (uniquePaths.size < 2) continue; - const uniqueNames = new Set(instances.map((instance) => instance.typeName)); - const isAllSameName = uniqueNames.size === 1; - findings.push({ - structuralHash, - instances, - confidence: isAllSameName ? "high" : "medium", - reason: isAllSameName - ? `${instances.length} identically-named type definitions of the same shape across ${uniquePaths.size} files — extract a shared definition` - : `${instances.length} structurally-identical type definitions detected across ${uniquePaths.size} files under different names (${[...uniqueNames].join(", ")}) — confirm whether the rename is intentional`, - }); - } - - return findings; -}; - -export const detectDuplicateConstants = (graph: DependencyGraph): DuplicateConstant[] => { - const hashToBuckets = new Map< - string, - { literalPreview: string; occurrences: DuplicateConstantOccurrence[] } - >(); - - for (const module of graph.modules) { - if (module.isDeclarationFile) continue; - for (const candidate of module.duplicateConstantCandidates) { - const occurrence: DuplicateConstantOccurrence = { - path: module.fileId.path, - constantName: candidate.constantName, - line: candidate.line, - column: candidate.column, - }; - const existing = hashToBuckets.get(candidate.literalHash); - if (existing) { - existing.occurrences.push(occurrence); - } else { - hashToBuckets.set(candidate.literalHash, { - literalPreview: candidate.literalPreview, - occurrences: [occurrence], - }); - } - } - } - - const findings: DuplicateConstant[] = []; - for (const [literalHash, bucket] of hashToBuckets) { - const uniqueFilePaths = new Set(bucket.occurrences.map((occurrence) => occurrence.path)); - if (uniqueFilePaths.size < MIN_FILES_FOR_DUPLICATE_CONSTANT) continue; - const uniqueNames = new Set(bucket.occurrences.map((occurrence) => occurrence.constantName)); - if (uniqueNames.size > 1 && hasDistinctUnitSuffixes([...uniqueNames])) continue; - findings.push({ - literalHash, - literalPreview: bucket.literalPreview, - occurrences: bucket.occurrences, - confidence: uniqueNames.size === 1 ? "high" : "medium", - reason: - uniqueNames.size === 1 - ? `${bucket.occurrences.length} copies of \`const ${[...uniqueNames][0]} = ${bucket.literalPreview}\` across ${uniqueFilePaths.size} files — extract to a shared module` - : `${bucket.occurrences.length} constants across ${uniqueFilePaths.size} files share the same literal value ${bucket.literalPreview} under different names (${[...uniqueNames].join(", ")}) — consider extracting`, - }); - } - return findings; -}; - -const TRAILING_NAME_TOKEN_PATTERN = /_([A-Z][A-Z0-9]*)$/; - -const extractTrailingNameToken = (constantName: string): string | undefined => { - const match = constantName.match(TRAILING_NAME_TOKEN_PATTERN); - return match ? match[1] : undefined; -}; - -/** - * AGENTS.md requires magic numbers to use trailing unit suffixes (`_MS`, `_PX`, - * `_TOKENS`, `_WIDTH`, …). When same-value constants carry DIFFERENT trailing - * tokens (e.g. `STEP_DELAY_MS = 1000` vs `MINIMUM_TOKENS = 1000`), they - * represent semantically distinct quantities that cannot be consolidated — - * flagging them as duplicates is misleading. Constants sharing the same - * trailing token (e.g. `CACHE_INTERVAL_MS` + `RECONNECT_DELAY_MS`, both `_MS`) - * stay flagged because they are at least same-unit and might be extractable. - */ -const hasDistinctUnitSuffixes = (constantNames: string[]): boolean => { - const trailingTokens = new Set<string>(); - for (const name of constantNames) { - const token = extractTrailingNameToken(name); - if (!token) return false; - trailingTokens.add(token); - } - return trailingTokens.size > 1; -}; - -export const detectSimplifiableExpressions = (graph: DependencyGraph): SimplifiableExpression[] => { - const findings: SimplifiableExpression[] = []; - for (const module of graph.modules) { - if (module.isDeclarationFile) continue; - for (const parsedExpression of module.simplifiableExpressions) { - findings.push({ - path: module.fileId.path, - kind: parsedExpression.kind, - snippet: parsedExpression.snippet, - line: parsedExpression.line, - column: parsedExpression.column, - confidence: - parsedExpression.kind === "double-bang-boolean" || - parsedExpression.kind === "ternary-returns-boolean" || - parsedExpression.kind === "redundant-null-and-undefined-check" - ? "high" - : "medium", - reason: parsedExpression.reason, - suggestion: parsedExpression.suggestion, - }); - } - } - return findings; -}; - -export const detectSimplifiableFunctions = (graph: DependencyGraph): SimplifiableFunction[] => { - const findings: SimplifiableFunction[] = []; - for (const module of graph.modules) { - if (module.isDeclarationFile) continue; - for (const parsedFunction of module.simplifiableFunctions) { - findings.push({ - path: module.fileId.path, - kind: parsedFunction.kind, - functionName: parsedFunction.functionName, - line: parsedFunction.line, - column: parsedFunction.column, - confidence: parsedFunction.kind === "useless-async-no-await" ? "low" : "high", - reason: parsedFunction.reason, - suggestion: parsedFunction.suggestion, - }); - } - } - return findings; -}; - -export const detectDuplicateInlineTypes = (graph: DependencyGraph): DuplicateInlineType[] => { - const hashToOccurrences = new Map< - string, - { memberCount: number; preview: string; occurrences: InlineTypeOccurrence[] } - >(); - - for (const module of graph.modules) { - if (module.isDeclarationFile) continue; - for (const inlineLiteral of module.inlineTypeLiterals) { - const occurrence: InlineTypeOccurrence = { - path: module.fileId.path, - line: inlineLiteral.line, - column: inlineLiteral.column, - context: inlineLiteral.context, - nearestName: inlineLiteral.nearestName, - }; - const existing = hashToOccurrences.get(inlineLiteral.structuralHash); - if (existing) { - existing.occurrences.push(occurrence); - } else { - hashToOccurrences.set(inlineLiteral.structuralHash, { - memberCount: inlineLiteral.memberCount, - preview: inlineLiteral.preview, - occurrences: [occurrence], - }); - } - } - } - - const findings: DuplicateInlineType[] = []; - for (const [structuralHash, group] of hashToOccurrences) { - if (group.occurrences.length < 2) continue; - const uniqueSiteKeys = new Set( - group.occurrences.map((occurrence) => `${occurrence.path}:${occurrence.line}`), - ); - if (uniqueSiteKeys.size < 2) continue; - const uniquePaths = new Set(group.occurrences.map((occurrence) => occurrence.path)); - const confidence = - uniquePaths.size >= 2 || group.memberCount >= DUPLICATE_INLINE_TYPE_HIGH_MEMBER_COUNT - ? "medium" - : "low"; - findings.push({ - structuralHash, - memberCount: group.memberCount, - preview: group.preview, - occurrences: group.occurrences, - confidence, - reason: `inline object shape ${group.preview} appears at ${group.occurrences.length} sites across ${uniquePaths.size} file(s) — extract a named type`, - }); - } - - return findings; -}; diff --git a/packages/deslop-js/src/report/exports.ts b/packages/deslop-js/src/report/exports.ts deleted file mode 100644 index 13f468c1e5..0000000000 --- a/packages/deslop-js/src/report/exports.ts +++ /dev/null @@ -1,374 +0,0 @@ -import type { - DependencyGraph, - Edge, - SourceModule, - ExportReference, - UnusedExport, - DeslopConfig, - MemberAccess, -} from "../types.js"; - -export const detectDeadExports = (graph: DependencyGraph, config: DeslopConfig): UnusedExport[] => { - const usageMap = buildUsageMap(graph); - const unusedExports: UnusedExport[] = []; - - for (const module of graph.modules) { - if (!module.isReachable) continue; - if (module.isDeclarationFile) continue; - if (module.isGitIgnored) continue; - if (module.isEntryPoint && !config.includeEntryExports) continue; - - const defaultExportLinkedNames = new Set<string>(); - for (const exportInfo of module.exports) { - if ( - exportInfo.isDefault && - exportInfo.defaultExportLocalName && - usageMap.has(`${module.fileId.path}::default`) - ) { - defaultExportLinkedNames.add(exportInfo.defaultExportLocalName); - } - } - - for (const exportInfo of module.exports) { - if (exportInfo.name === "*" && exportInfo.isNamespaceReExport) continue; - if (exportInfo.isReExport && exportInfo.reExportOriginalName) continue; - if (!config.reportTypes && exportInfo.isTypeOnly) continue; - - const usageKey = `${module.fileId.path}::${exportInfo.name}`; - if (usageMap.has(usageKey)) continue; - - if (module.localIdentifierReferences.includes(exportInfo.name)) continue; - - if (!exportInfo.isDefault && defaultExportLinkedNames.has(exportInfo.name)) { - continue; - } - - // `export default Page` aliasing a named export that IS consumed: - // deleting the default would be busywork the named usage disproves. - if ( - exportInfo.isDefault && - exportInfo.defaultExportLocalName && - usageMap.has(`${module.fileId.path}::${exportInfo.defaultExportLocalName}`) - ) { - continue; - } - - unusedExports.push({ - path: module.fileId.path, - name: exportInfo.name, - line: exportInfo.line, - column: exportInfo.column, - isTypeOnly: exportInfo.isTypeOnly, - }); - } - } - - return unusedExports; -}; - -const buildUsageMap = (graph: DependencyGraph): Set<string> => { - const usedExportKeys = new Set<string>(); - const sourceToTargetMap = buildSourceToTargetsMap(graph); - - // Indexed by source so the entry-point pass is O(edges), not - // O(entry points × edges) — on a large repo with thousands of entry - // modules the unindexed scan dominated this detector. - const reExportEdgesBySource = new Map<number, Edge[]>(); - for (const edge of graph.edges) { - if (!edge.isReExportEdge) continue; - const existingEdges = reExportEdgesBySource.get(edge.source); - if (existingEdges) { - existingEdges.push(edge); - } else { - reExportEdgesBySource.set(edge.source, [edge]); - } - } - - for (const module of graph.modules) { - if (!module.isEntryPoint) continue; - - for (const edge of reExportEdgesBySource.get(module.fileId.index) ?? []) { - const targetModule = graph.modules[edge.target]; - if (!targetModule) continue; - - const isWildcardReExport = edge.reExportedNames.includes("*"); - if (isWildcardReExport) { - markAllExportsUsedRecursive( - targetModule, - graph, - sourceToTargetMap, - usedExportKeys, - new Set(), - ); - } else { - for (const mapping of edge.reExportMappings) { - markExportUsedRecursive( - targetModule.fileId.path, - mapping.originalName, - graph, - sourceToTargetMap, - usedExportKeys, - new Set(), - ); - } - } - } - } - - for (const edge of graph.edges) { - const targetModule = graph.modules[edge.target]; - if (!targetModule) continue; - - const sourceModule = graph.modules[edge.source]; - - // `import()` consumers are opaque: `lazy(() => import("./page"))` takes - // the default, `.then((m) => m.X)` takes named members, and neither shows - // up as an imported symbol. Treat every export of a dynamically imported - // module as used rather than flag exports we cannot trace. - if (edge.isDynamic && edge.importedSymbols.length === 0) { - markAllExportsUsedRecursive( - targetModule, - graph, - sourceToTargetMap, - usedExportKeys, - new Set(), - ); - continue; - } - - for (const symbol of edge.importedSymbols) { - if (symbol.isNamespace) { - handleNamespaceImport( - sourceModule, - targetModule, - symbol.localName, - graph, - sourceToTargetMap, - usedExportKeys, - ); - } else { - const importName = symbol.isDefault ? "default" : symbol.importedName; - markExportUsedRecursive( - targetModule.fileId.path, - importName, - graph, - sourceToTargetMap, - usedExportKeys, - new Set(), - ); - - if (symbol.isDefault) { - const hasDefaultExport = targetModule.exports.some((exportInfo) => exportInfo.isDefault); - if (!hasDefaultExport && symbol.localName !== "default") { - const matchingNamedExport = targetModule.exports.find( - (exportInfo) => exportInfo.name === symbol.localName, - ); - if (matchingNamedExport) { - markExportUsedRecursive( - targetModule.fileId.path, - symbol.localName, - graph, - sourceToTargetMap, - usedExportKeys, - new Set(), - ); - } - } - } - } - } - } - - return usedExportKeys; -}; - -const handleNamespaceImport = ( - sourceModule: SourceModule | undefined, - targetModule: SourceModule, - namespaceLocalName: string, - graph: DependencyGraph, - sourceToTargets: Map<number, number[]>, - usedKeys: Set<string>, -): void => { - if (!sourceModule) { - markAllExportsUsedRecursive(targetModule, graph, sourceToTargets, usedKeys, new Set()); - return; - } - - const isWholeObjectUse = sourceModule.wholeObjectUses.includes(namespaceLocalName); - if (isWholeObjectUse) { - markAllExportsUsedRecursive(targetModule, graph, sourceToTargets, usedKeys, new Set()); - return; - } - - const accessedMemberNames = extractAccessedMemberNames( - sourceModule.memberAccesses, - namespaceLocalName, - ); - - const isNamespaceReExported = sourceModule.exports.some( - (exportInfo) => - exportInfo.reExportOriginalName === namespaceLocalName || - (!exportInfo.isReExport && exportInfo.name === namespaceLocalName), - ); - - if (accessedMemberNames.length === 0 && !isNamespaceReExported) { - markAllExportsUsedRecursive(targetModule, graph, sourceToTargets, usedKeys, new Set()); - return; - } - - if (isNamespaceReExported && !sourceModule.isEntryPoint) { - markAllExportsUsedRecursive(targetModule, graph, sourceToTargets, usedKeys, new Set()); - return; - } - - for (const memberName of accessedMemberNames) { - markExportUsedRecursive( - targetModule.fileId.path, - memberName, - graph, - sourceToTargets, - usedKeys, - new Set(), - ); - } -}; - -const extractAccessedMemberNames = ( - memberAccesses: MemberAccess[], - objectName: string, -): string[] => { - const memberNames: string[] = []; - const seenNames = new Set<string>(); - for (const access of memberAccesses) { - if (access.objectName === objectName && !seenNames.has(access.memberName)) { - seenNames.add(access.memberName); - memberNames.push(access.memberName); - } - } - return memberNames; -}; - -const buildSourceToTargetsMap = (graph: DependencyGraph): Map<number, number[]> => { - const sourceToTargets = new Map<number, number[]>(); - - for (const edge of graph.edges) { - if (!edge.isReExportEdge) continue; - const existing = sourceToTargets.get(edge.source); - if (existing) { - if (!existing.includes(edge.target)) { - existing.push(edge.target); - } - } else { - sourceToTargets.set(edge.source, [edge.target]); - } - } - - return sourceToTargets; -}; - -const markAllExportsUsedRecursive = ( - module: SourceModule, - graph: DependencyGraph, - sourceToTargets: Map<number, number[]>, - usedKeys: Set<string>, - visited: Set<string>, -): void => { - const visitKey = `all::${module.fileId.path}`; - if (visited.has(visitKey)) return; - visited.add(visitKey); - - for (const exportInfo of module.exports) { - if (exportInfo.name === "*" && exportInfo.isNamespaceReExport) continue; - - const usageKey = `${module.fileId.path}::${exportInfo.name}`; - usedKeys.add(usageKey); - - if (exportInfo.isReExport && exportInfo.reExportSource) { - followReExportChain( - module.fileId.index, - exportInfo, - graph, - sourceToTargets, - usedKeys, - visited, - ); - } - } -}; - -const markExportUsedRecursive = ( - filePath: string, - exportName: string, - graph: DependencyGraph, - sourceToTargets: Map<number, number[]>, - usedKeys: Set<string>, - visited: Set<string>, -): void => { - const visitKey = `${filePath}::${exportName}`; - if (visited.has(visitKey)) return; - visited.add(visitKey); - - usedKeys.add(visitKey); - - const moduleIndex = graph.fileIdMap.get(filePath); - if (moduleIndex === undefined) return; - - const module = graph.modules[moduleIndex]; - if (!module) return; - - for (const exportInfo of module.exports) { - if (exportInfo.name !== exportName) continue; - - if (exportInfo.isReExport && exportInfo.reExportSource) { - followReExportChain(moduleIndex, exportInfo, graph, sourceToTargets, usedKeys, visited); - } - } -}; - -const followReExportChain = ( - reExporterModuleIndex: number, - exportInfo: ExportReference, - graph: DependencyGraph, - sourceToTargets: Map<number, number[]>, - usedKeys: Set<string>, - visited: Set<string>, -): void => { - const targetIndices = sourceToTargets.get(reExporterModuleIndex); - if (!targetIndices) return; - - const originalName = exportInfo.reExportOriginalName ?? exportInfo.name; - - for (const targetIndex of targetIndices) { - const targetModule = graph.modules[targetIndex]; - if (!targetModule) continue; - - if (originalName === "*" || exportInfo.isNamespaceReExport) { - markExportUsedRecursive( - targetModule.fileId.path, - exportInfo.name, - graph, - sourceToTargets, - usedKeys, - visited, - ); - } else { - const targetHasExport = targetModule.exports.some( - (targetExport) => - targetExport.name === originalName || - (targetExport.isNamespaceReExport && targetExport.name === "*"), - ); - - if (targetHasExport) { - markExportUsedRecursive( - targetModule.fileId.path, - originalName, - graph, - sourceToTargets, - usedKeys, - visited, - ); - } - } - } -}; diff --git a/packages/deslop-js/src/report/feature-flags.ts b/packages/deslop-js/src/report/feature-flags.ts deleted file mode 100644 index 6b541399b0..0000000000 --- a/packages/deslop-js/src/report/feature-flags.ts +++ /dev/null @@ -1,455 +0,0 @@ -import { readFileSync } from "node:fs"; -import { parseSync } from "oxc-parser"; -import type { DependencyGraph, FeatureFlag, FeatureFlagsConfig, ScanResult } from "../types.js"; -import { computeLineStarts } from "../utils/compute-line-starts.js"; -import { offsetToLineColumn } from "../utils/offset-to-line-column.js"; -import { isAstNode } from "../utils/is-ast-node.js"; - -interface SdkPattern { - functionName: string; - nameArgIndex: number; - provider: string; -} - -const BUILTIN_SDK_PATTERNS: readonly SdkPattern[] = [ - { functionName: "useFlag", nameArgIndex: 0, provider: "LaunchDarkly" }, - { functionName: "useLDFlag", nameArgIndex: 0, provider: "LaunchDarkly" }, - { functionName: "useFeatureFlag", nameArgIndex: 0, provider: "LaunchDarkly" }, - { functionName: "variation", nameArgIndex: 0, provider: "LaunchDarkly" }, - { functionName: "boolVariation", nameArgIndex: 0, provider: "LaunchDarkly" }, - { functionName: "stringVariation", nameArgIndex: 0, provider: "LaunchDarkly" }, - { functionName: "numberVariation", nameArgIndex: 0, provider: "LaunchDarkly" }, - { functionName: "jsonVariation", nameArgIndex: 0, provider: "LaunchDarkly" }, - { functionName: "useGate", nameArgIndex: 0, provider: "Statsig" }, - { functionName: "checkGate", nameArgIndex: 0, provider: "Statsig" }, - { functionName: "useExperiment", nameArgIndex: 0, provider: "Statsig" }, - { functionName: "useConfig", nameArgIndex: 0, provider: "Statsig" }, - { functionName: "isEnabled", nameArgIndex: 0, provider: "Unleash" }, - { functionName: "getVariant", nameArgIndex: 0, provider: "Unleash" }, - { functionName: "isOn", nameArgIndex: 0, provider: "GrowthBook" }, - { functionName: "isOff", nameArgIndex: 0, provider: "GrowthBook" }, - { functionName: "getFeatureValue", nameArgIndex: 0, provider: "GrowthBook" }, - { functionName: "getTreatment", nameArgIndex: 0, provider: "Split" }, - { functionName: "useFeatureFlagEnabled", nameArgIndex: 0, provider: "PostHog" }, - { functionName: "useFeatureFlagPayload", nameArgIndex: 0, provider: "PostHog" }, - { functionName: "useFeatureFlagVariantKey", nameArgIndex: 0, provider: "PostHog" }, - { functionName: "getFeatureFlagPayload", nameArgIndex: 0, provider: "PostHog" }, - { functionName: "getValueAsync", nameArgIndex: 0, provider: "ConfigCat" }, - { functionName: "getValueDetailsAsync", nameArgIndex: 0, provider: "ConfigCat" }, - { functionName: "hasFeature", nameArgIndex: 0, provider: "Flagsmith" }, - { functionName: "useDecision", nameArgIndex: 0, provider: "Optimizely" }, - { functionName: "getFeatureVariable", nameArgIndex: 0, provider: "Optimizely" }, - { functionName: "getFeatureVariableBoolean", nameArgIndex: 0, provider: "Optimizely" }, - { functionName: "getFeatureVariableString", nameArgIndex: 0, provider: "Optimizely" }, - { functionName: "getFeatureVariableInteger", nameArgIndex: 0, provider: "Optimizely" }, - { functionName: "getFeatureVariableDouble", nameArgIndex: 0, provider: "Optimizely" }, - { functionName: "getFeatureVariableJson", nameArgIndex: 0, provider: "Optimizely" }, - { functionName: "getFeatureVariableJSON", nameArgIndex: 0, provider: "Optimizely" }, - { functionName: "getStringAssignment", nameArgIndex: 0, provider: "Eppo" }, - { functionName: "getBooleanAssignment", nameArgIndex: 0, provider: "Eppo" }, - { functionName: "getNumericAssignment", nameArgIndex: 0, provider: "Eppo" }, - { functionName: "getIntegerAssignment", nameArgIndex: 0, provider: "Eppo" }, - { functionName: "getJSONAssignment", nameArgIndex: 0, provider: "Eppo" }, -]; - -const VERCEL_FLAGS_FUNCTION_NAMES: ReadonlySet<string> = new Set(["flag", "evaluate"]); - -const BUILTIN_ENV_PREFIXES: readonly string[] = [ - "FEATURE_", - "NEXT_PUBLIC_FEATURE_", - "NEXT_PUBLIC_ENABLE_", - "REACT_APP_FEATURE_", - "REACT_APP_ENABLE_", - "VITE_FEATURE_", - "VITE_ENABLE_", - "NUXT_PUBLIC_FEATURE_", - "ENABLE_", - "FF_", - "FLAG_", - "TOGGLE_", -]; - -const CONFIG_OBJECT_KEYWORDS: ReadonlySet<string> = new Set([ - "feature", - "features", - "featureflags", - "featureflag", - "flag", - "flags", - "toggle", - "toggles", -]); - -const getStaticName = (node: unknown): string | undefined => { - if (!isAstNode(node)) return undefined; - if (node.type === "Identifier" || node.type === "PrivateIdentifier") { - const identifierName = node.name; - return typeof identifierName === "string" ? identifierName : undefined; - } - if (node.type === "Literal") { - const literalValue = node.value; - return typeof literalValue === "string" ? literalValue : undefined; - } - return undefined; -}; - -const extractStringArgument = ( - callArguments: unknown, - argumentIndex: number, -): string | undefined => { - if (!Array.isArray(callArguments)) return undefined; - const argumentNode = callArguments[argumentIndex]; - if (!isAstNode(argumentNode)) return undefined; - if (argumentNode.type === "Literal") { - const literalValue = argumentNode.value; - return typeof literalValue === "string" ? literalValue : undefined; - } - if (argumentNode.type === "ObjectExpression") { - const properties = argumentNode.properties; - if (!Array.isArray(properties)) return undefined; - for (const property of properties) { - if (!isAstNode(property)) continue; - if (property.type !== "Property") continue; - const propertyKey = getStaticName(property.key); - if (propertyKey !== "key" && propertyKey !== "name") continue; - const propertyValueName = getStaticName(property.value); - if (propertyValueName !== undefined) return propertyValueName; - } - } - return undefined; -}; - -interface VisitContext { - filePath: string; - lineStarts: number[]; - results: FeatureFlag[]; - envPrefixes: string[]; - sdkPatterns: SdkPattern[]; - detectConfigObjects: boolean; - vercelFlagsLocalNames: Set<string>; - guard: { startLine: number; endLine: number } | undefined; -} - -const extractProcessEnvName = (memberExpression: unknown): string | undefined => { - if (!isAstNode(memberExpression)) return undefined; - if ( - memberExpression.type !== "MemberExpression" && - memberExpression.type !== "StaticMemberExpression" - ) { - return undefined; - } - const propertyName = getStaticName(memberExpression.property); - if (propertyName === undefined) return undefined; - const objectNode = memberExpression.object; - if (!isAstNode(objectNode)) return undefined; - if (objectNode.type !== "MemberExpression" && objectNode.type !== "StaticMemberExpression") { - return undefined; - } - const innerObjectName = getStaticName(objectNode.object); - const innerPropertyName = getStaticName(objectNode.property); - if (innerObjectName === "process" && innerPropertyName === "env") return propertyName; - return undefined; -}; - -const isFlagEnvName = (envName: string, extraEnvPrefixes: string[]): boolean => { - for (const prefix of BUILTIN_ENV_PREFIXES) if (envName.startsWith(prefix)) return true; - for (const prefix of extraEnvPrefixes) if (envName.startsWith(prefix)) return true; - return false; -}; - -const collectVercelFlagsImports = (programNode: unknown): Set<string> => { - const localNames = new Set<string>(); - if (!isAstNode(programNode)) return localNames; - const body = programNode.body; - if (!Array.isArray(body)) return localNames; - for (const statement of body) { - if (!isAstNode(statement)) continue; - if (statement.type !== "ImportDeclaration") continue; - const sourceLiteral = statement.source; - const sourceValue = isAstNode(sourceLiteral) ? sourceLiteral.value : undefined; - if (typeof sourceValue !== "string") continue; - const isVercelFlagsSource = - sourceValue === "flags" || - sourceValue.startsWith("flags/") || - sourceValue === "@vercel/flags" || - sourceValue.startsWith("@vercel/flags/"); - if (!isVercelFlagsSource) continue; - const specifiers = statement.specifiers; - if (!Array.isArray(specifiers)) continue; - for (const specifier of specifiers) { - if (!isAstNode(specifier)) continue; - if (specifier.type === "ImportSpecifier") { - const imported = specifier.imported; - const local = specifier.local; - const importedName = getStaticName(imported); - const localName = getStaticName(local); - if (importedName && VERCEL_FLAGS_FUNCTION_NAMES.has(importedName) && localName) { - localNames.add(localName); - } - } - } - } - return localNames; -}; - -const visitChildrenWithGuard = (node: unknown, visitor: (child: unknown) => void): void => { - if (!isAstNode(node)) return; - for (const key of Object.keys(node)) { - if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") { - continue; - } - const value = node[key]; - if (Array.isArray(value)) { - for (const item of value) visitor(item); - } else if (value !== null && typeof value === "object") { - visitor(value); - } - } -}; - -const recordFlag = ( - context: VisitContext, - flagName: string, - kind: FeatureFlag["kind"], - byteOffset: number, - sdkProvider: string | undefined, -): void => { - const { line, column } = offsetToLineColumn(byteOffset, context.lineStarts); - context.results.push({ - path: context.filePath, - name: flagName, - kind, - line, - column, - sdkProvider, - guardLineStart: context.guard?.startLine, - guardLineEnd: context.guard?.endLine, - guardsDeadCode: false, - }); -}; - -const visitNode = (node: unknown, context: VisitContext): void => { - if (!isAstNode(node)) return; - - if (node.type === "IfStatement") { - const start = node.start; - const end = node.end; - const guard = - typeof start === "number" && typeof end === "number" - ? { - startLine: offsetToLineColumn(start, context.lineStarts).line, - endLine: offsetToLineColumn(end, context.lineStarts).line, - } - : undefined; - const previousGuard = context.guard; - context.guard = guard; - visitNode(node.test, context); - context.guard = previousGuard; - visitNode(node.consequent, context); - visitNode(node.alternate, context); - return; - } - - if (node.type === "ConditionalExpression") { - const start = node.start; - const end = node.end; - const guard = - typeof start === "number" && typeof end === "number" - ? { - startLine: offsetToLineColumn(start, context.lineStarts).line, - endLine: offsetToLineColumn(end, context.lineStarts).line, - } - : undefined; - const previousGuard = context.guard; - context.guard = guard; - visitNode(node.test, context); - context.guard = previousGuard; - visitNode(node.consequent, context); - visitNode(node.alternate, context); - return; - } - - visitFlagPatternsInExpression(node, context); - visitChildrenWithGuard(node, (child) => visitNode(child, context)); -}; - -const visitFlagPatternsInExpression = (node: unknown, context: VisitContext): void => { - if (!isAstNode(node)) return; - - if (node.type === "MemberExpression" || node.type === "StaticMemberExpression") { - const envName = extractProcessEnvName(node); - if (envName !== undefined && isFlagEnvName(envName, context.envPrefixes)) { - const start = node.start; - if (typeof start === "number") recordFlag(context, envName, "env-var", start, undefined); - } else if (context.detectConfigObjects) { - const objectName = getStaticName(node.object); - const propertyName = getStaticName(node.property); - if (objectName && propertyName) { - if ( - CONFIG_OBJECT_KEYWORDS.has(objectName.toLowerCase()) || - CONFIG_OBJECT_KEYWORDS.has(propertyName.toLowerCase()) - ) { - const start = node.start; - if (typeof start === "number") { - recordFlag(context, `${objectName}.${propertyName}`, "config-object", start, undefined); - } - } - } - } - } - - if (node.type === "CallExpression") { - const callee = node.callee; - let functionName: string | undefined; - let calleeIsMemberExpression = false; - let receiverIsItselfMemberExpression = false; - if (isAstNode(callee)) { - if (callee.type === "Identifier") functionName = getStaticName(callee); - else if (callee.type === "MemberExpression" || callee.type === "StaticMemberExpression") { - calleeIsMemberExpression = true; - functionName = getStaticName(callee.property); - const receiver = callee.object; - if ( - isAstNode(receiver) && - (receiver.type === "MemberExpression" || receiver.type === "StaticMemberExpression") - ) { - receiverIsItselfMemberExpression = true; - } - } - } - if (functionName !== undefined) { - // Vercel Flags' `flag()` is only ever called as an identifier from a - // Vercel Flags import — there is no `someObject.flag(...)` form. Without - // this restriction we catch `runtime.evaluate('Uint8Array')`, - // `page.evaluate('...')`, and anything else that happens to share a name. - const isVercelFlagsCandidate = - !calleeIsMemberExpression && context.vercelFlagsLocalNames.has(functionName); - if (isVercelFlagsCandidate) { - const callArguments = node.arguments; - const flagName = extractStringArgument(callArguments, 0); - if (flagName !== undefined) { - const start = node.start; - if (typeof start === "number") { - recordFlag(context, flagName, "sdk-call", start, "Vercel Flags"); - } - } - return; - } - // Other SDK patterns: skip chained member calls like - // `document.implementation.hasFeature(...)` — the immediate receiver is - // itself a member expression, so the call is plainly not an SDK client - // method invocation. - if (calleeIsMemberExpression && receiverIsItselfMemberExpression) return; - for (const sdkPattern of context.sdkPatterns) { - if (sdkPattern.functionName !== functionName) continue; - const callArguments = node.arguments; - const flagName = extractStringArgument(callArguments, sdkPattern.nameArgIndex); - if (flagName === undefined) continue; - const start = node.start; - if (typeof start === "number") { - recordFlag( - context, - flagName, - "sdk-call", - start, - sdkPattern.provider === "" ? undefined : sdkPattern.provider, - ); - } - break; - } - } - } -}; - -const buildSdkPatterns = (extraSdkFunctionNames: string[]): SdkPattern[] => { - const merged: SdkPattern[] = [...BUILTIN_SDK_PATTERNS]; - for (const extraName of extraSdkFunctionNames) { - merged.push({ functionName: extraName, nameArgIndex: 0, provider: "" }); - } - return merged; -}; - -export const detectFeatureFlags = ( - graph: DependencyGraph, - config: FeatureFlagsConfig | undefined, -): FeatureFlag[] => { - if (!config?.enabled) return []; - - const sdkPatterns = buildSdkPatterns(config.extraSdkFunctionNames); - const collectedFlags: FeatureFlag[] = []; - - for (const module of graph.modules) { - if (module.isDeclarationFile) continue; - if (module.isConfigFile) continue; - - let sourceText: string; - try { - sourceText = readFileSync(module.fileId.path, "utf-8"); - } catch { - continue; - } - - let parseResult: ReturnType<typeof parseSync>; - try { - parseResult = parseSync(module.fileId.path, sourceText); - } catch { - continue; - } - - const lineStarts = computeLineStarts(sourceText); - const vercelFlagsLocalNames = collectVercelFlagsImports(parseResult.program); - - const visitContext: VisitContext = { - filePath: module.fileId.path, - lineStarts, - results: [], - envPrefixes: config.extraEnvPrefixes, - sdkPatterns, - detectConfigObjects: config.detectConfigObjects, - vercelFlagsLocalNames, - guard: undefined, - }; - - visitNode(parseResult.program, visitContext); - collectedFlags.push(...visitContext.results); - } - - collectedFlags.sort((leftFlag, rightFlag) => { - if (leftFlag.path !== rightFlag.path) return leftFlag.path.localeCompare(rightFlag.path); - if (leftFlag.line !== rightFlag.line) return leftFlag.line - rightFlag.line; - return leftFlag.column - rightFlag.column; - }); - - return collectedFlags; -}; - -/** - * Mark each flag whose guard span overlaps an unused export as - * `guardsDeadCode: true`. - */ -export const correlateFlagsWithDeadCode = ( - flags: FeatureFlag[], - scanResult: Pick<ScanResult, "unusedExports">, -): void => { - if (flags.length === 0 || scanResult.unusedExports.length === 0) return; - const unusedByFile = new Map<string, number[]>(); - for (const unusedExport of scanResult.unusedExports) { - const existing = unusedByFile.get(unusedExport.path); - if (existing) existing.push(unusedExport.line); - else unusedByFile.set(unusedExport.path, [unusedExport.line]); - } - - for (const flag of flags) { - if (flag.guardLineStart === undefined || flag.guardLineEnd === undefined) continue; - const linesInFile = unusedByFile.get(flag.path); - if (!linesInFile) continue; - const guardStart = flag.guardLineStart; - const guardEnd = flag.guardLineEnd; - for (const unusedLine of linesInFile) { - if (unusedLine >= guardStart && unusedLine <= guardEnd) { - flag.guardsDeadCode = true; - break; - } - } - } -}; diff --git a/packages/deslop-js/src/report/generate.ts b/packages/deslop-js/src/report/generate.ts deleted file mode 100644 index e70926527b..0000000000 --- a/packages/deslop-js/src/report/generate.ts +++ /dev/null @@ -1,323 +0,0 @@ -import type { DeslopConfig, DependencyGraph, DeslopError, ScanResult } from "../types.js"; -import { detectOrphanFiles } from "./files.js"; -import { detectDeadExports } from "./exports.js"; -import { detectStalePackages } from "./packages.js"; -import { detectCycles } from "./cycles.js"; -import { - detectRedundantAliases, - detectDuplicateExports, - detectUselessAliasedReExports, -} from "./redundancy.js"; -import { - detectDuplicateImports, - detectRedundantTypePatterns, - detectIdentityWrappers, - detectDuplicateTypeDefinitions, - detectDuplicateInlineTypes, - detectSimplifiableFunctions, - detectSimplifiableExpressions, - detectDuplicateConstants, -} from "./dry-patterns.js"; -import { detectCrossFileDuplicateExports } from "./cross-file-duplicate-exports.js"; -import { detectDuplicateBlocks } from "../duplicate-blocks/index.js"; -import { detectReExportCycles } from "./re-export-cycles.js"; -import { correlateFlagsWithDeadCode, detectFeatureFlags } from "./feature-flags.js"; -import { detectComplexHotspots } from "./complexity.js"; -import { detectPrivateTypeLeaks } from "./private-type-leaks.js"; -import { detectTypeScriptSmells } from "./typescript-smells.js"; -import { runSemanticAnalysis } from "../semantic/index.js"; -import { DetectorError, describeUnknownError } from "../errors.js"; -import { MAX_ANALYSIS_ERRORS } from "../constants.js"; -import { runSafeDetector } from "../utils/run-safe-detector.js"; -import type { SummaryCache } from "../summary-cache.js"; - -const safeReportDetector = <ResultType>( - detectorName: string, - detector: () => ResultType, - fallback: ResultType, - errorSink: DeslopError[], -): ResultType => - runSafeDetector({ - detectorName, - detector, - fallback, - errorSink, - module: "report", - contextDescription: "while building findings", - }); - -export const generateReport = ( - graph: DependencyGraph, - config: DeslopConfig, - summaryCache?: SummaryCache, -): ScanResult => { - const analysisStartTime = performance.now(); - const errorSink: DeslopError[] = []; - - for (const module of graph.modules) { - for (const parseError of module.parseErrors) { - if (errorSink.length >= MAX_ANALYSIS_ERRORS) break; - errorSink.push(parseError); - } - if (errorSink.length >= MAX_ANALYSIS_ERRORS) break; - } - - const unusedFiles = safeReportDetector( - "detectOrphanFiles", - () => detectOrphanFiles(graph), - [], - errorSink, - ); - const unusedExports = safeReportDetector( - "detectDeadExports", - () => detectDeadExports(graph, config), - [], - errorSink, - ); - const stalePackageReport = safeReportDetector( - "detectStalePackages", - () => detectStalePackages(graph, config, summaryCache), - { unusedDependencies: [], skippedDependencies: [] }, - errorSink, - ); - const unusedDependencies = stalePackageReport.unusedDependencies; - const circularDependencies = safeReportDetector( - "detectCycles", - () => detectCycles(graph), - [], - errorSink, - ); - const syntacticRedundantAliases = config.reportRedundancy - ? [ - ...safeReportDetector( - "detectRedundantAliases", - () => detectRedundantAliases(graph), - [], - errorSink, - ), - ...safeReportDetector( - "detectUselessAliasedReExports", - () => detectUselessAliasedReExports(graph), - [], - errorSink, - ), - ] - : []; - const duplicateExports = config.reportRedundancy - ? safeReportDetector( - "detectDuplicateExports", - () => detectDuplicateExports(graph), - [], - errorSink, - ) - : []; - const duplicateImports = config.reportRedundancy - ? safeReportDetector( - "detectDuplicateImports", - () => detectDuplicateImports(graph), - [], - errorSink, - ) - : []; - const redundantTypePatterns = config.reportRedundancy - ? safeReportDetector( - "detectRedundantTypePatterns", - () => detectRedundantTypePatterns(graph), - [], - errorSink, - ) - : []; - const identityWrappers = config.reportRedundancy - ? safeReportDetector( - "detectIdentityWrappers", - () => detectIdentityWrappers(graph), - [], - errorSink, - ) - : []; - const duplicateTypeDefinitions = config.reportRedundancy - ? safeReportDetector( - "detectDuplicateTypeDefinitions", - () => detectDuplicateTypeDefinitions(graph), - [], - errorSink, - ) - : []; - const duplicateInlineTypes = config.reportRedundancy - ? safeReportDetector( - "detectDuplicateInlineTypes", - () => detectDuplicateInlineTypes(graph), - [], - errorSink, - ) - : []; - const simplifiableFunctions = config.reportRedundancy - ? safeReportDetector( - "detectSimplifiableFunctions", - () => detectSimplifiableFunctions(graph), - [], - errorSink, - ) - : []; - const simplifiableExpressions = config.reportRedundancy - ? safeReportDetector( - "detectSimplifiableExpressions", - () => detectSimplifiableExpressions(graph), - [], - errorSink, - ) - : []; - const duplicateConstants = config.reportRedundancy - ? safeReportDetector( - "detectDuplicateConstants", - () => detectDuplicateConstants(graph), - [], - errorSink, - ) - : []; - const crossFileDuplicateExports = config.reportRedundancy - ? safeReportDetector( - "detectCrossFileDuplicateExports", - () => detectCrossFileDuplicateExports(graph), - [], - errorSink, - ) - : []; - // The "code quality" detectors below are independent of the dead-code graph - // findings above and are by far the most expensive (duplicate-block detection - // alone dominates a large-repo scan), so `reportCodeQuality: false` skips them - // entirely — each falls back to its empty result. - const emptyDuplicateBlockResult = { - duplicateBlocks: [], - duplicateBlockClusters: [], - shadowedDirectoryPairs: [], - }; - const duplicateBlockResult = config.reportCodeQuality - ? safeReportDetector( - "detectDuplicateBlocks", - () => detectDuplicateBlocks(graph, config.duplicateBlocks, config.rootDir), - emptyDuplicateBlockResult, - errorSink, - ) - : emptyDuplicateBlockResult; - - const reExportCycles = config.reportCodeQuality - ? safeReportDetector("detectReExportCycles", () => detectReExportCycles(graph), [], errorSink) - : []; - const featureFlags = config.reportCodeQuality - ? safeReportDetector( - "detectFeatureFlags", - () => detectFeatureFlags(graph, config.featureFlags), - [], - errorSink, - ) - : []; - const complexFunctions = config.reportCodeQuality - ? safeReportDetector( - "detectComplexHotspots", - () => detectComplexHotspots(graph, config.complexity), - [], - errorSink, - ) - : []; - const privateTypeLeaks = config.reportCodeQuality - ? safeReportDetector( - "detectPrivateTypeLeaks", - () => detectPrivateTypeLeaks(graph), - [], - errorSink, - ) - : []; - const emptyTypeScriptSmellsResult = { - unnecessaryAssertions: [], - lazyImportsAtTopLevel: [], - commonjsInEsm: [], - typeScriptEscapeHatches: [], - }; - const typeScriptSmellsResult = config.reportCodeQuality - ? safeReportDetector( - "detectTypeScriptSmells", - () => detectTypeScriptSmells(graph), - emptyTypeScriptSmellsResult, - errorSink, - ) - : emptyTypeScriptSmellsResult; - let semanticResult: ReturnType<typeof runSemanticAnalysis>; - try { - semanticResult = runSemanticAnalysis(graph, config); - } catch (semanticError) { - errorSink.push( - new DetectorError({ - module: "semantic", - message: "runSemanticAnalysis threw at the top level", - detail: describeUnknownError(semanticError), - }), - ); - semanticResult = { - unusedTypes: [], - unusedEnumMembers: [], - unusedClassMembers: [], - misclassifiedDependencies: [], - redundantAliases: [], - errors: [], - }; - } - for (const semanticError of semanticResult.errors) { - if (errorSink.length >= MAX_ANALYSIS_ERRORS) break; - errorSink.push(semanticError); - } - - const redundantAliases = config.reportRedundancy - ? [...syntacticRedundantAliases, ...semanticResult.redundantAliases] - : []; - - if (featureFlags.length > 0) { - correlateFlagsWithDeadCode(featureFlags, { unusedExports }); - } - const totalExports = graph.modules.reduce( - (exportCount, module) => - exportCount + - module.exports.filter( - (exportInfo) => !(exportInfo.name === "*" && exportInfo.isNamespaceReExport), - ).length, - 0, - ); - - return { - unusedFiles, - unusedExports, - unusedDependencies, - skippedDependencies: stalePackageReport.skippedDependencies, - circularDependencies, - unusedTypes: semanticResult.unusedTypes, - misclassifiedDependencies: semanticResult.misclassifiedDependencies, - unusedEnumMembers: semanticResult.unusedEnumMembers, - unusedClassMembers: semanticResult.unusedClassMembers, - redundantAliases, - duplicateExports, - duplicateImports, - redundantTypePatterns, - identityWrappers, - duplicateTypeDefinitions, - duplicateInlineTypes, - simplifiableFunctions, - simplifiableExpressions, - duplicateConstants, - crossFileDuplicateExports, - duplicateBlocks: duplicateBlockResult.duplicateBlocks, - duplicateBlockClusters: duplicateBlockResult.duplicateBlockClusters, - shadowedDirectoryPairs: duplicateBlockResult.shadowedDirectoryPairs, - reExportCycles, - featureFlags, - complexFunctions, - privateTypeLeaks, - unnecessaryAssertions: typeScriptSmellsResult.unnecessaryAssertions, - lazyImportsAtTopLevel: typeScriptSmellsResult.lazyImportsAtTopLevel, - commonjsInEsm: typeScriptSmellsResult.commonjsInEsm, - typeScriptEscapeHatches: typeScriptSmellsResult.typeScriptEscapeHatches, - analysisErrors: errorSink, - totalFiles: graph.modules.length, - totalExports, - analysisTimeMs: performance.now() - analysisStartTime, - }; -}; diff --git a/packages/deslop-js/src/report/packages.ts b/packages/deslop-js/src/report/packages.ts deleted file mode 100644 index f36d6e9ab4..0000000000 --- a/packages/deslop-js/src/report/packages.ts +++ /dev/null @@ -1,1050 +0,0 @@ -import { resolve, join } from "node:path"; -import { readFileSync, existsSync } from "node:fs"; -import fg from "fast-glob"; -import type { - DependencyGraph, - DeslopConfig, - SkippedDependency, - SkippedDependencyReason, - UnusedDependency, -} from "../types.js"; -import { IMPLICIT_DEPENDENCIES } from "../constants.js"; -import { extractPackageName } from "../utils/package-name.js"; -import { - collectOverrideMappingsFromRecord, - type OverrideMapping, -} from "../utils/collect-override-mappings-from-record.js"; -import { collectPnpmWorkspaceOverrideMappings } from "../utils/parse-pnpm-workspace-overrides.js"; -import { matchesPackageImportReference } from "../utils/matches-package-import-reference.js"; -import { matchesPackageTokenReference } from "../utils/matches-package-token-reference.js"; -import { findMonorepoRoot } from "../utils/find-monorepo-root.js"; -import { extractExpoConfigPluginEntries } from "../collect/expo-config-plugin-entries.js"; -import type { PackageFactKind, SummaryCache } from "../summary-cache.js"; - -interface PackageFileGlobOptions { - readonly ignore: ReadonlyArray<string>; - readonly deep: number; - readonly dot?: boolean; -} - -// The stale-package file scans, answered from the summary cache's shared tree -// walk when one is live (verified byte-identical against fg on real corpora) -// and by a real fast-glob scan otherwise — including when the search root is -// not the walk root (a monorepo root above the scanned project). -const globPackageFiles = ( - cwd: string, - patterns: ReadonlyArray<string>, - options: PackageFileGlobOptions, - summaryCache: SummaryCache | undefined, -): string[] => - summaryCache?.matchWalkedFiles({ cwd, patterns, ...options }) ?? - fg.sync([...patterns], { - cwd, - absolute: true, - onlyFiles: true, - ignore: [...options.ignore], - deep: options.deep, - ...(options.dot === undefined ? {} : { dot: options.dot }), - }); - -const containsPackageName = (content: string, packageName: string): boolean => - content.includes(packageName); - -// The per-file content predicates behind the config/docs/rescue scans, served -// from the summary cache's fact layer when available (a fresh `readFileSync` -// otherwise). Read failures throw exactly like the raw loops this replaces. -const matchPackageNamesInFile = ( - filePath: string, - kind: PackageFactKind, - names: ReadonlySet<string>, - matcher: (content: string, packageName: string) => boolean, - summaryCache: SummaryCache | undefined, -): string[] => { - if (summaryCache !== undefined) { - return summaryCache.matchPackageNames(filePath, kind, names, matcher); - } - const content = readFileSync(filePath, "utf-8"); - const matchedNames: string[] = []; - for (const packageName of names) { - if (matcher(content, packageName)) matchedNames.push(packageName); - } - return matchedNames; -}; - -interface PackageJsonDependencies { - dependencies?: Record<string, string>; - devDependencies?: Record<string, string>; -} - -const discoverAllPackageJsonPaths = ( - rootDir: string, - summaryCache: SummaryCache | undefined, -): string[] => { - const paths = [join(rootDir, "package.json")]; - const workspacePackageJsons = globPackageFiles( - rootDir, - ["**/package.json"], - { - ignore: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.git/**"], - deep: 5, - }, - summaryCache, - ); - for (const workspacePath of workspacePackageJsons) { - if (workspacePath !== paths[0] && !paths.includes(workspacePath)) { - paths.push(workspacePath); - } - } - return paths; -}; - -interface StalePackageReport { - unusedDependencies: UnusedDependency[]; - skippedDependencies: SkippedDependency[]; -} - -export const detectStalePackages = ( - graph: DependencyGraph, - config: DeslopConfig, - summaryCache?: SummaryCache, -): StalePackageReport => { - const packageJsonPath = resolve(config.rootDir, "package.json"); - let packageJson: PackageJsonDependencies; - - try { - const content = readFileSync(packageJsonPath, "utf-8"); - packageJson = JSON.parse(content); - } catch { - return { unusedDependencies: [], skippedDependencies: [] }; - } - - const dependencies = packageJson.dependencies ?? {}; - const devDependencies = packageJson.devDependencies ?? {}; - - const declaredDependencies = new Map<string, boolean>(); - for (const dependencyName of Object.keys(dependencies)) { - declaredDependencies.set(dependencyName, false); - } - for (const dependencyName of Object.keys(devDependencies)) { - declaredDependencies.set(dependencyName, true); - } - - const declaredNames = new Set(declaredDependencies.keys()); - const observedPackageNames = collectUsedPackages(graph); - const usedPackageNames = new Set(observedPackageNames); - const markPackageUsed = (packageName: string): void => { - observedPackageNames.add(packageName); - usedPackageNames.add(packageName); - }; - - const monorepoRoot = findMonorepoRoot(config.rootDir); - const nodeModulesSearchRoots = - monorepoRoot && monorepoRoot !== config.rootDir - ? [config.rootDir, monorepoRoot] - : [config.rootDir]; - - const allPackageJsonPaths = discoverAllPackageJsonPaths(config.rootDir, summaryCache); - if (monorepoRoot) { - const monorepoPackageJson = join(monorepoRoot, "package.json"); - if (!allPackageJsonPaths.includes(monorepoPackageJson) && existsSync(monorepoPackageJson)) { - allPackageJsonPaths.push(monorepoPackageJson); - } - } - - const { binToPackage, packagesProvidingBinary } = buildBinaryPackageIndex( - nodeModulesSearchRoots, - declaredNames, - ); - - // Shipping a CLI binary is itself evidence of use — binaries run from - // Makefiles, CI, git hooks, and `npx`, none of which the static scan sees. - for (const packageName of packagesProvidingBinary) usedPackageNames.add(packageName); - - for (const workspacePackageJsonPath of allPackageJsonPaths) { - const scriptReferenced = collectScriptReferencedPackages( - workspacePackageJsonPath, - declaredNames, - binToPackage, - ); - for (const packageName of scriptReferenced) markPackageUsed(packageName); - - const packageJsonConfigReferenced = collectPackageJsonConfigReferences( - workspacePackageJsonPath, - declaredNames, - ); - for (const packageName of packageJsonConfigReferenced) markPackageUsed(packageName); - } - - const nxProjectReferenced = collectNxProjectJsonReferences( - config.rootDir, - declaredNames, - binToPackage, - summaryCache, - ); - for (const packageName of nxProjectReferenced) markPackageUsed(packageName); - - const configSearchRoots = - monorepoRoot && monorepoRoot !== config.rootDir - ? [config.rootDir, monorepoRoot] - : [config.rootDir]; - for (const configSearchRoot of configSearchRoots) { - const configReferenced = collectConfigReferencedPackages( - configSearchRoot, - graph, - declaredNames, - summaryCache, - ); - for (const packageName of configReferenced) markPackageUsed(packageName); - - const tsconfigReferenced = collectTsconfigReferencedPackages(configSearchRoot, summaryCache); - for (const packageName of tsconfigReferenced) markPackageUsed(packageName); - - const { packageNames: expoPluginPackageNames } = extractExpoConfigPluginEntries( - configSearchRoot, - { ...dependencies, ...devDependencies }, - ); - for (const packageName of expoPluginPackageNames) { - if (declaredNames.has(packageName)) { - markPackageUsed(packageName); - } - } - } - - if (hasJsxFiles(graph)) { - if (declaredNames.has("react")) markPackageUsed("react"); - if (declaredNames.has("react-dom")) markPackageUsed("react-dom"); - if (declaredNames.has("react-native")) markPackageUsed("react-native"); - if (declaredNames.has("react-native-web")) markPackageUsed("react-native-web"); - } - - if (declaredNames.has("react-dom")) { - const webFrameworks = [ - "next", - "gatsby", - "@remix-run/react", - "react-router-dom", - "vite", - "@docusaurus/core", - "react-scripts", - "astro", - "@tanstack/react-router", - "@tanstack/react-start", - "react-app-rewired", - ]; - const hasWebFramework = webFrameworks.some( - (framework) => declaredNames.has(framework) || usedPackageNames.has(framework), - ); - if (hasWebFramework) markPackageUsed("react-dom"); - } - - if (declaredNames.has("astro") && declaredNames.has("sharp")) { - markPackageUsed("sharp"); - } - - if (declaredNames.has("react") && declaredNames.has("react-dom")) { - const packageJsonPath = resolve(config.rootDir, "package.json"); - try { - const content = readFileSync(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(content); - const peerDeps = packageJson.peerDependencies ?? {}; - if ("react" in peerDeps && declaredDependencies.get("react") === true) { - markPackageUsed("react"); - } - if ("react-dom" in peerDeps && declaredDependencies.get("react-dom") === true) { - markPackageUsed("react-dom"); - } - } catch { - // fall through - } - } - - const peerSatisfied = collectPeerSatisfiedPackages( - nodeModulesSearchRoots, - declaredNames, - usedPackageNames, - ); - for (const packageName of peerSatisfied) usedPackageNames.add(packageName); - - const overrideMappings = collectOverrideMappings( - configSearchRoots, - allPackageJsonPaths, - monorepoRoot, - ); - for (const { fromPackage, toPackage } of overrideMappings) { - if (declaredNames.has(toPackage)) markPackageUsed(toPackage); - if (usedPackageNames.has(fromPackage) && declaredNames.has(toPackage)) { - markPackageUsed(toPackage); - } - } - - const candidateUnused = new Set<string>(); - const skippedDependenciesByName = new Map<string, Set<SkippedDependencyReason>>(); - const recordSkippedDependency = ( - dependencyName: string, - reason: SkippedDependencyReason, - ): void => { - const reasons = skippedDependenciesByName.get(dependencyName) ?? new Set(); - reasons.add(reason); - skippedDependenciesByName.set(dependencyName, reasons); - }; - - for (const [dependencyName] of declaredDependencies) { - if (observedPackageNames.has(dependencyName) || peerSatisfied.has(dependencyName)) continue; - if (isAlwaysConsideredUsed(dependencyName)) { - recordSkippedDependency(dependencyName, "allowlisted-name"); - } - if (packagesProvidingBinary.has(dependencyName)) { - recordSkippedDependency(dependencyName, "provides-binary"); - } - } - - for (const [dependencyName] of declaredDependencies) { - if (isAlwaysConsideredUsed(dependencyName)) continue; - if (usedPackageNames.has(dependencyName)) continue; - candidateUnused.add(dependencyName); - } - - if (candidateUnused.size > 0) { - const sourceFileRescued = scanSourceFilesForPackageImports( - config.rootDir, - candidateUnused, - summaryCache, - ); - for (const packageName of sourceFileRescued) { - markPackageUsed(packageName); - candidateUnused.delete(packageName); - } - } - - const unusedDependencies: UnusedDependency[] = []; - - for (const dependencyName of candidateUnused) { - const isDevDependency = declaredDependencies.get(dependencyName) ?? false; - const dependencySection = isDevDependency ? "devDependencies" : "dependencies"; - unusedDependencies.push({ - name: dependencyName, - isDevDependency, - reason: `"${dependencyName}" is declared in ${dependencySection} but is never imported or referenced by any source file, script, or config — remove it from package.json if it is genuinely unused`, - }); - } - - const skippedDependencies = [...skippedDependenciesByName.entries()] - .sort(([leftName], [rightName]) => leftName.localeCompare(rightName)) - .map(([name, reasons]) => ({ - name, - isDevDependency: declaredDependencies.get(name) ?? false, - reasons: [...reasons].sort(), - })); - - return { unusedDependencies, skippedDependencies }; -}; - -const collectUsedPackages = (graph: DependencyGraph): Set<string> => { - const usedPackages = new Set<string>(); - - for (const module of graph.modules) { - for (const importInfo of module.imports) { - const packageName = extractPackageName(importInfo.specifier); - if (packageName) { - usedPackages.add(packageName); - } - } - } - - return usedPackages; -}; - -const hasJsxFiles = (graph: DependencyGraph): boolean => - graph.modules.some((module) => { - const filePath = module.fileId.path; - return filePath.endsWith(".tsx") || filePath.endsWith(".jsx"); - }); - -// Peer relationships knowable WITHOUT an installed node_modules tree, for -// the same uninstalled-checkout reason as `KNOWN_PACKAGE_BIN_NAMES`: the -// host app must install these peers for the consumer package to work, so -// flagging them breaks the consumer. -const KNOWN_PEER_DEPENDENCY_NAMES = new Map<string, ReadonlyArray<string>>([ - ["vitest-axe", ["axe-core"]], - ["jest-axe", ["axe-core"]], -]); - -const collectPeerSatisfiedPackages = ( - nodeModulesSearchRoots: string[], - declaredNames: Set<string>, - confirmedUsedNames: Set<string>, -): Set<string> => { - const peerSatisfied = new Set<string>(); - - for (const installedName of declaredNames) { - if (!confirmedUsedNames.has(installedName)) continue; - - for (const knownPeerName of KNOWN_PEER_DEPENDENCY_NAMES.get(installedName) ?? []) { - if (declaredNames.has(knownPeerName)) { - peerSatisfied.add(knownPeerName); - } - } - - const installedPackageJsonPath = findInstalledPackageJsonPath( - installedName, - nodeModulesSearchRoots, - ); - if (!installedPackageJsonPath) continue; - - try { - const content = readFileSync(installedPackageJsonPath, "utf-8"); - const packageJson = JSON.parse(content); - const peerDeps = packageJson.peerDependencies; - if (peerDeps && typeof peerDeps === "object") { - for (const peerName of Object.keys(peerDeps)) { - if (declaredNames.has(peerName)) { - peerSatisfied.add(peerName); - } - } - } - } catch { - continue; - } - } - - return peerSatisfied; -}; - -const findInstalledPackageJsonPath = ( - packageName: string, - nodeModulesSearchRoots: string[], -): string | undefined => { - for (const searchRoot of nodeModulesSearchRoots) { - const candidatePath = packageName.startsWith("@") - ? join(searchRoot, "node_modules", ...packageName.split("/"), "package.json") - : join(searchRoot, "node_modules", packageName, "package.json"); - if (existsSync(candidatePath)) return candidatePath; - } - return undefined; -}; - -const SHELL_SPLIT_PATTERN = /\s*(?:&&|\|\||[;&|])\s*/; - -const INLINE_ENV_VAR_PATTERN = /^[A-Z_][A-Z0-9_]*=/; - -interface BinaryPackageIndex { - binToPackage: Map<string, Set<string>>; - packagesProvidingBinary: Set<string>; -} - -// Bin names knowable WITHOUT an installed node_modules tree. Scanned -// checkouts are frequently uninstalled, so the installed-metadata pass below -// finds nothing and a script-invoked CLI whose bin differs from its package -// name gets flagged as unused. These packages' bins can't be derived from -// their names; the `<name>-cli` → `<name>` convention is derived generically -// in `staticBinNamesForPackage`. Statically-inferred bins only feed the -// bin→package lookup (credited when a script actually invokes the bin) — they -// do NOT mark the package as used by mere presence the way installed bin -// metadata does, so a genuinely unreferenced CLI dep stays flagged. -const KNOWN_PACKAGE_BIN_NAMES = new Map<string, ReadonlyArray<string>>([ - ["@tauri-apps/cli", ["tauri"]], - ["@typescript/native-preview", ["tsgo"]], - // The browser-flavor packages exist to be driven by the `playwright` CLI - // (they download their browser at install time); a `playwright test` - // script is their use. - ["playwright-chromium", ["playwright"]], - ["playwright-firefox", ["playwright"]], - ["playwright-webkit", ["playwright"]], -]); - -const staticBinNamesForPackage = (packageName: string): string[] => { - const binNames = [...(KNOWN_PACKAGE_BIN_NAMES.get(packageName) ?? [])]; - const unscopedName = packageName.split("/").pop()!; - if (unscopedName.endsWith("-cli") && unscopedName.length > "-cli".length) { - binNames.push(unscopedName.slice(0, -"-cli".length)); - } - return binNames; -}; - -const buildBinaryPackageIndex = ( - nodeModulesSearchRoots: string[], - declaredNames: Set<string>, -): BinaryPackageIndex => { - const binToPackage = new Map<string, Set<string>>(); - const packagesProvidingBinary = new Set<string>(); - const addBinMapping = (binaryName: string, packageName: string): void => { - const mappedPackages = binToPackage.get(binaryName) ?? new Set<string>(); - mappedPackages.add(packageName); - binToPackage.set(binaryName, mappedPackages); - }; - for (const packageName of declaredNames) { - for (const staticBinName of staticBinNamesForPackage(packageName)) { - addBinMapping(staticBinName, packageName); - } - const packageBinJsonPath = findInstalledPackageJsonPath(packageName, nodeModulesSearchRoots); - if (!packageBinJsonPath) continue; - try { - const binContent = readFileSync(packageBinJsonPath, "utf-8"); - const binPackageJson = JSON.parse(binContent); - const binField = binPackageJson.bin; - if (typeof binField === "string" && binField.length > 0) { - addBinMapping(packageName.split("/").pop()!, packageName); - packagesProvidingBinary.add(packageName); - } else if (typeof binField === "object" && binField !== null) { - const binaryNames = Object.keys(binField); - if (binaryNames.length === 0) continue; - for (const binaryName of binaryNames) { - addBinMapping(binaryName, packageName); - } - packagesProvidingBinary.add(packageName); - } - } catch { - continue; - } - } - return { binToPackage, packagesProvidingBinary }; -}; - -const collectScriptReferencedPackages = ( - packageJsonPath: string, - declaredNames: Set<string>, - binToPackage: Map<string, Set<string>>, -): Set<string> => { - const referenced = new Set<string>(); - - try { - const content = readFileSync(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(content); - const scripts = packageJson.scripts; - if (!scripts || typeof scripts !== "object") return referenced; - - for (const scriptCommand of Object.values(scripts)) { - if (typeof scriptCommand !== "string") continue; - const commandReferenced = collectCommandReferencedPackages( - scriptCommand, - declaredNames, - binToPackage, - ); - for (const packageName of commandReferenced) referenced.add(packageName); - - // A dep can appear in a script as a flag argument rather than the - // leading binary (`jest --testResultsProcessor jest-sonar-reporter`), - // which the binary matcher above skips. Treat any declared package named - // as a standalone token anywhere in the command as referenced. - for (const declaredName of declaredNames) { - if (referenced.has(declaredName)) continue; - if (matchesPackageTokenReference(scriptCommand, declaredName)) { - referenced.add(declaredName); - } - } - } - } catch { - return referenced; - } - - return referenced; -}; - -const collectCommandReferencedPackages = ( - command: string, - declaredNames: Set<string>, - binToPackage: Map<string, Set<string>>, -): Set<string> => { - const referenced = new Set<string>(); - - const segments = command.split(SHELL_SPLIT_PATTERN); - for (const segment of segments) { - const tokens = segment.trim().split(/\s+/); - if (tokens.length === 0) continue; - - let binaryIndex = 0; - while (binaryIndex < tokens.length && INLINE_ENV_VAR_PATTERN.test(tokens[binaryIndex])) { - binaryIndex++; - } - if (binaryIndex >= tokens.length) continue; - - const binaryToken = tokens[binaryIndex].replace(/^.*\//, ""); - const effectiveBinary = - binaryToken === "npx" || binaryToken === "pnpx" || binaryToken === "bunx" - ? (tokens[binaryIndex + 1]?.replace(/^.*\//, "") ?? "") - : binaryToken; - - for (const candidateBinary of [binaryToken, effectiveBinary]) { - if (!candidateBinary) continue; - for (const mappedPackage of binToPackage.get(candidateBinary) ?? []) { - if (declaredNames.has(mappedPackage)) { - referenced.add(mappedPackage); - } - } - if (declaredNames.has(candidateBinary)) { - referenced.add(candidateBinary); - } - } - } - - return referenced; -}; - -const CONFIG_FILE_GLOBS = [ - "postcss.config.{js,cjs,mjs,ts}", - ".babelrc", - ".babelrc.{js,cjs,mjs,json}", - "babel.config.{js,cjs,mjs,json,ts}", - ".eslintrc", - ".eslintrc.{js,cjs,mjs,json,yaml,yml}", - ".prettierrc", - ".prettierrc.{js,cjs,mjs,json,json5,yaml,yml,toml}", - "prettier.config.{js,cjs,mjs,ts,mts,cts}", - "eslint.config.{js,cjs,mjs,ts,mts,cts}", - "webpack.config.{js,ts,mjs,cjs}", - "**/webpack*.config.{js,ts,mjs,cjs}", - "**/webpack*.config*.{js,ts,mjs,cjs}", - "**/webpack*.babel.{js,ts}", - "vite.config.{js,ts,mjs,mts}", - "rollup.config.{js,ts,mjs,cjs}", - ".storybook/main.{js,ts,mjs,cjs}", - ".storybook/preview.{js,ts,mjs,cjs,tsx,jsx}", - "docusaurus.config.{js,ts,mjs}", - "next.config.{js,ts,mjs,mts}", - "tailwind.config.{js,ts,cjs,mjs}", - "jest.config.{js,ts,mjs,cjs}", - "vitest.config.{js,ts,mjs,mts}", - "app.json", - "forge.config.{js,ts,cjs}", - "wrangler.toml", - "wrangler.json", - "wrangler.jsonc", - "metro.config.{js,ts}", - "electron.vite.config.{js,ts,mjs}", - "api-extractor.json", - "codegen.{ts,js,yml,yaml}", - ".graphqlrc.{ts,js,json,yml,yaml}", - "graphql.config.{ts,js,json,yml,yaml}", - ".lintstagedrc.{js,cjs,mjs,json}", - "commitlint.config.{js,cjs,mjs,ts}", - ".commitlintrc.{js,cjs,mjs,json,yaml,yml}", - "tslint.json", - ".remarkrc", - ".remarkrc.{js,cjs,mjs,json}", - ".dumirc.ts", - ".dumirc.js", - "dumi.config.{ts,js}", -]; - -const collectConfigReferencedPackages = ( - rootDir: string, - graph: DependencyGraph, - declaredNames: Set<string>, - summaryCache: SummaryCache | undefined, -): Set<string> => { - const referenced = new Set<string>(); - - const addMatchesFromFile = ( - filePath: string, - kind: PackageFactKind, - matcher: (content: string, packageName: string) => boolean, - ): void => { - try { - for (const packageName of matchPackageNamesInFile( - filePath, - kind, - declaredNames, - matcher, - summaryCache, - )) { - referenced.add(packageName); - } - } catch { - return; - } - }; - - for (const module of graph.modules) { - if (!module.isConfigFile) continue; - addMatchesFromFile(module.fileId.path, "substring", containsPackageName); - } - - const configFiles = globPackageFiles( - rootDir, - CONFIG_FILE_GLOBS, - { ignore: ["**/node_modules/**"], dot: true, deep: 3 }, - summaryCache, - ); - - for (const configPath of configFiles) { - addMatchesFromFile(configPath, "substring", containsPackageName); - } - - const documentationFiles = globPackageFiles( - rootDir, - ["**/*.{mdx,md}"], - { - ignore: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/CHANGELOG.md"], - deep: 6, - }, - summaryCache, - ); - - for (const documentationPath of documentationFiles) { - addMatchesFromFile(documentationPath, "importReference", matchesPackageImportReference); - } - - // Dot-directory tooling source trees (a dumi docs theme, storybook config - // components) import real dependencies but live outside the module graph's - // traversal, so their imports must be credited by content scan. - const toolingSourceFiles = globPackageFiles( - rootDir, - ["**/{.dumi,.storybook,.docz,.styleguidist}/**/*.{ts,tsx,js,jsx,mts,mjs}"], - { ignore: ["**/node_modules/**"], dot: true, deep: 8 }, - summaryCache, - ); - - for (const toolingSourcePath of toolingSourceFiles) { - addMatchesFromFile(toolingSourcePath, "importReference", matchesPackageImportReference); - } - - return referenced; -}; - -const PACKAGE_JSON_CONFIG_SECTIONS = [ - "jest", - "babel", - "eslintConfig", - "prettier", - "stylelint", - "lint-staged", - "commitlint", - "browserslist", - "postcss", - "ava", - "config", - "pnpm", - "resolutions", - "overrides", -] as const; - -const collectOverrideMappingsFromPackageJson = (packageJsonPath: string): OverrideMapping[] => { - const mappings: OverrideMapping[] = []; - - try { - const content = readFileSync(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(content); - - const overrideSections = [ - packageJson.overrides, - packageJson.resolutions, - packageJson.pnpm?.overrides, - ]; - - for (const overrideSection of overrideSections) { - if (!overrideSection || typeof overrideSection !== "object") continue; - mappings.push(...collectOverrideMappingsFromRecord(overrideSection)); - } - } catch { - return mappings; - } - - return mappings; -}; - -const collectOverrideMappings = ( - configSearchRoots: string[], - packageJsonPaths: string[], - monorepoRoot: string | undefined, -): OverrideMapping[] => { - const mappings: OverrideMapping[] = []; - const seenMappings = new Set<string>(); - - const addMappings = (nextMappings: OverrideMapping[]): void => { - for (const mapping of nextMappings) { - const mappingKey = `${mapping.fromPackage}->${mapping.toPackage}`; - if (seenMappings.has(mappingKey)) continue; - seenMappings.add(mappingKey); - mappings.push(mapping); - } - }; - - for (const packageJsonPath of packageJsonPaths) { - addMappings(collectOverrideMappingsFromPackageJson(packageJsonPath)); - } - - const workspaceRoots = new Set(configSearchRoots); - if (monorepoRoot) workspaceRoots.add(monorepoRoot); - - for (const workspaceRoot of workspaceRoots) { - addMappings(collectPnpmWorkspaceOverrideMappings(workspaceRoot)); - } - - return mappings; -}; - -const collectPackageJsonConfigReferences = ( - packageJsonPath: string, - declaredNames: Set<string>, -): Set<string> => { - const referenced = new Set<string>(); - - try { - const content = readFileSync(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(content); - - for (const sectionName of PACKAGE_JSON_CONFIG_SECTIONS) { - const sectionValue = packageJson[sectionName]; - if (!sectionValue || typeof sectionValue !== "object") continue; - - const sectionText = JSON.stringify(sectionValue); - for (const packageName of declaredNames) { - if (sectionText.includes(packageName)) { - referenced.add(packageName); - } - } - } - } catch { - return referenced; - } - - return referenced; -}; - -const collectNxProjectJsonReferences = ( - rootDir: string, - declaredNames: Set<string>, - binToPackage: Map<string, Set<string>>, - summaryCache: SummaryCache | undefined, -): Set<string> => { - const referenced = new Set<string>(); - - const projectJsonPaths = globPackageFiles( - rootDir, - ["project.json", "**/project.json"], - { ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], deep: 5 }, - summaryCache, - ); - - for (const projectJsonPath of projectJsonPaths) { - try { - const content = readFileSync(projectJsonPath, "utf-8"); - const projectJson = JSON.parse(content); - const projectText = JSON.stringify(projectJson); - for (const packageName of declaredNames) { - if (projectText.includes(packageName)) { - referenced.add(packageName); - } - } - - for (const stringValue of collectStringValues(projectJson)) { - const commandReferenced = collectCommandReferencedPackages( - stringValue, - declaredNames, - binToPackage, - ); - for (const packageName of commandReferenced) referenced.add(packageName); - } - } catch { - continue; - } - } - - return referenced; -}; - -const collectStringValues = (value: unknown): string[] => { - if (typeof value === "string") return [value]; - if (!value || typeof value !== "object") return []; - if (Array.isArray(value)) return value.flatMap(collectStringValues); - return Object.values(value).flatMap(collectStringValues); -}; - -const TSCONFIG_GLOBS = [ - "tsconfig.json", - "tsconfig.*.json", - "jsconfig.json", - "**/tsconfig.json", - "**/tsconfig.*.json", -]; - -const collectTsconfigReferencedPackages = ( - rootDir: string, - summaryCache: SummaryCache | undefined, -): Set<string> => { - const referenced = new Set<string>(); - - const tsconfigFiles = globPackageFiles( - rootDir, - TSCONFIG_GLOBS, - { ignore: ["**/node_modules/**"], dot: false, deep: 4 }, - summaryCache, - ); - - for (const tsconfigPath of tsconfigFiles) { - try { - const content = readFileSync(tsconfigPath, "utf-8"); - const cleaned = content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""); - const parsed = JSON.parse(cleaned); - - if (typeof parsed.extends === "string") { - const extendsPackage = extractExtendsPackageName(parsed.extends); - if (extendsPackage) referenced.add(extendsPackage); - } - if (Array.isArray(parsed.extends)) { - for (const extendsEntry of parsed.extends) { - if (typeof extendsEntry === "string") { - const extendsPackage = extractExtendsPackageName(extendsEntry); - if (extendsPackage) referenced.add(extendsPackage); - } - } - } - - const compilerOptions = parsed.compilerOptions; - if (compilerOptions?.jsxImportSource && typeof compilerOptions.jsxImportSource === "string") { - referenced.add(compilerOptions.jsxImportSource); - } - if (Array.isArray(compilerOptions?.types)) { - for (const typesEntry of compilerOptions.types) { - if (typeof typesEntry === "string") { - const typesPackage = extractPackageName(typesEntry); - if (typesPackage) referenced.add(typesPackage); - } - } - } - } catch { - continue; - } - } - - return referenced; -}; - -const extractExtendsPackageName = (extendsValue: string): string | undefined => { - if (extendsValue.startsWith(".") || extendsValue.startsWith("/")) return undefined; - if (extendsValue.startsWith("@")) { - const parts = extendsValue.split("/"); - return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined; - } - return extendsValue.split("/")[0]; -}; - -const SOURCE_FILE_GLOBS = ["**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}"]; - -const SOURCE_FILE_IGNORES = [ - "**/node_modules/**", - "**/dist/**", - "**/build/**", - "**/out/**", - "**/.git/**", - "**/coverage/**", - "**/*.min.js", - "**/*.d.ts", -]; - -const scanSourceFilesForPackageImports = ( - rootDir: string, - candidatePackages: Set<string>, - summaryCache: SummaryCache | undefined, -): Set<string> => { - const found = new Set<string>(); - if (candidatePackages.size === 0) return found; - - const sourceFiles = globPackageFiles( - rootDir, - SOURCE_FILE_GLOBS, - { ignore: SOURCE_FILE_IGNORES, deep: 15 }, - summaryCache, - ); - - if (summaryCache !== undefined) { - // Cached mode queries the FULL candidate set against every file (no - // early-shrink) so the per-file fact key stays stable across runs. The - // found-set is identical — a candidate is found iff any file matches it. - for (const filePath of sourceFiles) { - try { - for (const packageName of summaryCache.matchPackageNames( - filePath, - "importReference", - candidatePackages, - matchesPackageImportReference, - )) { - found.add(packageName); - } - } catch { - continue; - } - } - for (const packageName of found) candidatePackages.delete(packageName); - return found; - } - - for (const filePath of sourceFiles) { - if (candidatePackages.size === 0) break; - try { - const content = readFileSync(filePath, "utf-8"); - for (const packageName of candidatePackages) { - if (matchesPackageImportReference(content, packageName)) { - found.add(packageName); - candidatePackages.delete(packageName); - } - } - } catch { - continue; - } - } - - return found; -}; - -const ALWAYS_USED_PREFIXES = [ - "@types/", - "eslint-config-", - "eslint-plugin-", - "@eslint/", - "prettier-plugin-", - "@commitlint/", - "babel-plugin-", - "babel-preset-", - "@babel/plugin-", - "@babel/preset-", - "@fontsource/", - "@next/", - "@svgr/", - "@docusaurus/", - "stylelint-config-", - "stylelint-plugin-", - "@testing-library/", - "@vitest/", - "@playwright/", - "@storybook/", - "jest-environment-", - "@graphql-codegen/", - "@size-limit/", - "@nestjs/", - "@swc/", - "@electron-forge/", - "@parcel/", - "@wyw-in-js/", - "@typescript-eslint/", - "@react-native/", - "@react-native-community/", - "postcss-", - "@tailwindcss/", - "rollup-plugin-", - "@rollup/", - "vite-plugin-", - "@vitejs/", - "webpack-", - "esbuild-", - "@esbuild-plugins/", - "@lingui/", - "@emotion/", - "tslint-config-", - "eslint-import-resolver-", - "@changesets/", - "@react-navigation/", - "@vercel/", - "@expo/", - "expo-", - "react-native-", -]; - -const ALWAYS_USED_SUFFIXES = ["-loader"]; - -const isAlwaysConsideredUsed = (dependencyName: string): boolean => { - if (IMPLICIT_DEPENDENCIES.has(dependencyName)) return true; - if (ALWAYS_USED_PREFIXES.some((prefix) => dependencyName.startsWith(prefix))) return true; - if (ALWAYS_USED_SUFFIXES.some((suffix) => dependencyName.endsWith(suffix))) return true; - return false; -}; diff --git a/packages/deslop-js/src/report/private-type-leaks.ts b/packages/deslop-js/src/report/private-type-leaks.ts deleted file mode 100644 index 931ef69b8d..0000000000 --- a/packages/deslop-js/src/report/private-type-leaks.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { readFileSync } from "node:fs"; -import { parseSync } from "oxc-parser"; -import type { DependencyGraph, PrivateTypeLeak } from "../types.js"; -import { computeLineStarts } from "../utils/compute-line-starts.js"; -import { offsetToLineColumn } from "../utils/offset-to-line-column.js"; -import { isAstNode } from "../utils/is-ast-node.js"; - -const extractIdentifierName = (node: unknown): string | undefined => { - if (!isAstNode(node)) return undefined; - if (node.type === "Identifier") { - const identifierName = node.name; - return typeof identifierName === "string" ? identifierName : undefined; - } - return undefined; -}; - -const collectTypeReferenceNamesFromTypeNode = (typeNode: unknown, into: Set<string>): void => { - if (!isAstNode(typeNode)) return; - - if (typeNode.type === "TSTypeReference") { - const referencedTypeName = typeNode.typeName; - if (isAstNode(referencedTypeName) && referencedTypeName.type === "Identifier") { - const name = referencedTypeName.name; - if (typeof name === "string") into.add(name); - } - } - - for (const key of Object.keys(typeNode)) { - if (key === "type" || key === "start" || key === "end") continue; - const value = typeNode[key]; - if (Array.isArray(value)) { - for (const item of value) collectTypeReferenceNamesFromTypeNode(item, into); - } else if (value !== null && typeof value === "object") { - collectTypeReferenceNamesFromTypeNode(value, into); - } - } -}; - -interface PublicSignatureReference { - exportName: string; - typeName: string; - byteOffset: number; -} - -const isExportedDeclaration = (statement: unknown): boolean => { - if (!isAstNode(statement)) return false; - return ( - statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" - ); -}; - -const declarationOf = (statement: unknown): unknown => { - if (!isAstNode(statement)) return undefined; - return statement.declaration; -}; - -const exportedNameOfDeclaration = (declarationNode: unknown): string | undefined => { - if (!isAstNode(declarationNode)) return undefined; - if ( - declarationNode.type === "FunctionDeclaration" || - declarationNode.type === "ClassDeclaration" - ) { - return extractIdentifierName(declarationNode.id); - } - if (declarationNode.type === "VariableDeclaration") { - const declarators = declarationNode.declarations; - if (Array.isArray(declarators) && declarators.length > 0) { - const firstDeclarator = declarators[0]; - if (isAstNode(firstDeclarator)) { - return extractIdentifierName(firstDeclarator.id); - } - } - } - if ( - declarationNode.type === "TSInterfaceDeclaration" || - declarationNode.type === "TSTypeAliasDeclaration" - ) { - return extractIdentifierName(declarationNode.id); - } - return undefined; -}; - -const collectFromFunctionLikeSignature = ( - functionLikeNode: unknown, - exportName: string, - collected: PublicSignatureReference[], -): void => { - if (!isAstNode(functionLikeNode)) return; - const params = functionLikeNode.params; - if (Array.isArray(params)) { - for (const param of params) collectFromParameter(param, exportName, collected); - } - const returnTypeAnnotation = functionLikeNode.returnType; - if (isAstNode(returnTypeAnnotation)) { - const annotation = returnTypeAnnotation.typeAnnotation; - pushTypeReferences(annotation, exportName, collected, returnTypeAnnotation); - } -}; - -const collectFromParameter = ( - parameterNode: unknown, - exportName: string, - collected: PublicSignatureReference[], -): void => { - if (!isAstNode(parameterNode)) return; - const annotation = parameterNode.typeAnnotation; - if (isAstNode(annotation)) { - const innerTypeNode = annotation.typeAnnotation; - pushTypeReferences(innerTypeNode, exportName, collected, annotation); - } -}; - -const pushTypeReferences = ( - typeNode: unknown, - exportName: string, - collected: PublicSignatureReference[], - spanFallbackNode: unknown, -): void => { - if (!isAstNode(typeNode)) return; - const referencedTypeNames = new Set<string>(); - collectTypeReferenceNamesFromTypeNode(typeNode, referencedTypeNames); - for (const referencedName of referencedTypeNames) { - const offset = typeNode.start; - const fallbackOffset = - isAstNode(spanFallbackNode) && typeof spanFallbackNode.start === "number" - ? (spanFallbackNode.start as number) - : 0; - collected.push({ - exportName, - typeName: referencedName, - byteOffset: typeof offset === "number" ? offset : fallbackOffset, - }); - } -}; - -const collectPublicSignatureReferences = (programNode: unknown): PublicSignatureReference[] => { - const collected: PublicSignatureReference[] = []; - if (!isAstNode(programNode)) return collected; - const programBody = programNode.body; - if (!Array.isArray(programBody)) return collected; - - for (const statement of programBody) { - if (!isExportedDeclaration(statement)) continue; - const declarationNode = declarationOf(statement); - if (declarationNode === undefined || declarationNode === null) continue; - - const exportedName = exportedNameOfDeclaration(declarationNode); - if (!exportedName) continue; - - if (isAstNode(declarationNode)) { - if ( - declarationNode.type === "FunctionDeclaration" || - declarationNode.type === "ArrowFunctionExpression" || - declarationNode.type === "FunctionExpression" - ) { - collectFromFunctionLikeSignature(declarationNode, exportedName, collected); - continue; - } - if (declarationNode.type === "VariableDeclaration") { - const declarators = declarationNode.declarations; - if (Array.isArray(declarators)) { - for (const declarator of declarators) { - if (!isAstNode(declarator)) continue; - const id = declarator.id; - if (isAstNode(id)) { - const annotation = id.typeAnnotation; - if (isAstNode(annotation)) { - const inner = annotation.typeAnnotation; - pushTypeReferences(inner, exportedName, collected, annotation); - } - } - const init = declarator.init; - if ( - isAstNode(init) && - (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression") - ) { - collectFromFunctionLikeSignature(init, exportedName, collected); - } - } - } - continue; - } - if (declarationNode.type === "ClassDeclaration") { - const classBody = declarationNode.body; - if (isAstNode(classBody)) { - const members = classBody.body; - if (Array.isArray(members)) { - for (const member of members) { - if (!isAstNode(member)) continue; - if (member.type === "MethodDefinition") { - const value = member.value; - collectFromFunctionLikeSignature(value, exportedName, collected); - } else if (member.type === "PropertyDefinition") { - const annotation = member.typeAnnotation; - if (isAstNode(annotation)) { - const inner = annotation.typeAnnotation; - pushTypeReferences(inner, exportedName, collected, annotation); - } - } - } - } - } - } - } - } - - return collected; -}; - -const collectLocalTypeNames = ( - programNode: unknown, -): { localTypeNames: Set<string>; exportedNames: Set<string> } => { - const localTypeNames = new Set<string>(); - const exportedNames = new Set<string>(); - if (!isAstNode(programNode)) return { localTypeNames, exportedNames }; - const programBody = programNode.body; - if (!Array.isArray(programBody)) return { localTypeNames, exportedNames }; - - for (const statement of programBody) { - if (!isAstNode(statement)) continue; - if ( - statement.type === "TSInterfaceDeclaration" || - statement.type === "TSTypeAliasDeclaration" - ) { - const name = extractIdentifierName(statement.id); - if (name) localTypeNames.add(name); - continue; - } - if (statement.type === "ExportNamedDeclaration") { - const declarationNode = statement.declaration; - if (isAstNode(declarationNode)) { - if ( - declarationNode.type === "TSInterfaceDeclaration" || - declarationNode.type === "TSTypeAliasDeclaration" - ) { - const name = extractIdentifierName(declarationNode.id); - if (name) exportedNames.add(name); - continue; - } - const declaredName = exportedNameOfDeclaration(declarationNode); - if (declaredName) exportedNames.add(declaredName); - } - const specifiers = statement.specifiers; - if (Array.isArray(specifiers)) { - for (const specifier of specifiers) { - if (!isAstNode(specifier)) continue; - if (specifier.type === "ExportSpecifier") { - const exported = specifier.exported; - const exportedNameValue = extractIdentifierName(exported); - if (exportedNameValue) exportedNames.add(exportedNameValue); - } - } - } - } - } - - return { localTypeNames, exportedNames }; -}; - -/** - * Storybook CSF3 convention: a story file declares - * - * const meta = { ... } satisfies Meta<...>; - * export default meta; - * type Story = StoryObj<typeof meta>; - * export const Primary: Story = { ... }; - * - * `Story` is intentionally a local alias — consumers don't import it; the - * Storybook runtime reads the default export. Flagging this as a leak - * produces near-100% false positives on Storybook codebases, so skip story - * files entirely. Storybook supports both common glob conventions — match - * the `*.stories.{ext}` style as well as a bare `stories.{ext}` basename. - */ -const STORYBOOK_STORY_BASENAME_PATTERN = /^(?:.*\.)?stories\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/i; - -const isStorybookStoryFile = (filePath: string): boolean => { - const lastSlash = filePath.lastIndexOf("/"); - const basename = lastSlash === -1 ? filePath : filePath.slice(lastSlash + 1); - return STORYBOOK_STORY_BASENAME_PATTERN.test(basename); -}; - -/** - * Detect TypeScript "private type leak": an exported declaration's signature - * references a type that was declared locally in the same module but is not - * itself exported. Consumers of the export need that type to satisfy the - * signature, but cannot import it. - * - * Skips declaration files (`.d.ts`) — they are pure type modules where this - * pattern is the norm. Keeps it simple: doesn't try to chase aliased re-export - * paths (deslop-js's broader resolver work covers that elsewhere); a leak - * that's actually re-exported gets filtered out at the `exportedNames` set. - */ -export const detectPrivateTypeLeaks = (graph: DependencyGraph): PrivateTypeLeak[] => { - const findings: PrivateTypeLeak[] = []; - - for (const module of graph.modules) { - if (module.isDeclarationFile) continue; - if (module.isConfigFile) continue; - if (!module.isReachable) continue; - if (isStorybookStoryFile(module.fileId.path)) continue; - - let sourceText: string; - try { - sourceText = readFileSync(module.fileId.path, "utf-8"); - } catch { - continue; - } - let parseResult: ReturnType<typeof parseSync>; - try { - parseResult = parseSync(module.fileId.path, sourceText); - } catch { - continue; - } - - const programNode = parseResult.program; - const { localTypeNames, exportedNames } = collectLocalTypeNames(programNode); - if (localTypeNames.size === 0) continue; - - const publicSignatureReferences = collectPublicSignatureReferences(programNode); - if (publicSignatureReferences.length === 0) continue; - - const lineStarts = computeLineStarts(sourceText); - const seenPairs = new Set<string>(); - - for (const reference of publicSignatureReferences) { - if (!localTypeNames.has(reference.typeName)) continue; - if (exportedNames.has(reference.typeName)) continue; - const pairKey = `${reference.exportName}::${reference.typeName}`; - if (seenPairs.has(pairKey)) continue; - seenPairs.add(pairKey); - - const { line, column } = offsetToLineColumn(reference.byteOffset, lineStarts); - findings.push({ - path: module.fileId.path, - exportName: reference.exportName, - typeName: reference.typeName, - line, - column, - confidence: "high", - reason: `${reference.exportName}'s signature references ${reference.typeName}, declared locally but not exported — consumers can't satisfy the type without importing it`, - }); - } - } - - findings.sort((leftLeak, rightLeak) => { - if (leftLeak.path !== rightLeak.path) return leftLeak.path.localeCompare(rightLeak.path); - return leftLeak.line - rightLeak.line; - }); - return findings; -}; diff --git a/packages/deslop-js/src/report/re-export-cycles.ts b/packages/deslop-js/src/report/re-export-cycles.ts deleted file mode 100644 index 6eb5bf0e94..0000000000 --- a/packages/deslop-js/src/report/re-export-cycles.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { DependencyGraph, ReExportCycle } from "../types.js"; -import { findStronglyConnectedComponents } from "../utils/find-strongly-connected-components.js"; - -/** - * Reports cycles in the subgraph of `isReExportEdge` edges only. These are - * a strict subset of `circularDependencies` but worth separating: every - * general cycle can have a legitimate bidirectional-collaboration reason, - * but a re-export cycle has none — it always tanks tree-shaking and risks - * the "Cannot access X before initialization" TDZ runtime error. - */ -export const detectReExportCycles = (graph: DependencyGraph): ReExportCycle[] => { - const adjacency: number[][] = Array.from({ length: graph.modules.length }, () => []); - const reExportTargetSets: Set<number>[] = Array.from( - { length: graph.modules.length }, - () => new Set(), - ); - - for (const edge of graph.edges) { - if (!edge.isReExportEdge) continue; - if (edge.target >= graph.modules.length) continue; - if (reExportTargetSets[edge.source].has(edge.target)) continue; - reExportTargetSets[edge.source].add(edge.target); - adjacency[edge.source].push(edge.target); - } - - const sccComponents = findStronglyConnectedComponents(adjacency); - const findings: ReExportCycle[] = []; - - for (const component of sccComponents) { - if (component.length === 1) { - const onlyNode = component[0]; - const hasSelfLoop = adjacency[onlyNode].includes(onlyNode); - if (!hasSelfLoop) continue; - const filePath = graph.modules[onlyNode].fileId.path; - findings.push({ - files: [filePath], - kind: "self-loop", - confidence: "high", - reason: `${filePath} re-exports from itself — the barrel imports its own root, which breaks bundler tree-shaking and risks TDZ runtime errors`, - }); - continue; - } - - const sortedFiles = component - .map((moduleIndex) => graph.modules[moduleIndex].fileId.path) - .sort(); - findings.push({ - files: sortedFiles, - kind: "multi-node", - confidence: "high", - reason: `${sortedFiles.length} modules form a re-export cycle — refactor consumers to import from the leaf module instead of the barrel`, - }); - } - - findings.sort((firstFinding, secondFinding) => - firstFinding.files[0].localeCompare(secondFinding.files[0]), - ); - return findings; -}; diff --git a/packages/deslop-js/src/report/redundancy.ts b/packages/deslop-js/src/report/redundancy.ts deleted file mode 100644 index 07fc4f4d06..0000000000 --- a/packages/deslop-js/src/report/redundancy.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { - DependencyGraph, - DuplicateExport, - DuplicateExportOccurrence, - RedundantAlias, -} from "../types.js"; -import { PLATFORM_SUFFIXES } from "../constants.js"; - -const isPlatformSpecificModulePath = (modulePath: string): boolean => { - const extensionIndex = modulePath.lastIndexOf("."); - if (extensionIndex === -1) return false; - const withoutExtension = modulePath.slice(0, extensionIndex); - return PLATFORM_SUFFIXES.some((suffix) => withoutExtension.endsWith(suffix)); -}; - -const platformStrippedBasePath = (modulePath: string): string => { - const extensionIndex = modulePath.lastIndexOf("."); - if (extensionIndex === -1) return modulePath; - const withoutExtension = modulePath.slice(0, extensionIndex); - for (const suffix of PLATFORM_SUFFIXES) { - if (withoutExtension.endsWith(suffix)) { - return withoutExtension.slice(0, -suffix.length) + modulePath.slice(extensionIndex); - } - } - return modulePath; -}; - -const buildPlatformSiblingGroupSizes = (graph: DependencyGraph): Map<string, number> => { - const baseToCount = new Map<string, number>(); - for (const module of graph.modules) { - const base = platformStrippedBasePath(module.fileId.path); - baseToCount.set(base, (baseToCount.get(base) ?? 0) + 1); - } - return baseToCount; -}; - -export const detectUselessAliasedReExports = (graph: DependencyGraph): RedundantAlias[] => { - const findings: RedundantAlias[] = []; - - const moduleConsumerImportedNames = new Map<number, Set<string>>(); - const moduleConsumedWholesale = new Set<number>(); - const platformSiblingGroupSizes = buildPlatformSiblingGroupSizes(graph); - - for (const edge of graph.edges) { - if (edge.isReExportEdge) { - const reExportedSet = moduleConsumerImportedNames.get(edge.target); - if (edge.reExportedNames.includes("*")) { - moduleConsumedWholesale.add(edge.target); - } - if (reExportedSet) { - for (const reExportedName of edge.reExportedNames) reExportedSet.add(reExportedName); - } else { - moduleConsumerImportedNames.set(edge.target, new Set(edge.reExportedNames)); - } - continue; - } - if (edge.importedSymbols.length === 0) { - moduleConsumedWholesale.add(edge.target); - continue; - } - const importedSet = moduleConsumerImportedNames.get(edge.target); - for (const symbol of edge.importedSymbols) { - if (symbol.isNamespace || symbol.importedName === "*") { - moduleConsumedWholesale.add(edge.target); - continue; - } - const importedName = symbol.isDefault ? "default" : symbol.importedName; - if (importedSet) importedSet.add(importedName); - else moduleConsumerImportedNames.set(edge.target, new Set([importedName])); - } - } - - for (const module of graph.modules) { - if (!module.isReachable) continue; - if (module.isDeclarationFile) continue; - if (moduleConsumedWholesale.has(module.fileId.index)) continue; - if (isPlatformSpecificModulePath(module.fileId.path)) continue; - const platformBase = platformStrippedBasePath(module.fileId.path); - if ((platformSiblingGroupSizes.get(platformBase) ?? 0) > 1) continue; - - const consumerImportedNames = moduleConsumerImportedNames.get(module.fileId.index) ?? new Set(); - - for (const exportInfo of module.exports) { - if (exportInfo.isSynthetic) continue; - if (!exportInfo.isReExport) continue; - if (!exportInfo.reExportOriginalName) continue; - const exportedName = exportInfo.name; - const originalName = exportInfo.reExportOriginalName; - if (exportedName === originalName) continue; - if (exportedName === "*") continue; - if (originalName === "*") continue; - if (originalName === "default") continue; - if (exportInfo.isNamespaceReExport) continue; - if (consumerImportedNames.has(exportedName)) continue; - - findings.push({ - path: module.fileId.path, - kind: "reexport-aliased-not-used", - name: exportedName, - aliasedFrom: originalName, - line: exportInfo.line, - column: exportInfo.column, - confidence: "medium", - reason: `\`export { ${originalName} as ${exportedName} } from ...\` renames the symbol but no consumer imports it as \`${exportedName}\` — either drop the alias or have consumers use the new name`, - }); - } - } - - return findings; -}; - -export const detectRedundantAliases = (graph: DependencyGraph): RedundantAlias[] => { - const findings: RedundantAlias[] = []; - - for (const module of graph.modules) { - if (module.isDeclarationFile) continue; - if (!module.isReachable) continue; - - for (const importInfo of module.imports) { - for (const binding of importInfo.importedNames) { - if (!binding.isRedundantAlias) continue; - findings.push({ - path: module.fileId.path, - kind: "import-self-alias", - name: binding.name, - aliasedFrom: binding.name, - line: importInfo.line, - column: importInfo.column, - confidence: "high", - reason: `\`import { ${binding.name} as ${binding.name} }\` aliases an identifier to its own name`, - }); - } - } - - for (const exportInfo of module.exports) { - if (exportInfo.isSynthetic) continue; - if (!exportInfo.isRedundantAlias) continue; - const kind = exportInfo.isReExport ? "reexport-self-alias" : "export-self-alias"; - const sourceSuffix = exportInfo.reExportSource ? ` from "${exportInfo.reExportSource}"` : ""; - findings.push({ - path: module.fileId.path, - kind, - name: exportInfo.name, - aliasedFrom: exportInfo.name, - line: exportInfo.line, - column: exportInfo.column, - confidence: "high", - reason: `\`export { ${exportInfo.name} as ${exportInfo.name} }${sourceSuffix}\` aliases an identifier to its own name`, - }); - } - } - - return findings; -}; - -export const detectDuplicateExports = (graph: DependencyGraph): DuplicateExport[] => { - const findings: DuplicateExport[] = []; - - for (const module of graph.modules) { - if (module.isDeclarationFile) continue; - - const nameToOccurrences = new Map<string, DuplicateExportOccurrence[]>(); - const nameHasReExport = new Map<string, boolean>(); - - for (const exportInfo of module.exports) { - if (exportInfo.isSynthetic) continue; - if (exportInfo.name === "*" && exportInfo.isNamespaceReExport) continue; - - const occurrence: DuplicateExportOccurrence = { - line: exportInfo.line, - column: exportInfo.column, - reExportSource: exportInfo.reExportSource, - isReExport: exportInfo.isReExport, - }; - const existing = nameToOccurrences.get(exportInfo.name); - if (existing) { - existing.push(occurrence); - } else { - nameToOccurrences.set(exportInfo.name, [occurrence]); - } - if (exportInfo.isReExport) { - nameHasReExport.set(exportInfo.name, true); - } - } - - for (const [name, occurrences] of nameToOccurrences) { - if (occurrences.length < 2) continue; - if (!nameHasReExport.get(name)) continue; - - findings.push({ - path: module.fileId.path, - name, - occurrences, - confidence: "high", - reason: `"${name}" is exported ${occurrences.length} times from the same module`, - }); - } - } - - return findings; -}; diff --git a/packages/deslop-js/src/report/typescript-smells.ts b/packages/deslop-js/src/report/typescript-smells.ts deleted file mode 100644 index 3e6c56ec5c..0000000000 --- a/packages/deslop-js/src/report/typescript-smells.ts +++ /dev/null @@ -1,714 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { parseSync } from "oxc-parser"; -import type { - CommonjsInEsm, - DependencyGraph, - LazyImportAtTopLevel, - TypeScriptEscapeHatch, - TypeScriptEscapeHatchKind, - UnnecessaryAssertion, - UnnecessaryAssertionKind, -} from "../types.js"; -import { computeLineStarts } from "../utils/compute-line-starts.js"; -import { offsetToLineColumn } from "../utils/offset-to-line-column.js"; -import { isAstNode } from "../utils/is-ast-node.js"; - -interface ParsedSourceComment { - type: "Line" | "Block"; - value: string; - start: number; - end: number; -} - -interface ParsedSource { - programNode: unknown; - sourceText: string; - lineStarts: number[]; - comments: ParsedSourceComment[]; -} - -const parseSource = (filePath: string): ParsedSource | undefined => { - let sourceText: string; - try { - sourceText = readFileSync(filePath, "utf-8"); - } catch { - return undefined; - } - let parseResult: ReturnType<typeof parseSync>; - try { - parseResult = parseSync(filePath, sourceText); - } catch { - return undefined; - } - const rawComments = parseResult.comments; - const comments = Array.isArray(rawComments) ? rawComments.filter(isParsedSourceComment) : []; - return { - programNode: parseResult.program, - sourceText, - lineStarts: computeLineStarts(sourceText), - comments, - }; -}; - -const isParsedSourceComment = (candidate: unknown): candidate is ParsedSourceComment => { - if (typeof candidate !== "object" || candidate === null) return false; - const fields = candidate as Record<string, unknown>; - return ( - (fields.type === "Line" || fields.type === "Block") && - typeof fields.value === "string" && - typeof fields.start === "number" && - typeof fields.end === "number" - ); -}; - -const sliceSnippet = (sourceText: string, start: number, end: number): string => { - const SNIPPET_BUDGET_CHARS = 80; - const raw = sourceText - .slice(start, Math.min(end, start + SNIPPET_BUDGET_CHARS)) - .replace(/\s+/g, " ") - .trim(); - return end - start > SNIPPET_BUDGET_CHARS ? `${raw}…` : raw; -}; - -const isAnyOrUnknownTypeAnnotation = (typeAnnotation: unknown): "any" | "unknown" | undefined => { - if (!isAstNode(typeAnnotation)) return undefined; - if (typeAnnotation.type === "TSAnyKeyword") return "any"; - if (typeAnnotation.type === "TSUnknownKeyword") return "unknown"; - return undefined; -}; - -const isLiteralLikeNonNull = (expression: unknown): boolean => { - if (!isAstNode(expression)) return false; - if (expression.type === "Literal") { - const literalValue = expression.value; - return literalValue !== null; - } - if ( - expression.type === "TemplateLiteral" || - expression.type === "ArrayExpression" || - expression.type === "ObjectExpression" || - expression.type === "FunctionExpression" || - expression.type === "ArrowFunctionExpression" || - expression.type === "ClassExpression" - ) { - return true; - } - return false; -}; - -const collectUnnecessaryAssertionsInNode = ( - node: unknown, - filePath: string, - sourceText: string, - lineStarts: number[], - results: UnnecessaryAssertion[], -): void => { - if (!isAstNode(node)) return; - - if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") { - const innerExpression = node.expression; - const typeAnnotation = node.typeAnnotation; - - if (node.type === "TSAsExpression") { - const outerKind = isAnyOrUnknownTypeAnnotation(typeAnnotation); - if ( - outerKind === undefined && - isAstNode(innerExpression) && - innerExpression.type === "TSAsExpression" - ) { - const innerTypeAnnotation = innerExpression.typeAnnotation; - const innerKind = isAnyOrUnknownTypeAnnotation(innerTypeAnnotation); - if (innerKind !== undefined) { - pushAssertion( - node, - "redundant-double-assertion", - `\`x as ${innerKind} as T\` first widens to ${innerKind} just to assert to T — drop the intermediate ${innerKind} and assert directly`, - "if you must assert, write `x as T` directly", - filePath, - sourceText, - lineStarts, - results, - ); - } - } - if (outerKind === "any") { - pushAssertion( - node, - "assertion-to-any", - "`as any` opts out of TypeScript's type system — narrow to a specific type or use `unknown`", - "replace `as any` with the actual type, or use `as unknown as T` only when you genuinely need to discard the inferred type", - filePath, - sourceText, - lineStarts, - results, - ); - } - } - } - - if (node.type === "TSTypeAssertion") { - pushAssertion( - node, - "angle-bracket-assertion", - "`<T>x` style assertion is parsed as a JSX tag in `.tsx` and is deprecated in mixed-extension projects — prefer `x as T`", - "rewrite `<T>x` as `x as T`", - filePath, - sourceText, - lineStarts, - results, - ); - } - - if (node.type === "TSNonNullExpression") { - const innerExpression = node.expression; - if (isAstNode(innerExpression) && innerExpression.type === "TSNonNullExpression") { - pushAssertion( - node, - "double-non-null", - "`x!!` is the non-null assertion applied twice — the second `!` is always a no-op", - "drop one of the `!` operators", - filePath, - sourceText, - lineStarts, - results, - ); - } else if (isLiteralLikeNonNull(innerExpression)) { - pushAssertion( - node, - "redundant-non-null-on-literal", - "`!` after a literal / array / object / function expression is redundant — those values are never null", - "remove the trailing `!`", - filePath, - sourceText, - lineStarts, - results, - ); - } - } -}; - -const pushAssertion = ( - node: unknown, - kind: UnnecessaryAssertionKind, - reason: string, - suggestion: string, - filePath: string, - sourceText: string, - lineStarts: number[], - results: UnnecessaryAssertion[], -): void => { - if (!isAstNode(node)) return; - const startOffset = node.start; - const endOffset = node.end; - if (typeof startOffset !== "number" || typeof endOffset !== "number") return; - const { line, column } = offsetToLineColumn(startOffset, lineStarts); - const isHighConfidenceKind = - kind === "double-non-null" || - kind === "redundant-non-null-on-literal" || - kind === "redundant-double-assertion"; - results.push({ - path: filePath, - kind, - snippet: sliceSnippet(sourceText, startOffset, endOffset), - line, - column, - confidence: isHighConfidenceKind ? "high" : "medium", - reason, - suggestion, - }); -}; - -const visitForUnnecessaryAssertions = ( - node: unknown, - filePath: string, - sourceText: string, - lineStarts: number[], - results: UnnecessaryAssertion[], -): void => { - if (!isAstNode(node)) return; - collectUnnecessaryAssertionsInNode(node, filePath, sourceText, lineStarts, results); - for (const propertyKey of Object.keys(node)) { - if ( - propertyKey === "type" || - propertyKey === "start" || - propertyKey === "end" || - propertyKey === "loc" || - propertyKey === "range" - ) { - continue; - } - const value = node[propertyKey]; - if (Array.isArray(value)) { - for (const item of value) { - visitForUnnecessaryAssertions(item, filePath, sourceText, lineStarts, results); - } - } else if (value !== null && typeof value === "object") { - visitForUnnecessaryAssertions(value, filePath, sourceText, lineStarts, results); - } - } -}; - -const importExpressionSpecifier = (importExpression: unknown): string | undefined => { - if (!isAstNode(importExpression)) return undefined; - if (importExpression.type !== "ImportExpression") return undefined; - const sourceNode = importExpression.source; - if (!isAstNode(sourceNode)) return undefined; - if (sourceNode.type !== "Literal") return undefined; - const literalValue = sourceNode.value; - return typeof literalValue === "string" ? literalValue : undefined; -}; - -const findThenImportInExpressionStatement = ( - expressionNode: unknown, -): { importExpression: unknown; specifier: string } | undefined => { - if (!isAstNode(expressionNode)) return undefined; - if (expressionNode.type !== "CallExpression") return undefined; - const callee = expressionNode.callee; - if (!isAstNode(callee)) return undefined; - if (callee.type !== "MemberExpression" && callee.type !== "StaticMemberExpression") - return undefined; - const propertyNode = callee.property; - const propertyName = isAstNode(propertyNode) ? propertyNode.name : undefined; - if (propertyName !== "then" && propertyName !== "catch" && propertyName !== "finally") - return undefined; - const objectNode = callee.object; - const specifier = importExpressionSpecifier(objectNode); - if (specifier === undefined) return undefined; - return { importExpression: objectNode, specifier }; -}; - -const findAwaitImportInExpression = ( - expressionNode: unknown, -): { importExpression: unknown; specifier: string } | undefined => { - if (!isAstNode(expressionNode)) return undefined; - if (expressionNode.type !== "AwaitExpression") return undefined; - const argumentNode = expressionNode.argument; - const specifier = importExpressionSpecifier(argumentNode); - if (specifier === undefined) return undefined; - return { importExpression: argumentNode, specifier }; -}; - -const collectLazyImportsAtTopLevel = ( - programNode: unknown, - filePath: string, - lineStarts: number[], - results: LazyImportAtTopLevel[], -): void => { - if (!isAstNode(programNode)) return; - const programBody = programNode.body; - if (!Array.isArray(programBody)) return; - - for (const topLevelStatement of programBody) { - if (!isAstNode(topLevelStatement)) continue; - - if (topLevelStatement.type === "VariableDeclaration") { - const declarators = topLevelStatement.declarations; - if (!Array.isArray(declarators)) continue; - for (const declarator of declarators) { - if (!isAstNode(declarator)) continue; - const initializer = declarator.init; - const awaitImport = findAwaitImportInExpression(initializer); - if (awaitImport) { - recordLazyImport(awaitImport, "top-level-await-import", filePath, lineStarts, results); - } - } - continue; - } - - if (topLevelStatement.type === "ExpressionStatement") { - const innerExpression = topLevelStatement.expression; - const awaitImport = findAwaitImportInExpression(innerExpression); - if (awaitImport) { - recordLazyImport(awaitImport, "top-level-await-import", filePath, lineStarts, results); - continue; - } - const thenImport = findThenImportInExpressionStatement(innerExpression); - if (thenImport) { - recordLazyImport(thenImport, "top-level-then-import", filePath, lineStarts, results); - } - } - } -}; - -const recordLazyImport = ( - match: { importExpression: unknown; specifier: string }, - kind: LazyImportAtTopLevel["kind"], - filePath: string, - lineStarts: number[], - results: LazyImportAtTopLevel[], -): void => { - if (!isAstNode(match.importExpression)) return; - const startOffset = match.importExpression.start; - if (typeof startOffset !== "number") return; - const { line, column } = offsetToLineColumn(startOffset, lineStarts); - results.push({ - path: filePath, - specifier: match.specifier, - kind, - line, - column, - confidence: kind === "top-level-await-import" ? "high" : "medium", - reason: - kind === "top-level-await-import" - ? `top-level \`await import("${match.specifier}")\` runs synchronously before the module finishes loading anyway — there is no laziness benefit, prefer a static \`import\`` - : `top-level \`import("${match.specifier}").then(...)\` runs at module evaluation — prefer a static \`import\` and a regular function call unless the dynamic-import contract is intentional`, - }); -}; - -interface PackageJsonTypeCache { - resolveModuleType: (filePath: string) => "module" | "commonjs" | undefined; -} - -const buildPackageJsonTypeCache = (): PackageJsonTypeCache => { - const directoryToType = new Map<string, "module" | "commonjs" | undefined>(); - const resolveModuleType = (filePath: string): "module" | "commonjs" | undefined => { - let currentDirectory = dirname(resolve(filePath)); - const visitedDirectories: string[] = []; - while (true) { - visitedDirectories.push(currentDirectory); - const cached = directoryToType.get(currentDirectory); - if (cached !== undefined) { - for (const visitedDirectory of visitedDirectories) - directoryToType.set(visitedDirectory, cached); - return cached; - } - const packageJsonPath = join(currentDirectory, "package.json"); - if (existsSync(packageJsonPath)) { - try { - const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { - type?: string; - }; - const moduleType = - packageJson.type === "module" - ? ("module" as const) - : packageJson.type === "commonjs" - ? ("commonjs" as const) - : undefined; - for (const visitedDirectory of visitedDirectories) - directoryToType.set(visitedDirectory, moduleType); - return moduleType; - } catch { - for (const visitedDirectory of visitedDirectories) - directoryToType.set(visitedDirectory, undefined); - return undefined; - } - } - const parentDirectory = dirname(currentDirectory); - if (parentDirectory === currentDirectory) { - for (const visitedDirectory of visitedDirectories) - directoryToType.set(visitedDirectory, undefined); - return undefined; - } - currentDirectory = parentDirectory; - } - }; - return { resolveModuleType }; -}; - -const isEsmFilePath = (filePath: string, typeCache: PackageJsonTypeCache): boolean => { - if (filePath.endsWith(".mts") || filePath.endsWith(".mjs")) return true; - if (filePath.endsWith(".cts") || filePath.endsWith(".cjs")) return false; - const moduleType = typeCache.resolveModuleType(filePath); - return moduleType === "module"; -}; - -const collectCommonjsInEsm = ( - programNode: unknown, - filePath: string, - sourceText: string, - lineStarts: number[], - results: CommonjsInEsm[], -): void => { - if (!isAstNode(programNode)) return; - visitForCommonjs(programNode, filePath, sourceText, lineStarts, results); -}; - -const visitForCommonjs = ( - node: unknown, - filePath: string, - sourceText: string, - lineStarts: number[], - results: CommonjsInEsm[], -): void => { - if (!isAstNode(node)) return; - - if (node.type === "CallExpression") { - const callee = node.callee; - if (isAstNode(callee) && callee.type === "Identifier") { - const calleeName = callee.name; - if (calleeName === "require") { - const callArguments = node.arguments; - if (Array.isArray(callArguments) && callArguments.length > 0) { - const firstArgument = callArguments[0]; - if ( - isAstNode(firstArgument) && - firstArgument.type === "Literal" && - typeof firstArgument.value === "string" - ) { - const startOffset = node.start; - const endOffset = node.end; - if (typeof startOffset === "number" && typeof endOffset === "number") { - const { line, column } = offsetToLineColumn(startOffset, lineStarts); - results.push({ - path: filePath, - kind: "require", - line, - column, - confidence: "high", - reason: - "synchronous `require()` is unavailable in native ESM — use a static `import` or top-level `await import()`", - snippet: sliceSnippet(sourceText, startOffset, endOffset), - }); - } - } - } - } - } - } - - if (node.type === "AssignmentExpression") { - const leftSide = node.left; - if (isAstNode(leftSide)) { - const isMemberExpr = - leftSide.type === "MemberExpression" || leftSide.type === "StaticMemberExpression"; - if (isMemberExpr) { - const objectNode = leftSide.object; - const propertyNode = leftSide.property; - const objectName = isAstNode(objectNode) ? objectNode.name : undefined; - const propertyName = isAstNode(propertyNode) ? propertyNode.name : undefined; - if (objectName === "module" && propertyName === "exports") { - const startOffset = node.start; - const endOffset = node.end; - if (typeof startOffset === "number" && typeof endOffset === "number") { - const { line, column } = offsetToLineColumn(startOffset, lineStarts); - results.push({ - path: filePath, - kind: "module-exports", - line, - column, - confidence: "high", - reason: - "`module.exports = ...` is CommonJS — replace with `export default` or named `export` for ESM", - snippet: sliceSnippet(sourceText, startOffset, endOffset), - }); - } - } else if (objectName === "exports") { - const startOffset = node.start; - const endOffset = node.end; - if (typeof startOffset === "number" && typeof endOffset === "number") { - const { line, column } = offsetToLineColumn(startOffset, lineStarts); - results.push({ - path: filePath, - kind: "exports-assignment", - line, - column, - confidence: "high", - reason: "`exports.x = ...` is CommonJS — replace with a named `export` for ESM", - snippet: sliceSnippet(sourceText, startOffset, endOffset), - }); - } - } - } - } - } - - for (const propertyKey of Object.keys(node)) { - if ( - propertyKey === "type" || - propertyKey === "start" || - propertyKey === "end" || - propertyKey === "loc" || - propertyKey === "range" - ) { - continue; - } - const value = node[propertyKey]; - if (Array.isArray(value)) { - for (const item of value) visitForCommonjs(item, filePath, sourceText, lineStarts, results); - } else if (value !== null && typeof value === "object") { - visitForCommonjs(value, filePath, sourceText, lineStarts, results); - } - } -}; - -const TS_IGNORE_LEADING = /^\s*@ts-ignore\b/; -const TS_NOCHECK_LEADING = /^\s*@ts-nocheck\b/; -const TS_EXPECT_ERROR_LEADING = /^\s*@ts-expect-error\b(.*)$/; - -const collectTypeScriptEscapeHatches = ( - comments: ParsedSourceComment[], - filePath: string, - lineStarts: number[], - results: TypeScriptEscapeHatch[], -): void => { - for (const comment of comments) { - const commentBody = comment.type === "Block" ? comment.value.split("\n")[0] : comment.value; - if (TS_IGNORE_LEADING.test(commentBody)) { - pushEscapeHatch( - comment.start, - "ts-ignore", - "`@ts-ignore` silently swallows the next line's type errors forever — use `@ts-expect-error` so the suppression breaks if the underlying error gets fixed", - "rewrite as `@ts-expect-error <why this is okay>`", - "high", - filePath, - lineStarts, - results, - ); - continue; - } - if (TS_NOCHECK_LEADING.test(commentBody)) { - pushEscapeHatch( - comment.start, - "ts-nocheck", - "`@ts-nocheck` disables type checking for the entire file — fix the underlying types or scope the suppression to a specific line", - "remove `@ts-nocheck` and address the underlying type errors, or use per-line `@ts-expect-error` with a justification", - "medium", - filePath, - lineStarts, - results, - ); - continue; - } - const expectErrorMatch = commentBody.match(TS_EXPECT_ERROR_LEADING); - if (expectErrorMatch) { - const trailingExplanation = (expectErrorMatch[1] ?? "").trim(); - if (trailingExplanation.length === 0) { - pushEscapeHatch( - comment.start, - "ts-expect-error-without-explanation", - "`@ts-expect-error` should be followed by a comment explaining why the next line legitimately produces a type error", - "add a short justification: `// @ts-expect-error: <why this is okay>`", - "low", - filePath, - lineStarts, - results, - ); - } - } - } -}; - -const pushEscapeHatch = ( - commentStartOffset: number, - kind: TypeScriptEscapeHatchKind, - reason: string, - suggestion: string, - confidence: TypeScriptEscapeHatch["confidence"], - filePath: string, - lineStarts: number[], - results: TypeScriptEscapeHatch[], -): void => { - const { line, column } = offsetToLineColumn(commentStartOffset, lineStarts); - results.push({ - path: filePath, - kind, - line, - column, - confidence, - reason, - suggestion, - }); -}; - -export interface TypeScriptSmellsResult { - unnecessaryAssertions: UnnecessaryAssertion[]; - lazyImportsAtTopLevel: LazyImportAtTopLevel[]; - commonjsInEsm: CommonjsInEsm[]; - typeScriptEscapeHatches: TypeScriptEscapeHatch[]; -} - -const isTypeScriptOrJsFile = (filePath: string): boolean => - filePath.endsWith(".ts") || - filePath.endsWith(".tsx") || - filePath.endsWith(".mts") || - filePath.endsWith(".cts") || - filePath.endsWith(".js") || - filePath.endsWith(".jsx") || - filePath.endsWith(".mjs") || - filePath.endsWith(".cjs"); - -const isTypeScriptFileExtension = (filePath: string): boolean => - filePath.endsWith(".ts") || - filePath.endsWith(".tsx") || - filePath.endsWith(".mts") || - filePath.endsWith(".cts"); - -export const detectTypeScriptSmells = (graph: DependencyGraph): TypeScriptSmellsResult => { - const unnecessaryAssertions: UnnecessaryAssertion[] = []; - const lazyImportsAtTopLevel: LazyImportAtTopLevel[] = []; - const commonjsInEsm: CommonjsInEsm[] = []; - const typeScriptEscapeHatches: TypeScriptEscapeHatch[] = []; - - const packageJsonTypeCache = buildPackageJsonTypeCache(); - - for (const module of graph.modules) { - if (module.isDeclarationFile) continue; - const filePath = module.fileId.path; - if (!isTypeScriptOrJsFile(filePath)) continue; - - const parsedSource = parseSource(filePath); - if (!parsedSource) continue; - - if (isTypeScriptFileExtension(filePath)) { - visitForUnnecessaryAssertions( - parsedSource.programNode, - filePath, - parsedSource.sourceText, - parsedSource.lineStarts, - unnecessaryAssertions, - ); - collectTypeScriptEscapeHatches( - parsedSource.comments, - filePath, - parsedSource.lineStarts, - typeScriptEscapeHatches, - ); - } - - collectLazyImportsAtTopLevel( - parsedSource.programNode, - filePath, - parsedSource.lineStarts, - lazyImportsAtTopLevel, - ); - - if (isEsmFilePath(filePath, packageJsonTypeCache)) { - collectCommonjsInEsm( - parsedSource.programNode, - filePath, - parsedSource.sourceText, - parsedSource.lineStarts, - commonjsInEsm, - ); - } - } - - unnecessaryAssertions.sort((leftFinding, rightFinding) => { - if (leftFinding.path !== rightFinding.path) - return leftFinding.path.localeCompare(rightFinding.path); - return leftFinding.line - rightFinding.line; - }); - lazyImportsAtTopLevel.sort((leftFinding, rightFinding) => { - if (leftFinding.path !== rightFinding.path) - return leftFinding.path.localeCompare(rightFinding.path); - return leftFinding.line - rightFinding.line; - }); - commonjsInEsm.sort((leftFinding, rightFinding) => { - if (leftFinding.path !== rightFinding.path) - return leftFinding.path.localeCompare(rightFinding.path); - return leftFinding.line - rightFinding.line; - }); - typeScriptEscapeHatches.sort((leftFinding, rightFinding) => { - if (leftFinding.path !== rightFinding.path) - return leftFinding.path.localeCompare(rightFinding.path); - return leftFinding.line - rightFinding.line; - }); - - return { - unnecessaryAssertions, - lazyImportsAtTopLevel, - commonjsInEsm, - typeScriptEscapeHatches, - }; -}; diff --git a/packages/deslop-js/src/semantic/index.ts b/packages/deslop-js/src/semantic/index.ts deleted file mode 100644 index b2ead78e6f..0000000000 --- a/packages/deslop-js/src/semantic/index.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { - DependencyGraph, - DeslopConfig, - DeslopError, - MisclassifiedDependency, - RedundantAlias, - UnusedClassMember, - UnusedEnumMember, - UnusedType, -} from "../types.js"; -import { TypeScriptError, describeUnknownError } from "../errors.js"; -import { runSafeDetector } from "../utils/run-safe-detector.js"; -import { createSemanticContext } from "./program.js"; -import { buildReferenceIndex } from "./references.js"; -import { detectUnusedTypes } from "./unused-types.js"; -import { detectUnusedEnumMembers } from "./unused-enum-members.js"; -import { detectUnusedClassMembers } from "./unused-class-members.js"; -import { detectMisclassifiedDependencies } from "./misclassified-dependencies.js"; -import { detectRedundantVariableAliases } from "./variable-aliases.js"; -import { detectRoundTripAliases } from "./redundant-reexports.js"; - -export interface SemanticAnalysisResult { - unusedTypes: UnusedType[]; - unusedEnumMembers: UnusedEnumMember[]; - unusedClassMembers: UnusedClassMember[]; - misclassifiedDependencies: MisclassifiedDependency[]; - redundantAliases: RedundantAlias[]; - errors: DeslopError[]; -} - -const createDisabledSemanticResult = (): SemanticAnalysisResult => ({ - unusedTypes: [], - unusedEnumMembers: [], - unusedClassMembers: [], - misclassifiedDependencies: [], - redundantAliases: [], - errors: [], -}); - -export const runSemanticAnalysis = ( - graph: DependencyGraph, - config: DeslopConfig, -): SemanticAnalysisResult => { - const semanticConfig = config.semantic; - if (!semanticConfig?.enabled) return createDisabledSemanticResult(); - - const errors: DeslopError[] = []; - - const safeDetector = <ResultType>( - detectorName: string, - detector: () => ResultType, - fallback: ResultType, - ): ResultType => - runSafeDetector({ - detectorName, - detector, - fallback, - errorSink: errors, - module: "semantic", - contextDescription: "during semantic analysis", - }); - - const misclassifiedDependencies = semanticConfig.reportMisclassifiedDependencies - ? safeDetector( - "detectMisclassifiedDependencies", - () => detectMisclassifiedDependencies(graph, config), - [], - ) - : []; - - const needsTsContext = - semanticConfig.reportUnusedTypes || - semanticConfig.reportUnusedEnumMembers || - semanticConfig.reportUnusedClassMembers || - semanticConfig.reportRedundantVariableAliases || - semanticConfig.reportRoundTripAliases; - if (!needsTsContext) { - return { - unusedTypes: [], - unusedEnumMembers: [], - unusedClassMembers: [], - misclassifiedDependencies, - redundantAliases: [], - errors, - }; - } - - let contextResult: ReturnType<typeof createSemanticContext>; - try { - contextResult = createSemanticContext(config.rootDir, config.tsConfigPath); - } catch (contextError) { - return { - unusedTypes: [], - unusedEnumMembers: [], - unusedClassMembers: [], - misclassifiedDependencies, - redundantAliases: [], - errors: [ - ...errors, - new TypeScriptError({ - code: "ts-not-loadable", - message: "createSemanticContext threw before returning a result", - detail: describeUnknownError(contextError), - }), - ], - }; - } - - if (!contextResult.ok) { - return { - unusedTypes: [], - unusedEnumMembers: [], - unusedClassMembers: [], - misclassifiedDependencies, - redundantAliases: [], - errors: [...errors, contextResult.failure.error], - }; - } - - const { context } = contextResult; - let referenceIndex: ReturnType<typeof buildReferenceIndex> | undefined; - const getReferenceIndex = (): ReturnType<typeof buildReferenceIndex> => { - if (!referenceIndex) { - referenceIndex = buildReferenceIndex(context.program, context.checker); - } - return referenceIndex; - }; - - const unusedTypes = semanticConfig.reportUnusedTypes - ? safeDetector( - "detectUnusedTypes", - () => detectUnusedTypes(graph, config, context, getReferenceIndex()), - [], - ) - : []; - const unusedEnumMembers = semanticConfig.reportUnusedEnumMembers - ? safeDetector( - "detectUnusedEnumMembers", - () => detectUnusedEnumMembers(graph, config, context, getReferenceIndex()), - [], - ) - : []; - const unusedClassMembers = semanticConfig.reportUnusedClassMembers - ? safeDetector( - "detectUnusedClassMembers", - () => - detectUnusedClassMembers( - graph, - config, - context, - getReferenceIndex(), - semanticConfig.decoratorAllowlist, - ), - [], - ) - : []; - const variableAliases = semanticConfig.reportRedundantVariableAliases - ? safeDetector( - "detectRedundantVariableAliases", - () => detectRedundantVariableAliases(graph, context, getReferenceIndex()), - [], - ) - : []; - const roundTripAliases = semanticConfig.reportRoundTripAliases - ? safeDetector("detectRoundTripAliases", () => detectRoundTripAliases(graph, context), []) - : []; - - return { - unusedTypes, - unusedEnumMembers, - unusedClassMembers, - misclassifiedDependencies, - redundantAliases: [...variableAliases, ...roundTripAliases], - errors, - }; -}; diff --git a/packages/deslop-js/src/semantic/misclassified-dependencies.ts b/packages/deslop-js/src/semantic/misclassified-dependencies.ts deleted file mode 100644 index aeacb9b690..0000000000 --- a/packages/deslop-js/src/semantic/misclassified-dependencies.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { readFileSync } from "node:fs"; -import { resolve as resolvePath } from "node:path"; -import type { - DependencyGraph, - DependencyDeclaredAs, - DeslopConfig, - MisclassifiedDependency, -} from "../types.js"; -import { extractPackageName } from "../utils/package-name.js"; -import { SEMANTIC_TRACE_MAX_ENTRIES } from "../constants.js"; - -interface PackageUsageSummary { - packageName: string; - hasValueUse: boolean; - hasTypeOnlyUse: boolean; - importSites: string[]; -} - -interface DeclaredDependencyEntry { - name: string; - declaredAs: DependencyDeclaredAs; -} - -interface PackageJsonShape { - dependencies?: Record<string, string>; - devDependencies?: Record<string, string>; - peerDependencies?: Record<string, string>; -} - -const TYPES_PACKAGE_PREFIX = "@types/"; - -const recordImportSite = (summary: PackageUsageSummary, sitePath: string): void => { - if (summary.importSites.length >= SEMANTIC_TRACE_MAX_ENTRIES) return; - if (summary.importSites.includes(sitePath)) return; - summary.importSites.push(sitePath); -}; - -const isImportEffectivelyTypeOnly = ( - isTypeOnlyDeclaration: boolean, - importedBindings: Array<{ isTypeOnly: boolean }>, -): boolean => { - if (isTypeOnlyDeclaration) return true; - if (importedBindings.length === 0) return false; - return importedBindings.every((binding) => binding.isTypeOnly); -}; - -const collectPackageUsageSummaries = (graph: DependencyGraph): Map<string, PackageUsageSummary> => { - const summaries = new Map<string, PackageUsageSummary>(); - - const upsertSummary = (packageName: string): PackageUsageSummary => { - const existing = summaries.get(packageName); - if (existing) return existing; - const fresh: PackageUsageSummary = { - packageName, - hasValueUse: false, - hasTypeOnlyUse: false, - importSites: [], - }; - summaries.set(packageName, fresh); - return fresh; - }; - - for (const module of graph.modules) { - for (const importInfo of module.imports) { - const packageName = extractPackageName(importInfo.specifier); - if (!packageName) continue; - const summary = upsertSummary(packageName); - const sitePath = `${module.fileId.path}:${importInfo.line}`; - - if (importInfo.isSideEffect) { - summary.hasValueUse = true; - recordImportSite(summary, sitePath); - continue; - } - - if (importInfo.isDynamic) { - summary.hasValueUse = true; - recordImportSite(summary, sitePath); - continue; - } - - const isTypeOnly = isImportEffectivelyTypeOnly( - importInfo.isTypeOnly, - importInfo.importedNames, - ); - if (isTypeOnly) { - summary.hasTypeOnlyUse = true; - } else { - summary.hasValueUse = true; - } - recordImportSite(summary, sitePath); - } - - for (const exportInfo of module.exports) { - if (!exportInfo.isReExport || !exportInfo.reExportSource) continue; - const packageName = extractPackageName(exportInfo.reExportSource); - if (!packageName) continue; - const summary = upsertSummary(packageName); - const sitePath = `${module.fileId.path}:${exportInfo.line}`; - - if (exportInfo.isTypeOnly) { - summary.hasTypeOnlyUse = true; - } else { - summary.hasValueUse = true; - } - recordImportSite(summary, sitePath); - } - } - - return summaries; -}; - -const readDeclaredDependencies = (rootDir: string): DeclaredDependencyEntry[] => { - const packageJsonPath = resolvePath(rootDir, "package.json"); - let packageJson: PackageJsonShape; - try { - const contents = readFileSync(packageJsonPath, "utf-8"); - packageJson = JSON.parse(contents); - } catch { - return []; - } - - const entries: DeclaredDependencyEntry[] = []; - for (const name of Object.keys(packageJson.dependencies ?? {})) { - entries.push({ name, declaredAs: "dependencies" }); - } - return entries; -}; - -export const detectMisclassifiedDependencies = ( - graph: DependencyGraph, - config: DeslopConfig, -): MisclassifiedDependency[] => { - const declaredEntries = readDeclaredDependencies(config.rootDir); - if (declaredEntries.length === 0) return []; - - const packageUsage = collectPackageUsageSummaries(graph); - const findings: MisclassifiedDependency[] = []; - - for (const declaredEntry of declaredEntries) { - const usage = packageUsage.get(declaredEntry.name); - if (!usage) continue; - if (usage.hasValueUse) continue; - if (!usage.hasTypeOnlyUse) continue; - - const isTypesPackage = declaredEntry.name.startsWith(TYPES_PACKAGE_PREFIX); - - findings.push({ - name: declaredEntry.name, - declaredAs: declaredEntry.declaredAs, - suggestedAs: "devDependencies", - confidence: isTypesPackage ? "high" : "medium", - reason: isTypesPackage - ? `"${declaredEntry.name}" is a @types/* package in dependencies but is only consumed via type imports — should be in devDependencies` - : `"${declaredEntry.name}" is in dependencies but only consumed via \`import type\` / \`export type\` — consider devDependencies (or keep here if downstream consumers need its types)`, - trace: usage.importSites, - }); - } - - return findings; -}; diff --git a/packages/deslop-js/src/semantic/program.ts b/packages/deslop-js/src/semantic/program.ts deleted file mode 100644 index fec065f520..0000000000 --- a/packages/deslop-js/src/semantic/program.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { existsSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import ts from "typescript"; -import { SEMANTIC_MAX_PROGRAM_FILES, DEFAULT_SEMANTIC_TSCONFIG_NAMES } from "../constants.js"; -import { type DeslopError, TypeScriptError, describeUnknownError } from "../errors.js"; - -export interface SemanticContext { - program: ts.Program; - checker: ts.TypeChecker; - rootSourceFiles: ts.SourceFile[]; - tsconfigPath: string; -} - -export interface SemanticContextFailure { - reason: - | "no-tsconfig" - | "tsconfig-parse-error" - | "program-creation-failed" - | "too-many-files" - | "typescript-load-failed"; - message: string; - error: DeslopError; -} - -export type SemanticContextResult = - | { ok: true; context: SemanticContext } - | { ok: false; failure: SemanticContextFailure }; - -const failureFor = ( - reason: SemanticContextFailure["reason"], - message: string, - options: { rootDir: string; detail?: string } = { rootDir: "" }, -): SemanticContextFailure => { - const codeByReason: Record< - SemanticContextFailure["reason"], - | "tsconfig-not-found" - | "tsconfig-parse-failed" - | "ts-program-creation-failed" - | "ts-program-too-large" - | "ts-not-loadable" - > = { - "no-tsconfig": "tsconfig-not-found", - "tsconfig-parse-error": "tsconfig-parse-failed", - "program-creation-failed": "ts-program-creation-failed", - "too-many-files": "ts-program-too-large", - "typescript-load-failed": "ts-not-loadable", - }; - return { - reason, - message, - error: new TypeScriptError({ - code: codeByReason[reason], - severity: reason === "no-tsconfig" ? "info" : "warning", - message, - path: options.rootDir || undefined, - detail: options.detail, - }), - }; -}; - -const findNearestTsconfig = ( - rootDir: string, - explicitPath: string | undefined, -): string | undefined => { - if (explicitPath) { - const absoluteExplicit = resolve(rootDir, explicitPath); - if (existsSync(absoluteExplicit)) return absoluteExplicit; - return undefined; - } - for (const candidateName of DEFAULT_SEMANTIC_TSCONFIG_NAMES) { - const candidatePath = resolve(rootDir, candidateName); - if (existsSync(candidatePath)) return candidatePath; - } - return undefined; -}; - -export const createSemanticContext = ( - rootDir: string, - tsconfigPath: string | undefined, -): SemanticContextResult => { - const resolvedTsconfigPath = findNearestTsconfig(rootDir, tsconfigPath); - if (!resolvedTsconfigPath) { - return { - ok: false, - failure: failureFor("no-tsconfig", `No tsconfig found under ${rootDir}`, { rootDir }), - }; - } - - let configFileContent: ReturnType<typeof ts.readConfigFile>; - try { - configFileContent = ts.readConfigFile(resolvedTsconfigPath, ts.sys.readFile); - } catch (readError) { - return { - ok: false, - failure: failureFor("tsconfig-parse-error", "ts.readConfigFile threw", { - rootDir: resolvedTsconfigPath, - detail: describeUnknownError(readError), - }), - }; - } - if (configFileContent.error) { - return { - ok: false, - failure: failureFor( - "tsconfig-parse-error", - ts.flattenDiagnosticMessageText(configFileContent.error.messageText, "\n"), - { rootDir: resolvedTsconfigPath }, - ), - }; - } - - let parsedCommandLine: ts.ParsedCommandLine; - try { - parsedCommandLine = ts.parseJsonConfigFileContent( - configFileContent.config, - ts.sys, - dirname(resolvedTsconfigPath), - { - noEmit: true, - skipLibCheck: true, - allowJs: true, - isolatedModules: false, - }, - resolvedTsconfigPath, - ); - } catch (parseError) { - return { - ok: false, - failure: failureFor("tsconfig-parse-error", "ts.parseJsonConfigFileContent threw", { - rootDir: resolvedTsconfigPath, - detail: describeUnknownError(parseError), - }), - }; - } - - if (parsedCommandLine.errors.length > 0) { - const fatalErrors = parsedCommandLine.errors.filter( - (diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error, - ); - if (fatalErrors.length > 0 && parsedCommandLine.fileNames.length === 0) { - return { - ok: false, - failure: failureFor( - "tsconfig-parse-error", - ts.flattenDiagnosticMessageText(fatalErrors[0].messageText, "\n"), - { rootDir: resolvedTsconfigPath }, - ), - }; - } - } - - if (parsedCommandLine.fileNames.length > SEMANTIC_MAX_PROGRAM_FILES) { - return { - ok: false, - failure: failureFor( - "too-many-files", - `Project has ${parsedCommandLine.fileNames.length} files, exceeds SEMANTIC_MAX_PROGRAM_FILES=${SEMANTIC_MAX_PROGRAM_FILES}`, - { rootDir: resolvedTsconfigPath }, - ), - }; - } - - try { - const program = ts.createProgram({ - rootNames: parsedCommandLine.fileNames, - options: parsedCommandLine.options, - projectReferences: parsedCommandLine.projectReferences, - }); - const checker = program.getTypeChecker(); - const rootSourceFiles = program - .getSourceFiles() - .filter( - (sourceFile) => !sourceFile.isDeclarationFile || sourceFile.fileName.endsWith(".d.ts"), - ); - - return { - ok: true, - context: { - program, - checker, - rootSourceFiles, - tsconfigPath: resolvedTsconfigPath, - }, - }; - } catch (programError) { - return { - ok: false, - failure: failureFor("program-creation-failed", "ts.createProgram threw", { - rootDir: resolvedTsconfigPath, - detail: describeUnknownError(programError), - }), - }; - } -}; diff --git a/packages/deslop-js/src/semantic/redundant-reexports.ts b/packages/deslop-js/src/semantic/redundant-reexports.ts deleted file mode 100644 index 061dea1af7..0000000000 --- a/packages/deslop-js/src/semantic/redundant-reexports.ts +++ /dev/null @@ -1,101 +0,0 @@ -import ts from "typescript"; -import type { DependencyGraph, RedundantAlias } from "../types.js"; -import type { SemanticContext } from "./program.js"; -import { buildSourceFileLookup, normalizeSourcePath } from "./utils/source-file-lookup.js"; - -const safeGetAliasedSymbol = ( - symbol: ts.Symbol, - checker: ts.TypeChecker, -): ts.Symbol | undefined => { - try { - return checker.getAliasedSymbol(symbol); - } catch { - return undefined; - } -}; - -interface RoundTripChainEntry { - modulePath: string; - sourceFile: ts.SourceFile; - importSpecifier: ts.ImportSpecifier; - importedName: string; - localName: string; -} - -const collectImportSpecifierRoundTrips = ( - graph: DependencyGraph, - sourceFileLookup: Map<string, ts.SourceFile>, -): RoundTripChainEntry[] => { - const entries: RoundTripChainEntry[] = []; - - for (const module of graph.modules) { - if (!module.isReachable) continue; - if (module.isDeclarationFile) continue; - const sourceFile = sourceFileLookup.get(normalizeSourcePath(module.fileId.path)); - if (!sourceFile) continue; - - for (const statement of sourceFile.statements) { - if (!ts.isImportDeclaration(statement)) continue; - const importClause = statement.importClause; - if (!importClause?.namedBindings) continue; - if (!ts.isNamedImports(importClause.namedBindings)) continue; - for (const importSpecifier of importClause.namedBindings.elements) { - if (!importSpecifier.propertyName) continue; - const importedName = importSpecifier.propertyName.text; - const localName = importSpecifier.name.text; - if (importedName === localName) continue; - entries.push({ - modulePath: module.fileId.path, - sourceFile, - importSpecifier, - importedName, - localName, - }); - } - } - } - - return entries; -}; - -export const detectRoundTripAliases = ( - graph: DependencyGraph, - context: SemanticContext, -): RedundantAlias[] => { - const findings: RedundantAlias[] = []; - const sourceFileLookup = buildSourceFileLookup(context.program); - const importEntries = collectImportSpecifierRoundTrips(graph, sourceFileLookup); - if (importEntries.length === 0) return findings; - - const { checker } = context; - - for (const entry of importEntries) { - const localBindingSymbol = checker.getSymbolAtLocation(entry.importSpecifier.name); - if (!localBindingSymbol) continue; - if (!(localBindingSymbol.flags & ts.SymbolFlags.Alias)) continue; - const resolvedTargetSymbol = safeGetAliasedSymbol(localBindingSymbol, checker); - if (!resolvedTargetSymbol) continue; - const originalDeclarationName = resolvedTargetSymbol.name; - if (!originalDeclarationName) continue; - if (originalDeclarationName !== entry.localName) continue; - if (originalDeclarationName === entry.importedName) continue; - - const { line: zeroIndexedLine, character: zeroIndexedColumn } = - entry.sourceFile.getLineAndCharacterOfPosition( - entry.importSpecifier.getStart(entry.sourceFile), - ); - - findings.push({ - path: entry.modulePath, - kind: "roundtrip-alias", - name: entry.localName, - aliasedFrom: entry.importedName, - line: zeroIndexedLine + 1, - column: zeroIndexedColumn + 1, - confidence: "high", - reason: `\`import { ${entry.importedName} as ${entry.localName} }\` renames back to the original declaration name — the upstream rename can be removed`, - }); - } - - return findings; -}; diff --git a/packages/deslop-js/src/semantic/references.ts b/packages/deslop-js/src/semantic/references.ts deleted file mode 100644 index b4ff904a64..0000000000 --- a/packages/deslop-js/src/semantic/references.ts +++ /dev/null @@ -1,162 +0,0 @@ -import ts from "typescript"; -import { MAX_AST_WALK_DEPTH, MAX_TYPE_CONTEXT_PARENT_WALK } from "../constants.js"; - -export interface SymbolReferenceSite { - sourceFile: ts.SourceFile; - identifier: ts.Identifier; - isDeclarationName: boolean; - isExportSpecifier: boolean; - isImportSpecifier: boolean; - isTypeContext: boolean; -} - -export interface ReferenceIndex { - getReferences: (symbol: ts.Symbol) => SymbolReferenceSite[]; - size: number; -} - -const canonicalKeyForSymbol = (symbol: ts.Symbol): ts.Symbol | ts.Node => { - const firstDeclaration = symbol.declarations?.[0]; - return firstDeclaration ?? symbol; -}; - -const isDeclarationNameIdentifier = (identifier: ts.Identifier): boolean => { - const parent = identifier.parent; - if (!parent) return false; - if ( - (ts.isInterfaceDeclaration(parent) || - ts.isTypeAliasDeclaration(parent) || - ts.isClassDeclaration(parent) || - ts.isFunctionDeclaration(parent) || - ts.isEnumDeclaration(parent) || - ts.isModuleDeclaration(parent) || - ts.isVariableDeclaration(parent)) && - parent.name === identifier - ) { - return true; - } - if (ts.isEnumMember(parent) && parent.name === identifier) return true; - if (ts.isPropertyDeclaration(parent) && parent.name === identifier) return true; - if (ts.isMethodDeclaration(parent) && parent.name === identifier) return true; - if (ts.isParameter(parent) && parent.name === identifier) return true; - if (ts.isBindingElement(parent) && parent.name === identifier) return true; - return false; -}; - -const isExportSpecifierIdentifier = (identifier: ts.Identifier): boolean => { - const parent = identifier.parent; - return Boolean(parent && ts.isExportSpecifier(parent)); -}; - -const isImportSpecifierIdentifier = (identifier: ts.Identifier): boolean => { - const parent = identifier.parent; - if (!parent) return false; - return ts.isImportSpecifier(parent) || ts.isImportClause(parent) || ts.isNamespaceImport(parent); -}; - -const isInTypeContext = (identifier: ts.Identifier): boolean => { - let current: ts.Node | undefined = identifier.parent; - let depth = 0; - while (current && depth < MAX_TYPE_CONTEXT_PARENT_WALK) { - if ( - ts.isTypeReferenceNode(current) || - ts.isTypeQueryNode(current) || - ts.isTypeAliasDeclaration(current) || - ts.isInterfaceDeclaration(current) || - ts.isHeritageClause(current) || - ts.isImportTypeNode(current) || - ts.isTypePredicateNode(current) || - ts.isTypeOperatorNode(current) || - ts.isTypeLiteralNode(current) || - ts.isIndexedAccessTypeNode(current) || - ts.isMappedTypeNode(current) || - ts.isConditionalTypeNode(current) || - ts.isInferTypeNode(current) - ) { - return true; - } - if (ts.isExpressionStatement(current) || ts.isBlock(current)) return false; - current = current.parent; - depth++; - } - return false; -}; - -const resolveSymbolForIdentifier = ( - identifier: ts.Identifier, - checker: ts.TypeChecker, -): ts.Symbol | undefined => { - let symbol: ts.Symbol | undefined; - try { - symbol = checker.getSymbolAtLocation(identifier); - } catch { - return undefined; - } - if (!symbol) return undefined; - if (symbol.flags & ts.SymbolFlags.Alias) { - try { - return checker.getAliasedSymbol(symbol); - } catch { - return symbol; - } - } - return symbol; -}; - -interface NodeWithJsDoc extends ts.Node { - jsDoc?: ts.JSDoc[]; -} - -const visitJsDocNodes = (node: ts.Node, visit: (jsDocNode: ts.Node) => void): void => { - const jsDocContainer = node as NodeWithJsDoc; - if (!jsDocContainer.jsDoc) return; - for (const jsDocNode of jsDocContainer.jsDoc) { - visit(jsDocNode); - } -}; - -export const buildReferenceIndex = ( - program: ts.Program, - checker: ts.TypeChecker, -): ReferenceIndex => { - const keyedToReferences = new Map<ts.Symbol | ts.Node, SymbolReferenceSite[]>(); - - const recordIdentifier = (identifier: ts.Identifier, sourceFile: ts.SourceFile): void => { - const resolvedSymbol = resolveSymbolForIdentifier(identifier, checker); - if (!resolvedSymbol) return; - const key = canonicalKeyForSymbol(resolvedSymbol); - const site: SymbolReferenceSite = { - sourceFile, - identifier, - isDeclarationName: isDeclarationNameIdentifier(identifier), - isExportSpecifier: isExportSpecifierIdentifier(identifier), - isImportSpecifier: isImportSpecifierIdentifier(identifier), - isTypeContext: isInTypeContext(identifier), - }; - const existing = keyedToReferences.get(key); - if (existing) { - existing.push(site); - } else { - keyedToReferences.set(key, [site]); - } - }; - - const visitNode = (node: ts.Node, sourceFile: ts.SourceFile, recursionDepth: number): void => { - if (recursionDepth > MAX_AST_WALK_DEPTH) return; - if (ts.isIdentifier(node)) { - recordIdentifier(node, sourceFile); - } - visitJsDocNodes(node, (jsDocNode) => visitNode(jsDocNode, sourceFile, recursionDepth + 1)); - node.forEachChild((child) => visitNode(child, sourceFile, recursionDepth + 1)); - }; - - for (const sourceFile of program.getSourceFiles()) { - if (sourceFile.isDeclarationFile) continue; - visitNode(sourceFile, sourceFile, 0); - } - - return { - getReferences: (symbol) => keyedToReferences.get(canonicalKeyForSymbol(symbol)) ?? [], - size: keyedToReferences.size, - }; -}; diff --git a/packages/deslop-js/src/semantic/unused-class-members.ts b/packages/deslop-js/src/semantic/unused-class-members.ts deleted file mode 100644 index 8c4e203c3f..0000000000 --- a/packages/deslop-js/src/semantic/unused-class-members.ts +++ /dev/null @@ -1,270 +0,0 @@ -import ts from "typescript"; -import type { - ClassMemberKind, - DependencyGraph, - DeslopConfig, - SemanticConfidence, - UnusedClassMember, -} from "../types.js"; -import type { SemanticContext } from "./program.js"; -import type { ReferenceIndex } from "./references.js"; -import { SEMANTIC_TRACE_MAX_ENTRIES } from "../constants.js"; -import { buildSourceFileLookup, normalizeSourcePath } from "./utils/source-file-lookup.js"; -import { isFrameworkLifecycleMethod } from "../utils/is-framework-lifecycle-method.js"; - -interface ClassContext { - sourceFile: ts.SourceFile; - declaration: ts.ClassDeclaration; - modulePath: string; - isExported: boolean; -} - -const isClassExported = (declaration: ts.ClassDeclaration): boolean => { - const modifiers = ts.canHaveModifiers(declaration) ? ts.getModifiers(declaration) : undefined; - if (!modifiers) return false; - return modifiers.some( - (modifier) => - modifier.kind === ts.SyntaxKind.ExportKeyword || - modifier.kind === ts.SyntaxKind.DefaultKeyword, - ); -}; - -const collectClassDeclarations = ( - graph: DependencyGraph, - config: DeslopConfig, - sourceFileLookup: Map<string, ts.SourceFile>, -): ClassContext[] => { - const contexts: ClassContext[] = []; - - for (const module of graph.modules) { - if (!module.isReachable) continue; - if (module.isDeclarationFile) continue; - if (module.isEntryPoint && !config.includeEntryExports) continue; - - const sourceFile = sourceFileLookup.get(normalizeSourcePath(module.fileId.path)); - if (!sourceFile) continue; - - for (const statement of sourceFile.statements) { - if (!ts.isClassDeclaration(statement)) continue; - if (!statement.name) continue; - contexts.push({ - sourceFile, - declaration: statement, - modulePath: module.fileId.path, - isExported: isClassExported(statement), - }); - } - } - - return contexts; -}; - -interface SubclassMemberIndex { - getOverridingMemberNames: (parentClassSymbol: ts.Symbol) => Set<string>; -} - -const buildSubclassMemberIndex = ( - classContexts: ClassContext[], - checker: ts.TypeChecker, -): SubclassMemberIndex => { - const parentToOverriddenMemberNames = new Map<ts.Symbol, Set<string>>(); - - const addOverrideNames = (parentSymbol: ts.Symbol, memberNames: string[]): void => { - const existing = parentToOverriddenMemberNames.get(parentSymbol); - if (existing) { - for (const memberName of memberNames) existing.add(memberName); - } else { - parentToOverriddenMemberNames.set(parentSymbol, new Set(memberNames)); - } - }; - - const collectMemberNames = (declaration: ts.ClassDeclaration): string[] => { - const names: string[] = []; - for (const member of declaration.members) { - if (!member.name || !ts.isIdentifier(member.name)) continue; - names.push(member.name.text); - } - return names; - }; - - for (const { declaration } of classContexts) { - if (!declaration.heritageClauses) continue; - for (const heritageClause of declaration.heritageClauses) { - if (heritageClause.token !== ts.SyntaxKind.ExtendsKeyword) continue; - for (const heritageType of heritageClause.types) { - const baseSymbol = checker.getSymbolAtLocation(heritageType.expression); - if (!baseSymbol) continue; - const resolvedBaseSymbol = - baseSymbol.flags & ts.SymbolFlags.Alias - ? safeGetAliasedSymbol(baseSymbol, checker) - : baseSymbol; - if (!resolvedBaseSymbol) continue; - addOverrideNames(resolvedBaseSymbol, collectMemberNames(declaration)); - } - } - } - - return { - getOverridingMemberNames: (parentClassSymbol) => - parentToOverriddenMemberNames.get(parentClassSymbol) ?? new Set(), - }; -}; - -const safeGetAliasedSymbol = ( - symbol: ts.Symbol, - checker: ts.TypeChecker, -): ts.Symbol | undefined => { - try { - return checker.getAliasedSymbol(symbol); - } catch { - return undefined; - } -}; - -const isPrivateMember = (member: ts.ClassElement): boolean => { - if (ts.isPrivateIdentifier(member.name as ts.Node)) return true; - const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined; - if (!modifiers) return false; - return modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.PrivateKeyword); -}; - -const isStaticMember = (member: ts.ClassElement): boolean => { - const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined; - if (!modifiers) return false; - return modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword); -}; - -const hasAllowedDecorator = (member: ts.ClassElement, decoratorAllowlist: Set<string>): boolean => { - const decorators = ts.canHaveDecorators(member) ? ts.getDecorators(member) : undefined; - if (!decorators || decorators.length === 0) return false; - for (const decorator of decorators) { - const expression = decorator.expression; - let decoratorName: string | undefined; - if (ts.isIdentifier(expression)) { - decoratorName = expression.text; - } else if (ts.isCallExpression(expression) && ts.isIdentifier(expression.expression)) { - decoratorName = expression.expression.text; - } else if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.name)) { - decoratorName = expression.name.text; - } - if (decoratorName && decoratorAllowlist.has(decoratorName)) return true; - } - return false; -}; - -const classifyMemberKind = (member: ts.ClassElement): ClassMemberKind | undefined => { - if (ts.isMethodDeclaration(member)) return "method"; - if (ts.isPropertyDeclaration(member)) return "property"; - if (ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)) return "accessor"; - return undefined; -}; - -const memberHasExternalReference = ( - memberSymbol: ts.Symbol, - referenceIndex: ReferenceIndex, -): boolean => { - const references = referenceIndex.getReferences(memberSymbol); - for (const referenceSite of references) { - if (referenceSite.isDeclarationName) continue; - return true; - } - return false; -}; - -const buildClassMemberTrace = ( - className: string, - memberName: string, - modulePath: string, - line: number, - column: number, - isOverriddenInSubclass: boolean, - isExportedClass: boolean, -): string[] => { - const trace: string[] = [ - `${modulePath}:${line}:${column} declares ${className}.${memberName}`, - `no \`${className}.${memberName}\` reference found outside the declaration`, - ]; - if (isExportedClass) { - trace.push(`${className} is exported — confidence reduced for public-API safety`); - } - if (isOverriddenInSubclass) { - trace.push(`subclass override observed — polymorphic call path possible`); - } - return trace.slice(0, SEMANTIC_TRACE_MAX_ENTRIES); -}; - -export const detectUnusedClassMembers = ( - graph: DependencyGraph, - config: DeslopConfig, - context: SemanticContext, - referenceIndex: ReferenceIndex, - decoratorAllowlist: string[], -): UnusedClassMember[] => { - const findings: UnusedClassMember[] = []; - const sourceFileLookup = buildSourceFileLookup(context.program); - const classContexts = collectClassDeclarations(graph, config, sourceFileLookup); - if (classContexts.length === 0) return findings; - - const { checker } = context; - const decoratorAllowSet = new Set(decoratorAllowlist); - const subclassMemberIndex = buildSubclassMemberIndex(classContexts, checker); - - for (const { sourceFile, declaration, modulePath, isExported } of classContexts) { - const classSymbol = checker.getSymbolAtLocation(declaration.name!); - if (!classSymbol) continue; - - const overriddenMemberNames = subclassMemberIndex.getOverridingMemberNames(classSymbol); - - for (const member of declaration.members) { - if (ts.isConstructorDeclaration(member)) continue; - if (!member.name) continue; - const memberKind = classifyMemberKind(member); - if (!memberKind) continue; - if (isPrivateMember(member)) continue; - if (hasAllowedDecorator(member, decoratorAllowSet)) continue; - - const memberSymbol = checker.getSymbolAtLocation(member.name); - if (!memberSymbol) continue; - if (memberHasExternalReference(memberSymbol, referenceIndex)) continue; - - const memberName = ts.isIdentifier(member.name) - ? member.name.text - : member.name.getText(sourceFile); - const isOverriddenInSubclass = overriddenMemberNames.has(memberName); - if (isOverriddenInSubclass) continue; - if (isFrameworkLifecycleMethod(memberName)) continue; - - const { line: zeroIndexedLine, character: zeroIndexedColumn } = - sourceFile.getLineAndCharacterOfPosition(member.getStart(sourceFile)); - const line = zeroIndexedLine + 1; - const column = zeroIndexedColumn + 1; - - const confidence: SemanticConfidence = isExported ? "low" : "high"; - - findings.push({ - path: modulePath, - className: declaration.name!.text, - memberName, - memberKind, - isStatic: isStaticMember(member), - line, - column, - confidence, - reason: isExported - ? `${declaration.name!.text}.${memberName} has no internal references; flagged at low confidence because ${declaration.name!.text} is part of the public API surface` - : `${declaration.name!.text}.${memberName} is declared but never referenced`, - trace: buildClassMemberTrace( - declaration.name!.text, - memberName, - modulePath, - line, - column, - false, - isExported, - ), - }); - } - } - - return findings; -}; diff --git a/packages/deslop-js/src/semantic/unused-enum-members.ts b/packages/deslop-js/src/semantic/unused-enum-members.ts deleted file mode 100644 index 9cf1c7c3c2..0000000000 --- a/packages/deslop-js/src/semantic/unused-enum-members.ts +++ /dev/null @@ -1,197 +0,0 @@ -import ts from "typescript"; -import type { - DependencyGraph, - DeslopConfig, - SemanticConfidence, - UnusedEnumMember, -} from "../types.js"; -import type { SemanticContext } from "./program.js"; -import type { ReferenceIndex } from "./references.js"; -import { SEMANTIC_TRACE_MAX_ENTRIES } from "../constants.js"; -import { buildSourceFileLookup, normalizeSourcePath } from "./utils/source-file-lookup.js"; - -interface EnumDeclarationContext { - sourceFile: ts.SourceFile; - declaration: ts.EnumDeclaration; - modulePath: string; -} - -const collectEnumDeclarations = ( - graph: DependencyGraph, - config: DeslopConfig, - sourceFileLookup: Map<string, ts.SourceFile>, -): EnumDeclarationContext[] => { - const declarations: EnumDeclarationContext[] = []; - - const visitTopLevel = (sourceFile: ts.SourceFile, modulePath: string): void => { - for (const statement of sourceFile.statements) { - if (ts.isEnumDeclaration(statement)) { - declarations.push({ sourceFile, declaration: statement, modulePath }); - } - } - }; - - for (const module of graph.modules) { - if (!module.isReachable) continue; - if (module.isDeclarationFile) continue; - if (module.isEntryPoint && !config.includeEntryExports) continue; - - const sourceFile = sourceFileLookup.get(normalizeSourcePath(module.fileId.path)); - if (!sourceFile) continue; - visitTopLevel(sourceFile, module.fileId.path); - } - - return declarations; -}; - -const isStringLiteralEnum = (declaration: ts.EnumDeclaration): boolean => { - if (declaration.members.length === 0) return false; - for (const member of declaration.members) { - if (!member.initializer) return false; - if (!ts.isStringLiteral(member.initializer)) return false; - } - return true; -}; - -const isConstEnum = (declaration: ts.EnumDeclaration): boolean => { - const modifiers = ts.canHaveModifiers(declaration) ? ts.getModifiers(declaration) : undefined; - if (!modifiers) return false; - return modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ConstKeyword); -}; - -const enumHasComputedAccess = (enumSymbol: ts.Symbol, referenceIndex: ReferenceIndex): boolean => { - const references = referenceIndex.getReferences(enumSymbol); - for (const referenceSite of references) { - const parent = referenceSite.identifier.parent; - if (!parent) continue; - if (ts.isElementAccessExpression(parent) && parent.expression === referenceSite.identifier) { - return true; - } - } - return false; -}; - -const enumHasWholeObjectUse = (enumSymbol: ts.Symbol, referenceIndex: ReferenceIndex): boolean => { - const references = referenceIndex.getReferences(enumSymbol); - for (const referenceSite of references) { - if (referenceSite.isDeclarationName) continue; - if (referenceSite.isExportSpecifier) continue; - if (referenceSite.isImportSpecifier) continue; - const parent = referenceSite.identifier.parent; - if (!parent) continue; - if (ts.isPropertyAccessExpression(parent) && parent.expression === referenceSite.identifier) { - continue; - } - if (ts.isQualifiedName(parent) && parent.left === referenceSite.identifier) continue; - if (ts.isElementAccessExpression(parent) && parent.expression === referenceSite.identifier) { - continue; - } - if (ts.isTypeReferenceNode(parent)) continue; - if (ts.isTypeQueryNode(parent)) continue; - return true; - } - return false; -}; - -const memberHasExternalReference = ( - memberSymbol: ts.Symbol, - referenceIndex: ReferenceIndex, -): boolean => { - const references = referenceIndex.getReferences(memberSymbol); - for (const referenceSite of references) { - if (referenceSite.isDeclarationName) continue; - return true; - } - return false; -}; - -const buildEnumMemberTrace = ( - enumName: string, - memberName: string, - declarationPath: string, - line: number, - column: number, - hasComputedAccess: boolean, - hasWholeObjectUse: boolean, -): string[] => { - const trace = [ - `${declarationPath}:${line}:${column} declares ${enumName}.${memberName}`, - `no static \`${enumName}.${memberName}\` reference found in the project`, - ]; - if (hasComputedAccess) { - trace.push(`${enumName}[...] computed access observed — confidence downgraded`); - } - if (hasWholeObjectUse) { - trace.push(`${enumName} used as a whole value — confidence downgraded`); - } - return trace.slice(0, SEMANTIC_TRACE_MAX_ENTRIES); -}; - -export const detectUnusedEnumMembers = ( - graph: DependencyGraph, - config: DeslopConfig, - context: SemanticContext, - referenceIndex: ReferenceIndex, -): UnusedEnumMember[] => { - const findings: UnusedEnumMember[] = []; - const sourceFileLookup = buildSourceFileLookup(context.program); - const enumDeclarations = collectEnumDeclarations(graph, config, sourceFileLookup); - if (enumDeclarations.length === 0) return findings; - - const { checker } = context; - - for (const { sourceFile, declaration, modulePath } of enumDeclarations) { - const enumSymbol = checker.getSymbolAtLocation(declaration.name); - if (!enumSymbol) continue; - - const hasComputedAccess = enumHasComputedAccess(enumSymbol, referenceIndex); - const hasWholeObjectUse = enumHasWholeObjectUse(enumSymbol, referenceIndex); - const isPureStringEnum = isStringLiteralEnum(declaration); - const isConst = isConstEnum(declaration); - - if (hasWholeObjectUse) continue; - if (hasComputedAccess) continue; - - let confidence: SemanticConfidence; - if (isConst) { - confidence = "low"; - } else if (isPureStringEnum) { - confidence = "high"; - } else { - confidence = "medium"; - } - - for (const member of declaration.members) { - const memberSymbol = checker.getSymbolAtLocation(member.name); - if (!memberSymbol) continue; - if (memberHasExternalReference(memberSymbol, referenceIndex)) continue; - - const memberName = member.name.getText(sourceFile); - const { line: zeroIndexedLine, character: zeroIndexedColumn } = - sourceFile.getLineAndCharacterOfPosition(member.getStart(sourceFile)); - const line = zeroIndexedLine + 1; - const column = zeroIndexedColumn + 1; - - findings.push({ - path: modulePath, - enumName: declaration.name.text, - memberName, - line, - column, - confidence, - reason: `${declaration.name.text}.${memberName} is declared but never referenced`, - trace: buildEnumMemberTrace( - declaration.name.text, - memberName, - modulePath, - line, - column, - false, - false, - ), - }); - } - } - - return findings; -}; diff --git a/packages/deslop-js/src/semantic/unused-types.ts b/packages/deslop-js/src/semantic/unused-types.ts deleted file mode 100644 index de732be8ba..0000000000 --- a/packages/deslop-js/src/semantic/unused-types.ts +++ /dev/null @@ -1,186 +0,0 @@ -import ts from "typescript"; -import type { - DependencyGraph, - DeslopConfig, - SourceModule, - UnusedType, - UnusedTypeKind, -} from "../types.js"; -import type { SemanticContext } from "./program.js"; -import type { ReferenceIndex, SymbolReferenceSite } from "./references.js"; -import { SEMANTIC_TRACE_MAX_ENTRIES } from "../constants.js"; -import { buildSourceFileLookup, normalizeSourcePath } from "./utils/source-file-lookup.js"; - -interface TypeExportCandidate { - module: SourceModule; - exportName: string; - line: number; - column: number; -} - -const TYPE_DECLARATION_FLAGS = - ts.SymbolFlags.Interface | - ts.SymbolFlags.TypeAlias | - ts.SymbolFlags.Enum | - ts.SymbolFlags.ConstEnum | - ts.SymbolFlags.RegularEnum; - -const VALUE_DECLARATION_FLAGS = - ts.SymbolFlags.Variable | - ts.SymbolFlags.Function | - ts.SymbolFlags.Class | - ts.SymbolFlags.BlockScopedVariable | - ts.SymbolFlags.FunctionScopedVariable; - -const collectTypeExportCandidates = ( - graph: DependencyGraph, - config: DeslopConfig, -): TypeExportCandidate[] => { - const candidates: TypeExportCandidate[] = []; - for (const module of graph.modules) { - if (!module.isReachable) continue; - if (module.isDeclarationFile) continue; - if (module.isEntryPoint && !config.includeEntryExports) continue; - - for (const exportInfo of module.exports) { - if (exportInfo.isSynthetic) continue; - if (!exportInfo.isTypeOnly) continue; - if (exportInfo.isReExport) continue; - if (exportInfo.name === "*") continue; - candidates.push({ - module, - exportName: exportInfo.name, - line: exportInfo.line, - column: exportInfo.column, - }); - } - } - return candidates; -}; - -const resolveExportSymbol = ( - sourceFile: ts.SourceFile, - exportName: string, - checker: ts.TypeChecker, -): ts.Symbol | undefined => { - const moduleSymbol = checker.getSymbolAtLocation(sourceFile); - if (!moduleSymbol) return undefined; - - const exportsOfModule = checker.getExportsOfModule(moduleSymbol); - const matchingExport = exportsOfModule.find((exportSymbol) => exportSymbol.name === exportName); - if (!matchingExport) return undefined; - - if (matchingExport.flags & ts.SymbolFlags.Alias) { - try { - return checker.getAliasedSymbol(matchingExport); - } catch { - return matchingExport; - } - } - return matchingExport; -}; - -const isPureTypeSymbol = (symbol: ts.Symbol): boolean => { - const hasTypeFlags = (symbol.flags & TYPE_DECLARATION_FLAGS) !== 0; - const hasValueFlags = (symbol.flags & VALUE_DECLARATION_FLAGS) !== 0; - return hasTypeFlags && !hasValueFlags; -}; - -const classifyTypeKind = (symbol: ts.Symbol): UnusedTypeKind | undefined => { - if (symbol.flags & ts.SymbolFlags.Interface) return "interface"; - if (symbol.flags & ts.SymbolFlags.TypeAlias) return "type-alias"; - if ( - symbol.flags & - (ts.SymbolFlags.Enum | ts.SymbolFlags.ConstEnum | ts.SymbolFlags.RegularEnum) - ) { - return "enum-type"; - } - return undefined; -}; - -const isReferenceMeaningful = (site: SymbolReferenceSite): boolean => { - if (site.isDeclarationName) return false; - return true; -}; - -const buildTrace = ( - candidate: TypeExportCandidate, - meaningfulReferenceCount: number, - totalReferenceCount: number, - reExportSiteCount: number, -): string[] => { - const trace = [ - `${candidate.module.fileId.path}:${candidate.line}:${candidate.column} declares "${candidate.exportName}"`, - `total identifier references resolved to symbol: ${totalReferenceCount}`, - `references excluding declaration site: ${meaningfulReferenceCount}`, - `re-export specifier sites: ${reExportSiteCount}`, - ]; - return trace.slice(0, SEMANTIC_TRACE_MAX_ENTRIES); -}; - -export const detectUnusedTypes = ( - graph: DependencyGraph, - config: DeslopConfig, - context: SemanticContext, - referenceIndex: ReferenceIndex, -): UnusedType[] => { - const findings: UnusedType[] = []; - const candidates = collectTypeExportCandidates(graph, config); - if (candidates.length === 0) return findings; - - const sourceFileLookup = buildSourceFileLookup(context.program); - - for (const candidate of candidates) { - const sourceFile = sourceFileLookup.get(normalizeSourcePath(candidate.module.fileId.path)); - if (!sourceFile) continue; - - const exportSymbol = resolveExportSymbol(sourceFile, candidate.exportName, context.checker); - if (!exportSymbol) continue; - if (!isPureTypeSymbol(exportSymbol)) continue; - - const kind = classifyTypeKind(exportSymbol); - if (!kind) continue; - - const allReferences = referenceIndex.getReferences(exportSymbol); - const reExportSites = allReferences.filter((site) => site.isExportSpecifier); - const meaningfulReferences = allReferences.filter(isReferenceMeaningful); - const externalUseSites = meaningfulReferences.filter((site) => !site.isExportSpecifier); - - if (externalUseSites.length > 0) continue; - - const declarations = exportSymbol.declarations ?? []; - if (declarations.length > 1) { - const declarationFiles = new Set( - declarations.map((decl) => normalizeSourcePath(decl.getSourceFile().fileName)), - ); - if (declarationFiles.size > 1) { - const mergedHasExternalRef = meaningfulReferences.some((site) => { - const referenceFileName = normalizeSourcePath(site.sourceFile.fileName); - return !declarationFiles.has(referenceFileName); - }); - if (mergedHasExternalRef) continue; - } - } - - findings.push({ - path: candidate.module.fileId.path, - name: candidate.exportName, - line: candidate.line, - column: candidate.column, - kind, - confidence: reExportSites.length > 0 ? "medium" : "high", - reason: - reExportSites.length > 0 - ? `type "${candidate.exportName}" is only re-exported through ${reExportSites.length} barrel(s) and never used` - : `type "${candidate.exportName}" has no references in the project`, - trace: buildTrace( - candidate, - meaningfulReferences.length, - allReferences.length, - reExportSites.length, - ), - }); - } - - return findings; -}; diff --git a/packages/deslop-js/src/semantic/utils/source-file-lookup.ts b/packages/deslop-js/src/semantic/utils/source-file-lookup.ts deleted file mode 100644 index 86ebad2221..0000000000 --- a/packages/deslop-js/src/semantic/utils/source-file-lookup.ts +++ /dev/null @@ -1,13 +0,0 @@ -import ts from "typescript"; -import { resolve } from "node:path"; - -export const normalizeSourcePath = resolve; - -export const buildSourceFileLookup = (program: ts.Program): Map<string, ts.SourceFile> => { - const lookup = new Map<string, ts.SourceFile>(); - for (const sourceFile of program.getSourceFiles()) { - if (sourceFile.isDeclarationFile) continue; - lookup.set(normalizeSourcePath(sourceFile.fileName), sourceFile); - } - return lookup; -}; diff --git a/packages/deslop-js/src/semantic/variable-aliases.ts b/packages/deslop-js/src/semantic/variable-aliases.ts deleted file mode 100644 index c3c18d3d13..0000000000 --- a/packages/deslop-js/src/semantic/variable-aliases.ts +++ /dev/null @@ -1,159 +0,0 @@ -import ts from "typescript"; -import type { DependencyGraph, RedundantAlias } from "../types.js"; -import type { SemanticContext } from "./program.js"; -import type { ReferenceIndex, SymbolReferenceSite } from "./references.js"; -import { buildSourceFileLookup, normalizeSourcePath } from "./utils/source-file-lookup.js"; - -interface CandidateVariableAlias { - sourceFile: ts.SourceFile; - declaration: ts.VariableDeclaration; - aliasName: string; - aliasedFromName: string; - modulePath: string; -} - -const isSimpleIdentifierInitializer = ( - initializer: ts.Expression | undefined, -): initializer is ts.Identifier => Boolean(initializer && ts.isIdentifier(initializer)); - -const isModuleLevelDeclaration = (declaration: ts.VariableDeclaration): boolean => { - const variableDeclarationList = declaration.parent; - if (!variableDeclarationList || !ts.isVariableDeclarationList(variableDeclarationList)) { - return false; - } - const statement = variableDeclarationList.parent; - return Boolean(statement && ts.isSourceFile(statement.parent)); -}; - -const isDeclarationExported = (declaration: ts.VariableDeclaration): boolean => { - const variableDeclarationList = declaration.parent; - if (!variableDeclarationList || !ts.isVariableDeclarationList(variableDeclarationList)) { - return false; - } - const statement = variableDeclarationList.parent; - if (!statement || !ts.isVariableStatement(statement)) return false; - const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined; - if (!modifiers) return false; - return modifiers.some( - (modifier) => - modifier.kind === ts.SyntaxKind.ExportKeyword || - modifier.kind === ts.SyntaxKind.DefaultKeyword, - ); -}; - -const collectVariableAliasCandidates = ( - graph: DependencyGraph, - sourceFileLookup: Map<string, ts.SourceFile>, -): CandidateVariableAlias[] => { - const candidates: CandidateVariableAlias[] = []; - - for (const module of graph.modules) { - if (!module.isReachable) continue; - if (module.isDeclarationFile) continue; - - const sourceFile = sourceFileLookup.get(normalizeSourcePath(module.fileId.path)); - if (!sourceFile) continue; - - for (const statement of sourceFile.statements) { - if (!ts.isVariableStatement(statement)) continue; - for (const declaration of statement.declarationList.declarations) { - if (!ts.isIdentifier(declaration.name)) continue; - if (!isSimpleIdentifierInitializer(declaration.initializer)) continue; - if (!isModuleLevelDeclaration(declaration)) continue; - - const aliasName = declaration.name.text; - const aliasedFromName = declaration.initializer.text; - if (aliasName === aliasedFromName) continue; - - candidates.push({ - sourceFile, - declaration, - aliasName, - aliasedFromName, - modulePath: module.fileId.path, - }); - } - } - } - - return candidates; -}; - -const isMeaningfulReference = (site: SymbolReferenceSite): boolean => { - if (site.isDeclarationName) return false; - if (site.isImportSpecifier) return false; - if (site.isExportSpecifier) return false; - return true; -}; - -const resolveThroughAliasChain = (symbol: ts.Symbol, checker: ts.TypeChecker): ts.Symbol => { - if (symbol.flags & ts.SymbolFlags.Alias) { - try { - return checker.getAliasedSymbol(symbol); - } catch { - return symbol; - } - } - return symbol; -}; - -export const detectRedundantVariableAliases = ( - graph: DependencyGraph, - context: SemanticContext, - referenceIndex: ReferenceIndex, -): RedundantAlias[] => { - const findings: RedundantAlias[] = []; - const sourceFileLookup = buildSourceFileLookup(context.program); - const candidates = collectVariableAliasCandidates(graph, sourceFileLookup); - if (candidates.length === 0) return findings; - - const { checker } = context; - - for (const candidate of candidates) { - if (isDeclarationExported(candidate.declaration)) continue; - - const aliasNameIdentifier = candidate.declaration.name; - if (!ts.isIdentifier(aliasNameIdentifier)) continue; - if (!candidate.declaration.initializer || !ts.isIdentifier(candidate.declaration.initializer)) { - continue; - } - - const rawAliasSymbol = checker.getSymbolAtLocation(aliasNameIdentifier); - const rawSourceSymbol = checker.getSymbolAtLocation(candidate.declaration.initializer); - if (!rawAliasSymbol || !rawSourceSymbol) continue; - const aliasSymbol = resolveThroughAliasChain(rawAliasSymbol, checker); - const sourceSymbol = resolveThroughAliasChain(rawSourceSymbol, checker); - if (aliasSymbol === sourceSymbol) continue; - - const sourceMeaningfulRefs = referenceIndex - .getReferences(sourceSymbol) - .filter(isMeaningfulReference); - const aliasMeaningfulRefs = referenceIndex - .getReferences(aliasSymbol) - .filter(isMeaningfulReference); - - const sourceReferenceSitesOutsideAliasInit = sourceMeaningfulRefs.filter( - (site) => site.identifier !== candidate.declaration.initializer, - ); - if (sourceReferenceSitesOutsideAliasInit.length > 0) continue; - if (aliasMeaningfulRefs.length === 0) continue; - - const { line: zeroIndexedLine, character: zeroIndexedColumn } = - candidate.sourceFile.getLineAndCharacterOfPosition( - candidate.declaration.getStart(candidate.sourceFile), - ); - - findings.push({ - path: candidate.modulePath, - kind: "variable-alias", - name: candidate.aliasName, - aliasedFrom: candidate.aliasedFromName, - line: zeroIndexedLine + 1, - column: zeroIndexedColumn + 1, - confidence: "high", - reason: `\`const ${candidate.aliasName} = ${candidate.aliasedFromName}\` is the only consumer of \`${candidate.aliasedFromName}\` — rename or inline`, - }); - } - - return findings; -}; diff --git a/packages/deslop-js/src/summary-cache.ts b/packages/deslop-js/src/summary-cache.ts deleted file mode 100644 index 3640ca92ba..0000000000 --- a/packages/deslop-js/src/summary-cache.ts +++ /dev/null @@ -1,736 +0,0 @@ -// The incremental analysis cache behind `DeslopConfig.incrementalCachePath`. -// One tree walk per run is the single change detector; from it four layers are -// validated independently: -// 1. per-file parse summaries (`ParsedSource`) keyed by (mtimeMs, size) with -// a content-hash REPAIR path: a stat mismatch over identical bytes (a -// fresh CI checkout bumps every mtime) re-hashes the file, accepts the -// entry, and refreshes the stored stat — so the hash cost is paid once -// per checkout, not once per run (the ninja/restat pattern); -// 2. the collected file LIST keyed by `collectHash` (sorted file names, with -// manifest-like files content-hashed so a re-clone of identical content -// keys identically). Entry RESOLUTION is -// deliberately NOT cached: `resolveEntries` reads an unbounded content -// set (bundler/test-runner config strings, workflow yml, HTML, tsconfig -// contents, even sibling-workspace sources), so no name-based fingerprint -// can validate it — it re-runs live every scan, overlapped with parsing; -// 3. the `fromDir::specifier → ResolvedImport` map keyed by `resolutionHash` -// (`collectHash` ⊕ bundler-config content hashes — module resolution -// depends on the file SET, so summaries cache raw specifiers only and any -// add/delete/rename drops the whole resolution map); -// 4. per-file package-reference facts for `detectStalePackages`' content -// scans, keyed by (mtimeMs, size, queried-name-set hash), with the same -// content-hash repair as the summaries. -// Every read fails open (corrupt / missing / version- or scope-mismatched data -// degrades to a fresh computation, never a wrong result), saves are atomic -// (temp file + rename) and skipped when nothing changed. The accepted blind -// spot, shared with the stat-based caches in @react-doctor/core: an edit that -// preserves both a file's mtime and its byte size is invisible. The walk is -// rooted at `rootDir` (matching core's whole-result dead-code cache), so -// manifest edits ABOVE the scanned root share that cache's accepted gap. -import crypto from "node:crypto"; -import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, resolve } from "node:path"; -import { Minimatch } from "minimatch"; -import type { DeslopConfig, SourceFile } from "./types.js"; -import type { ParsedSource } from "./collect/parse.js"; -import type { ResolvedImport } from "./resolver/resolve.js"; -import { DeslopError, type DeslopErrorJson } from "./errors.js"; -import { - ANALYZED_MANIFEST_FILENAMES, - SUMMARY_CACHE_MAX_BYTES, - SUMMARY_CACHE_SCHEMA_VERSION, -} from "./constants.js"; -import { atomicWriteFile } from "./utils/atomic-write-file.js"; -import { toPosixPath } from "./utils/to-posix-path.js"; - -export type PackageFactKind = "substring" | "importReference"; - -/** - * A fast-glob-shaped query answered from the already-walked tree instead of a - * fresh directory scan. Mirrors the fg options the stale-package scans use: - * `deep` admits files at most that many path segments below `cwd`, `dot` - * governs whether wildcards match dot-prefixed names (fg default: false), and - * `ignore` patterns always match dot names (fg behavior). - */ -export interface WalkedFileQuery { - readonly cwd: string; - readonly patterns: ReadonlyArray<string>; - readonly ignore: ReadonlyArray<string>; - readonly deep: number; - readonly dot?: boolean; -} - -export interface SummaryCache { - /** The cached collected-file list, or `null` when the tree shape changed. */ - readonly lookupFileList: () => SourceFile[] | null; - readonly storeFileList: (files: SourceFile[]) => void; - readonly lookupSummary: (filePath: string) => ParsedSource | null; - readonly storeSummary: (filePath: string, parsed: ParsedSource) => void; - readonly lookupResolution: (specifier: string, fromFile: string) => ResolvedImport | null; - readonly storeResolution: (specifier: string, fromFile: string, resolved: ResolvedImport) => void; - /** - * The subset of `names` that `matcher` matches in `filePath`, served from - * the per-file fact layer when (mtime, size, queried-name-set) all match. - * Reads the file (and throws like a raw `readFileSync`) only on a miss. - * Callers must not mutate `names` while iterating one scan loop — the - * sorted/hashed form is memoized per set instance. - */ - readonly matchPackageNames: ( - filePath: string, - kind: PackageFactKind, - names: ReadonlySet<string>, - matcher: (content: string, packageName: string) => boolean, - ) => string[]; - /** - * The walked files matching a fast-glob-shaped query, sorted, as absolute - * POSIX paths — the shared-walk replacement for the stale-package `fg.sync` - * scans (verified byte-identical against fg on real corpora). Returns `null` - * when the query's `cwd` is not the walk root (e.g. a monorepo root above - * the scanned project) or a pattern fails to compile; callers then fall - * back to a real glob scan. - */ - readonly matchWalkedFiles: (query: WalkedFileQuery) => string[] | null; - /** Compacts and atomically persists the store; no-op when nothing changed. */ - readonly save: () => void; -} - -interface FileStatFingerprint { - m: number; - s: number; -} - -interface PersistedParsedSource { - imports?: ParsedSource["imports"]; - exports?: ParsedSource["exports"]; - memberAccesses?: ParsedSource["memberAccesses"]; - wholeObjectUses?: string[]; - localIdentifierReferences?: string[]; - topLevelImportReferences?: string[]; - referencedFilenames?: string[]; - redundantTypePatterns?: ParsedSource["redundantTypePatterns"]; - identityWrappers?: ParsedSource["identityWrappers"]; - typeDefinitionHashes?: ParsedSource["typeDefinitionHashes"]; - inlineTypeLiterals?: ParsedSource["inlineTypeLiterals"]; - simplifiableFunctions?: ParsedSource["simplifiableFunctions"]; - simplifiableExpressions?: ParsedSource["simplifiableExpressions"]; - duplicateConstantCandidates?: ParsedSource["duplicateConstantCandidates"]; - errors?: DeslopErrorJson[]; -} - -interface PersistedSummaryEntry extends FileStatFingerprint { - /** SHA-1 of the file's bytes at store time — the mtime-repair witness. */ - h: string; - p: PersistedParsedSource; -} - -interface PersistedPackageFactMatch { - h: string; - matched: string[]; -} - -interface PersistedPackageFactEntry extends FileStatFingerprint { - /** SHA-1 of the file's decoded content at store time — the mtime-repair witness. */ - h: string; - substring?: PersistedPackageFactMatch; - importReference?: PersistedPackageFactMatch; -} - -interface PersistedFileList { - hash: string; - files: string[]; -} - -interface PersistedResolution { - p: string | null; - e: boolean; - n: string | null; -} - -interface PersistedResolutions { - hash: string; - entries: Record<string, PersistedResolution>; -} - -interface PersistedSummaryCache { - version: number; - scopeHash: string; - fileList: PersistedFileList | null; - resolutions: PersistedResolutions | null; - summaries: Record<string, PersistedSummaryEntry>; - packageFacts: Record<string, PersistedPackageFactEntry>; -} - -interface SortedNameSet { - sortedNames: string[]; - hash: string; -} - -const WALK_SKIP_DIRECTORY_NAMES = new Set(["node_modules", ".git"]); - -const MANIFEST_LIKE_FILENAMES = new Set(ANALYZED_MANIFEST_FILENAMES); - -const isManifestLikeFileName = (fileName: string): boolean => - MANIFEST_LIKE_FILENAMES.has(fileName) || - ((fileName.startsWith("tsconfig") || fileName.startsWith("jsconfig")) && - fileName.endsWith(".json")); - -// The resolver reads bundler/test-runner alias configs by CONTENT -// (`loadBundlerAliasConfigs`), so their stats must invalidate the resolution -// map even though the file NAME set (already in `collectHash`) is unchanged. -const isResolverConfigFileName = (fileName: string): boolean => - fileName.includes("webpack") || - fileName.startsWith("vite.config.") || - fileName.startsWith("vitest.config.") || - fileName.startsWith("babel.config.") || - fileName.startsWith(".babelrc") || - fileName.startsWith("jest.config."); - -const sha1Hex = (text: string): string => crypto.createHash("sha1").update(text).digest("hex"); - -const sha1OfFileBytes = (filePath: string): string | null => { - try { - return crypto.createHash("sha1").update(readFileSync(filePath)).digest("hex"); - } catch { - return null; - } -}; - -const fileNameOfPosixPath = (posixPath: string): string => - posixPath.slice(posixPath.lastIndexOf("/") + 1); - -const deslopRequire = createRequire(import.meta.url); - -const resolveOwnPackageVersion = (): string => { - try { - const packageJson = deslopRequire("deslop-js/package.json"); - return typeof packageJson?.version === "string" ? packageJson.version : "unknown"; - } catch { - return "unknown"; - } -}; - -const walkTreeStats = (rootDirectory: string): Map<string, FileStatFingerprint> => { - const collected = new Map<string, FileStatFingerprint>(); - // fast-glob (whose scans this walk both fingerprints and answers via - // `matchWalkedFiles`) follows directory symlinks, so the walk must too; - // following each distinct link target once bounds symlink cycles. - const followedLinkTargets = new Set<string>(); - const walk = (directory: string): void => { - let entries; - try { - entries = readdirSync(directory, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const entryPath = `${directory}/${entry.name}`; - if (entry.isDirectory()) { - if (!WALK_SKIP_DIRECTORY_NAMES.has(entry.name)) walk(entryPath); - continue; - } - if (entry.isSymbolicLink()) { - if (WALK_SKIP_DIRECTORY_NAMES.has(entry.name)) continue; - try { - const linkStat = statSync(entryPath); - if (linkStat.isDirectory()) { - const linkTarget = realpathSync(entryPath); - if (!followedLinkTargets.has(linkTarget)) { - followedLinkTargets.add(linkTarget); - walk(entryPath); - } - } else if (linkStat.isFile()) { - collected.set(entryPath, { m: linkStat.mtimeMs, s: linkStat.size }); - } - } catch { - continue; - } - continue; - } - if (!entry.isFile()) continue; - try { - const fileStat = statSync(entryPath); - collected.set(entryPath, { m: fileStat.mtimeMs, s: fileStat.size }); - } catch { - continue; - } - } - }; - walk(toPosixPath(resolve(rootDirectory))); - return collected; -}; - -// One alternation regex over all patterns: the walked-file matcher tests each -// of ~20k paths once instead of once per pattern. `null` on any pattern -// minimatch cannot compile — the caller falls back to a real glob scan. -const compileGlobAlternation = ( - patterns: ReadonlyArray<string>, - matchDotNames: boolean, -): RegExp | null => { - const regexSources: string[] = []; - for (const pattern of patterns) { - const compiled = new Minimatch(pattern, { dot: matchDotNames }).makeRe(); - if (compiled === false) return null; - regexSources.push(compiled.source); - } - return regexSources.length === 0 ? null : new RegExp(regexSources.join("|")); -}; - -const countPathSegments = (relativePath: string): number => { - let segmentCount = 1; - for (let charIndex = 0; charIndex < relativePath.length; charIndex++) { - if (relativePath.charCodeAt(charIndex) === 47) segmentCount++; - } - return segmentCount; -}; - -// Content identity for the few files whose CONTENT feeds a layer hash -// (manifests, bundler configs). Hashed — not stat-fingerprinted — so a fresh -// CI checkout of identical content keys identically; the set is small, so the -// per-run hash cost is milliseconds. An unreadable file falls back to the -// conservative stat identity (it can never spuriously match a hash). -const contentFingerprintOf = (filePath: string, fileStat: FileStatFingerprint): string => - `${filePath}:${fileStat.s}:${sha1OfFileBytes(filePath) ?? `stat-${fileStat.m}`}`; - -const computeCollectHash = (walkedStats: Map<string, FileStatFingerprint>): string => { - const fingerprintLines: string[] = []; - for (const [filePath, fileStat] of walkedStats) { - fingerprintLines.push( - isManifestLikeFileName(fileNameOfPosixPath(filePath)) - ? contentFingerprintOf(filePath, fileStat) - : filePath, - ); - } - fingerprintLines.sort(); - return sha1Hex(fingerprintLines.join("\n")); -}; - -const computeResolutionHash = ( - collectHash: string, - walkedStats: Map<string, FileStatFingerprint>, -): string => { - const fingerprintLines: string[] = []; - for (const [filePath, fileStat] of walkedStats) { - if (isResolverConfigFileName(fileNameOfPosixPath(filePath))) { - fingerprintLines.push(contentFingerprintOf(filePath, fileStat)); - } - } - fingerprintLines.sort(); - return sha1Hex(`${collectHash}\n${fingerprintLines.join("\n")}`); -}; - -// Everything that changes what a stored entry MEANS: parser behavior (the -// deslop version), which project is scanned, what gets collected, and the -// summary-slimming choice (`reportRedundancy`). A mismatch discards the whole -// store rather than risking a stale-semantics hit. -const computeScopeHash = (config: DeslopConfig): string => - sha1Hex( - JSON.stringify({ - deslopVersion: resolveOwnPackageVersion(), - rootDir: toPosixPath(resolve(config.rootDir)), - entryPatterns: config.entryPatterns, - ignorePatterns: config.ignorePatterns, - includeExtensions: config.includeExtensions, - reportTypes: config.reportTypes, - includeEntryExports: config.includeEntryExports, - reportRedundancy: config.reportRedundancy, - tsConfigPath: config.tsConfigPath ?? null, - paths: config.paths ?? null, - }), - ); - -const isRecordValue = (value: unknown): value is Record<string, unknown> => - typeof value === "object" && value !== null && !Array.isArray(value); - -const isStringArray = (value: unknown): value is string[] => - Array.isArray(value) && value.every((entry) => typeof entry === "string"); - -const isOptionalArray = (value: unknown): boolean => value === undefined || Array.isArray(value); - -const emptyStore = (scopeHash: string): PersistedSummaryCache => ({ - version: SUMMARY_CACHE_SCHEMA_VERSION, - scopeHash, - fileList: null, - resolutions: null, - summaries: {}, - packageFacts: {}, -}); - -const isPersistedSummaryCache = (value: unknown): value is PersistedSummaryCache => - isRecordValue(value) && - value.version === SUMMARY_CACHE_SCHEMA_VERSION && - typeof value.scopeHash === "string" && - isRecordValue(value.summaries) && - isRecordValue(value.packageFacts); - -const readPersistedStore = (cachePath: string, scopeHash: string): PersistedSummaryCache => { - try { - const parsed: unknown = JSON.parse(readFileSync(cachePath, "utf-8")); - if (isPersistedSummaryCache(parsed) && parsed.scopeHash === scopeHash) { - return { - version: SUMMARY_CACHE_SCHEMA_VERSION, - scopeHash, - fileList: isRecordValue(parsed.fileList) ? parsed.fileList : null, - resolutions: - isRecordValue(parsed.resolutions) && - typeof parsed.resolutions.hash === "string" && - isRecordValue(parsed.resolutions.entries) - ? parsed.resolutions - : null, - summaries: parsed.summaries, - packageFacts: parsed.packageFacts, - }; - } - } catch { - // fall through to the empty store - } - return emptyStore(scopeHash); -}; - -// `detectDeadExports` is the only consumer of `localIdentifierReferences`, and -// it only ever queries names of the module's OWN parse-time exports (synthetic -// graph-time re-exports are skipped before the local-use check), so the -// persisted list can be intersected down to those names without changing any -// answer it can produce. -const intersectLocalReferencesWithOwnExports = (parsed: ParsedSource): string[] => { - if (parsed.localIdentifierReferences.length === 0) return []; - const ownExportNames = new Set(parsed.exports.map((exportInfo) => exportInfo.name)); - return parsed.localIdentifierReferences.filter((identifierName) => - ownExportNames.has(identifierName), - ); -}; - -const toPersistedArray = <ValueType>(values: ValueType[]): ValueType[] | undefined => - values.length > 0 ? values : undefined; - -const serializeParsedSource = ( - parsed: ParsedSource, - shouldPersistDryPatternFields: boolean, -): PersistedParsedSource => ({ - imports: toPersistedArray(parsed.imports), - exports: toPersistedArray(parsed.exports), - memberAccesses: toPersistedArray(parsed.memberAccesses), - wholeObjectUses: toPersistedArray(parsed.wholeObjectUses), - localIdentifierReferences: toPersistedArray(intersectLocalReferencesWithOwnExports(parsed)), - topLevelImportReferences: toPersistedArray(parsed.topLevelImportReferences), - referencedFilenames: toPersistedArray(parsed.referencedFilenames), - ...(shouldPersistDryPatternFields - ? { - redundantTypePatterns: toPersistedArray(parsed.redundantTypePatterns), - identityWrappers: toPersistedArray(parsed.identityWrappers), - typeDefinitionHashes: toPersistedArray(parsed.typeDefinitionHashes), - inlineTypeLiterals: toPersistedArray(parsed.inlineTypeLiterals), - simplifiableFunctions: toPersistedArray(parsed.simplifiableFunctions), - simplifiableExpressions: toPersistedArray(parsed.simplifiableExpressions), - duplicateConstantCandidates: toPersistedArray(parsed.duplicateConstantCandidates), - } - : {}), - errors: toPersistedArray(parsed.errors.map((deslopError) => deslopError.toJSON())), -}); - -const isPersistedErrorJson = (value: unknown): value is DeslopErrorJson => - isRecordValue(value) && - typeof value.code === "string" && - typeof value.module === "string" && - typeof value.message === "string"; - -const PERSISTED_SOURCE_ARRAY_FIELDS = [ - "imports", - "exports", - "memberAccesses", - "wholeObjectUses", - "localIdentifierReferences", - "topLevelImportReferences", - "referencedFilenames", - "redundantTypePatterns", - "identityWrappers", - "typeDefinitionHashes", - "inlineTypeLiterals", - "simplifiableFunctions", - "simplifiableExpressions", - "duplicateConstantCandidates", - "errors", -] as const; - -const isPersistedParsedSource = (value: unknown): value is PersistedParsedSource => - isRecordValue(value) && - PERSISTED_SOURCE_ARRAY_FIELDS.every((fieldName) => isOptionalArray(value[fieldName])); - -const reviveParsedSource = (persisted: unknown): ParsedSource | null => { - if (!isPersistedParsedSource(persisted)) return null; - const persistedErrors = persisted.errors ?? []; - if (!persistedErrors.every(isPersistedErrorJson)) return null; - return { - imports: persisted.imports ?? [], - exports: persisted.exports ?? [], - memberAccesses: persisted.memberAccesses ?? [], - wholeObjectUses: persisted.wholeObjectUses ?? [], - localIdentifierReferences: persisted.localIdentifierReferences ?? [], - topLevelImportReferences: persisted.topLevelImportReferences ?? [], - referencedFilenames: persisted.referencedFilenames ?? [], - redundantTypePatterns: persisted.redundantTypePatterns ?? [], - identityWrappers: persisted.identityWrappers ?? [], - typeDefinitionHashes: persisted.typeDefinitionHashes ?? [], - inlineTypeLiterals: persisted.inlineTypeLiterals ?? [], - simplifiableFunctions: persisted.simplifiableFunctions ?? [], - simplifiableExpressions: persisted.simplifiableExpressions ?? [], - duplicateConstantCandidates: persisted.duplicateConstantCandidates ?? [], - errors: persistedErrors.map( - (errorJson) => - new DeslopError({ - code: errorJson.code, - module: errorJson.module, - severity: errorJson.severity, - message: errorJson.message, - path: errorJson.path, - detail: errorJson.detail, - }), - ), - }; -}; - -const createSummaryCache = (cachePath: string, config: DeslopConfig): SummaryCache => { - const scopeHash = computeScopeHash(config); - const store = readPersistedStore(cachePath, scopeHash); - const walkRoot = toPosixPath(resolve(config.rootDir)); - const walkedStats = walkTreeStats(config.rootDir); - const collectHash = computeCollectHash(walkedStats); - const resolutionHash = computeResolutionHash(collectHash, walkedStats); - const shouldPersistDryPatternFields = config.reportRedundancy; - - if (store.resolutions === null || store.resolutions.hash !== resolutionHash) { - store.resolutions = { hash: resolutionHash, entries: {} }; - } - const resolutionEntries = store.resolutions.entries; - - let isDirty = false; - const activeSummaryPaths = new Set<string>(); - const activeResolutionKeys = new Set<string>(); - const activeFactPaths = new Set<string>(); - const sortedNameSetMemo = new WeakMap<ReadonlySet<string>, SortedNameSet>(); - - const statOfLive = (filePath: string): FileStatFingerprint | null => { - try { - const fileStat = statSync(filePath); - return { m: fileStat.mtimeMs, s: fileStat.size }; - } catch { - return null; - } - }; - - const statOf = (filePath: string): FileStatFingerprint | null => - walkedStats.get(filePath) ?? statOfLive(filePath); - - return { - lookupFileList: () => { - const cachedFileList = store.fileList; - if ( - cachedFileList === null || - cachedFileList.hash !== collectHash || - !isStringArray(cachedFileList.files) - ) { - return null; - } - return cachedFileList.files.map((filePath, fileIndex) => ({ - index: fileIndex, - path: filePath, - })); - }, - - storeFileList: (files) => { - store.fileList = { - hash: collectHash, - files: files.map((file) => file.path), - }; - isDirty = true; - }, - - lookupSummary: (filePath) => { - const cachedSummary = store.summaries[filePath]; - if (!isRecordValue(cachedSummary)) return null; - const fileStat = statOf(filePath); - if (!fileStat) return null; - if (fileStat.m !== cachedSummary.m || fileStat.s !== cachedSummary.s) { - // Mtime repair: a fresh checkout bumps every mtime over identical - // bytes. A size change is a content change (skip the read); a - // same-size stat mismatch re-hashes, and a hash match accepts the - // entry and refreshes the stored stat so the next run stats through. - if (fileStat.s !== cachedSummary.s || typeof cachedSummary.h !== "string") return null; - const contentHash = sha1OfFileBytes(filePath); - if (contentHash === null || contentHash !== cachedSummary.h) return null; - cachedSummary.m = fileStat.m; - cachedSummary.s = fileStat.s; - isDirty = true; - } - const revived = reviveParsedSource(cachedSummary.p); - if (revived === null) return null; - activeSummaryPaths.add(filePath); - return revived; - }, - - storeSummary: (filePath, parsed) => { - const walkStat = statOf(filePath); - if (!walkStat) return; - // Hash first, then re-stat: an edit racing the parse either changed the - // stat (skip the store) or landed after the hash captured the parsed - // content (the next lookup's hash check misses on it). Without the - // re-verification, a racing edit could pair walk-time stats with - // post-edit content and mint a repairable stale entry. - const contentHash = sha1OfFileBytes(filePath); - if (contentHash === null) return; - const liveStat = statOfLive(filePath); - if (liveStat === null || liveStat.m !== walkStat.m || liveStat.s !== walkStat.s) return; - store.summaries[filePath] = { - m: walkStat.m, - s: walkStat.s, - h: contentHash, - p: serializeParsedSource(parsed, shouldPersistDryPatternFields), - }; - activeSummaryPaths.add(filePath); - isDirty = true; - }, - - lookupResolution: (specifier, fromFile) => { - const resolutionKey = `${dirname(fromFile)}::${specifier}`; - const cachedResolution = resolutionEntries[resolutionKey]; - if (!isRecordValue(cachedResolution) || typeof cachedResolution.e !== "boolean") { - return null; - } - activeResolutionKeys.add(resolutionKey); - return { - resolvedPath: typeof cachedResolution.p === "string" ? cachedResolution.p : undefined, - isExternal: cachedResolution.e, - packageName: typeof cachedResolution.n === "string" ? cachedResolution.n : undefined, - }; - }, - - storeResolution: (specifier, fromFile, resolved) => { - const resolutionKey = `${dirname(fromFile)}::${specifier}`; - resolutionEntries[resolutionKey] = { - p: resolved.resolvedPath ?? null, - e: resolved.isExternal, - n: resolved.packageName ?? null, - }; - activeResolutionKeys.add(resolutionKey); - isDirty = true; - }, - - matchPackageNames: (filePath, kind, names, matcher) => { - let nameSet = sortedNameSetMemo.get(names); - if (nameSet === undefined) { - const sortedNames = [...names].sort(); - nameSet = { sortedNames, hash: sha1Hex(sortedNames.join("\n")) }; - sortedNameSetMemo.set(names, nameSet); - } - activeFactPaths.add(filePath); - const fileStat = statOf(filePath); - const existingEntry = isRecordValue(store.packageFacts[filePath]) - ? store.packageFacts[filePath] - : null; - let validEntry = - fileStat !== null && - existingEntry !== null && - existingEntry.m === fileStat.m && - existingEntry.s === fileStat.s - ? existingEntry - : null; - const existingMatch = validEntry?.[kind]; - if ( - isRecordValue(existingMatch) && - existingMatch.h === nameSet.hash && - isStringArray(existingMatch.matched) - ) { - return existingMatch.matched; - } - const content = readFileSync(filePath, "utf-8"); - const contentHash = sha1Hex(content); - if (validEntry === null && fileStat !== null && existingEntry?.h === contentHash) { - // Mtime repair: the fact-scan read the content anyway, so a hash match - // revalidates the whole entry (both kinds) under the fresh stat. - existingEntry.m = fileStat.m; - existingEntry.s = fileStat.s; - isDirty = true; - validEntry = existingEntry; - const repairedMatch = validEntry[kind]; - if ( - isRecordValue(repairedMatch) && - repairedMatch.h === nameSet.hash && - isStringArray(repairedMatch.matched) - ) { - return repairedMatch.matched; - } - } - const matchedNames = nameSet.sortedNames.filter((packageName) => - matcher(content, packageName), - ); - if (fileStat !== null) { - const factEntry = validEntry ?? { m: fileStat.m, s: fileStat.s, h: contentHash }; - factEntry[kind] = { h: nameSet.hash, matched: matchedNames }; - store.packageFacts[filePath] = factEntry; - isDirty = true; - } - return matchedNames; - }, - - matchWalkedFiles: (query) => { - if (toPosixPath(resolve(query.cwd)) !== walkRoot) return null; - const positiveMatcher = compileGlobAlternation(query.patterns, query.dot === true); - if (positiveMatcher === null) return null; - const ignoreMatcher = - query.ignore.length > 0 ? compileGlobAlternation(query.ignore, true) : null; - if (query.ignore.length > 0 && ignoreMatcher === null) return null; - const rootPrefixLength = walkRoot.length + 1; - const matchedPaths: string[] = []; - for (const filePath of walkedStats.keys()) { - const relativePath = filePath.slice(rootPrefixLength); - if (countPathSegments(relativePath) > query.deep) continue; - if (ignoreMatcher !== null && ignoreMatcher.test(relativePath)) continue; - if (positiveMatcher.test(relativePath)) matchedPaths.push(filePath); - } - return matchedPaths.sort(); - }, - - save: () => { - if (!isDirty) return; - const compactedSummaries: Record<string, PersistedSummaryEntry> = {}; - for (const filePath of activeSummaryPaths) { - const summaryEntry = store.summaries[filePath]; - if (summaryEntry !== undefined) compactedSummaries[filePath] = summaryEntry; - } - const compactedFacts: Record<string, PersistedPackageFactEntry> = {}; - for (const filePath of activeFactPaths) { - const factEntry = store.packageFacts[filePath]; - if (factEntry !== undefined) compactedFacts[filePath] = factEntry; - } - const compactedResolutions: Record<string, PersistedResolution> = {}; - for (const resolutionKey of activeResolutionKeys) { - const resolutionEntry = resolutionEntries[resolutionKey]; - if (resolutionEntry !== undefined) compactedResolutions[resolutionKey] = resolutionEntry; - } - const serialized = JSON.stringify({ - version: SUMMARY_CACHE_SCHEMA_VERSION, - scopeHash, - fileList: store.fileList, - resolutions: { hash: resolutionHash, entries: compactedResolutions }, - summaries: compactedSummaries, - packageFacts: compactedFacts, - } satisfies PersistedSummaryCache); - if (serialized.length > SUMMARY_CACHE_MAX_BYTES) return; - atomicWriteFile(cachePath, serialized); - }, - }; -}; - -/** - * Loads the incremental cache configured at `config.incrementalCachePath`. - * Returns `null` (analysis runs exactly as without a cache) when the path is - * unset or anything about initialization fails. - */ -export const loadSummaryCache = (config: DeslopConfig): SummaryCache | null => { - if (!config.incrementalCachePath) return null; - try { - return createSummaryCache(config.incrementalCachePath, config); - } catch { - return null; - } -}; diff --git a/packages/deslop-js/src/types.ts b/packages/deslop-js/src/types.ts deleted file mode 100644 index caf93b698d..0000000000 --- a/packages/deslop-js/src/types.ts +++ /dev/null @@ -1,717 +0,0 @@ -import type { DeslopError } from "./errors.js"; - -export type { - DeslopError, - DeslopErrorCode, - DeslopErrorModule, - DeslopErrorSeverity, -} from "./errors.js"; - -export interface SourceFile { - index: number; - path: string; -} - -export interface ImportReference { - specifier: string; - importedNames: ImportBinding[]; - isTypeOnly: boolean; - isDynamic: boolean; - isSideEffect: boolean; - isGlob?: boolean; - line: number; - column: number; -} - -export interface ImportBinding { - name: string; - alias: string | undefined; - isNamespace: boolean; - isDefault: boolean; - isTypeOnly: boolean; - isRedundantAlias?: boolean; -} - -export interface ExportReference { - name: string; - isDefault: boolean; - isTypeOnly: boolean; - isReExport: boolean; - isSynthetic: boolean; - reExportSource: string | undefined; - reExportOriginalName: string | undefined; - isNamespaceReExport: boolean; - line: number; - column: number; - defaultExportLocalName?: string; - isRedundantAlias?: boolean; -} - -export interface MemberAccess { - objectName: string; - memberName: string; -} - -export interface SourceModuleRedundantTypePattern { - typeName: string; - kind: RedundantTypePatternKind; - line: number; - column: number; - reason: string; - suggestion: string; -} - -export interface SourceModuleIdentityWrapper { - wrapperName: string; - wrappedExpression: string; - line: number; - column: number; -} - -export interface SourceModuleTypeDefinitionHash { - typeName: string; - structuralHash: string; - line: number; - column: number; -} - -export interface SourceModuleInlineTypeLiteral { - structuralHash: string; - memberCount: number; - preview: string; - context: InlineTypeContext; - nearestName?: string; - line: number; - column: number; -} - -export interface SourceModuleSimplifiableFunction { - kind: SimplifiableFunctionKind; - functionName?: string; - line: number; - column: number; - reason: string; - suggestion: string; -} - -export interface SourceModuleSimplifiableExpression { - kind: SimplifiableExpressionKind; - snippet: string; - line: number; - column: number; - reason: string; - suggestion: string; -} - -export interface SourceModuleDuplicateConstantCandidate { - constantName: string; - literalHash: string; - literalPreview: string; - line: number; - column: number; -} - -export interface SourceModuleAnalysis { - imports: ImportReference[]; - exports: ExportReference[]; - memberAccesses: MemberAccess[]; - wholeObjectUses: string[]; - localIdentifierReferences: string[]; - /** - * Local names of static import bindings referenced in module-init-executed - * positions (top-level statements outside function bodies and erased TS - * type positions). Cycle detection uses this to tell an initialization- - * order hazard from a cycle whose back edges are only dereferenced later, - * inside function bodies invoked after every module has initialized. - */ - topLevelImportReferences: string[]; - referencedFilenames: string[]; - redundantTypePatterns: SourceModuleRedundantTypePattern[]; - identityWrappers: SourceModuleIdentityWrapper[]; - typeDefinitionHashes: SourceModuleTypeDefinitionHash[]; - inlineTypeLiterals: SourceModuleInlineTypeLiteral[]; - simplifiableFunctions: SourceModuleSimplifiableFunction[]; - simplifiableExpressions: SourceModuleSimplifiableExpression[]; - duplicateConstantCandidates: SourceModuleDuplicateConstantCandidate[]; -} - -export interface SourceModule extends SourceModuleAnalysis { - fileId: SourceFile; - parseErrors: DeslopError[]; - isEntryPoint: boolean; - isTestEntry: boolean; - isReachable: boolean; - isDeclarationFile: boolean; - isConfigFile: boolean; - isGitIgnored: boolean; -} - -export interface ReExportMapping { - exportedName: string; - originalName: string; -} - -export interface Edge { - source: number; - target: number; - importedSymbols: LinkedSymbol[]; - isReExportEdge: boolean; - isDynamic: boolean; - reExportedNames: string[]; - reExportMappings: ReExportMapping[]; -} - -export interface LinkedSymbol { - importedName: string; - localName: string; - isTypeOnly: boolean; - isNamespace: boolean; - isDefault: boolean; -} - -export interface DependencyGraph { - modules: SourceModule[]; - edges: Edge[]; - reverseEdges: Map<number, number[]>; - fileIdMap: Map<string, number>; -} - -export interface UnusedFile { - path: string; -} - -export interface UnusedExport { - path: string; - name: string; - line: number; - column: number; - isTypeOnly: boolean; -} - -export interface UnusedDependency { - name: string; - isDevDependency: boolean; - reason: string; -} - -export type SkippedDependencyReason = "allowlisted-name" | "provides-binary"; - -export interface SkippedDependency { - name: string; - isDevDependency: boolean; - reasons: SkippedDependencyReason[]; -} - -export interface CircularDependency { - files: string[]; -} - -export type SemanticConfidence = "high" | "medium" | "low"; - -export type UnusedTypeKind = "interface" | "type-alias" | "enum-type"; - -export interface UnusedType { - path: string; - name: string; - line: number; - column: number; - kind: UnusedTypeKind; - confidence: SemanticConfidence; - reason: string; - trace: string[]; - suppressionHint?: string; -} - -export type DependencyDeclaredAs = "dependencies" | "peerDependencies"; - -export interface MisclassifiedDependency { - name: string; - declaredAs: DependencyDeclaredAs; - suggestedAs: "devDependencies"; - confidence: SemanticConfidence; - reason: string; - trace: string[]; -} - -export interface UnusedEnumMember { - path: string; - enumName: string; - memberName: string; - line: number; - column: number; - confidence: SemanticConfidence; - reason: string; - trace: string[]; -} - -export type ClassMemberKind = "method" | "property" | "accessor"; - -export interface UnusedClassMember { - path: string; - className: string; - memberName: string; - memberKind: ClassMemberKind; - isStatic: boolean; - line: number; - column: number; - confidence: SemanticConfidence; - reason: string; - trace: string[]; -} - -export type RedundantAliasKind = - | "import-self-alias" - | "export-self-alias" - | "reexport-self-alias" - | "variable-alias" - | "reexport-aliased-not-used" - | "roundtrip-alias"; - -export interface RedundantAlias { - path: string; - kind: RedundantAliasKind; - name: string; - aliasedFrom: string; - line: number; - column: number; - confidence: SemanticConfidence; - reason: string; -} - -export interface DuplicateExportOccurrence { - line: number; - column: number; - reExportSource?: string; - isReExport: boolean; -} - -export interface DuplicateExport { - path: string; - name: string; - occurrences: DuplicateExportOccurrence[]; - confidence: SemanticConfidence; - reason: string; -} - -export interface DuplicateImportOccurrence { - line: number; - column: number; - importedNames: string[]; - isTypeOnly: boolean; -} - -export interface DuplicateImport { - path: string; - specifier: string; - occurrences: DuplicateImportOccurrence[]; - confidence: SemanticConfidence; - reason: string; -} - -export type RedundantTypePatternKind = - | "intersection-with-empty-object" - | "self-union" - | "self-intersection" - | "nested-partial" - | "nested-readonly" - | "nested-required" - | "pick-all-keys" - | "omit-no-keys" - | "empty-interface-extends-one"; - -export interface RedundantTypePattern { - path: string; - typeName: string; - kind: RedundantTypePatternKind; - line: number; - column: number; - confidence: SemanticConfidence; - reason: string; - suggestion: string; -} - -export interface IdentityWrapper { - path: string; - wrapperName: string; - wrappedExpression: string; - line: number; - column: number; - confidence: SemanticConfidence; - reason: string; -} - -export interface DuplicateTypeDefinitionInstance { - path: string; - typeName: string; - line: number; - column: number; -} - -export interface DuplicateTypeDefinition { - structuralHash: string; - instances: DuplicateTypeDefinitionInstance[]; - confidence: SemanticConfidence; - reason: string; -} - -export type InlineTypeContext = - | "function-parameter" - | "function-return" - | "variable-annotation" - | "local-type-alias" - | "class-property" - | "interface-property" - | "generic-type-argument"; - -export interface InlineTypeOccurrence { - path: string; - line: number; - column: number; - context: InlineTypeContext; - nearestName?: string; -} - -export interface DuplicateInlineType { - structuralHash: string; - memberCount: number; - preview: string; - occurrences: InlineTypeOccurrence[]; - confidence: SemanticConfidence; - reason: string; -} - -export type SimplifiableFunctionKind = - | "block-arrow-single-return" - | "redundant-await-return" - | "useless-async-no-await"; - -export interface SimplifiableFunction { - path: string; - kind: SimplifiableFunctionKind; - functionName?: string; - line: number; - column: number; - confidence: SemanticConfidence; - reason: string; - suggestion: string; -} - -export type SimplifiableExpressionKind = - | "self-fallback-ternary" - | "double-bang-boolean" - | "ternary-returns-boolean" - | "nullish-coalescing-with-nullish" - | "redundant-null-and-undefined-check"; - -export interface SimplifiableExpression { - path: string; - kind: SimplifiableExpressionKind; - snippet: string; - line: number; - column: number; - confidence: SemanticConfidence; - reason: string; - suggestion: string; -} - -export interface DuplicateConstantOccurrence { - path: string; - constantName: string; - line: number; - column: number; -} - -export interface DuplicateConstant { - literalHash: string; - literalPreview: string; - occurrences: DuplicateConstantOccurrence[]; - confidence: SemanticConfidence; - reason: string; -} - -export interface CrossFileDuplicateExportLocation { - path: string; - line: number; - column: number; - isTypeOnly: boolean; -} - -export interface CrossFileDuplicateExport { - name: string; - locations: CrossFileDuplicateExportLocation[]; - confidence: SemanticConfidence; - reason: string; -} - -export type DuplicateBlockDetectionMode = "strict" | "semantic"; - -export interface DuplicateBlockOccurrence { - path: string; - startLine: number; - endLine: number; - startColumn: number; - endColumn: number; -} - -export interface DuplicateBlock { - instances: DuplicateBlockOccurrence[]; - tokenCount: number; - lineCount: number; - confidence: SemanticConfidence; - reason: string; -} - -export type DuplicateBlockRefactoringKind = "extract-function" | "extract-module"; - -export interface DuplicateBlockRefactoringHint { - kind: DuplicateBlockRefactoringKind; - description: string; - estimatedSavings: number; -} - -export interface DuplicateBlockCluster { - files: string[]; - groups: DuplicateBlock[]; - totalDuplicatedLines: number; - totalDuplicatedTokens: number; - suggestions: DuplicateBlockRefactoringHint[]; -} - -export interface ShadowedDirectoryPair { - directoryA: string; - directoryB: string; - sharedFiles: string[]; - totalDuplicatedLines: number; -} - -export interface DuplicateBlocksConfig { - enabled: boolean; - mode: DuplicateBlockDetectionMode; - minTokens: number; - minLines: number; - minOccurrences: number; - skipLocal: boolean; -} - -export type ReExportCycleKind = "self-loop" | "multi-node"; - -export interface ReExportCycle { - files: string[]; - kind: ReExportCycleKind; - confidence: SemanticConfidence; - reason: string; -} - -export type FeatureFlagKind = "env-var" | "sdk-call" | "config-object"; - -export interface FeatureFlag { - path: string; - name: string; - kind: FeatureFlagKind; - line: number; - column: number; - sdkProvider?: string; - guardLineStart?: number; - guardLineEnd?: number; - guardsDeadCode: boolean; -} - -export interface FeatureFlagsConfig { - enabled: boolean; - extraEnvPrefixes: string[]; - extraSdkFunctionNames: string[]; - detectConfigObjects: boolean; -} - -export interface FunctionComplexity { - path: string; - functionName: string; - line: number; - column: number; - cyclomatic: number; - cognitive: number; - lineCount: number; - paramCount: number; - confidence: SemanticConfidence; - reason: string; -} - -export interface ComplexityConfig { - enabled: boolean; - cyclomaticThreshold: number; - cognitiveThreshold: number; - paramCountThreshold: number; - functionLineThreshold: number; -} - -export interface PrivateTypeLeak { - path: string; - exportName: string; - typeName: string; - line: number; - column: number; - confidence: SemanticConfidence; - reason: string; -} - -export type UnnecessaryAssertionKind = - | "redundant-double-assertion" - | "assertion-to-any" - | "redundant-non-null-on-literal" - | "double-non-null" - | "angle-bracket-assertion"; - -export interface UnnecessaryAssertion { - path: string; - kind: UnnecessaryAssertionKind; - snippet: string; - line: number; - column: number; - confidence: SemanticConfidence; - reason: string; - suggestion: string; -} - -export type LazyImportKind = "top-level-await-import" | "top-level-then-import"; - -export interface LazyImportAtTopLevel { - path: string; - specifier: string; - kind: LazyImportKind; - line: number; - column: number; - confidence: SemanticConfidence; - reason: string; -} - -export type CommonjsInEsmKind = "require" | "module-exports" | "exports-assignment"; - -export interface CommonjsInEsm { - path: string; - kind: CommonjsInEsmKind; - line: number; - column: number; - confidence: SemanticConfidence; - reason: string; - snippet: string; -} - -export type TypeScriptEscapeHatchKind = - | "ts-ignore" - | "ts-nocheck" - | "ts-expect-error-without-explanation"; - -export interface TypeScriptEscapeHatch { - path: string; - kind: TypeScriptEscapeHatchKind; - line: number; - column: number; - confidence: SemanticConfidence; - reason: string; - suggestion: string; -} - -export interface ScanResult { - unusedFiles: UnusedFile[]; - unusedExports: UnusedExport[]; - unusedDependencies: UnusedDependency[]; - /** Declared dependencies conservatively excluded from unused-dependency analysis. */ - skippedDependencies?: SkippedDependency[]; - circularDependencies: CircularDependency[]; - unusedTypes: UnusedType[]; - misclassifiedDependencies: MisclassifiedDependency[]; - unusedEnumMembers: UnusedEnumMember[]; - unusedClassMembers: UnusedClassMember[]; - redundantAliases: RedundantAlias[]; - duplicateExports: DuplicateExport[]; - duplicateImports: DuplicateImport[]; - redundantTypePatterns: RedundantTypePattern[]; - identityWrappers: IdentityWrapper[]; - duplicateTypeDefinitions: DuplicateTypeDefinition[]; - duplicateInlineTypes: DuplicateInlineType[]; - simplifiableFunctions: SimplifiableFunction[]; - simplifiableExpressions: SimplifiableExpression[]; - duplicateConstants: DuplicateConstant[]; - crossFileDuplicateExports: CrossFileDuplicateExport[]; - duplicateBlocks: DuplicateBlock[]; - duplicateBlockClusters: DuplicateBlockCluster[]; - shadowedDirectoryPairs: ShadowedDirectoryPair[]; - reExportCycles: ReExportCycle[]; - featureFlags: FeatureFlag[]; - complexFunctions: FunctionComplexity[]; - privateTypeLeaks: PrivateTypeLeak[]; - unnecessaryAssertions: UnnecessaryAssertion[]; - lazyImportsAtTopLevel: LazyImportAtTopLevel[]; - commonjsInEsm: CommonjsInEsm[]; - typeScriptEscapeHatches: TypeScriptEscapeHatch[]; - analysisErrors: DeslopError[]; - totalFiles: number; - totalExports: number; - analysisTimeMs: number; - /** - * Incremental-cache outcome for this run's per-file parse phase: how many - * collected files were served from cached summaries vs freshly parsed. - * Present only when `incrementalCachePath` was set and the cache loaded. - */ - incrementalCacheStats?: IncrementalCacheStats; -} - -export interface IncrementalCacheStats { - summaryHits: number; - summaryMisses: number; -} - -export interface ResolvedEntries { - productionEntries: string[]; - testEntries: string[]; - alwaysUsedFiles: string[]; -} - -export interface SemanticConfig { - enabled: boolean; - reportUnusedTypes: boolean; - reportUnusedEnumMembers: boolean; - reportUnusedClassMembers: boolean; - reportRedundantVariableAliases: boolean; - reportMisclassifiedDependencies: boolean; - reportRoundTripAliases: boolean; - decoratorAllowlist: string[]; -} - -export interface DeslopConfig { - rootDir: string; - entryPatterns: string[]; - ignorePatterns: string[]; - includeExtensions: string[]; - tsConfigPath: string | undefined; - paths: Record<string, string[]> | undefined; - /** - * Path of the on-disk incremental analysis cache (per-file parse summaries, - * the collected file list, the module-resolution map, and - * `detectStalePackages`' per-file package-reference facts; entry resolution - * always runs live). Unset (the default) means no caching — every run - * analyzes from scratch. The file is created on first use, validated - * stat-by-stat against the current tree on every run, and fails open on any - * corruption or version mismatch; results are byte-identical to an uncached - * run. Point it OUTSIDE the analyzed tree (e.g. `node_modules/.cache/...`) - * so its own writes don't churn the file fingerprint. - */ - incrementalCachePath: string | undefined; - reportTypes: boolean; - includeEntryExports: boolean; - reportRedundancy: boolean; - /** - * Run the non-dead-code "code quality" detectors — duplicate-block (copy-paste) - * detection, complexity hotspots, feature flags, TypeScript smells, - * private-type leaks, and re-export cycles. On by default. These are by far - * the most expensive detectors (duplicate-block detection alone can dominate - * a large-repo scan), and they're independent of the dead-code graph findings - * (unused files/exports/dependencies, circular dependencies) — so a consumer - * that only wants dead-code can set this `false` to skip the bulk of the work. - */ - reportCodeQuality: boolean; - semantic: SemanticConfig | undefined; - duplicateBlocks: DuplicateBlocksConfig | undefined; - featureFlags: FeatureFlagsConfig | undefined; - complexity: ComplexityConfig | undefined; -} diff --git a/packages/deslop-js/src/utils/atomic-write-file.ts b/packages/deslop-js/src/utils/atomic-write-file.ts deleted file mode 100644 index fbb6f91907..0000000000 --- a/packages/deslop-js/src/utils/atomic-write-file.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { randomUUID } from "node:crypto"; -import * as fs from "node:fs"; -import * as path from "node:path"; - -export const atomicWriteFile = (filePath: string, contents: string): void => { - let temporaryPath: string | null = null; - try { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; - fs.writeFileSync(temporaryPath, contents); - fs.renameSync(temporaryPath, filePath); - temporaryPath = null; - } catch { - return; - } finally { - if (temporaryPath !== null) { - try { - fs.rmSync(temporaryPath, { force: true }); - } catch {} - } - } -}; diff --git a/packages/deslop-js/src/utils/collect-duplicate-constants.ts b/packages/deslop-js/src/utils/collect-duplicate-constants.ts deleted file mode 100644 index 8ac89574db..0000000000 --- a/packages/deslop-js/src/utils/collect-duplicate-constants.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { - MIN_NUMERIC_LITERAL_MAGNITUDE_FOR_DUPLICATE, - MIN_STRING_LITERAL_LENGTH_FOR_DUPLICATE, -} from "../constants.js"; -import { isOxcAstNode, type OxcAstNode } from "./oxc-ast-node.js"; - -export interface DuplicateConstantCandidate { - constantName: string; - literalHash: string; - literalPreview: string; - startOffset: number; -} - -const FRAMEWORK_RESERVED_CONSTANT_NAMES = new Set([ - "dynamic", - "dynamicParams", - "revalidate", - "runtime", - "fetchCache", - "preferredRegion", - "maxDuration", - "metadata", - "viewport", - "generateStaticParams", - "generateMetadata", - "config", - "loader", - "action", - "links", - "meta", - "headers", - "handle", - "shouldRevalidate", - "ErrorBoundary", - "HydrateFallback", - "Layout", -]); - -const isLiteralCandidate = (node: OxcAstNode): boolean => { - if (node.type === "Literal") { - const value = (node as { value?: unknown }).value; - if (typeof value === "string") { - if (value.length < MIN_STRING_LITERAL_LENGTH_FOR_DUPLICATE) return false; - return true; - } - if (typeof value === "number") { - if (!Number.isFinite(value)) return false; - if (Math.abs(value) < MIN_NUMERIC_LITERAL_MAGNITUDE_FOR_DUPLICATE) return false; - return true; - } - return false; - } - if (node.type === "TemplateLiteral") { - const expressions = (node as { expressions?: unknown[] }).expressions; - if (Array.isArray(expressions) && expressions.length > 0) return false; - const quasis = (node as { quasis?: Array<{ value?: { cooked?: string } }> }).quasis; - if (!Array.isArray(quasis) || quasis.length === 0) return false; - const cooked = quasis[0].value?.cooked ?? ""; - return cooked.length >= MIN_STRING_LITERAL_LENGTH_FOR_DUPLICATE; - } - if (node.type === "ArrayExpression") { - const elements = (node as { elements?: unknown[] }).elements ?? []; - if (elements.length === 0) return false; - for (const element of elements) { - if (!isOxcAstNode(element)) return false; - if (element.type !== "Literal") return false; - } - return true; - } - return false; -}; - -const hashLiteralNode = (node: OxcAstNode): string => { - if (node.type === "Literal") { - return `lit:${typeof (node as { value?: unknown }).value}:${JSON.stringify((node as { value?: unknown }).value)}`; - } - if (node.type === "TemplateLiteral") { - const quasis = (node as { quasis?: Array<{ value?: { cooked?: string } }> }).quasis ?? []; - return `tpl:${JSON.stringify(quasis[0]?.value?.cooked ?? "")}`; - } - if (node.type === "ArrayExpression") { - const elements = (node as { elements?: unknown[] }).elements ?? []; - const values = elements.map((element) => { - if (!isOxcAstNode(element)) return "?"; - if (element.type !== "Literal") return "?"; - return JSON.stringify((element as { value?: unknown }).value); - }); - return `arr:[${values.join(",")}]`; - } - return "?"; -}; - -const previewLiteralNode = (node: OxcAstNode): string => { - if (node.type === "Literal") { - const value = (node as { value?: unknown }).value; - if (typeof value === "string") - return `"${value.length > 60 ? value.slice(0, 57) + "..." : value}"`; - return String(value); - } - if (node.type === "TemplateLiteral") { - const quasis = (node as { quasis?: Array<{ value?: { cooked?: string } }> }).quasis ?? []; - const cooked = quasis[0]?.value?.cooked ?? ""; - return `\`${cooked.length > 60 ? cooked.slice(0, 57) + "..." : cooked}\``; - } - if (node.type === "ArrayExpression") { - const elements = (node as { elements?: unknown[] }).elements ?? []; - const head = elements - .slice(0, 3) - .map((element) => - isOxcAstNode(element) && element.type === "Literal" - ? JSON.stringify((element as { value?: unknown }).value) - : "?", - ) - .join(", "); - const suffix = elements.length > 3 ? `, +${elements.length - 3} more` : ""; - return `[${head}${suffix}]`; - } - return "<literal>"; -}; - -const visitForConstants = ( - statementNode: unknown, - candidates: DuplicateConstantCandidate[], -): void => { - if (!isOxcAstNode(statementNode)) return; - const inner = - (statementNode.type === "ExportNamedDeclaration" || - statementNode.type === "ExportDefaultDeclaration") && - (statementNode as { declaration?: unknown }).declaration - ? (statementNode as { declaration?: unknown }).declaration - : statementNode; - if (!isOxcAstNode(inner)) return; - if (inner.type !== "VariableDeclaration") return; - if ((inner as { kind?: string }).kind !== "const") return; - const declarators = (inner as { declarations?: unknown[] }).declarations ?? []; - for (const declarator of declarators) { - if (!isOxcAstNode(declarator)) continue; - const idNode = (declarator as { id?: OxcAstNode }).id; - const initializerNode = (declarator as { init?: OxcAstNode }).init; - if (!idNode || !initializerNode) continue; - if (idNode.type !== "Identifier") continue; - const constantName = (idNode as { name?: string }).name; - if (!constantName) continue; - if (FRAMEWORK_RESERVED_CONSTANT_NAMES.has(constantName)) continue; - if (!isLiteralCandidate(initializerNode)) continue; - candidates.push({ - constantName, - literalHash: hashLiteralNode(initializerNode), - literalPreview: previewLiteralNode(initializerNode), - startOffset: declarator.start ?? inner.start ?? 0, - }); - } -}; - -export const collectDuplicateConstantCandidates = ( - programBody: unknown[], -): DuplicateConstantCandidate[] => { - const candidates: DuplicateConstantCandidate[] = []; - for (const statement of programBody) { - visitForConstants(statement, candidates); - } - return candidates; -}; diff --git a/packages/deslop-js/src/utils/collect-inline-type-literals.ts b/packages/deslop-js/src/utils/collect-inline-type-literals.ts deleted file mode 100644 index cd1e0fe1c3..0000000000 --- a/packages/deslop-js/src/utils/collect-inline-type-literals.ts +++ /dev/null @@ -1,386 +0,0 @@ -import type { InlineTypeContext } from "../types.js"; -import { - INLINE_TYPE_PREVIEW_KEYS, - MAX_AST_WALK_DEPTH, - MAX_TYPE_REFERENCE_WALK_DEPTH, - MIN_PROPERTIES_FOR_INLINE_TYPE_LITERAL, -} from "../constants.js"; -import { normalizeTypeAstHash } from "./normalize-type-hash.js"; -import { getIdentifierName, isOxcAstNode, type OxcAstNode } from "./oxc-ast-node.js"; - -export interface InlineTypeLiteralCapture { - structuralHash: string; - memberCount: number; - preview: string; - context: InlineTypeContext; - nearestName?: string; - startOffset: number; -} - -const isTypeLiteralNode = (node: OxcAstNode): boolean => node.type === "TSTypeLiteral"; - -const buildPreview = (typeLiteralNode: OxcAstNode): string => { - const members = (typeLiteralNode.members as unknown[]) ?? []; - const propertyKeys: string[] = []; - for (const memberCandidate of members) { - if (!isOxcAstNode(memberCandidate)) continue; - if (memberCandidate.type !== "TSPropertySignature") continue; - const keyNode = memberCandidate.key as { name?: string; value?: string } | undefined; - const keyName = keyNode?.name ?? keyNode?.value; - if (keyName) propertyKeys.push(String(keyName)); - } - propertyKeys.sort(); - const truncatedKeys = propertyKeys.slice(0, INLINE_TYPE_PREVIEW_KEYS); - const suffix = - propertyKeys.length > INLINE_TYPE_PREVIEW_KEYS - ? `, +${propertyKeys.length - INLINE_TYPE_PREVIEW_KEYS} more` - : ""; - return `{ ${truncatedKeys.join(", ")}${suffix} }`; -}; - -const countPropertySignatures = (typeLiteralNode: OxcAstNode): number => { - const members = (typeLiteralNode.members as unknown[]) ?? []; - let signatureCount = 0; - for (const memberCandidate of members) { - if (!isOxcAstNode(memberCandidate)) continue; - if (memberCandidate.type === "TSPropertySignature") signatureCount++; - } - return signatureCount; -}; - -const captureIfTypeLiteral = ( - candidateNode: unknown, - captures: InlineTypeLiteralCapture[], - context: InlineTypeContext, - nearestName: string | undefined, -): void => { - if (!isOxcAstNode(candidateNode)) return; - if (!isTypeLiteralNode(candidateNode)) return; - const memberCount = countPropertySignatures(candidateNode); - if (memberCount < MIN_PROPERTIES_FOR_INLINE_TYPE_LITERAL) return; - captures.push({ - structuralHash: `inline:${normalizeTypeAstHash(candidateNode)}`, - memberCount, - preview: buildPreview(candidateNode), - context, - nearestName, - startOffset: candidateNode.start ?? 0, - }); -}; - -const GENERIC_WRAPPERS_TO_RECURSE = new Set([ - "Array", - "ReadonlyArray", - "Promise", - "Set", - "ReadonlySet", - "Map", - "ReadonlyMap", - "Record", - "Partial", - "Required", - "Readonly", - "NonNullable", - "Awaited", -]); - -const inspectAnyTypeNode = ( - candidateNode: unknown, - captures: InlineTypeLiteralCapture[], - context: InlineTypeContext, - nearestName: string | undefined, - recursionDepth: number, -): void => { - if (!isOxcAstNode(candidateNode)) return; - if (recursionDepth > MAX_TYPE_REFERENCE_WALK_DEPTH) return; - - if (isTypeLiteralNode(candidateNode)) { - captureIfTypeLiteral(candidateNode, captures, context, nearestName); - const members = (candidateNode.members as unknown[]) ?? []; - for (const memberCandidate of members) { - if (!isOxcAstNode(memberCandidate)) continue; - if (memberCandidate.type !== "TSPropertySignature") continue; - const memberKey = (memberCandidate as { key?: { name?: string } }).key?.name; - const nested = (memberCandidate as { typeAnnotation?: unknown }).typeAnnotation; - inspectAnyTypeNode( - nested, - captures, - "interface-property", - memberKey ?? nearestName, - recursionDepth + 1, - ); - } - return; - } - - if (candidateNode.type === "TSTypeAnnotation") { - inspectAnyTypeNode( - (candidateNode as { typeAnnotation?: unknown }).typeAnnotation, - captures, - context, - nearestName, - recursionDepth + 1, - ); - return; - } - - if (candidateNode.type === "TSArrayType") { - inspectAnyTypeNode( - (candidateNode as { elementType?: unknown }).elementType, - captures, - context, - nearestName, - recursionDepth + 1, - ); - return; - } - - if (candidateNode.type === "TSUnionType" || candidateNode.type === "TSIntersectionType") { - const operands = (candidateNode.types as unknown[]) ?? []; - for (const operand of operands) { - inspectAnyTypeNode(operand, captures, context, nearestName, recursionDepth + 1); - } - return; - } - - if (candidateNode.type === "TSTupleType") { - const elements = (candidateNode.elementTypes as unknown[]) ?? []; - for (const element of elements) { - inspectAnyTypeNode(element, captures, context, nearestName, recursionDepth + 1); - } - return; - } - - if (candidateNode.type === "TSTypeReference") { - const referenceTypeName = (candidateNode as { typeName?: { name?: string } }).typeName?.name; - const typeArguments = (candidateNode as { typeArguments?: { params?: unknown[] } }) - .typeArguments; - if ( - referenceTypeName && - typeArguments?.params && - GENERIC_WRAPPERS_TO_RECURSE.has(referenceTypeName) - ) { - for (const param of typeArguments.params) { - inspectAnyTypeNode(param, captures, context, nearestName, recursionDepth + 1); - } - } - } -}; - -const inspectTypeAnnotation = ( - typeAnnotationNode: unknown, - captures: InlineTypeLiteralCapture[], - context: InlineTypeContext, - nearestName: string | undefined, -): void => { - inspectAnyTypeNode(typeAnnotationNode, captures, context, nearestName, 0); -}; - -const visitFunctionParameters = ( - parameters: unknown[] | undefined, - captures: InlineTypeLiteralCapture[], - functionName: string | undefined, -): void => { - if (!parameters) return; - for (const parameter of parameters) { - if (!isOxcAstNode(parameter)) continue; - const parameterIdentifierName = getIdentifierName(parameter); - inspectTypeAnnotation( - parameter.typeAnnotation, - captures, - "function-parameter", - functionName ? `${functionName}(${parameterIdentifierName ?? "?"})` : parameterIdentifierName, - ); - } -}; - -const visitFunctionLike = ( - functionNode: OxcAstNode, - captures: InlineTypeLiteralCapture[], - functionName: string | undefined, -): void => { - const parameters = functionNode.params as unknown[] | undefined; - visitFunctionParameters(parameters, captures, functionName); - const returnTypeNode = functionNode.returnType as unknown; - if (returnTypeNode) { - inspectTypeAnnotation(returnTypeNode, captures, "function-return", functionName); - } - const bodyNode = functionNode.body as unknown; - if (bodyNode) { - walkBodyForInlineTypes(bodyNode, captures, functionName); - } -}; - -const visitVariableDeclaration = ( - declarationNode: OxcAstNode, - captures: InlineTypeLiteralCapture[], - enclosingName: string | undefined, -): void => { - const declarators = (declarationNode.declarations as unknown[]) ?? []; - for (const declarator of declarators) { - if (!isOxcAstNode(declarator)) continue; - const declarationName = getIdentifierName(declarator.id); - inspectTypeAnnotation( - declarator.typeAnnotation ?? - (declarator.id && isOxcAstNode(declarator.id) ? declarator.id.typeAnnotation : undefined), - captures, - "variable-annotation", - declarationName, - ); - const initializerNode = declarator.init; - if (isOxcAstNode(initializerNode)) { - if ( - initializerNode.type === "ArrowFunctionExpression" || - initializerNode.type === "FunctionExpression" - ) { - visitFunctionLike(initializerNode, captures, declarationName ?? enclosingName); - } else { - walkExpressionForInlineTypes(initializerNode, captures, declarationName ?? enclosingName); - } - } - } -}; - -const walkBodyForInlineTypes = ( - bodyNode: unknown, - captures: InlineTypeLiteralCapture[], - enclosingName: string | undefined, - recursionDepth: number = 0, -): void => { - if (recursionDepth > MAX_AST_WALK_DEPTH) return; - if (!isOxcAstNode(bodyNode)) return; - const statements = (bodyNode.body as unknown[]) ?? []; - if (!Array.isArray(statements)) return; - for (const statement of statements) { - if (!isOxcAstNode(statement)) continue; - if (statement.type === "VariableDeclaration") { - visitVariableDeclaration(statement, captures, enclosingName); - } else if (statement.type === "FunctionDeclaration") { - const functionName = getIdentifierName(statement.id); - visitFunctionLike(statement, captures, functionName ?? enclosingName); - } else if (statement.type === "TSTypeAliasDeclaration") { - const typeAliasName = getIdentifierName(statement.id); - captureIfTypeLiteral(statement.typeAnnotation, captures, "local-type-alias", typeAliasName); - } else if (statement.type === "ReturnStatement") { - walkExpressionForInlineTypes(statement.argument, captures, enclosingName, recursionDepth + 1); - } else if (statement.type === "BlockStatement") { - walkBodyForInlineTypes(statement, captures, enclosingName, recursionDepth + 1); - } else if (statement.type === "ExpressionStatement") { - walkExpressionForInlineTypes( - statement.expression, - captures, - enclosingName, - recursionDepth + 1, - ); - } - } -}; - -const walkExpressionForInlineTypes = ( - expressionNode: unknown, - captures: InlineTypeLiteralCapture[], - enclosingName: string | undefined, - recursionDepth: number = 0, -): void => { - if (recursionDepth > MAX_AST_WALK_DEPTH) return; - if (!isOxcAstNode(expressionNode)) return; - if ( - expressionNode.type === "ArrowFunctionExpression" || - expressionNode.type === "FunctionExpression" - ) { - visitFunctionLike(expressionNode, captures, enclosingName); - return; - } - for (const value of Object.values(expressionNode)) { - if (Array.isArray(value)) { - for (const element of value) { - walkExpressionForInlineTypes(element, captures, enclosingName, recursionDepth + 1); - } - } else if (isOxcAstNode(value)) { - walkExpressionForInlineTypes(value, captures, enclosingName, recursionDepth + 1); - } - } -}; - -const visitTopLevelStatement = ( - statementNode: unknown, - captures: InlineTypeLiteralCapture[], -): void => { - if (!isOxcAstNode(statementNode)) return; - - const innerNode = - statementNode.type === "ExportNamedDeclaration" || - statementNode.type === "ExportDefaultDeclaration" - ? ((statementNode.declaration as unknown) ?? statementNode) - : statementNode; - const targetNode = isOxcAstNode(innerNode) ? innerNode : statementNode; - - if (targetNode.type === "FunctionDeclaration") { - const functionName = getIdentifierName(targetNode.id); - visitFunctionLike(targetNode, captures, functionName); - return; - } - - if (targetNode.type === "VariableDeclaration") { - visitVariableDeclaration(targetNode, captures, undefined); - return; - } - - if (targetNode.type === "ClassDeclaration") { - const className = getIdentifierName(targetNode.id); - const bodyContainer = targetNode.body as { body?: unknown[] } | undefined; - const members = bodyContainer?.body ?? []; - for (const memberCandidate of members) { - if (!isOxcAstNode(memberCandidate)) continue; - const memberKeyName = getIdentifierName((memberCandidate as { key?: unknown }).key); - const qualifiedName = - className && memberKeyName ? `${className}.${memberKeyName}` : memberKeyName; - if (memberCandidate.type === "PropertyDefinition") { - inspectTypeAnnotation( - (memberCandidate as { typeAnnotation?: unknown }).typeAnnotation, - captures, - "class-property", - qualifiedName, - ); - continue; - } - if ( - memberCandidate.type === "MethodDefinition" || - memberCandidate.type === "TSAbstractMethodDefinition" - ) { - const methodValue = (memberCandidate as { value?: OxcAstNode }).value; - if (isOxcAstNode(methodValue)) { - visitFunctionLike(methodValue, captures, qualifiedName); - } - } - } - return; - } - - if (targetNode.type === "TSInterfaceDeclaration") { - const interfaceName = getIdentifierName(targetNode.id); - const interfaceBodyContainer = targetNode.body as { body?: unknown[] } | undefined; - const interfaceMembers = interfaceBodyContainer?.body ?? []; - for (const memberCandidate of interfaceMembers) { - if (!isOxcAstNode(memberCandidate)) continue; - if (memberCandidate.type !== "TSPropertySignature") continue; - const memberKeyName = getIdentifierName((memberCandidate as { key?: unknown }).key); - const qualifiedName = - interfaceName && memberKeyName ? `${interfaceName}.${memberKeyName}` : memberKeyName; - inspectTypeAnnotation( - (memberCandidate as { typeAnnotation?: unknown }).typeAnnotation, - captures, - "interface-property", - qualifiedName, - ); - } - } -}; - -export const collectInlineTypeLiterals = (programBody: unknown[]): InlineTypeLiteralCapture[] => { - const captures: InlineTypeLiteralCapture[] = []; - for (const statement of programBody) { - visitTopLevelStatement(statement, captures); - } - return captures; -}; diff --git a/packages/deslop-js/src/utils/collect-simplifiable-expressions.ts b/packages/deslop-js/src/utils/collect-simplifiable-expressions.ts deleted file mode 100644 index 735b9fae67..0000000000 --- a/packages/deslop-js/src/utils/collect-simplifiable-expressions.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { SimplifiableExpressionKind } from "../types.js"; -import { - MAX_EXPRESSION_DETECTOR_WALK_DEPTH, - SIMPLIFIABLE_EXPRESSION_MEMBER_ACCESS_DEPTH, -} from "../constants.js"; -import { isOxcAstNode, type OxcAstNode } from "./oxc-ast-node.js"; - -export interface SimplifiableExpressionCapture { - kind: SimplifiableExpressionKind; - snippet: string; - startOffset: number; - reason: string; - suggestion: string; -} - -const memberAccessText = (node: OxcAstNode, depth = 0): string | undefined => { - if (depth > SIMPLIFIABLE_EXPRESSION_MEMBER_ACCESS_DEPTH) return undefined; - if (node.type === "Identifier") return (node as { name?: string }).name; - if (node.type === "ThisExpression") return "this"; - if (node.type === "MemberExpression") { - const computed = (node as { computed?: boolean }).computed; - if (computed) return undefined; - const objectNode = (node as { object?: OxcAstNode }).object; - const propertyNode = (node as { property?: OxcAstNode }).property; - if (!objectNode || !propertyNode) return undefined; - const objectText = memberAccessText(objectNode, depth + 1); - const propertyText = - propertyNode.type === "Identifier" ? (propertyNode as { name?: string }).name : undefined; - if (!objectText || !propertyText) return undefined; - return `${objectText}.${propertyText}`; - } - return undefined; -}; - -const isBooleanLiteral = (node: OxcAstNode, expected: boolean): boolean => { - if (node.type !== "Literal") return false; - return (node as { value?: unknown }).value === expected; -}; - -const detectSelfFallbackTernary = ( - conditionalNode: OxcAstNode, -): SimplifiableExpressionCapture | undefined => { - if (conditionalNode.type !== "ConditionalExpression") return undefined; - const testNode = (conditionalNode as { test?: OxcAstNode }).test; - const consequentNode = (conditionalNode as { consequent?: OxcAstNode }).consequent; - if (!testNode || !consequentNode) return undefined; - const testText = memberAccessText(testNode); - const consequentText = memberAccessText(consequentNode); - if (!testText || !consequentText) return undefined; - if (testText !== consequentText) return undefined; - return { - kind: "self-fallback-ternary", - snippet: `${testText} ? ${consequentText} : ...`, - startOffset: conditionalNode.start ?? 0, - reason: `\`${testText} ? ${testText} : x\` is a self-fallback ternary`, - suggestion: `use \`${testText} ?? x\` (nullish-only) or \`${testText} || x\` (falsy fallback) depending on intent`, - }; -}; - -const detectTernaryReturnsBoolean = ( - conditionalNode: OxcAstNode, -): SimplifiableExpressionCapture | undefined => { - if (conditionalNode.type !== "ConditionalExpression") return undefined; - const consequentNode = (conditionalNode as { consequent?: OxcAstNode }).consequent; - const alternateNode = (conditionalNode as { alternate?: OxcAstNode }).alternate; - if (!consequentNode || !alternateNode) return undefined; - const isTrueFalse = - isBooleanLiteral(consequentNode, true) && isBooleanLiteral(alternateNode, false); - const isFalseTrue = - isBooleanLiteral(consequentNode, false) && isBooleanLiteral(alternateNode, true); - if (!isTrueFalse && !isFalseTrue) return undefined; - return { - kind: "ternary-returns-boolean", - snippet: isTrueFalse ? "cond ? true : false" : "cond ? false : true", - startOffset: conditionalNode.start ?? 0, - reason: isTrueFalse - ? "`cond ? true : false` collapses to `Boolean(cond)`" - : "`cond ? false : true` collapses to `!cond`", - suggestion: isTrueFalse - ? "replace with `Boolean(cond)` or just `cond` when types match" - : "replace with `!cond`", - }; -}; - -const isNullLiteral = (node: OxcAstNode): boolean => - node.type === "Literal" && (node as { value?: unknown }).value === null; - -const isUndefinedIdentifier = (node: OxcAstNode): boolean => - node.type === "Identifier" && (node as { name?: string }).name === "undefined"; - -const detectNullishCoalescingWithNullish = ( - logicalNode: OxcAstNode, -): SimplifiableExpressionCapture | undefined => { - if (logicalNode.type !== "LogicalExpression") return undefined; - if ((logicalNode as { operator?: string }).operator !== "??") return undefined; - const rightNode = (logicalNode as { right?: OxcAstNode }).right; - if (!rightNode) return undefined; - const isNullish = isNullLiteral(rightNode) || isUndefinedIdentifier(rightNode); - if (!isNullish) return undefined; - const leftNode = (logicalNode as { left?: OxcAstNode }).left; - const leftText = leftNode ? (memberAccessText(leftNode) ?? "expr") : "expr"; - const rightLabel = isNullLiteral(rightNode) ? "null" : "undefined"; - return { - kind: "nullish-coalescing-with-nullish", - snippet: `${leftText} ?? ${rightLabel}`, - startOffset: logicalNode.start ?? 0, - reason: `\`x ?? ${rightLabel}\` looks like a no-op — but may be intentional when a caller's signature requires \`${rightLabel}\` (PropTypes, form-control onChange, etc.)`, - suggestion: `if \`x\` is already \`T | ${rightLabel}\`, drop the \`?? ${rightLabel}\`; otherwise keep — the coercion changes the resolved type`, - }; -}; - -const detectRedundantNullAndUndefinedCheck = ( - logicalNode: OxcAstNode, -): SimplifiableExpressionCapture | undefined => { - if (logicalNode.type !== "LogicalExpression") return undefined; - if ((logicalNode as { operator?: string }).operator !== "&&") return undefined; - const leftNode = (logicalNode as { left?: OxcAstNode }).left; - const rightNode = (logicalNode as { right?: OxcAstNode }).right; - if (!leftNode || !rightNode) return undefined; - if (leftNode.type !== "BinaryExpression" || rightNode.type !== "BinaryExpression") - return undefined; - const leftOp = (leftNode as { operator?: string }).operator; - const rightOp = (rightNode as { operator?: string }).operator; - if (leftOp !== "!==" || rightOp !== "!==") return undefined; - const leftLeft = (leftNode as { left?: OxcAstNode }).left; - const leftRight = (leftNode as { right?: OxcAstNode }).right; - const rightLeft = (rightNode as { left?: OxcAstNode }).left; - const rightRight = (rightNode as { right?: OxcAstNode }).right; - if (!leftLeft || !leftRight || !rightLeft || !rightRight) return undefined; - const leftLeftText = memberAccessText(leftLeft); - const rightLeftText = memberAccessText(rightLeft); - if (!leftLeftText || leftLeftText !== rightLeftText) return undefined; - const leftRhsIsNull = isNullLiteral(leftRight); - const leftRhsIsUndefined = isUndefinedIdentifier(leftRight); - const rightRhsIsNull = isNullLiteral(rightRight); - const rightRhsIsUndefined = isUndefinedIdentifier(rightRight); - const coversBoth = - (leftRhsIsNull && rightRhsIsUndefined) || (leftRhsIsUndefined && rightRhsIsNull); - if (!coversBoth) return undefined; - return { - kind: "redundant-null-and-undefined-check", - snippet: `${leftLeftText} !== null && ${leftLeftText} !== undefined`, - startOffset: logicalNode.start ?? 0, - reason: `\`x !== null && x !== undefined\` is equivalent to \`x != null\` (loose comparison checks both)`, - suggestion: `replace with \`${leftLeftText} != null\``, - }; -}; - -const detectDoubleBangBoolean = ( - unaryNode: OxcAstNode, -): SimplifiableExpressionCapture | undefined => { - if (unaryNode.type !== "UnaryExpression") return undefined; - if ((unaryNode as { operator?: string }).operator !== "!") return undefined; - const inner = (unaryNode as { argument?: OxcAstNode }).argument; - if (!inner || inner.type !== "UnaryExpression") return undefined; - if ((inner as { operator?: string }).operator !== "!") return undefined; - const coerced = (inner as { argument?: OxcAstNode }).argument; - if (!coerced) return undefined; - const coercedText = memberAccessText(coerced) ?? "expr"; - return { - kind: "double-bang-boolean", - snippet: `!!${coercedText}`, - startOffset: unaryNode.start ?? 0, - reason: "`!!x` is a double-negation boolean coercion", - suggestion: `replace with \`Boolean(${coercedText})\``, - }; -}; - -const visit = ( - node: OxcAstNode, - captures: SimplifiableExpressionCapture[], - depth: number, -): void => { - if (depth > MAX_EXPRESSION_DETECTOR_WALK_DEPTH) return; - - const conditionalCapture = detectSelfFallbackTernary(node) ?? detectTernaryReturnsBoolean(node); - if (conditionalCapture) captures.push(conditionalCapture); - - const doubleBangCapture = detectDoubleBangBoolean(node); - if (doubleBangCapture) captures.push(doubleBangCapture); - - const logicalCapture = - detectNullishCoalescingWithNullish(node) ?? detectRedundantNullAndUndefinedCheck(node); - if (logicalCapture) captures.push(logicalCapture); - - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const element of value) { - if (isOxcAstNode(element)) visit(element, captures, depth + 1); - } - } else if (isOxcAstNode(value)) { - visit(value, captures, depth + 1); - } - } -}; - -export const collectSimplifiableExpressions = ( - programBody: unknown[], -): SimplifiableExpressionCapture[] => { - const captures: SimplifiableExpressionCapture[] = []; - for (const statement of programBody) { - if (isOxcAstNode(statement)) visit(statement, captures, 0); - } - return captures; -}; diff --git a/packages/deslop-js/src/utils/collect-simplifiable-functions.ts b/packages/deslop-js/src/utils/collect-simplifiable-functions.ts deleted file mode 100644 index 7b3a4ca803..0000000000 --- a/packages/deslop-js/src/utils/collect-simplifiable-functions.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { SimplifiableFunctionKind } from "../types.js"; -import { MAX_AST_WALK_DEPTH } from "../constants.js"; -import { detectSimplifiableFunctionPatterns } from "./detect-simplifiable-function.js"; -import { getIdentifierName, isOxcAstNode, type OxcAstNode } from "./oxc-ast-node.js"; - -export interface SimplifiableFunctionCapture { - kind: SimplifiableFunctionKind; - functionName?: string; - startOffset: number; - reason: string; - suggestion: string; -} - -const looksLikeFunction = (node: OxcAstNode): boolean => - node.type === "FunctionDeclaration" || - node.type === "FunctionExpression" || - node.type === "ArrowFunctionExpression"; - -const inferFunctionName = ( - functionNode: OxcAstNode, - parentContext: string | undefined, -): string | undefined => { - const declaredId = (functionNode as { id?: { name?: string } }).id; - if (declaredId?.name) return declaredId.name; - return parentContext; -}; - -const visitFunctionAndDescend = ( - functionNode: OxcAstNode, - captures: SimplifiableFunctionCapture[], - contextName: string | undefined, - recursionDepth: number, - isMethodContext: boolean, - isInlineCallback: boolean, -): void => { - const functionName = inferFunctionName(functionNode, contextName); - const detections = detectSimplifiableFunctionPatterns(functionNode, { - isMethodContext, - isInlineCallback, - }); - for (const detection of detections) { - captures.push({ - kind: detection.kind, - functionName, - startOffset: detection.startOffset, - reason: detection.reason, - suggestion: detection.suggestion, - }); - } - const bodyNode = (functionNode as { body?: OxcAstNode }).body; - if (isOxcAstNode(bodyNode)) - walkForFunctions(bodyNode, captures, functionName, recursionDepth + 1); - const parameters = (functionNode as { params?: unknown[] }).params ?? []; - for (const parameter of parameters) { - if (isOxcAstNode(parameter)) - walkForFunctions(parameter, captures, functionName, recursionDepth + 1); - } -}; - -const isObjectMethodShorthand = (node: OxcAstNode): boolean => - (node.type === "Property" || node.type === "ObjectProperty") && - (node as { method?: boolean }).method === true; - -const isObjectPropertyAssignment = (node: OxcAstNode): boolean => - (node.type === "Property" || node.type === "ObjectProperty") && - (node as { method?: boolean }).method !== true; - -const isCallOrNewExpression = (node: OxcAstNode): boolean => - node.type === "CallExpression" || node.type === "NewExpression"; - -const walkForFunctions = ( - node: OxcAstNode, - captures: SimplifiableFunctionCapture[], - contextName: string | undefined, - recursionDepth: number = 0, -): void => { - if (recursionDepth > MAX_AST_WALK_DEPTH) return; - if (looksLikeFunction(node)) { - visitFunctionAndDescend(node, captures, contextName, recursionDepth, false, false); - return; - } - - let nextContext = contextName; - if (node.type === "VariableDeclarator") { - const declaredName = getIdentifierName((node as { id?: unknown }).id); - if (declaredName) nextContext = declaredName; - } - if (node.type === "MethodDefinition" || node.type === "PropertyDefinition") { - const propertyKeyName = getIdentifierName((node as { key?: unknown }).key); - if (propertyKeyName) nextContext = propertyKeyName; - } - if (node.type === "ClassDeclaration") { - const className = getIdentifierName((node as { id?: unknown }).id); - if (className) nextContext = className; - } - - const isMethodDefining = node.type === "MethodDefinition" || isObjectMethodShorthand(node); - if (isMethodDefining) { - const methodValue = (node as { value?: OxcAstNode }).value; - if (methodValue && isOxcAstNode(methodValue) && looksLikeFunction(methodValue)) { - const methodKeyName = getIdentifierName((node as { key?: unknown }).key); - const methodContextName = methodKeyName ?? nextContext; - visitFunctionAndDescend( - methodValue, - captures, - methodContextName, - recursionDepth + 1, - true, - false, - ); - const keyNode = (node as { key?: OxcAstNode }).key; - if (keyNode && isOxcAstNode(keyNode) && (node as { computed?: boolean }).computed) { - walkForFunctions(keyNode, captures, nextContext, recursionDepth + 1); - } - return; - } - } - - if (isObjectPropertyAssignment(node)) { - const propertyValue = (node as { value?: OxcAstNode }).value; - if (propertyValue && isOxcAstNode(propertyValue) && looksLikeFunction(propertyValue)) { - const propertyKeyName = getIdentifierName((node as { key?: unknown }).key); - const propertyContextName = propertyKeyName ?? nextContext; - visitFunctionAndDescend( - propertyValue, - captures, - propertyContextName, - recursionDepth + 1, - false, - true, - ); - const keyNode = (node as { key?: OxcAstNode }).key; - if (keyNode && isOxcAstNode(keyNode) && (node as { computed?: boolean }).computed) { - walkForFunctions(keyNode, captures, nextContext, recursionDepth + 1); - } - return; - } - } - - if (isCallOrNewExpression(node)) { - const callee = (node as { callee?: OxcAstNode }).callee; - if (callee && isOxcAstNode(callee)) { - walkForFunctions(callee, captures, nextContext, recursionDepth + 1); - } - const callArguments = (node as { arguments?: unknown[] }).arguments ?? []; - for (const argument of callArguments) { - if (!isOxcAstNode(argument)) continue; - if (looksLikeFunction(argument)) { - visitFunctionAndDescend(argument, captures, nextContext, recursionDepth + 1, false, true); - } else { - walkForFunctions(argument, captures, nextContext, recursionDepth + 1); - } - } - return; - } - - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const element of value) { - if (isOxcAstNode(element)) - walkForFunctions(element, captures, nextContext, recursionDepth + 1); - } - } else if (isOxcAstNode(value)) { - walkForFunctions(value, captures, nextContext, recursionDepth + 1); - } - } -}; - -export const collectSimplifiableFunctions = ( - programBody: unknown[], -): SimplifiableFunctionCapture[] => { - const captures: SimplifiableFunctionCapture[] = []; - for (const statement of programBody) { - if (isOxcAstNode(statement)) walkForFunctions(statement, captures, undefined, 0); - } - return captures; -}; diff --git a/packages/deslop-js/src/utils/compute-line-starts.ts b/packages/deslop-js/src/utils/compute-line-starts.ts deleted file mode 100644 index de868d41ce..0000000000 --- a/packages/deslop-js/src/utils/compute-line-starts.ts +++ /dev/null @@ -1,9 +0,0 @@ -const LINE_FEED_CHAR_CODE = 10; - -export const computeLineStarts = (sourceText: string): number[] => { - const lineStarts: number[] = [0]; - for (let charIndex = 0; charIndex < sourceText.length; charIndex++) { - if (sourceText.charCodeAt(charIndex) === LINE_FEED_CHAR_CODE) lineStarts.push(charIndex + 1); - } - return lineStarts; -}; diff --git a/packages/deslop-js/src/utils/detect-identity-wrapper.ts b/packages/deslop-js/src/utils/detect-identity-wrapper.ts deleted file mode 100644 index 68fd535a98..0000000000 --- a/packages/deslop-js/src/utils/detect-identity-wrapper.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { getIdentifierName, isOxcAstNode, type OxcAstNode } from "./oxc-ast-node.js"; - -export interface IdentityWrapperDetection { - wrappedExpression: string; -} - -const getCalleeText = (calleeNode: OxcAstNode): string | undefined => { - if (calleeNode.type === "Identifier") { - return getIdentifierName(calleeNode); - } - if (calleeNode.type === "MemberExpression") { - const computed = (calleeNode as { computed?: boolean }).computed; - if (computed) return undefined; - const objectNode = calleeNode.object as OxcAstNode | undefined; - const propertyNode = calleeNode.property as OxcAstNode | undefined; - if (!objectNode || !propertyNode) return undefined; - const objectText = getCalleeText(objectNode); - const propertyText = - propertyNode.type === "Identifier" ? (propertyNode.name as string) : undefined; - if (!objectText || !propertyText) return undefined; - return `${objectText}.${propertyText}`; - } - return undefined; -}; - -const collectParameterNames = ( - parameters: unknown[], -): { names: string[]; hasRest: boolean; hasDefault: boolean; restName?: string } => { - const names: string[] = []; - let hasRest = false; - let hasDefault = false; - let restName: string | undefined; - for (const parameter of parameters) { - if (!isOxcAstNode(parameter)) return { names, hasRest: true, hasDefault, restName }; - if (parameter.type === "RestElement") { - const restArgument = parameter.argument as OxcAstNode | undefined; - if (restArgument && restArgument.type === "Identifier") { - hasRest = true; - restName = restArgument.name as string; - continue; - } - return { names, hasRest: true, hasDefault, restName }; - } - if (parameter.type === "AssignmentPattern") { - hasDefault = true; - return { names, hasRest, hasDefault, restName }; - } - if (parameter.type === "Identifier") { - names.push(parameter.name as string); - continue; - } - return { names: [], hasRest, hasDefault, restName }; - } - return { names, hasRest, hasDefault, restName }; -}; - -const argumentsMatchParameters = ( - callArguments: unknown[], - parameterNames: string[], - restName: string | undefined, -): boolean => { - if (restName !== undefined) { - if (callArguments.length !== 1) return false; - const onlyArgument = callArguments[0]; - if (!isOxcAstNode(onlyArgument)) return false; - if (onlyArgument.type !== "SpreadElement") return false; - const spreadArgumentNode = (onlyArgument as { argument?: OxcAstNode }).argument; - return Boolean( - spreadArgumentNode && - spreadArgumentNode.type === "Identifier" && - spreadArgumentNode.name === restName, - ); - } - if (callArguments.length !== parameterNames.length) return false; - for (let argumentIndex = 0; argumentIndex < callArguments.length; argumentIndex++) { - const argumentNode = callArguments[argumentIndex]; - if (!isOxcAstNode(argumentNode)) return false; - if (argumentNode.type !== "Identifier") return false; - if ((argumentNode as { name?: string }).name !== parameterNames[argumentIndex]) return false; - } - return true; -}; - -const extractCallExpressionFromBody = (bodyNode: OxcAstNode): OxcAstNode | undefined => { - if (bodyNode.type === "CallExpression") return bodyNode; - if (bodyNode.type === "BlockStatement") { - const blockBody = (bodyNode as { body?: unknown[] }).body; - if (!Array.isArray(blockBody) || blockBody.length !== 1) return undefined; - const onlyStatement = blockBody[0]; - if (!isOxcAstNode(onlyStatement)) return undefined; - if (onlyStatement.type !== "ReturnStatement") return undefined; - const returnedExpression = (onlyStatement as { argument?: OxcAstNode }).argument; - if (!returnedExpression) return undefined; - if (returnedExpression.type !== "CallExpression") return undefined; - return returnedExpression; - } - return undefined; -}; - -export const detectIdentityWrapperFromInitializer = ( - initializerNode: unknown, - wrapperName: string, -): IdentityWrapperDetection | undefined => { - if (!isOxcAstNode(initializerNode)) return undefined; - if ( - initializerNode.type !== "ArrowFunctionExpression" && - initializerNode.type !== "FunctionExpression" - ) { - return undefined; - } - if ((initializerNode as { async?: boolean }).async) return undefined; - if ((initializerNode as { generator?: boolean }).generator) return undefined; - const parameters = (initializerNode as { params?: unknown[] }).params ?? []; - const { - names: parameterNames, - hasRest, - hasDefault, - restName, - } = collectParameterNames(parameters); - if (hasDefault) return undefined; - - const bodyNode = (initializerNode as { body?: OxcAstNode }).body; - if (!bodyNode) return undefined; - const callExpression = extractCallExpressionFromBody(bodyNode); - if (!callExpression) return undefined; - - const calleeNode = (callExpression as { callee?: OxcAstNode }).callee; - if (!calleeNode) return undefined; - const calleeText = getCalleeText(calleeNode); - if (!calleeText) return undefined; - if (calleeText === wrapperName) return undefined; - - const callArguments = (callExpression as { arguments?: unknown[] }).arguments ?? []; - if (!argumentsMatchParameters(callArguments, parameterNames, hasRest ? restName : undefined)) { - return undefined; - } - - return { wrappedExpression: calleeText }; -}; diff --git a/packages/deslop-js/src/utils/detect-redundant-type-pattern.ts b/packages/deslop-js/src/utils/detect-redundant-type-pattern.ts deleted file mode 100644 index 799daa500b..0000000000 --- a/packages/deslop-js/src/utils/detect-redundant-type-pattern.ts +++ /dev/null @@ -1,220 +0,0 @@ -import type { RedundantTypePatternKind } from "../types.js"; - -export interface RedundantTypePatternDetection { - kind: RedundantTypePatternKind; - reason: string; - suggestion: string; -} - -interface TypeNodeLike { - type: string; - [key: string]: unknown; -} - -const isTypeNode = (value: unknown): value is TypeNodeLike => - Boolean(value) && typeof value === "object" && typeof (value as TypeNodeLike).type === "string"; - -const isEmptyTypeLiteral = (node: TypeNodeLike): boolean => { - if (node.type !== "TSTypeLiteral") return false; - const members = node.members as unknown[] | undefined; - return Array.isArray(members) && members.length === 0; -}; - -const typeReferenceName = (node: TypeNodeLike): string | undefined => { - if (node.type !== "TSTypeReference") return undefined; - const typeName = node.typeName as TypeNodeLike | undefined; - if (!typeName || typeName.type !== "Identifier") return undefined; - return typeName.name as string; -}; - -const isKeyofOfType = (candidate: TypeNodeLike, expectedReferenceName: string): boolean => { - if (candidate.type !== "TSTypeOperator") return false; - if (candidate.operator !== "keyof") return false; - const operand = candidate.typeAnnotation as TypeNodeLike | undefined; - if (!operand) return false; - const operandName = typeReferenceName(operand); - return operandName === expectedReferenceName; -}; - -const isNeverKeyword = (node: TypeNodeLike): boolean => node.type === "TSNeverKeyword"; - -const isLiterallyEqualByJson = (left: TypeNodeLike, right: TypeNodeLike): boolean => { - const stripPositions = (key: string, value: unknown): unknown => { - if (key === "start" || key === "end") return undefined; - return value; - }; - return JSON.stringify(left, stripPositions) === JSON.stringify(right, stripPositions); -}; - -const detectIntersectionWithEmpty = ( - node: TypeNodeLike, -): RedundantTypePatternDetection | undefined => { - if (node.type !== "TSIntersectionType") return undefined; - const operands = node.types as TypeNodeLike[] | undefined; - if (!Array.isArray(operands) || operands.length < 2) return undefined; - const hasEmptyLiteral = operands.some(isEmptyTypeLiteral); - if (!hasEmptyLiteral) return undefined; - return { - kind: "intersection-with-empty-object", - reason: "intersection with `{}` is a no-op; the empty object type does not constrain anything", - suggestion: "drop the `& {}` term", - }; -}; - -const detectSelfUnion = (node: TypeNodeLike): RedundantTypePatternDetection | undefined => { - if (node.type !== "TSUnionType") return undefined; - const operands = node.types as TypeNodeLike[] | undefined; - if (!Array.isArray(operands) || operands.length < 2) return undefined; - for (let leftIndex = 0; leftIndex < operands.length; leftIndex++) { - for (let rightIndex = leftIndex + 1; rightIndex < operands.length; rightIndex++) { - if (isLiterallyEqualByJson(operands[leftIndex], operands[rightIndex])) { - return { - kind: "self-union", - reason: "union contains the same member twice", - suggestion: "deduplicate the union members", - }; - } - } - } - return undefined; -}; - -const detectSelfIntersection = (node: TypeNodeLike): RedundantTypePatternDetection | undefined => { - if (node.type !== "TSIntersectionType") return undefined; - const operands = node.types as TypeNodeLike[] | undefined; - if (!Array.isArray(operands) || operands.length < 2) return undefined; - for (let leftIndex = 0; leftIndex < operands.length; leftIndex++) { - for (let rightIndex = leftIndex + 1; rightIndex < operands.length; rightIndex++) { - if (isLiterallyEqualByJson(operands[leftIndex], operands[rightIndex])) { - return { - kind: "self-intersection", - reason: "intersection contains the same operand twice", - suggestion: "deduplicate the intersection operands", - }; - } - } - } - return undefined; -}; - -const detectNestedUtility = ( - node: TypeNodeLike, - utilityName: string, - kind: RedundantTypePatternKind, -): RedundantTypePatternDetection | undefined => { - if (node.type !== "TSTypeReference") return undefined; - if (typeReferenceName(node) !== utilityName) return undefined; - const typeArguments = node.typeArguments as TypeNodeLike | undefined; - if (!typeArguments) return undefined; - const params = (typeArguments as { params?: TypeNodeLike[] }).params; - if (!Array.isArray(params) || params.length === 0) return undefined; - const firstArg = params[0]; - if (firstArg.type !== "TSTypeReference") return undefined; - if (typeReferenceName(firstArg) !== utilityName) return undefined; - return { - kind, - reason: `${utilityName}<${utilityName}<T>> collapses to ${utilityName}<T>`, - suggestion: `flatten the nested ${utilityName}<...>`, - }; -}; - -const detectPickAllKeys = (node: TypeNodeLike): RedundantTypePatternDetection | undefined => { - if (node.type !== "TSTypeReference") return undefined; - if (typeReferenceName(node) !== "Pick") return undefined; - const typeArguments = node.typeArguments as TypeNodeLike | undefined; - if (!typeArguments) return undefined; - const params = (typeArguments as { params?: TypeNodeLike[] }).params; - if (!Array.isArray(params) || params.length !== 2) return undefined; - const targetType = params[0]; - const keys = params[1]; - const targetName = typeReferenceName(targetType); - if (!targetName) return undefined; - if (!isKeyofOfType(keys, targetName)) return undefined; - return { - kind: "pick-all-keys", - reason: `Pick<${targetName}, keyof ${targetName}> is equivalent to ${targetName} itself`, - suggestion: `replace with ${targetName}`, - }; -}; - -const detectOmitNoKeys = (node: TypeNodeLike): RedundantTypePatternDetection | undefined => { - if (node.type !== "TSTypeReference") return undefined; - if (typeReferenceName(node) !== "Omit") return undefined; - const typeArguments = node.typeArguments as TypeNodeLike | undefined; - if (!typeArguments) return undefined; - const params = (typeArguments as { params?: TypeNodeLike[] }).params; - if (!Array.isArray(params) || params.length !== 2) return undefined; - const targetType = params[0]; - const keys = params[1]; - const targetName = typeReferenceName(targetType); - if (!targetName) return undefined; - if (!isNeverKeyword(keys)) return undefined; - return { - kind: "omit-no-keys", - reason: `Omit<${targetName}, never> is equivalent to ${targetName} itself`, - suggestion: `replace with ${targetName}`, - }; -}; - -const isZodInferDeclarationMergingExtension = ( - parentExpression: TypeNodeLike | undefined, -): boolean => { - if (!parentExpression || parentExpression.type !== "MemberExpression") return false; - const propertyNode = parentExpression.property as TypeNodeLike | undefined; - if (!propertyNode || propertyNode.type !== "Identifier") return false; - return (propertyNode as { name?: string }).name === "infer"; -}; - -const isRadixStylePropsAliasExtension = (parentExpression: TypeNodeLike | undefined): boolean => { - if (!parentExpression || parentExpression.type !== "MemberExpression") return false; - const propertyNode = parentExpression.property as TypeNodeLike | undefined; - if (!propertyNode || propertyNode.type !== "Identifier") return false; - return (propertyNode as { name?: string }).name === "Props"; -}; - -const detectEmptyInterfaceExtendsOne = ( - declarationNode: TypeNodeLike, -): RedundantTypePatternDetection | undefined => { - if (declarationNode.type !== "TSInterfaceDeclaration") return undefined; - const body = declarationNode.body as { body?: unknown[] } | undefined; - if (!body || !Array.isArray(body.body) || body.body.length !== 0) return undefined; - const extendsClauses = declarationNode.extends as unknown[] | undefined; - if (!Array.isArray(extendsClauses) || extendsClauses.length !== 1) return undefined; - const declarationName = (declarationNode.id as { name?: string } | undefined)?.name; - const parentNode = extendsClauses[0] as TypeNodeLike | undefined; - const parentExpression = parentNode?.expression as TypeNodeLike | undefined; - if (isZodInferDeclarationMergingExtension(parentExpression)) return undefined; - if (isRadixStylePropsAliasExtension(parentExpression)) return undefined; - const parentName = - parentExpression && parentExpression.type === "Identifier" - ? (parentExpression as { name?: string }).name - : undefined; - return { - kind: "empty-interface-extends-one", - reason: `interface ${declarationName ?? "<anon>"} extends ${parentName ?? "<base>"} with no new members`, - suggestion: `replace with \`type ${declarationName ?? "X"} = ${parentName ?? "Base"}\``, - }; -}; - -export const detectRedundantTypePatternForTypeAnnotation = ( - typeAnnotation: unknown, -): RedundantTypePatternDetection | undefined => { - if (!isTypeNode(typeAnnotation)) return undefined; - return ( - detectIntersectionWithEmpty(typeAnnotation) ?? - detectSelfUnion(typeAnnotation) ?? - detectSelfIntersection(typeAnnotation) ?? - detectNestedUtility(typeAnnotation, "Partial", "nested-partial") ?? - detectNestedUtility(typeAnnotation, "Readonly", "nested-readonly") ?? - detectNestedUtility(typeAnnotation, "Required", "nested-required") ?? - detectPickAllKeys(typeAnnotation) ?? - detectOmitNoKeys(typeAnnotation) - ); -}; - -export const detectRedundantInterfaceDeclaration = ( - declarationNode: unknown, -): RedundantTypePatternDetection | undefined => { - if (!isTypeNode(declarationNode)) return undefined; - return detectEmptyInterfaceExtendsOne(declarationNode); -}; diff --git a/packages/deslop-js/src/utils/detect-simplifiable-function.ts b/packages/deslop-js/src/utils/detect-simplifiable-function.ts deleted file mode 100644 index c7183ae261..0000000000 --- a/packages/deslop-js/src/utils/detect-simplifiable-function.ts +++ /dev/null @@ -1,227 +0,0 @@ -import type { SimplifiableFunctionKind } from "../types.js"; -import { MAX_FUNCTION_BODY_INSPECT_DEPTH } from "../constants.js"; -import { isOxcAstNode, type OxcAstNode } from "./oxc-ast-node.js"; - -export interface SimplifiableFunctionDetection { - kind: SimplifiableFunctionKind; - startOffset: number; - reason: string; - suggestion: string; -} - -const containsAwaitExpression = (node: unknown, recursionDepth = 0): boolean => { - if (recursionDepth > MAX_FUNCTION_BODY_INSPECT_DEPTH) return false; - if (!isOxcAstNode(node)) return false; - if (node.type === "AwaitExpression") return true; - if ( - node.type === "FunctionDeclaration" || - node.type === "FunctionExpression" || - node.type === "ArrowFunctionExpression" - ) { - return false; - } - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const element of value) { - if (containsAwaitExpression(element, recursionDepth + 1)) return true; - } - } else if (isOxcAstNode(value)) { - if (containsAwaitExpression(value, recursionDepth + 1)) return true; - } - } - return false; -}; - -const containsCallOrPromiseSurface = (node: unknown, recursionDepth = 0): boolean => { - if (recursionDepth > MAX_FUNCTION_BODY_INSPECT_DEPTH) return false; - if (!isOxcAstNode(node)) return false; - if ( - node.type === "FunctionDeclaration" || - node.type === "FunctionExpression" || - node.type === "ArrowFunctionExpression" - ) { - return false; - } - if ( - node.type === "CallExpression" || - node.type === "NewExpression" || - node.type === "TaggedTemplateExpression" || - node.type === "ThrowStatement" || - node.type === "YieldExpression" - ) { - return true; - } - if (node.type === "MemberExpression") { - const objectNode = (node as { object?: OxcAstNode }).object; - if (objectNode && isOxcAstNode(objectNode) && objectNode.type === "Identifier") { - const objectName = (objectNode as { name?: string }).name; - if (objectName === "Promise") return true; - } - } - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const element of value) { - if (containsCallOrPromiseSurface(element, recursionDepth + 1)) return true; - } - } else if (isOxcAstNode(value)) { - if (containsCallOrPromiseSurface(value, recursionDepth + 1)) return true; - } - } - return false; -}; - -const unwrapParenthesizedExpression = (node: OxcAstNode): OxcAstNode => { - let current = node; - while (current.type === "ParenthesizedExpression") { - const inner = (current as { expression?: OxcAstNode }).expression; - if (!inner || !isOxcAstNode(inner)) return current; - current = inner; - } - return current; -}; - -const isSimpleReturnArgument = (argumentNode: unknown): boolean => { - if (!isOxcAstNode(argumentNode)) return false; - const unwrapped = unwrapParenthesizedExpression(argumentNode); - if (unwrapped.type === "BlockStatement") return false; - if (unwrapped.type === "ObjectExpression") return false; - if (unwrapped.type === "JSXElement") return false; - if (unwrapped.type === "JSXFragment") return false; - return true; -}; - -const detectBlockArrowSingleReturn = ( - functionNode: OxcAstNode, -): SimplifiableFunctionDetection | undefined => { - if (functionNode.type !== "ArrowFunctionExpression") return undefined; - if ((functionNode as { async?: boolean }).async) return undefined; - const bodyNode = functionNode.body as OxcAstNode | undefined; - if (!bodyNode || bodyNode.type !== "BlockStatement") return undefined; - const statements = (bodyNode.body as unknown[]) ?? []; - if (statements.length !== 1) return undefined; - const onlyStatement = statements[0]; - if (!isOxcAstNode(onlyStatement)) return undefined; - if (onlyStatement.type !== "ReturnStatement") return undefined; - const returnArgument = (onlyStatement as { argument?: unknown }).argument; - if (!returnArgument) return undefined; - if (!isSimpleReturnArgument(returnArgument)) return undefined; - return { - kind: "block-arrow-single-return", - startOffset: functionNode.start ?? 0, - reason: - "arrow body is a single `return` statement; the block can be replaced by the expression directly", - suggestion: "rewrite as `() => expression` without `{}`", - }; -}; - -const detectRedundantAwaitReturn = ( - functionNode: OxcAstNode, -): SimplifiableFunctionDetection | undefined => { - const bodyNode = functionNode.body as OxcAstNode | undefined; - if (!bodyNode || bodyNode.type !== "BlockStatement") return undefined; - const statements = (bodyNode.body as unknown[]) ?? []; - if (statements.length < 2) return undefined; - const penultimate = statements[statements.length - 2]; - const last = statements[statements.length - 1]; - if (!isOxcAstNode(penultimate) || !isOxcAstNode(last)) return undefined; - if (penultimate.type !== "VariableDeclaration") return undefined; - if (last.type !== "ReturnStatement") return undefined; - - const declarators = (penultimate.declarations as unknown[]) ?? []; - if (declarators.length !== 1) return undefined; - const declarator = declarators[0]; - if (!isOxcAstNode(declarator)) return undefined; - const declaredIdentifier = (declarator as { id?: { name?: string } }).id; - const initializer = (declarator as { init?: OxcAstNode }).init; - if (!declaredIdentifier?.name) return undefined; - if (!isOxcAstNode(initializer)) return undefined; - if (initializer.type !== "AwaitExpression") return undefined; - - const returnedArgument = (last as { argument?: OxcAstNode }).argument; - if (!isOxcAstNode(returnedArgument)) return undefined; - if (returnedArgument.type !== "Identifier") return undefined; - if ((returnedArgument as { name?: string }).name !== declaredIdentifier.name) return undefined; - - return { - kind: "redundant-await-return", - startOffset: penultimate.start ?? 0, - reason: `\`const ${declaredIdentifier.name} = await …; return ${declaredIdentifier.name};\` can be \`return …;\` (the await is preserved by the implicit promise chain)`, - suggestion: `replace the await/assign/return sequence with a single \`return await …\` or \`return …\` if no try/catch wraps it`, - }; -}; - -const isAsyncFunction = (functionNode: OxcAstNode): boolean => - Boolean((functionNode as { async?: boolean }).async); - -const containsPromiseTypeReference = (node: unknown, recursionDepth = 0): boolean => { - if (recursionDepth > MAX_FUNCTION_BODY_INSPECT_DEPTH) return false; - if (!isOxcAstNode(node)) return false; - if (node.type === "TSTypeReference") { - const typeName = (node as { typeName?: { name?: string; right?: { name?: string } } }).typeName; - if (typeName?.name === "Promise") return true; - if (typeName?.right?.name === "Promise") return true; - } - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const element of value) { - if (containsPromiseTypeReference(element, recursionDepth + 1)) return true; - } - } else if (isOxcAstNode(value)) { - if (containsPromiseTypeReference(value, recursionDepth + 1)) return true; - } - } - return false; -}; - -const hasExplicitPromiseReturnType = (functionNode: OxcAstNode): boolean => { - const returnType = (functionNode as { returnType?: OxcAstNode }).returnType; - if (!returnType || !isOxcAstNode(returnType)) return false; - const annotation = (returnType as { typeAnnotation?: OxcAstNode }).typeAnnotation; - if (!annotation || !isOxcAstNode(annotation)) return false; - return containsPromiseTypeReference(annotation); -}; - -export interface DetectSimplifiableFunctionContext { - isMethodContext?: boolean; - isInlineCallback?: boolean; -} - -const detectUselessAsync = ( - functionNode: OxcAstNode, - context: DetectSimplifiableFunctionContext, -): SimplifiableFunctionDetection | undefined => { - if (!isAsyncFunction(functionNode)) return undefined; - if (functionNode.type === "ClassDeclaration" || functionNode.type === "MethodDefinition") { - return undefined; - } - if (context.isMethodContext) return undefined; - if (context.isInlineCallback) return undefined; - if (hasExplicitPromiseReturnType(functionNode)) return undefined; - const bodyNode = functionNode.body as unknown; - if (!isOxcAstNode(bodyNode)) return undefined; - if (containsAwaitExpression(bodyNode)) return undefined; - if (containsCallOrPromiseSurface(bodyNode)) return undefined; - return { - kind: "useless-async-no-await", - startOffset: functionNode.start ?? 0, - reason: - "async function body contains no `await`, no function calls, and no Promise surface — the implicit Promise wrap is purely decorative", - suggestion: - "drop `async` (caller's existing `await` keeps the type identical) or add an explicit return type", - }; -}; - -export const detectSimplifiableFunctionPatterns = ( - functionNode: unknown, - context: DetectSimplifiableFunctionContext = {}, -): SimplifiableFunctionDetection[] => { - if (!isOxcAstNode(functionNode)) return []; - const findings: SimplifiableFunctionDetection[] = []; - const blockArrow = detectBlockArrowSingleReturn(functionNode); - if (blockArrow) findings.push(blockArrow); - const awaitReturn = detectRedundantAwaitReturn(functionNode); - if (awaitReturn) findings.push(awaitReturn); - const uselessAsync = detectUselessAsync(functionNode, context); - if (uselessAsync) findings.push(uselessAsync); - return findings; -}; diff --git a/packages/deslop-js/src/utils/is-ast-node.ts b/packages/deslop-js/src/utils/is-ast-node.ts deleted file mode 100644 index 954d980dc1..0000000000 --- a/packages/deslop-js/src/utils/is-ast-node.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface AstNode { - type: string; - [field: string]: unknown; -} - -export const isAstNode = (candidate: unknown): candidate is AstNode => - typeof candidate === "object" && candidate !== null && "type" in candidate; diff --git a/packages/deslop-js/src/utils/is-framework-lifecycle-method.ts b/packages/deslop-js/src/utils/is-framework-lifecycle-method.ts deleted file mode 100644 index 4f530a37b2..0000000000 --- a/packages/deslop-js/src/utils/is-framework-lifecycle-method.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Methods invoked by-name by React / Angular runtimes. Static "no caller" - * analysis can't see those call sites, so without this allowlist - * `unusedClassMembers` would fire on every component. - */ -const FRAMEWORK_LIFECYCLE_METHODS = new Set<string>([ - "render", - "componentDidMount", - "componentDidUpdate", - "componentWillUnmount", - "shouldComponentUpdate", - "getSnapshotBeforeUpdate", - "getDerivedStateFromProps", - "getDerivedStateFromError", - "componentDidCatch", - "componentWillMount", - "componentWillReceiveProps", - "componentWillUpdate", - "UNSAFE_componentWillMount", - "UNSAFE_componentWillReceiveProps", - "UNSAFE_componentWillUpdate", - "getChildContext", - "contextType", - "ngOnInit", - "ngOnDestroy", - "ngOnChanges", - "ngDoCheck", - "ngAfterContentInit", - "ngAfterContentChecked", - "ngAfterViewInit", - "ngAfterViewChecked", - "ngAcceptInputType", - "canActivate", - "canDeactivate", - "canActivateChild", - "canMatch", - "resolve", - "intercept", - "transform", - "validate", - "registerOnChange", - "registerOnTouched", - "writeValue", - "setDisabledState", -]); - -export const isFrameworkLifecycleMethod = (name: string): boolean => - FRAMEWORK_LIFECYCLE_METHODS.has(name); diff --git a/packages/deslop-js/src/utils/matches-package-import-reference.ts b/packages/deslop-js/src/utils/matches-package-import-reference.ts deleted file mode 100644 index 4626ee5354..0000000000 --- a/packages/deslop-js/src/utils/matches-package-import-reference.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { escapeRegExp } from "./escape-reg-exp.js"; - -export const matchesPackageImportReference = (content: string, packageName: string): boolean => { - const escapedPackageName = escapeRegExp(packageName); - const subpathPattern = `(?:/[^'"]*)?`; - const patterns = [ - new RegExp(`\\bfrom\\s+['"]${escapedPackageName}${subpathPattern}['"]`), - new RegExp( - `\\bimport\\s+(?:[^'";\\n]*?\\sfrom\\s+)?['"]${escapedPackageName}${subpathPattern}['"]`, - ), - new RegExp(`\\brequire\\s*\\(\\s*['"]${escapedPackageName}${subpathPattern}['"]\\s*\\)`), - new RegExp(`\\brequire\\s*\\(\\s*\`${escapedPackageName}${subpathPattern}`), - new RegExp(`\\bimport\\s*\\(\\s*['"]${escapedPackageName}${subpathPattern}['"]`), - ]; - - return patterns.some((pattern) => pattern.test(content)); -}; diff --git a/packages/deslop-js/src/utils/matches-package-token-reference.ts b/packages/deslop-js/src/utils/matches-package-token-reference.ts deleted file mode 100644 index 7e38ad8818..0000000000 --- a/packages/deslop-js/src/utils/matches-package-token-reference.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { escapeRegExp } from "./escape-reg-exp.js"; - -// True when `packageName` appears as a standalone CLI token anywhere in a -// command string — not only as the leading binary. Catches deps passed as a -// flag argument, e.g. `jest --testResultsProcessor jest-sonar-reporter` or -// `--reporter=jest-junit`, which the binary-only matcher misses. The token may -// carry a `/subpath` (`some-pkg/register`) but must be bounded by a command -// separator (whitespace, `=`, quote, paren, or shell operator) on both sides so -// `my-jest-sonar-reporter` / `jest-sonar-reporter-extra` don't match. -export const matchesPackageTokenReference = (command: string, packageName: string): boolean => { - const escapedPackageName = escapeRegExp(packageName); - const pattern = new RegExp( - `(?:^|[\\s='"\`(,;:|&])${escapedPackageName}(?:/[^\\s'"\`]*)?(?=$|[\\s='"\`),;:|&])`, - ); - return pattern.test(command); -}; diff --git a/packages/deslop-js/src/utils/normalize-type-hash.ts b/packages/deslop-js/src/utils/normalize-type-hash.ts deleted file mode 100644 index 0b54f1dc88..0000000000 --- a/packages/deslop-js/src/utils/normalize-type-hash.ts +++ /dev/null @@ -1,66 +0,0 @@ -const POSITION_KEYS = new Set(["start", "end", "loc", "range"]); - -const NOISY_KEYS = new Set([ - "decorators", - "leadingComments", - "trailingComments", - "innerComments", - "directive", - "optional", - "computed", - "static", - "accessibility", - "declare", - "readonly", -]); - -const NAME_KEYS_TO_STRIP = new Set(["id"]); - -const sanitizeNode = (input: unknown): unknown => { - if (input === null || input === undefined) return input; - if (Array.isArray(input)) { - return input.map((element) => sanitizeNode(element)); - } - if (typeof input !== "object") return input; - const record = input as Record<string, unknown>; - const cleaned: Record<string, unknown> = {}; - for (const key of Object.keys(record)) { - if (POSITION_KEYS.has(key)) continue; - if (NOISY_KEYS.has(key)) continue; - if (NAME_KEYS_TO_STRIP.has(key)) continue; - cleaned[key] = sanitizeNode(record[key]); - } - if (cleaned.type === "TSTypeLiteral" && Array.isArray(cleaned.members)) { - cleaned.members = sortMembersByKey(cleaned.members); - } - if (cleaned.type === "TSInterfaceBody" && Array.isArray(cleaned.body)) { - cleaned.body = sortMembersByKey(cleaned.body); - } - return cleaned; -}; - -const extractMemberKey = (member: unknown): string => { - if (!member || typeof member !== "object") return ""; - const record = member as { key?: { name?: unknown; value?: unknown }; type?: string }; - if (record.key) { - const candidate = record.key.name ?? record.key.value; - if (candidate === undefined || candidate === null) return ""; - return String(candidate); - } - return `__${record.type ?? ""}__`; -}; - -const sortMembersByKey = (members: unknown[]): unknown[] => { - const tagged = members.map((member) => ({ key: extractMemberKey(member), member })); - tagged.sort((leftEntry, rightEntry) => { - if (leftEntry.key < rightEntry.key) return -1; - if (leftEntry.key > rightEntry.key) return 1; - return 0; - }); - return tagged.map((entry) => entry.member); -}; - -export const normalizeTypeAstHash = (typeAnnotation: unknown): string => { - const sanitized = sanitizeNode(typeAnnotation); - return JSON.stringify(sanitized); -}; diff --git a/packages/deslop-js/src/utils/offset-to-line-column.ts b/packages/deslop-js/src/utils/offset-to-line-column.ts deleted file mode 100644 index e9530d7d10..0000000000 --- a/packages/deslop-js/src/utils/offset-to-line-column.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface LineColumn { - line: number; - column: number; -} - -export const offsetToLineColumn = (byteOffset: number, lineStarts: number[]): LineColumn => { - let lowIndex = 0; - let highIndex = lineStarts.length - 1; - while (lowIndex < highIndex) { - const middleIndex = (lowIndex + highIndex + 1) >>> 1; - if (lineStarts[middleIndex] <= byteOffset) lowIndex = middleIndex; - else highIndex = middleIndex - 1; - } - return { line: lowIndex + 1, column: byteOffset - lineStarts[lowIndex] }; -}; diff --git a/packages/deslop-js/src/utils/parse-pnpm-workspace-overrides.ts b/packages/deslop-js/src/utils/parse-pnpm-workspace-overrides.ts deleted file mode 100644 index b77a9e0fa9..0000000000 --- a/packages/deslop-js/src/utils/parse-pnpm-workspace-overrides.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { - collectOverrideMappingsFromRecord, - type OverrideMapping, -} from "./collect-override-mappings-from-record.js"; - -const PNPM_WORKSPACE_FILENAMES = ["pnpm-workspace.yaml", "pnpm-workspace.yml"] as const; - -interface ParsedYamlMapping { - entries: Record<string, unknown>; - endLineIndex: number; -} - -const parseIndentedYamlMapping = ( - lines: string[], - startLineIndex: number, - sectionIndent: number, -): ParsedYamlMapping => { - const entries: Record<string, unknown> = {}; - let lineIndex = startLineIndex; - - while (lineIndex < lines.length) { - const line = lines[lineIndex]; - const trimmedLine = line.trim(); - - if (trimmedLine.length === 0 || trimmedLine.startsWith("#")) { - lineIndex++; - continue; - } - - const indent = line.length - line.trimStart().length; - if (indent <= sectionIndent) break; - - const colonIndex = trimmedLine.indexOf(":"); - if (colonIndex === -1) { - lineIndex++; - continue; - } - - const key = trimmedLine - .slice(0, colonIndex) - .trim() - .replace(/^["']|["']$/g, ""); - const rawValue = trimmedLine.slice(colonIndex + 1).trim(); - - if (!key) { - lineIndex++; - continue; - } - - if (rawValue.length === 0) { - const nestedMapping = parseIndentedYamlMapping(lines, lineIndex + 1, indent); - entries[key] = nestedMapping.entries; - lineIndex = nestedMapping.endLineIndex; - continue; - } - - entries[key] = rawValue.replace(/^["']|["']$/g, ""); - lineIndex++; - } - - return { entries, endLineIndex: lineIndex }; -}; - -const parsePnpmWorkspaceOverrideRecords = (yamlContent: string): Record<string, unknown>[] => { - const lines = yamlContent.split("\n"); - const overrideRecords: Record<string, unknown>[] = []; - - for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { - const trimmedLine = lines[lineIndex].trim(); - if (trimmedLine !== "overrides:") continue; - - const sectionIndent = lines[lineIndex].length - lines[lineIndex].trimStart().length; - const parsedMapping = parseIndentedYamlMapping(lines, lineIndex + 1, sectionIndent); - if (Object.keys(parsedMapping.entries).length > 0) { - overrideRecords.push(parsedMapping.entries); - } - } - - return overrideRecords; -}; - -export const collectPnpmWorkspaceOverrideMappings = (rootDir: string): OverrideMapping[] => { - const mappings: OverrideMapping[] = []; - - for (const workspaceFilename of PNPM_WORKSPACE_FILENAMES) { - const workspacePath = join(rootDir, workspaceFilename); - if (!existsSync(workspacePath)) continue; - - try { - const yamlContent = readFileSync(workspacePath, "utf-8"); - const overrideRecords = parsePnpmWorkspaceOverrideRecords(yamlContent); - for (const overrideRecord of overrideRecords) { - mappings.push(...collectOverrideMappingsFromRecord(overrideRecord)); - } - } catch { - continue; - } - } - - return mappings; -}; diff --git a/packages/deslop-js/src/utils/resolve-available-concurrency.ts b/packages/deslop-js/src/utils/resolve-available-concurrency.ts deleted file mode 100644 index 61aff4a59b..0000000000 --- a/packages/deslop-js/src/utils/resolve-available-concurrency.ts +++ /dev/null @@ -1,22 +0,0 @@ -import os from "node:os"; -import { MIN_PARSE_CONCURRENCY, MAX_PARSE_CONCURRENCY } from "../constants.js"; - -const clampParseConcurrency = (value: number): number => - Math.max(MIN_PARSE_CONCURRENCY, Math.min(Math.floor(value), MAX_PARSE_CONCURRENCY)); - -export const resolveAvailableConcurrency = (): number => { - // An embedding host that runs deslop alongside its own worker pool (e.g. - // react-doctor, whose lint pass spawns one oxlint child per core) can cap - // the parse pool via DESLOP_PARSE_CONCURRENCY so the two pools share the - // cores instead of each claiming all of them and oversubscribing — the - // contention that starves the parse pass past its host's phase timeout. - const requestedConcurrency = Number(process.env["DESLOP_PARSE_CONCURRENCY"]); - if (Number.isFinite(requestedConcurrency) && requestedConcurrency >= MIN_PARSE_CONCURRENCY) { - return clampParseConcurrency(requestedConcurrency); - } - const available = os.availableParallelism(); - if (!Number.isFinite(available) || available < MIN_PARSE_CONCURRENCY) { - return MIN_PARSE_CONCURRENCY; - } - return clampParseConcurrency(available); -}; diff --git a/packages/deslop-js/tests/analyze.test.ts b/packages/deslop-js/tests/analyze.test.ts deleted file mode 100644 index a277161aac..0000000000 --- a/packages/deslop-js/tests/analyze.test.ts +++ /dev/null @@ -1,5283 +0,0 @@ -import { describe, it, test } from "node:test"; -import assert from "node:assert/strict"; -import { resolve, relative } from "node:path"; -import { analyze, defineConfig } from "../src/index.js"; -import type { ScanResult } from "../src/types.js"; -import { FIXTURES_DIR } from "./helpers/fixtures-dir.js"; - -const scanFixture = async ( - fixtureName: string, - overrides: Record<string, unknown> = {}, -): Promise<ScanResult> => { - const fixtureDir = resolve(FIXTURES_DIR, fixtureName); - const config = defineConfig({ - rootDir: fixtureDir, - ...overrides, - }); - return analyze(config); -}; - -const orphanPaths = (result: ScanResult, fixtureDir: string): string[] => - result.unusedFiles.map((unusedFile) => relative(fixtureDir, unusedFile.path)).sort(); - -const deadExportNames = (result: ScanResult): string[] => - result.unusedExports.map((unusedExport) => unusedExport.name).sort(); - -const deadExportsByFile = (result: ScanResult, fixtureDir: string): Record<string, string[]> => { - const byFile: Record<string, string[]> = {}; - for (const unusedExport of result.unusedExports) { - const relativePath = relative(fixtureDir, unusedExport.path); - if (!byFile[relativePath]) byFile[relativePath] = []; - byFile[relativePath].push(unusedExport.name); - } - for (const key of Object.keys(byFile)) { - byFile[key].sort(); - } - return byFile; -}; - -const staleDependencyNames = (result: ScanResult): string[] => - result.unusedDependencies.map((dep) => dep.name).sort(); - -describe("simple-app", () => { - it("should detect orphan file", async () => { - const result = await scanFixture("simple-app"); - const fixtureDir = resolve(FIXTURES_DIR, "simple-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); - - it("should detect unused exports in utils", async () => { - const result = await scanFixture("simple-app"); - const fixtureDir = resolve(FIXTURES_DIR, "simple-app"); - const exportsByFile = deadExportsByFile(result, fixtureDir); - assert.ok( - exportsByFile["src/utils.ts"]?.includes("unusedFunction"), - `unusedFunction should be flagged, got: ${JSON.stringify(exportsByFile["src/utils.ts"])}`, - ); - }); - - it("should detect unused dependency", async () => { - const result = await scanFixture("simple-app"); - const deps = staleDependencyNames(result); - assert.ok(deps.includes("unused-dep"), `unused-dep should be flagged, got: ${deps}`); - }); - - it("should explain each unused dependency with a reason that names the package", async () => { - const result = await scanFixture("simple-app"); - const unusedDep = result.unusedDependencies.find((dep) => dep.name === "unused-dep"); - assert.ok(unusedDep, `unused-dep finding should exist, got: ${staleDependencyNames(result)}`); - assert.equal(unusedDep.isDevDependency, false); - assert.match(unusedDep.reason, /"unused-dep"/); - assert.match(unusedDep.reason, /declared in dependencies\b/); - }); - - it("should not flag usedFunction as unused", async () => { - const result = await scanFixture("simple-app"); - const allUnusedNames = deadExportNames(result); - assert.ok(!allUnusedNames.includes("usedFunction"), "usedFunction should not be unused"); - }); - - it("should flag react as unused (declared but never imported)", async () => { - const result = await scanFixture("simple-app"); - const deps = staleDependencyNames(result); - assert.ok(deps.includes("react"), `react should be unused since never imported, got: ${deps}`); - }); -}); - -describe("astro-app", () => { - it("treats Astro's default Sharp image service as used", async () => { - const result = await scanFixture("astro-app"); - const dependencies = staleDependencyNames(result); - assert.ok( - !dependencies.includes("sharp"), - `sharp should be implicit for Astro, got: ${dependencies}`, - ); - assert.ok( - dependencies.includes("unused-dep"), - `unused-dep should be unused, got: ${dependencies}`, - ); - }); -}); - -describe("gitignore-app", () => { - it("suppresses reports for gitignored files without dropping their import edges", async () => { - const result = await scanFixture("gitignore-app"); - const fixtureDir = resolve(FIXTURES_DIR, "gitignore-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `genuine orphan should still be reported, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.includes("generated")), - `gitignored files must not be reported as unused, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/home-route.ts"), - `a file reachable only through a gitignored importer must stay used (no cascade), got: ${unusedFilePaths}`, - ); - - const unusedExportPaths = result.unusedExports.map((unusedExport) => - relative(fixtureDir, unusedExport.path), - ); - assert.ok( - !unusedExportPaths.some((filePath) => filePath.includes("generated")), - `exports inside gitignored files must not be reported, got: ${unusedExportPaths}`, - ); - }); -}); - -describe("dependency-tooling", () => { - it("should keep peer dependencies, script binaries, overrides, and Nx project refs used", async () => { - const result = await scanFixture("dependency-tooling"); - const deps = staleDependencyNames(result); - const expectedUsedDeps = [ - "@babel/cli", - "@formatjs/cli", - "@hookform/resolvers", - "@nx/js", - "@tauri-apps/cli", - "@tinacms/cli", - "@typescript/native-preview", - "chokidar-peer", - // static bin fallback (no node_modules entry): `copy-styles` runs the - // `cpy` bin that cpy-cli ships. - "cpy-cli", - // static bin fallback: `test:browser` runs `playwright test`, which - // drives the browser that playwright-chromium downloads at install. - "playwright-chromium", - // static peer fallback: axe-core is vitest-axe's peer dependency and - // vitest-axe is imported. - "axe-core", - "vitest-axe", - // imported from the .dumi docs-theme tree, which the module graph - // never traverses — credited by the tooling-source content scan. - "docs-theme-widgets", - "babel-eslint", - "chart.js", - "chokidar-cli", - "jest-cli", - "prompt", - "react-chartjs-2", - "react-redux", - "redux", - "replace-in-file", - "tsc-alias", - "zod", - ]; - for (const dependencyName of expectedUsedDeps) { - assert.ok( - !deps.includes(dependencyName), - `${dependencyName} should be treated as used, got: ${deps}`, - ); - } - assert.ok(deps.includes("unused-dep"), `unused-dep should be unused, got: ${deps}`); - assert.ok(deps.includes("unused-tool"), `unused-tool should be unused, got: ${deps}`); - assert.ok(deps.includes("redux-thunk"), `redux-thunk should be unused, got: ${deps}`); - }); - - it("should name the package and devDependencies section for unused dev dependencies", async () => { - const result = await scanFixture("dependency-tooling"); - const unusedTool = result.unusedDependencies.find((dep) => dep.name === "unused-tool"); - assert.ok(unusedTool, `unused-tool finding should exist, got: ${staleDependencyNames(result)}`); - assert.equal(unusedTool.isDevDependency, true); - assert.match(unusedTool.reason, /"unused-tool"/); - assert.match(unusedTool.reason, /declared in devDependencies\b/); - }); - - it("should keep pnpm-workspace override targets used", async () => { - const result = await scanFixture("pnpm-workspace-override"); - const deps = staleDependencyNames(result); - assert.ok( - !deps.includes("@voidzero-dev/vite-plus-core"), - `@voidzero-dev/vite-plus-core should be treated as used via pnpm-workspace overrides, got: ${deps}`, - ); - assert.ok(deps.includes("unused-dep"), `unused-dep should be unused, got: ${deps}`); - }); - - it("resolves script-invoked CLIs without installed metadata only via same-name binaries, prefixes, or implicit deps", async () => { - const result = await scanFixture("script-cli-deps"); - const deps = staleDependencyNames(result); - // turbo's binary is `turbo` (same name), tsx is implicit, and `@changesets/*` - // is an always-used prefix — all resolve with no node_modules present. - for (const dependencyName of ["turbo", "tsx", "@changesets/cli"]) { - assert.ok( - !deps.includes(dependencyName), - `${dependencyName} should still be treated as used, got: ${deps}`, - ); - } - // vite-plus exposes the `vp` binary, whose name differs from the package; with - // no installed bin metadata there is nothing to map `vp` -> vite-plus, so it is - // flagged. Installing it (real bin metadata) resolves it — see dependency-tooling. - assert.ok( - deps.includes("vite-plus"), - `vite-plus (bin "vp") is unresolvable without installed metadata, got: ${deps}`, - ); - assert.ok(deps.includes("unused-dep"), `unused-dep should be unused, got: ${deps}`); - }); - - it("should keep nested package.json override targets used", async () => { - const result = await scanFixture("nested-overrides"); - const deps = staleDependencyNames(result); - assert.ok( - !deps.includes("@typescript/native-preview"), - `@typescript/native-preview should be treated as used via nested overrides, got: ${deps}`, - ); - assert.ok(deps.includes("unused-dep"), `unused-dep should be unused, got: ${deps}`); - }); - - it("should keep nested pnpm-workspace override targets used", async () => { - const result = await scanFixture("pnpm-nested-overrides"); - const deps = staleDependencyNames(result); - assert.ok( - !deps.includes("@typescript/native-preview"), - `@typescript/native-preview should be treated as used via nested pnpm-workspace overrides, got: ${deps}`, - ); - assert.ok(deps.includes("unused-dep"), `unused-dep should be unused, got: ${deps}`); - }); - - it("should keep vitest override targets used", async () => { - const result = await scanFixture("vitest-override-target"); - const deps = staleDependencyNames(result); - assert.ok( - !deps.includes("@voidzero-dev/vite-plus-test"), - `@voidzero-dev/vite-plus-test should be treated as used via pnpm-workspace overrides, got: ${deps}`, - ); - assert.ok(deps.includes("unused-dep"), `unused-dep should be unused, got: ${deps}`); - }); -}); - -describe("css-tilde-import", () => { - it("should detect Sass tilde package imports as dependency usage", async () => { - const result = await scanFixture("css-tilde-import"); - const deps = staleDependencyNames(result); - assert.ok(!deps.includes("bootstrap"), `bootstrap should be used from SCSS, got: ${deps}`); - assert.ok(deps.includes("unused-dep"), `unused-dep should be unused, got: ${deps}`); - }); -}); - -describe("tailwind-v4-plugin", () => { - it('should treat Tailwind v4 `@plugin "pkg"` directives as dependency usage', async () => { - const result = await scanFixture("tailwind-v4-plugin"); - const deps = staleDependencyNames(result); - assert.ok( - !deps.includes("tailwindcss-animate"), - `tailwindcss-animate is loaded via @plugin in CSS and must not be flagged, got: ${deps}`, - ); - assert.ok(deps.includes("unused-dep"), `unused-dep should still be unused, got: ${deps}`); - }); -}); - -describe("workspace-local-bin", () => { - it("should resolve script binaries from workspace-local node_modules (pnpm isolation)", async () => { - const result = await scanFixture("workspace-local-bin"); - const deps = staleDependencyNames(result); - assert.ok( - !deps.includes("react-email"), - `react-email provides the 'email' bin used by email:preview script and must not be flagged, got: ${deps}`, - ); - assert.ok( - deps.includes("unused-dev-tool"), - `unused-dev-tool should still be unused, got: ${deps}`, - ); - }); - - it("should not flag a package that ships a binary even when no script references it", async () => { - const result = await scanFixture("workspace-local-bin"); - const deps = staleDependencyNames(result); - assert.ok( - !deps.includes("bin-only-tool"), - `bin-only-tool declares a bin and is invokable outside the static scan (npx, hooks, CI) — it must not be flagged, got: ${deps}`, - ); - }); - - it("should expose conservative dependency exemptions without reporting them as unused", async () => { - const result = await scanFixture("workspace-local-bin"); - const skippedDependencies = result.skippedDependencies ?? []; - - assert.deepEqual(skippedDependencies, [ - { name: "bin-only-tool", isDevDependency: true, reasons: ["provides-binary"] }, - { name: "expo-unused", isDevDependency: true, reasons: ["allowlisted-name"] }, - ]); - assert.ok( - !staleDependencyNames(result).includes("expo-unused"), - "allowlisted dependencies must remain exempt from unused findings", - ); - }); -}); - -describe("monorepo-script-entry", () => { - it("should treat files referenced by parent monorepo scripts as workspace entry points", async () => { - const fixtureDir = resolve(FIXTURES_DIR, "monorepo-script-entry", "packages", "sub"); - const config = defineConfig({ rootDir: fixtureDir }); - const result = await analyze(config); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("internal-tools/tui.ts"), - `tui.ts is referenced by parent monorepo script and must not be flagged unused, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("internal-tools/renderer.ts"), - `renderer.ts is transitively reachable from parent monorepo script entry, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("workspace-subpath-import", () => { - it("should treat files imported by sibling workspaces via package subpaths as entry points", async () => { - const fixtureDir = resolve(FIXTURES_DIR, "workspace-subpath-import", "packages", "ui"); - const config = defineConfig({ rootDir: fixtureDir }); - const result = await analyze(config); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("button.tsx"), - `button.tsx is imported by a sibling workspace via @subpath-fixture/ui/button and must not be flagged, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("utils.ts"), - `utils.ts is transitively reachable from button.tsx, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("orphan.ts"), - `orphan.ts is not imported anywhere and should still be flagged, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("workspace-subpath-import-built", () => { - it("should map built dist subpath targets back to source files for sibling workspace imports", async () => { - const fixtureDir = resolve(FIXTURES_DIR, "workspace-subpath-import-built", "packages", "ui"); - const config = defineConfig({ rootDir: fixtureDir }); - const result = await analyze(config); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/button.ts"), - `src/button.ts must not be flagged even when the built dist artifact exists on disk, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts is not imported anywhere and should still be flagged, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("workspace-subpath-wildcard-export", () => { - it("should resolve sibling workspace subpath imports through wildcard exports patterns", async () => { - const fixtureDir = resolve(FIXTURES_DIR, "workspace-subpath-wildcard-export", "packages", "ui"); - const config = defineConfig({ rootDir: fixtureDir }); - const result = await analyze(config); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/components/button.tsx"), - `src/components/button.tsx is imported via the "./*" exports pattern (dist target mapped back to src) and must not be flagged, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/components/helpers.ts"), - `src/components/helpers.ts is transitively reachable from button.tsx, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/components/orphan.ts"), - `src/components/orphan.ts is not imported anywhere and should still be flagged, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("vercel-config-app", () => { - it("should not flag vercel.ts as an unused file", async () => { - const result = await scanFixture("vercel-config-app"); - const fixtureDir = resolve(FIXTURES_DIR, "vercel-config-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("vercel.ts"), - `vercel.ts is a deploy-time config file and must not be flagged, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("duplicate-import-type-value", () => { - it("should not flag a type-only import + a value import of the same module as duplicates", async () => { - const result = await scanFixture("duplicate-import-type-value"); - const fixtureDir = resolve(FIXTURES_DIR, "duplicate-import-type-value"); - const dupes = result.duplicateImports.filter( - (dup) => dup.path === resolve(fixtureDir, "src/consumer-split.ts"), - ); - const valueDupes = dupes.filter((dup) => - dup.occurrences.every((occurrence) => !occurrence.isTypeOnly), - ); - const typeDupes = dupes.filter((dup) => - dup.occurrences.every((occurrence) => occurrence.isTypeOnly), - ); - const mixedDupes = dupes.filter( - (dup) => - dup.occurrences.some((occurrence) => occurrence.isTypeOnly) && - dup.occurrences.some((occurrence) => !occurrence.isTypeOnly), - ); - assert.strictEqual( - mixedDupes.length, - 0, - `type-only + value imports must NOT be grouped together: ${JSON.stringify(mixedDupes)}`, - ); - assert.strictEqual( - typeDupes.length, - 0, - `single type-only import should not be flagged: ${JSON.stringify(typeDupes)}`, - ); - assert.ok( - valueDupes.some((dup) => dup.specifier === "./api"), - `3 value-imports of "./api" SHOULD be flagged as duplicates: ${JSON.stringify(dupes)}`, - ); - }); -}); - -describe("jsx-block-arrow", () => { - it("should not flag arrow components returning JSX as block-arrow-single-return", async () => { - const result = await scanFixture("jsx-block-arrow"); - const simplifiable = result.simplifiableFunctions.filter( - (item) => item.kind === "block-arrow-single-return", - ); - const jsxNames = ["HrComponent", "FragmentComponent"]; - for (const componentName of jsxNames) { - assert.ok( - !simplifiable.some((item) => item.functionName === componentName), - `JSX-returning arrow ${componentName} must not be flagged: ${JSON.stringify(simplifiable)}`, - ); - } - assert.ok( - simplifiable.some((item) => item.functionName === "shouldFlagIdentity"), - `non-JSX single-return arrow shouldFlagIdentity SHOULD still be flagged: ${JSON.stringify(simplifiable)}`, - ); - }); -}); - -describe("filename-registry-entries", () => { - it("should treat unique filename string literals in source as soft entries (dynamic-loading pattern)", async () => { - const result = await scanFixture("filename-registry-entries"); - const fixtureDir = resolve(FIXTURES_DIR, "filename-registry-entries"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("tools/diagnose-user.ts"), - `diagnose-user.ts is registered by basename string and must be treated as entry, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("tools/export-data.ts"), - `export-data.ts is registered by basename string and must be treated as entry, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("tools/dynamic/nested-task.ts"), - `nested-task.ts is registered by its extensionless path and must be treated as entry, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("tools/dynamic/dynamic-import-task.ts"), - `dynamic-import-task.ts is referenced by an extensionless import expression and must be treated as entry, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("other/nested-task.ts"), - `the same basename at an unregistered path SHOULD still be flagged, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("tools/genuinely-dead.ts"), - `genuinely-dead.ts has no string-literal references and SHOULD still be flagged, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("expo-config-plugins", () => { - it("should treat local Expo config plugins as entry points", async () => { - const result = await scanFixture("expo-config-plugins"); - const fixtureDir = resolve(FIXTURES_DIR, "expo-config-plugins"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - for (const expectedReachableFile of [ - // A plain template string in app.config.ts. - "plugins/template-literal-plugin.ts", - // A tuple entry that points at a directory with index.ts. - "plugins/directory-index-plugin/index.ts", - // An extensionless local path in expo.plugins. - "plugins/expo-json-extensionless-plugin.ts", - // A root-level plugins array in app.json. - "plugins/root-json-plugin.ts", - // A workspace app.config.js can point at a plugin outside its package. - "apps/shared/cross-workspace-plugin.ts", - ]) { - assert.ok( - !unusedFilePaths.includes(expectedReachableFile), - `${expectedReachableFile} is referenced by Expo config plugins and must not be flagged unused, got: ${unusedFilePaths}`, - ); - } - - assert.ok( - unusedFilePaths.includes("expo-camera.ts"), - `package-name lookalikes must not be treated as local plugin files, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("plugins/false-positive-target.ts"), - `nested non-Expo plugin arrays, dynamic tuple entries, absolute paths, and wildcard paths must not mark false-positive-target.ts reachable, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("expo-plugin-packages-false-positive", () => { - it("should not flag Expo config plugin packages (referenced by package name) as unused", async () => { - const result = await scanFixture("expo-plugin-packages-false-positive"); - const deps = staleDependencyNames(result); - - // Both plugin packages are deliberately NOT covered by the always-used - // prefix allowlist (unlike `expo-*` / `react-native-*`), and neither is - // imported in source — so without the config-plugin detection they WOULD - // be flagged. This makes the test fail on unfixed code. - assert.ok( - !deps.includes("@config-plugins/detox"), - `@config-plugins/detox is a config plugin (tuple form) in app.json and must not be flagged as unused, got: ${deps}`, - ); - assert.ok( - !deps.includes("@react-native-firebase/app"), - `@react-native-firebase/app is a config plugin nested under the \`expo\` key in app.config.js and must not be flagged as unused, got: ${deps}`, - ); - - // Negative control: a declared dependency that is neither a plugin nor - // imported must STILL be reported, proving the scan runs and the plugin - // assertions above aren't passing vacuously. - assert.ok( - deps.includes("left-pad"), - `left-pad is genuinely unused and must still be flagged, got: ${deps}`, - ); - }); -}); - -describe("nested-dist-non-workspace", () => { - it("should exclude `dist/` directories at ANY depth, not just at workspace roots", async () => { - const result = await scanFixture("nested-dist-non-workspace"); - const fixtureDir = resolve(FIXTURES_DIR, "nested-dist-non-workspace"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("apps/orphan/dist/index.mjs"), - `apps/orphan/dist/ must be globally excluded (no package.json so dir isn't a workspace), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("empty-and-binary-files", () => { - it("should not flag minified/binary files as unusedFiles (parser can't see their imports)", async () => { - const result = await scanFixture("empty-and-binary-files"); - const fixtureDir = resolve(FIXTURES_DIR, "empty-and-binary-files"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/minified-bundle.js"), - `minified bundle must not be in unusedFiles (analysisError already signals it), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/binary-file.ts"), - `binary file must not be in unusedFiles, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("reexport-star", () => { - it("should not flag foo as unused (used via barrel)", async () => { - const result = await scanFixture("reexport-star"); - const allUnusedNames = deadExportNames(result); - assert.ok(!allUnusedNames.includes("foo"), "foo is used through barrel"); - }); - - it("should flag fooUnused as unused", async () => { - const result = await scanFixture("reexport-star"); - const allUnusedNames = deadExportNames(result); - assert.ok( - allUnusedNames.includes("fooUnused"), - `fooUnused should be unused, got: ${allUnusedNames}`, - ); - }); - - it("should not flag module-b.ts as unused file (file-level: re-exported by barrel makes it reachable)", async () => { - const result = await scanFixture("reexport-star"); - const fixtureDir = resolve(FIXTURES_DIR, "reexport-star"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.some((filePath) => filePath === "src/module-b.ts"), - `module-b.ts should be reachable via barrel re-export (file-level), got: ${unusedFilePaths}`, - ); - }); - - it("should not flag module-c.ts as unused file (file-level: star re-exported by barrel makes it reachable)", async () => { - const result = await scanFixture("reexport-star"); - const fixtureDir = resolve(FIXTURES_DIR, "reexport-star"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.some((filePath) => filePath === "src/module-c.ts"), - `module-c.ts should be reachable via barrel star re-export (file-level), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("reexport-chains (3-level barrel chain)", () => { - it("should not flag alpha and beta (used via 3-level chain)", async () => { - const result = await scanFixture("reexport-chains"); - const allUnusedNames = deadExportNames(result); - assert.ok(!allUnusedNames.includes("alpha"), "alpha is used via chain"); - assert.ok(!allUnusedNames.includes("beta"), "beta is used via chain"); - }); - - it("should flag gamma and delta as unused", async () => { - const result = await scanFixture("reexport-chains"); - const allUnusedNames = deadExportNames(result); - assert.ok(allUnusedNames.includes("gamma"), `gamma should be unused, got: ${allUnusedNames}`); - assert.ok(allUnusedNames.includes("delta"), `delta should be unused, got: ${allUnusedNames}`); - }); - - it("should not flag any file as unused", async () => { - const result = await scanFixture("reexport-chains"); - assert.equal(result.unusedFiles.length, 0, "all files are reachable via chain"); - }); -}); - -describe("ns-imports", () => { - it("should flag exports not accessed via namespace member access", async () => { - const result = await scanFixture("ns-imports"); - const fixtureDir = resolve(FIXTURES_DIR, "ns-imports"); - const exportsByFile = deadExportsByFile(result, fixtureDir); - assert.deepStrictEqual(exportsByFile["src/utils.ts"], ["bar", "baz"]); - }); - - it("should not flag any files as unused", async () => { - const result = await scanFixture("ns-imports"); - assert.equal(result.unusedFiles.length, 0); - }); -}); - -describe("ns-partial", () => { - it("should flag only the exports not accessed via member access", async () => { - const result = await scanFixture("ns-partial"); - const fixtureDir = resolve(FIXTURES_DIR, "ns-partial"); - const exportsByFile = deadExportsByFile(result, fixtureDir); - const unusedMathExports = (exportsByFile["src/math.ts"] ?? []).sort(); - assert.deepStrictEqual(unusedMathExports, ["divide", "subtract"]); - }); -}); - -describe("ns-whole", () => { - it("should not flag any exports when Object.values is used on namespace", async () => { - const result = await scanFixture("ns-whole"); - assert.equal( - result.unusedExports.length, - 0, - `expected 0 unused exports, got: ${deadExportNames(result)}`, - ); - }); -}); - -describe("ns-spread", () => { - it("should not flag any exports when namespace is spread into object", async () => { - const result = await scanFixture("ns-spread"); - assert.equal( - result.unusedExports.length, - 0, - `expected 0 unused exports, got: ${deadExportNames(result)}`, - ); - }); -}); - -describe("ns-forin", () => { - it("should not flag any exports when namespace is used in for..in", async () => { - const result = await scanFixture("ns-forin"); - assert.equal( - result.unusedExports.length, - 0, - `expected 0 unused exports, got: ${deadExportNames(result)}`, - ); - }); -}); - -describe("ns-reexport", () => { - it("should flag only the exports not accessed through barrel via namespace member access", async () => { - const result = await scanFixture("ns-reexport"); - const fixtureDir = resolve(FIXTURES_DIR, "ns-reexport"); - const exportsByFile = deadExportsByFile(result, fixtureDir); - assert.deepStrictEqual(exportsByFile["src/lib/helpers.ts"], ["helperB", "helperC"]); - }); - - it("should not flag any files as unused", async () => { - const result = await scanFixture("ns-reexport"); - assert.equal(result.unusedFiles.length, 0); - }); -}); - -describe("export-default", () => { - it("should flag default export of component.ts (only named is used)", async () => { - const result = await scanFixture("export-default"); - const fixtureDir = resolve(FIXTURES_DIR, "export-default"); - const exportsByFile = deadExportsByFile(result, fixtureDir); - assert.ok( - exportsByFile["src/component.ts"]?.includes("default"), - `default should be unused in component.ts, got: ${JSON.stringify(exportsByFile)}`, - ); - }); - - it("should flag unused-default.ts as unused file", async () => { - const result = await scanFixture("export-default"); - const fixtureDir = resolve(FIXTURES_DIR, "export-default"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/unused-default.ts"), - `unused-default.ts should be unused file, got: ${unusedFilePaths}`, - ); - }); - - it("should not flag usedNamed as unused", async () => { - const result = await scanFixture("export-default"); - const allUnusedNames = deadExportNames(result); - assert.ok(!allUnusedNames.includes("usedNamed"), "usedNamed is imported"); - }); -}); - -describe("import-side-effect", () => { - it("should keep setup.ts reachable (side-effect import)", async () => { - const result = await scanFixture("import-side-effect"); - const fixtureDir = resolve(FIXTURES_DIR, "import-side-effect"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok(!unusedFilePaths.includes("src/setup.ts"), "setup.ts is side-effect imported"); - }); - - it("should flag orphan.ts as unused", async () => { - const result = await scanFixture("import-side-effect"); - const fixtureDir = resolve(FIXTURES_DIR, "import-side-effect"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("cycle-reexport", () => { - it("should not flag fromA or fromB (used despite circular re-exports)", async () => { - const result = await scanFixture("cycle-reexport"); - const allUnusedNames = deadExportNames(result); - assert.ok(!allUnusedNames.includes("fromA"), "fromA is used"); - assert.ok(!allUnusedNames.includes("fromB"), "fromB is used"); - }); - - it("should not hang or crash from circular re-export", async () => { - const result = await scanFixture("cycle-reexport"); - assert.ok(result.totalFiles > 0, "analysis should complete"); - }); -}); - -describe("star-reexport-chain", () => { - it("should not flag used as unused (via star re-export chain)", async () => { - const result = await scanFixture("star-reexport-chain"); - const allUnusedNames = deadExportNames(result); - assert.ok(!allUnusedNames.includes("used"), "used should be found through star chain"); - }); - - it("should flag unused export in source", async () => { - const result = await scanFixture("star-reexport-chain"); - const allUnusedNames = deadExportNames(result); - assert.ok( - allUnusedNames.includes("unused"), - `unused should be flagged, got: ${allUnusedNames}`, - ); - }); -}); - -describe("star-selective", () => { - it("should not flag usedOne and usedTwo (selectively imported via star barrel)", async () => { - const result = await scanFixture("star-selective"); - const allUnusedNames = deadExportNames(result); - assert.ok(!allUnusedNames.includes("usedOne"), "usedOne is used"); - assert.ok(!allUnusedNames.includes("usedTwo"), "usedTwo is used"); - }); - - it("should flag unusedThree and unusedFour", async () => { - const result = await scanFixture("star-selective"); - const allUnusedNames = deadExportNames(result); - assert.ok( - allUnusedNames.includes("unusedThree"), - `unusedThree should be flagged, got: ${allUnusedNames}`, - ); - assert.ok( - allUnusedNames.includes("unusedFour"), - `unusedFour should be flagged, got: ${allUnusedNames}`, - ); - }); -}); - -describe("reexport-multi-hop", () => { - it("should not flag used (imported through two barrel hops)", async () => { - const result = await scanFixture("reexport-multi-hop"); - const allUnusedNames = deadExportNames(result); - assert.ok(!allUnusedNames.includes("used"), "used is consumed through 2-hop barrel"); - }); - - it("should flag unused1 and unused2", async () => { - const result = await scanFixture("reexport-multi-hop"); - const allUnusedNames = deadExportNames(result); - assert.ok( - allUnusedNames.includes("unused1"), - `unused1 should be unused, got: ${allUnusedNames}`, - ); - assert.ok( - allUnusedNames.includes("unused2"), - `unused2 should be unused, got: ${allUnusedNames}`, - ); - }); -}); - -describe("reexport-multi-level", () => { - it("should not flag alpha and beta (used through 3-level named re-export chain)", async () => { - const result = await scanFixture("reexport-multi-level"); - const allUnusedNames = deadExportNames(result); - assert.ok(!allUnusedNames.includes("alpha"), "alpha is used"); - assert.ok(!allUnusedNames.includes("beta"), "beta is used"); - }); - - it("should flag gamma (re-exported in barrel-a but not imported)", async () => { - const result = await scanFixture("reexport-multi-level"); - const allUnusedNames = deadExportNames(result); - assert.ok(allUnusedNames.includes("gamma"), `gamma should be unused, got: ${allUnusedNames}`); - }); - - it("should flag delta (only in barrel-b, not re-exported by barrel-a)", async () => { - const result = await scanFixture("reexport-multi-level"); - const allUnusedNames = deadExportNames(result); - assert.ok(allUnusedNames.includes("delta"), `delta should be unused, got: ${allUnusedNames}`); - }); - - it("should flag epsilon (not re-exported at all)", async () => { - const result = await scanFixture("reexport-multi-level"); - const allUnusedNames = deadExportNames(result); - assert.ok( - allUnusedNames.includes("epsilon"), - `epsilon should be unused, got: ${allUnusedNames}`, - ); - }); -}); - -describe("reexport-default", () => { - it("should not flag Button (used via default re-export through barrel)", async () => { - const result = await scanFixture("reexport-default"); - const fixtureDir = resolve(FIXTURES_DIR, "reexport-default"); - const exportsByFile = deadExportsByFile(result, fixtureDir); - const buttonExports = exportsByFile["src/components/Button.ts"]; - assert.ok(!buttonExports?.includes("default"), "Button default export is used"); - }); -}); - -describe("reexport-unused", () => { - it("should not flag UsedComponent", async () => { - const result = await scanFixture("reexport-unused"); - const allUnusedNames = deadExportNames(result); - assert.ok(!allUnusedNames.includes("UsedComponent"), "UsedComponent is imported"); - }); - - it("should not flag unused-source.ts as unused file (file-level: barrel re-export makes it reachable)", async () => { - const result = await scanFixture("reexport-unused"); - const fixtureDir = resolve(FIXTURES_DIR, "reexport-unused"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.some((filePath) => filePath === "src/components/unused-source.ts"), - `unused-source.ts should be reachable via barrel re-export (file-level), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("deep-reexport-tracking", () => { - it("should keep used-source.ts reachable (usedHelper consumed through two barrel layers)", async () => { - const result = await scanFixture("deep-reexport-tracking"); - const fixtureDir = resolve(FIXTURES_DIR, "deep-reexport-tracking"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/used-source.ts"), - "used-source.ts should be reachable via barrel-mid → barrel-top → index", - ); - }); - - it("should not flag unused-source.ts as unused file (file-level: barrel re-export chain makes it reachable)", async () => { - const result = await scanFixture("deep-reexport-tracking"); - const fixtureDir = resolve(FIXTURES_DIR, "deep-reexport-tracking"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/unused-source.ts"), - `unused-source.ts should be reachable via barrel re-export chain (file-level), got: ${unusedFilePaths}`, - ); - }); - - it("should flag orphan.ts as unused file", async () => { - const result = await scanFixture("deep-reexport-tracking"); - const fixtureDir = resolve(FIXTURES_DIR, "deep-reexport-tracking"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); - - it("should flag usedHelperSibling as unused export", async () => { - const result = await scanFixture("deep-reexport-tracking"); - const allUnusedNames = deadExportNames(result); - assert.ok( - allUnusedNames.includes("usedHelperSibling"), - `usedHelperSibling should be unused, got: ${allUnusedNames}`, - ); - }); -}); - -describe("wildcard-late-consume", () => { - it("should keep color-picker reachable when consumed via plugin that imports from sibling component barrel", async () => { - const result = await scanFixture("wildcard-late-consume"); - const fixtureDir = resolve(FIXTURES_DIR, "wildcard-late-consume"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/components/color-picker/color-picker.ts"), - `color-picker.ts should be reachable via plugin → components barrel → color-picker barrel, got unused: ${unusedFilePaths}`, - ); - }); - - it("should keep color-picker/index.ts reachable as intermediate barrel", async () => { - const result = await scanFixture("wildcard-late-consume"); - const fixtureDir = resolve(FIXTURES_DIR, "wildcard-late-consume"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/components/color-picker/index.ts"), - `color-picker/index.ts should be reachable, got unused: ${unusedFilePaths}`, - ); - }); - - it("should flag unused-widget.ts as unused file (never imported by any plugin)", async () => { - const result = await scanFixture("wildcard-late-consume"); - const fixtureDir = resolve(FIXTURES_DIR, "wildcard-late-consume"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/components/unused-widget.ts"), - `unused-widget.ts should be unused, got: ${unusedFilePaths}`, - ); - }); - - it("should flag ColorUtils as unused export (only ColorPicker consumed from color-picker.ts)", async () => { - const result = await scanFixture("wildcard-late-consume"); - const allUnusedNames = deadExportNames(result); - assert.ok( - allUnusedNames.includes("ColorUtils"), - `ColorUtils should be unused export, got: ${allUnusedNames}`, - ); - }); -}); - -describe("import-reexport-same", () => { - it("should create both direct import and re-export edges when a file imports from and re-exports the same module", async () => { - const result = await scanFixture("import-reexport-same"); - const fixtureDir = resolve(FIXTURES_DIR, "import-reexport-same"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/components/widget.ts"), - `widget.ts should be reachable via re-export through components barrel (export * from), got unused: ${unusedFilePaths}`, - ); - }); - - it("should keep helper.ts reachable via both direct import and re-export", async () => { - const result = await scanFixture("import-reexport-same"); - const fixtureDir = resolve(FIXTURES_DIR, "import-reexport-same"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/components/helper.ts"), - `helper.ts should be reachable, got unused: ${unusedFilePaths}`, - ); - }); - - it("should flag orphan.ts as unused (not imported or re-exported by anyone)", async () => { - const result = await scanFixture("import-reexport-same"); - const fixtureDir = resolve(FIXTURES_DIR, "import-reexport-same"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("reexport-alias", () => { - it("should not flag original and renamed (used via aliased re-export chain)", async () => { - const result = await scanFixture("reexport-alias"); - const allUnusedNames = deadExportNames(result); - assert.ok(!allUnusedNames.includes("original"), "original is used via aliasC"); - assert.ok(!allUnusedNames.includes("renamed"), "renamed is used via doubleAlias"); - }); - - it("should flag unusedOriginal (aliased but never consumed)", async () => { - const result = await scanFixture("reexport-alias"); - const allUnusedNames = deadExportNames(result); - assert.ok( - allUnusedNames.includes("unusedOriginal"), - `unusedOriginal should be unused, got: ${allUnusedNames}`, - ); - }); - - it("should flag neverExported (not re-exported by any barrel)", async () => { - const result = await scanFixture("reexport-alias"); - const allUnusedNames = deadExportNames(result); - assert.ok( - allUnusedNames.includes("neverExported"), - `neverExported should be unused, got: ${allUnusedNames}`, - ); - }); -}); - -describe("import-dynamic", () => { - it("should keep lazy.ts reachable via dynamic import", async () => { - const result = await scanFixture("import-dynamic"); - const fixtureDir = resolve(FIXTURES_DIR, "import-dynamic"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok(!unusedFilePaths.includes("src/lazy.ts"), "lazy.ts is dynamically imported"); - }); - - it("should flag orphan.ts as unused file", async () => { - const result = await scanFixture("import-dynamic"); - const fixtureDir = resolve(FIXTURES_DIR, "import-dynamic"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); - - it("should flag unused export in utils", async () => { - const result = await scanFixture("import-dynamic"); - const allUnusedNames = deadExportNames(result); - assert.ok( - allUnusedNames.includes("unused"), - `unused should be flagged, got: ${allUnusedNames}`, - ); - }); -}); - -describe("type-deps", () => { - it("should detect type-only imports", async () => { - const result = await scanFixture("type-deps"); - assert.ok(result.totalFiles > 0, "should find files"); - }); -}); - -describe("orphan-barrel-subtree", () => { - it("should flag all files in the dead subtree", async () => { - const result = await scanFixture("orphan-barrel-subtree"); - const fixtureDir = resolve(FIXTURES_DIR, "orphan-barrel-subtree"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/subtree/setup.ts"), - `setup.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("orphan-mixed-exports", () => { - it("should flag both files in unreachable test-utils", async () => { - const result = await scanFixture("orphan-mixed-exports"); - const fixtureDir = resolve(FIXTURES_DIR, "orphan-mixed-exports"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/test-utils/helpers.ts"), - `helpers.ts should be unused, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/test-utils/setup.ts"), - `setup.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("reexport-mixed", () => { - it("should not flag namedUsed and starUsed", async () => { - const result = await scanFixture("reexport-mixed"); - const allUnusedNames = deadExportNames(result); - assert.ok(!allUnusedNames.includes("namedUsed"), "namedUsed is consumed"); - assert.ok(!allUnusedNames.includes("starUsed"), "starUsed is consumed"); - }); - - it("should flag namedUnused and starUnused", async () => { - const result = await scanFixture("reexport-mixed"); - const allUnusedNames = deadExportNames(result); - assert.ok( - allUnusedNames.includes("namedUnused"), - `namedUnused should be flagged, got: ${allUnusedNames}`, - ); - assert.ok( - allUnusedNames.includes("starUnused"), - `starUnused should be flagged, got: ${allUnusedNames}`, - ); - }); -}); - -describe("reexport-neighbor-import", () => { - it("does not credit same-name exports from imported-only neighbors", async () => { - const result = await scanFixture("reexport-neighbor-import"); - const fixtureDir = resolve(FIXTURES_DIR, "reexport-neighbor-import"); - const exportsByFile = deadExportsByFile(result, fixtureDir); - - assert.deepEqual(exportsByFile["src/imported-only.ts"], ["alsoUnused", "used"]); - }); -}); - -describe("alias-paths", () => { - it("should resolve @/ alias and not flag helper as unused", async () => { - const result = await scanFixture("alias-paths"); - const allUnusedNames = deadExportNames(result); - assert.ok(!allUnusedNames.includes("helper"), "helper is imported via @/ alias"); - }); - - it("should not flag any files as unused", async () => { - const result = await scanFixture("alias-paths"); - assert.equal(result.unusedFiles.length, 0, "all files reachable via alias"); - }); -}); - -describe("webpack-resolve", () => { - it("should resolve webpack aliases and module roots", async () => { - const result = await scanFixture("webpack-resolve"); - const fixtureDir = resolve(FIXTURES_DIR, "webpack-resolve"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/App.ts"), - `App.ts should be reachable through resolve.modules, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("app/views/actions/run-action.ts"), - `run-action.ts should be reachable through resolve.alias, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("app/views/utils/helper.ts"), - `helper.ts should be reachable through path.join alias, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("app/views/actions/orphan.ts"), - `alias orphan should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("entry-validation", () => { - it("should not flag entry exports by default", async () => { - const result = await scanFixture("entry-validation"); - const allUnusedNames = deadExportNames(result); - assert.ok(!allUnusedNames.includes("meatdata"), "entry exports are excluded by default"); - assert.ok(!allUnusedNames.includes("config"), "entry exports are excluded by default"); - }); - - it("should flag entry exports when includeEntryExports is true", async () => { - const result = await scanFixture("entry-validation", { - includeEntryExports: true, - }); - const allUnusedNames = deadExportNames(result); - assert.ok( - allUnusedNames.includes("meatdata"), - `meatdata should be unused when checking entry exports, got: ${allUnusedNames}`, - ); - assert.ok( - allUnusedNames.includes("config"), - `config should be unused when checking entry exports, got: ${allUnusedNames}`, - ); - }); -}); - -describe("ns-exports", () => { - it("should handle TypeScript namespace exports", async () => { - const result = await scanFixture("ns-exports"); - assert.ok(result.totalFiles > 0, "should parse files with namespace exports"); - const fixtureDir = resolve(FIXTURES_DIR, "ns-exports"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok(!unusedFilePaths.includes("src/helpers.ts"), "helpers.ts is imported"); - }); -}); - -describe("commonjs-app", () => { - it("should flag orphan.js as unused", async () => { - const result = await scanFixture("commonjs-app"); - const fixtureDir = resolve(FIXTURES_DIR, "commonjs-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.js"), - `orphan.js should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("config-detection", () => { - it("should flag orphan.ts as unused", async () => { - const result = await scanFixture("config-detection"); - const fixtureDir = resolve(FIXTURES_DIR, "config-detection"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); - - it("should flag unusedFunction as unused export", async () => { - const result = await scanFixture("config-detection"); - const allUnusedNames = deadExportNames(result); - assert.ok( - allUnusedNames.includes("unusedFunction"), - `unusedFunction should be unused, got: ${allUnusedNames}`, - ); - }); -}); - -describe("import-dynamic-literal", () => { - it("should keep notes.ts reachable via dynamic import from parent path", async () => { - const result = await scanFixture("import-dynamic-literal"); - const fixtureDir = resolve(FIXTURES_DIR, "import-dynamic-literal"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok(!unusedFilePaths.includes("notes.ts"), "notes.ts is dynamically imported"); - }); - - it("should flag orphan.ts as unused", async () => { - const result = await scanFixture("import-dynamic-literal"); - const fixtureDir = resolve(FIXTURES_DIR, "import-dynamic-literal"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("arrow-wrapped-import-dynamic", () => { - it("should keep Foo.tsx, Bar.tsx, Baz.tsx reachable via wrapped dynamic imports", async () => { - const result = await scanFixture("arrow-wrapped-import-dynamic"); - const fixtureDir = resolve(FIXTURES_DIR, "arrow-wrapped-import-dynamic"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok(!unusedFilePaths.includes("src/Foo.tsx"), "Foo.tsx is lazily imported"); - assert.ok(!unusedFilePaths.includes("src/Bar.tsx"), "Bar.tsx is lazily imported"); - assert.ok(!unusedFilePaths.includes("src/Baz.tsx"), "Baz.tsx is lazily imported"); - }); - - it("should keep feature.routes.ts reachable via loadChildren arrow", async () => { - const result = await scanFixture("arrow-wrapped-import-dynamic"); - const fixtureDir = resolve(FIXTURES_DIR, "arrow-wrapped-import-dynamic"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/feature.routes.ts"), - "feature.routes.ts is dynamically imported", - ); - }); - - it("should flag orphan.ts as unused", async () => { - const result = await scanFixture("arrow-wrapped-import-dynamic"); - const fixtureDir = resolve(FIXTURES_DIR, "arrow-wrapped-import-dynamic"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("type-cycle", () => { - it("should not crash on circular type-only imports", async () => { - const result = await scanFixture("type-cycle"); - assert.ok(result.totalFiles > 0, "should complete analysis without crashing"); - }); - - it("should not flag user.ts or post.ts as unused", async () => { - const result = await scanFixture("type-cycle"); - const fixtureDir = resolve(FIXTURES_DIR, "type-cycle"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok(!unusedFilePaths.includes("src/user.ts"), "user.ts is imported"); - assert.ok(!unusedFilePaths.includes("src/post.ts"), "post.ts is imported"); - }); - - it("should not flag createUser or createPost as unused", async () => { - const result = await scanFixture("type-cycle"); - const allUnusedNames = deadExportNames(result); - assert.ok(!allUnusedNames.includes("createUser"), "createUser is used"); - assert.ok(!allUnusedNames.includes("createPost"), "createPost is used"); - }); -}); - -describe("orphan-dynamic-subtree", () => { - it("should flag setup.ts and lazy.ts as unused (subtree not reachable from entry)", async () => { - const result = await scanFixture("orphan-dynamic-subtree"); - const fixtureDir = resolve(FIXTURES_DIR, "orphan-dynamic-subtree"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/subtree/setup.ts"), - `setup.ts should be unused, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/subtree/lazy.ts"), - `lazy.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("orphan-shared-child", () => { - it("should flag subtree/setup.ts and subtree/helpers.ts as unused", async () => { - const result = await scanFixture("orphan-shared-child"); - const fixtureDir = resolve(FIXTURES_DIR, "orphan-shared-child"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/subtree/setup.ts"), - `setup.ts should be unused, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/subtree/helpers.ts"), - `helpers.ts should be unused, got: ${unusedFilePaths}`, - ); - }); - - it("should not flag shared/utils.ts as unused (imported by entry)", async () => { - const result = await scanFixture("orphan-shared-child"); - const fixtureDir = resolve(FIXTURES_DIR, "orphan-shared-child"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok(!unusedFilePaths.includes("src/shared/utils.ts"), "shared/utils.ts is used by entry"); - }); -}); - -describe("style-tracking", () => { - it("should track imported CSS as reachable via import graph", async () => { - const result = await scanFixture("style-tracking"); - const fixtureDir = resolve(FIXTURES_DIR, "style-tracking"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/styles.css"), - "styles.css is imported and should be reachable", - ); - }); - - it("should flag unimported CSS files as unused", async () => { - const result = await scanFixture("style-tracking"); - const fixtureDir = resolve(FIXTURES_DIR, "style-tracking"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/unused.css"), - "CSS files are excluded from unused-file detection", - ); - }); - - it("should flag orphan TS files", async () => { - const result = await scanFixture("style-tracking"); - const fixtureDir = resolve(FIXTURES_DIR, "style-tracking"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("config-mixed-formats", () => { - it("should treat .cjs and .mjs config files as entry points", async () => { - const result = await scanFixture("config-mixed-formats"); - const fixtureDir = resolve(FIXTURES_DIR, "config-mixed-formats"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("prettier.config.mjs"), - "prettier.config.mjs should be treated as config entry point", - ); - assert.ok( - !unusedFilePaths.includes("vitest.config.mts"), - "vitest.config.mts should be treated as config entry point", - ); - assert.ok( - unusedFilePaths.includes("lage.config.cjs"), - "lage.config.cjs should be unused (not in the config file list)", - ); - }); - - it("should still flag orphan files", async () => { - const result = await scanFixture("config-mixed-formats"); - const fixtureDir = resolve(FIXTURES_DIR, "config-mixed-formats"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("prettier-rc-plugins", () => { - it("should not flag plugins referenced in .prettierrc as unused", async () => { - const result = await scanFixture("prettier-rc-plugins"); - const deps = staleDependencyNames(result); - assert.ok( - !deps.includes("@trivago/prettier-plugin-sort-imports"), - `@trivago/prettier-plugin-sort-imports is referenced in .prettierrc, got: ${deps}`, - ); - assert.ok(!deps.includes("prettier"), `prettier should not be flagged, got: ${deps}`); - }); - - it("should still flag genuinely unused devDependencies", async () => { - const result = await scanFixture("prettier-rc-plugins"); - const deps = staleDependencyNames(result); - assert.ok(deps.includes("unused-dev-dep"), `unused-dev-dep should be flagged, got: ${deps}`); - }); -}); - -describe("test-runner-detect", () => { - it("should treat .test.ts files as entry points", async () => { - const result = await scanFixture("test-runner-detect"); - const fixtureDir = resolve(FIXTURES_DIR, "test-runner-detect"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/helper.test.ts"), - `helper.test.ts should be an entry point (vitest detected), got: ${unusedFilePaths}`, - ); - }); - - it("should treat __tests__ files as entry points", async () => { - const result = await scanFixture("test-runner-detect"); - const fixtureDir = resolve(FIXTURES_DIR, "test-runner-detect"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/__tests__/utils.test.ts"), - `__tests__/utils.test.ts should be an entry point (vitest detected), got: ${unusedFilePaths}`, - ); - }); - - it("should keep files imported by test files as reachable", async () => { - const result = await scanFixture("test-runner-detect"); - const fixtureDir = resolve(FIXTURES_DIR, "test-runner-detect"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/helper.ts"), - `helper.ts should be reachable via test import, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/test-only-used.ts"), - `test-only-used.ts should be reachable via test import, got: ${unusedFilePaths}`, - ); - }); - - it("should still flag orphan files", async () => { - const result = await scanFixture("test-runner-detect"); - const fixtureDir = resolve(FIXTURES_DIR, "test-runner-detect"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("test-no-runner", () => { - it("should NOT treat .test.ts as entry point without a test runner dependency", async () => { - const result = await scanFixture("test-no-runner"); - const fixtureDir = resolve(FIXTURES_DIR, "test-no-runner"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/helper.test.ts"), - `test files are excluded from unused-file detection, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("alias-mixed-exports", () => { - it("should resolve @/ aliases and keep used files reachable", async () => { - const result = await scanFixture("alias-mixed-exports"); - const fixtureDir = resolve(FIXTURES_DIR, "alias-mixed-exports"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok(!unusedFilePaths.includes("src/types.ts"), "types.ts is imported via alias"); - assert.ok(!unusedFilePaths.includes("src/helpers.ts"), "helpers.ts is imported via alias"); - }); - - it("should flag orphan.ts as unused", async () => { - const result = await scanFixture("alias-mixed-exports"); - const fixtureDir = resolve(FIXTURES_DIR, "alias-mixed-exports"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); - - it("should flag unusedExport and unusedHelper", async () => { - const result = await scanFixture("alias-mixed-exports"); - const allUnusedNames = deadExportNames(result); - assert.ok( - allUnusedNames.includes("unusedExport"), - `unusedExport should be unused, got: ${allUnusedNames}`, - ); - assert.ok( - allUnusedNames.includes("unusedHelper"), - `unusedHelper should be unused, got: ${allUnusedNames}`, - ); - }); -}); - -describe("mock-patterns", () => { - it("should treat __fixtures__ files as entry points", async () => { - const result = await scanFixture("mock-patterns"); - const fixtureDir = resolve(FIXTURES_DIR, "mock-patterns"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/__fixtures__/user-data.ts"), - `__fixtures__/user-data.ts should be an entry point (vitest fixture), got: ${unusedFilePaths}`, - ); - }); - - it("should treat __mocks__ files as unused when only vitest is present (not jest)", async () => { - const result = await scanFixture("mock-patterns"); - const fixtureDir = resolve(FIXTURES_DIR, "mock-patterns"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/__mocks__/api-client.ts"), - `__mocks__/api-client.ts should be unused (vitest does not auto-discover __mocks__), got: ${unusedFilePaths}`, - ); - }); - - it("should still flag orphan files", async () => { - const result = await scanFixture("mock-patterns"); - const fixtureDir = resolve(FIXTURES_DIR, "mock-patterns"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("electron-app", () => { - it("should use directory-based Electron plugin patterns (src/main/**/)", async () => { - const result = await scanFixture("electron-app"); - const fixtureDir = resolve(FIXTURES_DIR, "electron-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/main/index.ts"), - `src/main/index.ts should be entry via Electron plugin src/main/**/*.{ts,js}, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/main/window.ts"), - `src/main/window.ts should be reachable from main/index.ts, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/preload/preload.ts"), - `src/preload/preload.ts should be entry via Electron plugin src/preload/**/*.{ts,js}, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/preload.ts"), - `src/preload.ts (file, not inside src/preload/ dir) should be unused, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("electron-entries", () => { - it("should detect vite src/main.ts entry and electron src/preload/ dir entries", async () => { - const result = await scanFixture("electron-entries"); - const fixtureDir = resolve(FIXTURES_DIR, "electron-entries"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/main.ts"), - `src/main.ts should be entry via vite plugin, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/app.ts"), - `src/app.ts should be reachable from main.ts, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/preload/index.ts"), - `src/preload/index.ts should be entry via electron plugin src/preload/**/*.{ts,...}, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/preload/bridge.ts"), - `src/preload/bridge.ts should be reachable from preload/index.ts, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("ava-app", () => { - it("should detect ava test files as entry points", async () => { - const result = await scanFixture("ava-app"); - const fixtureDir = resolve(FIXTURES_DIR, "ava-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("test/math.test.ts"), - `test/math.test.ts should be entry via ava plugin, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/math.ts"), - `src/math.ts should be reachable from test, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("src-path-fallback", () => { - it("should resolve dist/ exports to src/index.ts fallback when exact match not found", async () => { - const result = await scanFixture("src-path-fallback"); - const fixtureDir = resolve(FIXTURES_DIR, "src-path-fallback"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/index.ts"), - `src/index.ts should be resolved from dist/index.js export, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/helper.ts"), - `src/helper.ts should be reachable from index.ts, got: ${unusedFilePaths}`, - ); - }); - - it("should resolve dist/cli.js to src/cli/index.ts via tsconfig outDir", async () => { - const result = await scanFixture("src-path-fallback"); - const fixtureDir = resolve(FIXTURES_DIR, "src-path-fallback"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/cli/index.ts"), - `src/cli/index.ts should be reachable via dist/cli.js bin entry, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/cli/runner.ts"), - `src/cli/runner.ts should be reachable via cli/index.ts, got: ${unusedFilePaths}`, - ); - }); - - it("should still flag orphan files", async () => { - const result = await scanFixture("src-path-fallback"); - const fixtureDir = resolve(FIXTURES_DIR, "src-path-fallback"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("heuristic-no-dir-fallback", () => { - it("should not resolve dist/cli.js to src/cli/index.ts without tsconfig outDir", async () => { - const result = await scanFixture("heuristic-no-dir-fallback"); - const fixtureDir = resolve(FIXTURES_DIR, "heuristic-no-dir-fallback"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/cli/index.ts"), - `src/cli/index.ts should be unused (heuristic should not do directory fallback), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/index.ts"), - `src/index.ts should be resolved from dist/index.js via heuristic, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("spec-dash-patterns", () => { - it("should treat *-spec.ts and *_spec.ts as unused (not matched by vitest/jest patterns)", async () => { - const result = await scanFixture("spec-dash-patterns"); - const fixtureDir = resolve(FIXTURES_DIR, "spec-dash-patterns"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("spec/utils-spec.ts"), - `utils-spec.ts should be unused (*-spec not matched by vitest), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("spec/engine_spec.ts"), - `engine_spec.ts should be unused (*_spec not matched by vitest), got: ${unusedFilePaths}`, - ); - }); - - it("should still flag orphan files", async () => { - const result = await scanFixture("spec-dash-patterns"); - const fixtureDir = resolve(FIXTURES_DIR, "spec-dash-patterns"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("workspace-explicit", () => { - it("should treat workspace package main entry as reachable and keep non-imported files unused", async () => { - const result = await scanFixture("workspace-explicit"); - const fixtureDir = resolve(FIXTURES_DIR, "workspace-explicit"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("packages/ui/src/button.ts"), - `packages/ui/src/button.ts should be reachable (workspace main entry), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("packages/ui/src/index.ts"), - `packages/ui/src/index.ts should be unused (not imported by main entry), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("packages/utils/src/index.ts"), - `packages/utils/src/index.ts should be reachable (default index fallback for workspace without main), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("packages/utils/src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("lerna-workspace", () => { - it("should discover workspace packages from lerna.json", async () => { - const result = await scanFixture("lerna-workspace"); - const fixtureDir = resolve(FIXTURES_DIR, "lerna-workspace"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("packages/app/src/index.ts"), - `app index should be reachable as a lerna workspace entry, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("packages/ui/src/index.ts"), - `ui index should be reachable via workspace package import, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("packages/ui/src/button.ts"), - `button.ts should be reachable through the ui barrel, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("packages/ui/src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("workspace-defaults", () => { - it("should fall back to src/index when package.json entries point to non-existent dist", async () => { - const result = await scanFixture("workspace-defaults"); - const fixtureDir = resolve(FIXTURES_DIR, "workspace-defaults"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("packages/lib-a/src/index.ts"), - `packages/lib-a/src/index.ts should be reachable (default fallback from dist entry), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("packages/lib-a/src/helper.ts"), - `packages/lib-a/src/helper.ts should be reachable (imported by index.ts), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("packages/lib-a/src/orphan.ts"), - `packages/lib-a/src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("packages/lib-b/src/index.ts"), - `packages/lib-b/src/index.ts should be reachable (default index fallback for package without main), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("workspace-wildcards", () => { - it("should expand wildcard exports as entry points and resolve via imports", async () => { - const result = await scanFixture("workspace-wildcards"); - const fixtureDir = resolve(FIXTURES_DIR, "workspace-wildcards"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("packages/ui/src/components/index.ts"), - `components/index.ts should be reachable via wildcard export resolution, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("packages/ui/src/components/button.ts"), - `button.ts should be reachable via barrel re-export, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("packages/ui/src/orphan.ts"), - `orphan.ts should be reachable — wildcard export src/* expands it as entry point, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("packages/ui/internal/hidden.ts"), - `internal/hidden.ts should be unused (not covered by exports), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("wildcard-subpath", () => { - it("should expand wildcard exports as entry points", async () => { - const result = await scanFixture("wildcard-subpath"); - const fixtureDir = resolve(FIXTURES_DIR, "wildcard-subpath"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/templates/welcome.tsx"), - `welcome.tsx should be reachable — wildcard exports are expanded as entries, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/templates/goodbye.tsx"), - `goodbye.tsx should be reachable — wildcard exports are expanded as entries, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("orphan.ts"), - `orphan.ts should be unused (not in templates dir), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("vite-glob-import", () => { - it("should resolve import.meta.glob patterns including array syntax", async () => { - const result = await scanFixture("vite-glob-import"); - const fixtureDir = resolve(FIXTURES_DIR, "vite-glob-import"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/modules/alpha.ts"), - `alpha.ts should be reachable via import.meta.glob, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/modules/beta.ts"), - `beta.ts should be reachable via import.meta.glob, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/layouts/main.ts"), - `layouts/main.ts should be reachable via import.meta.glob array pattern, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused (not matched by glob pattern), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("jest-mock-files", () => { - it("should treat __mocks__ files as test entry points when jest is present", async () => { - const result = await scanFixture("jest-mock-files"); - const fixtureDir = resolve(FIXTURES_DIR, "jest-mock-files"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("__mocks__/fs.ts"), - `__mocks__/fs.ts should be reachable as Jest manual mock entry, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("__mocks__/api-client.ts"), - `__mocks__/api-client.ts should be reachable as Jest manual mock entry, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("jest-match", () => { - it("should use custom testMatch patterns from jest.config.ts instead of defaults", async () => { - const result = await scanFixture("jest-match"); - const fixtureDir = resolve(FIXTURES_DIR, "jest-match"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/utils.test.ts"), - `src/utils.test.ts should be reachable via custom testMatch, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/__tests__/app.test.ts"), - `src/__tests__/app.test.ts should be reachable via custom testMatch, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("tests/outside.test.ts"), - `test files are excluded from unused-file detection, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("webpack-require-ctx", () => { - it("should resolve require.context patterns with recursive flag and regex filter", async () => { - const result = await scanFixture("webpack-require-ctx"); - const fixtureDir = resolve(FIXTURES_DIR, "webpack-require-ctx"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/components/Button.tsx"), - `Button.tsx should be reachable via require.context('./components', true, /\\.tsx$/), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/components/nested/Card.tsx"), - `nested/Card.tsx should be reachable via recursive require.context, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/pages/home.ts"), - `pages/home.ts should be reachable via require.context('./pages', false), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused (not matched by any require.context), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("storybook-app", () => { - it("should treat .stories.ts files as entry points when @storybook/* is present", async () => { - const result = await scanFixture("storybook-app"); - const fixtureDir = resolve(FIXTURES_DIR, "storybook-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/components/Button.stories.ts"), - `Button.stories.ts should be entry point, got: ${unusedFilePaths}`, - ); - }); - - it("should treat .storybook config files as entry points", async () => { - const result = await scanFixture("storybook-app"); - const fixtureDir = resolve(FIXTURES_DIR, "storybook-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes(".storybook/main.ts"), - `.storybook/main.ts should be entry point, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes(".storybook/preview.ts"), - `.storybook/preview.ts should be entry point, got: ${unusedFilePaths}`, - ); - }); - - it("should mark components imported by stories as used", async () => { - const result = await scanFixture("storybook-app"); - const fixtureDir = resolve(FIXTURES_DIR, "storybook-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/components/Button.ts"), - `Button.ts should be reachable from stories, got: ${unusedFilePaths}`, - ); - }); - - it("should still flag orphan files in storybook projects", async () => { - const result = await scanFixture("storybook-app"); - const fixtureDir = resolve(FIXTURES_DIR, "storybook-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("graphql-schema", () => { - it("should track imported graphql files as reachable", async () => { - const result = await scanFixture("graphql-schema"); - const fixtureDir = resolve(FIXTURES_DIR, "graphql-schema"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/schema.graphql"), - `schema.graphql is imported and should be reachable, got: ${unusedFilePaths}`, - ); - }); - - it("should flag unused graphql files", async () => { - const result = await scanFixture("graphql-schema"); - const fixtureDir = resolve(FIXTURES_DIR, "graphql-schema"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/unused.graphql"), - `GraphQL files are excluded from unused-file detection, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("next-pages-mdx", () => { - it("should exclude MDX files from unused-file detection", async () => { - const result = await scanFixture("next-pages-mdx"); - const fixtureDir = resolve(FIXTURES_DIR, "next-pages-mdx"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("pages/about.mdx"), - `about.mdx should be excluded from unused-file (MDX files are excluded by default)`, - ); - }); - - it("should still discover TSX files in pages/ as entry points", async () => { - const result = await scanFixture("next-pages-mdx"); - const fixtureDir = resolve(FIXTURES_DIR, "next-pages-mdx"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("pages/index.tsx"), - `index.tsx should be entry point, got: ${unusedFilePaths}`, - ); - }); - - it("should mark components imported by pages as reachable", async () => { - const result = await scanFixture("next-pages-mdx"); - const fixtureDir = resolve(FIXTURES_DIR, "next-pages-mdx"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/Home.ts"), - `Home.ts is imported by index.tsx and should be reachable, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("migration-orm", () => { - it("should treat migration files as entry points when ORM is detected", async () => { - const result = await scanFixture("migration-orm"); - const fixtureDir = resolve(FIXTURES_DIR, "migration-orm"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("migrations/001-create-users.ts"), - `migration file should be entry point when knex is present, got: ${unusedFilePaths}`, - ); - }); - - it("should still flag orphan files", async () => { - const result = await scanFixture("migration-orm"); - const fixtureDir = resolve(FIXTURES_DIR, "migration-orm"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("migration-raw", () => { - it("should NOT treat migration files as entry points without ORM dependency", async () => { - const result = await scanFixture("migration-raw"); - const fixtureDir = resolve(FIXTURES_DIR, "migration-raw"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("migrations/001-create-users.ts"), - `migration file should be unused without ORM, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("style-imports", () => { - it("should track CSS files imported from TS as reachable", async () => { - const result = await scanFixture("style-imports"); - const fixtureDir = resolve(FIXTURES_DIR, "style-imports"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/app.css"), - `app.css is imported by index.ts and should be reachable, got: ${unusedFilePaths}`, - ); - }); - - it("should track CSS @import chains as reachable", async () => { - const result = await scanFixture("style-imports"); - const fixtureDir = resolve(FIXTURES_DIR, "style-imports"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("styles/base.css"), - `base.css is @imported from app.css and should be reachable, got: ${unusedFilePaths}`, - ); - }); - - it("should flag orphan CSS files as unused", async () => { - const result = await scanFixture("style-imports"); - const fixtureDir = resolve(FIXTURES_DIR, "style-imports"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("styles/orphan.css"), - `CSS files are excluded from unused-file detection, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("nestjs-app", () => { - it("should detect NestJS convention files as entry points", async () => { - const result = await scanFixture("nestjs-app"); - const fixtureDir = resolve(FIXTURES_DIR, "nestjs-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/app.module.ts"), - `app.module.ts should be entry point (NestJS module), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/users.controller.ts"), - `users.controller.ts should be entry point (NestJS controller), got: ${unusedFilePaths}`, - ); - }); - - it("should flag non-NestJS files as unused", async () => { - const result = await scanFixture("nestjs-app"); - const fixtureDir = resolve(FIXTURES_DIR, "nestjs-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("test-node-runner", () => { - it("should treat node --test files as entry points", async () => { - const result = await scanFixture("test-node-runner"); - const fixtureDir = resolve(FIXTURES_DIR, "test-node-runner"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/__tests__/main.test.ts"), - `main.test.ts should be an entry point (node test runner detected), got: ${unusedFilePaths}`, - ); - }); - - it("should flag non-test orphan files as unused", async () => { - const result = await scanFixture("test-node-runner"); - const fixtureDir = resolve(FIXTURES_DIR, "test-node-runner"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("config-script-flags", () => { - it("should detect --config flag files as entry points", async () => { - const result = await scanFixture("config-script-flags"); - const fixtureDir = resolve(FIXTURES_DIR, "config-script-flags"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("db/drizzle.config.ts"), - `drizzle.config.ts should be entry point (--config flag), got: ${unusedFilePaths}`, - ); - }); - - it("should detect tsx script files as entry points", async () => { - const result = await scanFixture("config-script-flags"); - const fixtureDir = resolve(FIXTURES_DIR, "config-script-flags"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("scripts/seed.ts"), - `seed.ts should be entry point (tsx script), got: ${unusedFilePaths}`, - ); - }); - - it("should flag orphan files as unused", async () => { - const result = await scanFixture("config-script-flags"); - const fixtureDir = resolve(FIXTURES_DIR, "config-script-flags"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused (config-script-flags), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("i18n-app", () => { - it("should mark locale JSON files as always-used when i18next is a dependency", async () => { - const result = await scanFixture("i18n-app"); - const fixtureDir = resolve(FIXTURES_DIR, "i18n-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("public/locales/en.json"), - `en.json should be always-used (i18next locale), got: ${unusedFilePaths}`, - ); - }); - - it("should flag orphan files as unused", async () => { - const result = await scanFixture("i18n-app"); - const fixtureDir = resolve(FIXTURES_DIR, "i18n-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused (i18n-app), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("subproject-standalone", () => { - it("should still scan standalone sub-project files and report unused", async () => { - const result = await scanFixture("subproject-standalone"); - const fixtureDir = resolve(FIXTURES_DIR, "subproject-standalone"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.some((filePath: string) => filePath.startsWith("docs/")), - `docs/ files should still be scanned, got: ${unusedFilePaths}`, - ); - }); - - it("should still detect unused files in the main app", async () => { - const result = await scanFixture("subproject-standalone"); - const fixtureDir = resolve(FIXTURES_DIR, "subproject-standalone"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("app/src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("build-script-map", () => { - it("should resolve build/ script references to src/ source files", async () => { - const result = await scanFixture("build-script-map"); - const fixtureDir = resolve(FIXTURES_DIR, "build-script-map"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/scripts/migrate.ts"), - `migrate.ts should be entry (build/ → src/ mapping), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/scripts/health-check.ts"), - `health-check.ts should be entry (build/ → src/ mapping), got: ${unusedFilePaths}`, - ); - }); - - it("should flag orphan files as unused", async () => { - const result = await scanFixture("build-script-map"); - const fixtureDir = resolve(FIXTURES_DIR, "build-script-map"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("tsconfig-wildcard", () => { - it("should resolve wildcard * path alias that shadows Node.js built-in modules", async () => { - const result = await scanFixture("tsconfig-wildcard"); - const fixtureDir = resolve(FIXTURES_DIR, "tsconfig-wildcard"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/constants/api.ts"), - `constants/api.ts should be resolved via wildcard path alias, got: ${unusedFilePaths}`, - ); - }); - - it("should flag orphan files as unused", async () => { - const result = await scanFixture("tsconfig-wildcard"); - const fixtureDir = resolve(FIXTURES_DIR, "tsconfig-wildcard"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused (wildcard alias), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("scss-partial", () => { - it("should resolve SCSS partial imports with underscore prefix", async () => { - const result = await scanFixture("scss-partial"); - const fixtureDir = resolve(FIXTURES_DIR, "scss-partial"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/styles/_variables.scss"), - `_variables.scss should be used (SCSS partial import), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/styles/_mixins.scss"), - `_mixins.scss should be used (SCSS @use), got: ${unusedFilePaths}`, - ); - }); - - it("should flag orphan SCSS partials as unused", async () => { - const result = await scanFixture("scss-partial"); - const fixtureDir = resolve(FIXTURES_DIR, "scss-partial"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/styles/_orphan.scss"), - `SCSS files are excluded from unused-file detection, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("test-custom-ext", () => { - it("should treat .clienttest, .servertest, and __e2e__ test files as unused (non-standard patterns)", async () => { - const result = await scanFixture("test-custom-ext"); - const fixtureDir = resolve(FIXTURES_DIR, "test-custom-ext"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/utils.clienttest.ts"), - `.clienttest.ts should be unused (non-standard pattern), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/api.servertest.ts"), - `.servertest.ts should be unused (non-standard pattern), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/__e2e__/login.test.ts"), - `__e2e__/*.test.ts should still be matched by **/*.test.* pattern, got: ${unusedFilePaths}`, - ); - }); - - it("should flag orphan files as unused", async () => { - const result = await scanFixture("test-custom-ext"); - const fixtureDir = resolve(FIXTURES_DIR, "test-custom-ext"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("vue-app", () => { - it("should follow imports inside Vue SFC script blocks", async () => { - const result = await scanFixture("vue-app"); - const fixtureDir = resolve(FIXTURES_DIR, "vue-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/App.vue"), - `App.vue should be used (imported from main.ts), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/components/HelloWorld.vue"), - `HelloWorld.vue should be used (imported from App.vue), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/utils.ts"), - `utils.ts should be used (imported from HelloWorld.vue), got: ${unusedFilePaths}`, - ); - }); - - it("should flag orphan Vue components as unused", async () => { - const result = await scanFixture("vue-app"); - const fixtureDir = resolve(FIXTURES_DIR, "vue-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/components/OrphanComponent.vue"), - `OrphanComponent.vue should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("vite-app", () => { - it("should detect entry points from vite.config rollupOptions.input", async () => { - const result = await scanFixture("vite-app"); - const fixtureDir = resolve(FIXTURES_DIR, "vite-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/main.tsx"), - `main.tsx should be used (vite entry), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/render.ts"), - `render.ts should be used (imported from vite entry), got: ${unusedFilePaths}`, - ); - }); - - it("should flag orphan files as unused with vite entry", async () => { - const result = await scanFixture("vite-app"); - const fixtureDir = resolve(FIXTURES_DIR, "vite-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("outdir-mapping", () => { - it("should resolve built paths back to source via tsconfig outDir", async () => { - const result = await scanFixture("outdir-mapping"); - const fixtureDir = resolve(FIXTURES_DIR, "outdir-mapping"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("main/index.ts"), - `main/index.ts should be used (entry via outDir mapping), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("main/setup.ts"), - `main/setup.ts should be used (imported from entry), got: ${unusedFilePaths}`, - ); - }); - - it("should flag orphan files even with outDir source mapping", async () => { - const result = await scanFixture("outdir-mapping"); - const fixtureDir = resolve(FIXTURES_DIR, "outdir-mapping"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("main/orphan.ts"), - `main/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -test("should resolve imports with query parameters (e.g. ?url, ?raw, ?worker)", async () => { - const result = await scanFixture("import-query-param"); - const unusedFilePaths = result.unusedFiles.map((file) => file.path); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("config.ts")), - `config.ts should NOT be unused (imported via ?raw), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("worker.ts")), - `worker.ts should NOT be unused (imported via ?worker), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("styles.css")), - `styles.css should NOT be unused (imported via ?url), got unused: ${unusedFilePaths}`, - ); -}); - -test("should flag orphan files even with query-param imports present", async () => { - const result = await scanFixture("import-query-param"); - const unusedFilePaths = result.unusedFiles.map((file) => file.path); - assert.ok( - unusedFilePaths.some((filePath) => filePath.endsWith("orphan.ts")), - `orphan.ts should be unused, got unused: ${unusedFilePaths}`, - ); -}); - -test("should detect script entry points with --key value flag pairs", async () => { - const result = await scanFixture("script-flags"); - const unusedFilePaths = result.unusedFiles.map((file) => file.path); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("scripts/build.ts")), - `scripts/build.ts should NOT be unused (referenced via tsx --tsconfig X scripts/build.ts), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("scripts/generate.mts")), - `scripts/generate.mts should NOT be unused (referenced via bun run), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("tests/run.ts")), - `tests/run.ts should NOT be unused (referenced via node --import tsx --test), got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.some((filePath) => filePath.endsWith("orphan.ts")), - `orphan.ts should be unused, got unused: ${unusedFilePaths}`, - ); -}); - -test("should detect Angular workspace entry points from angular.json", async () => { - const result = await scanFixture("angular-workspace"); - const unusedFilePaths = result.unusedFiles.map((file) => file.path); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("main.ts")), - `main.ts should NOT be unused (Angular entry), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("polyfills.ts")), - `polyfills.ts should NOT be unused (Angular polyfills), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("app.module.ts")), - `app.module.ts should NOT be unused (imported by main.ts), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("app.component.ts")), - `app.component.ts should NOT be unused (imported by app.module.ts), got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.some((filePath) => filePath.endsWith("environment.ts")), - `environment.ts should be unused (not imported), got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.some((filePath) => filePath.endsWith("orphan.ts")), - `orphan.ts should be unused, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("app.component.css")), - `app.component.css should NOT be unused (referenced by @Component styleUrls), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("app.component.html")), - `app.component.html should NOT be unused (referenced by @Component templateUrl), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("orphan.css")), - `CSS files are excluded from unused-file detection, got unused: ${unusedFilePaths}`, - ); -}); - -test("should resolve #hash subpath imports via tsconfig paths with .js extension", async () => { - const result = await scanFixture("import-subpath"); - const unusedFilePaths = result.unusedFiles.map((file) => file.path); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("api/user.ts")), - `api/user.ts should NOT be unused (imported via #src/api/user.js), got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.some((filePath) => filePath.endsWith("api/orphan.ts")), - `api/orphan.ts should be unused (not imported), got unused: ${unusedFilePaths}`, - ); -}); - -test("should treat vitest setupFiles as entry points", async () => { - const result = await scanFixture("vitest-setup"); - const unusedFilePaths = result.unusedFiles.map((file) => file.path); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("test/setup.ts")), - `test/setup.ts should be an entry point (vitest setup file), got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.some((filePath) => filePath.endsWith("orphan.ts")), - `orphan.ts should be unused (not imported), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("vitest.config.ts")), - `vitest.config.ts should NOT be unused (config file), got unused: ${unusedFilePaths}`, - ); -}); - -test("should detect new URL with import.meta.url as imports (web workers)", async () => { - const result = await scanFixture("worker-new-url"); - const unusedFilePaths = result.unusedFiles.map((file) => file.path); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("worker.js")), - `worker.js should NOT be unused (referenced via new URL), got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.some((filePath) => filePath.endsWith("orphan.ts")), - `orphan.ts should be unused (not imported), got unused: ${unusedFilePaths}`, - ); -}); - -test("should exclude multi-segment config files (e.g. cypress.config.contract.js)", async () => { - const result = await scanFixture("config-compound-name"); - const unusedFilePaths = result.unusedFiles.map((file) => file.path); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("cypress.config.contract.js")), - `cypress.config.contract.js should NOT be unused (config file), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("vitest.config.unit.ts")), - `vitest.config.unit.ts should NOT be unused (config file), got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.some((filePath) => filePath.endsWith("orphan.ts")), - `orphan.ts should be unused (not imported), got unused: ${unusedFilePaths}`, - ); -}); - -test("should treat jest __mocks__ files as entry points", async () => { - const result = await scanFixture("jest-mapper"); - const unusedFilePaths = result.unusedFiles.map((file) => file.path); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("__mocks__/styleMock.js")), - `styleMock.js should be reachable as Jest __mocks__ entry, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("__mocks__/fileMock.js")), - `fileMock.js should be reachable as Jest __mocks__ entry, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.some((filePath) => filePath.endsWith("orphan.ts")), - `orphan.ts should be unused (not imported), got unused: ${unusedFilePaths}`, - ); -}); - -test("should resolve CSS files imported via tsconfig path aliases", async () => { - const result = await scanFixture("style-alias"); - const unusedFilePaths = result.unusedFiles.map((file) => file.path); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("globals.css")), - `globals.css should NOT be unused (imported via @/styles/globals.css), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("lib/utils.ts")), - `lib/utils.ts should NOT be unused (imported via @/lib/utils), got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.some((filePath) => filePath.endsWith("orphan.ts")), - `orphan.ts should be unused (not imported), got unused: ${unusedFilePaths}`, - ); -}); - -test("should resolve @/ imports via Next.js default path alias when tsconfig is empty", async () => { - const result = await scanFixture("next-empty-tsconfig"); - const unusedFilePaths = result.unusedFiles.map((file) => file.path); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("src/env.ts")), - `env.ts should NOT be unused (imported via @/env), got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.some((filePath) => filePath.endsWith("orphan.ts")), - `orphan.ts should be unused (not imported), got unused: ${unusedFilePaths}`, - ); -}); - -describe("workspace-path-alias", () => { - it("should resolve imports via config paths when tsconfig has matching aliases", async () => { - const result = await scanFixture("workspace-path-alias"); - const fixtureDir = resolve(FIXTURES_DIR, "workspace-path-alias"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("packages/core/utils.ts"), - `utils.ts should NOT be unused (imported via @project/core/utils), got: ${unusedFilePaths}`, - ); - }); - - it("should resolve imports via config paths option without tsconfig", async () => { - const result = await scanFixture("workspace-path-alias-no-tsconfig", { - paths: { "@project/core/*": ["packages/core/*"] }, - }); - const fixtureDir = resolve(FIXTURES_DIR, "workspace-path-alias-no-tsconfig"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("packages/core/utils.ts"), - `utils.ts should NOT be unused (resolved via config paths), got: ${unusedFilePaths}`, - ); - }); - - it("should flag orphan files even with config paths", async () => { - const result = await scanFixture("workspace-path-alias", { - paths: { "@project/core/*": ["packages/core/*"] }, - }); - const fixtureDir = resolve(FIXTURES_DIR, "workspace-path-alias"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("packages/core/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("workspace-structural-alias", () => { - it("should auto-resolve @scope/<dir> imports from workspace layout without tsconfig or config", async () => { - const result = await scanFixture("workspace-structural-alias"); - const fixtureDir = resolve(FIXTURES_DIR, "workspace-structural-alias"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("packages/core/utils.ts"), - `utils.ts should resolve via @project/core structural alias, got: ${unusedFilePaths}`, - ); - }); - - it("should still flag genuinely unused files under a structurally-aliased package", async () => { - const result = await scanFixture("workspace-structural-alias"); - const fixtureDir = resolve(FIXTURES_DIR, "workspace-structural-alias"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("packages/core/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("config-paths-only", () => { - it("should resolve imports only via the explicit `paths` option (no tsconfig, no bundler config)", async () => { - const result = await scanFixture("config-paths-only", { - paths: { "@custom/*": ["lib/*"] }, - }); - const fixtureDir = resolve(FIXTURES_DIR, "config-paths-only"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("lib/thing.ts"), - `thing.ts should resolve via config paths, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("lib/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); - - it("should flag the aliased file as unused when `paths` is absent (proves the option drives resolution)", async () => { - const result = await scanFixture("config-paths-only"); - const fixtureDir = resolve(FIXTURES_DIR, "config-paths-only"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("lib/thing.ts"), - `thing.ts should be unused without config paths, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("path-alias-specificity", () => { - it("should resolve via the most specific matching alias, not the first declared", async () => { - const result = await scanFixture("path-alias-specificity", { - paths: { "@x/*": ["general/*"], "@x/feature/*": ["special/*"] }, - }); - const fixtureDir = resolve(FIXTURES_DIR, "path-alias-specificity"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("special/thing.ts"), - `special/thing.ts should win via the more specific @x/feature/* alias, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("general/feature/thing.ts"), - `general/feature/thing.ts should be unused (less specific alias lost), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("import-specifier-sanitize", () => { - it("should resolve targets through webpack loader prefixes, query strings, and hash fragments", async () => { - const result = await scanFixture("import-specifier-sanitize"); - const fixtureDir = resolve(FIXTURES_DIR, "import-specifier-sanitize"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/worker.ts"), - `worker.ts should resolve through "worker-loader!./worker", got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/query.ts"), - `query.ts should resolve through "./query?raw", got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/frag.ts"), - `frag.ts should resolve through "./frag#section", got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("bundler-alias-resolution", () => { - it("should resolve vite resolve.alias entries (path.resolve and fileURLToPath) without tsconfig", async () => { - const result = await scanFixture("vite-resolve-alias"); - const fixtureDir = resolve(FIXTURES_DIR, "vite-resolve-alias"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/lib/util.ts"), - `util.ts should resolve via @lib vite alias, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/widget.ts"), - `widget.ts should resolve via @ vite alias, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/lib/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); - - it("should resolve jest moduleNameMapper aliases without tsconfig", async () => { - const result = await scanFixture("jest-module-name-mapper"); - const fixtureDir = resolve(FIXTURES_DIR, "jest-module-name-mapper"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/value.ts"), - `value.ts should resolve via @app/* jest moduleNameMapper, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); - - it("should resolve babel module-resolver aliases without tsconfig", async () => { - const result = await scanFixture("babel-module-resolver"); - const fixtureDir = resolve(FIXTURES_DIR, "babel-module-resolver"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/components/button.ts"), - `button.ts should resolve via @components babel alias, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/components/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("docusaurus-docs", () => { - it("should exclude docs/ and blog/ content directories from file discovery", async () => { - const result = await scanFixture("docusaurus-docs"); - const fixtureDir = resolve(FIXTURES_DIR, "docusaurus-docs"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.startsWith("docs/")), - `docs/ content files should not be discovered at all, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.startsWith("blog/")), - `blog/ content files should not be discovered at all, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/components/orphan.tsx"), - `orphan.tsx should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -it("should resolve React Native platform extensions (.web.ts, .native.ts) when react-native detected", async () => { - const result = await scanFixture("rn-platform"); - const fixtureDir = resolve(FIXTURES_DIR, "rn-platform"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/handler.web.ts"), - `handler.web.ts should be reachable via platform extension, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/handler.native.ts"), - `handler.native.ts should be reachable via platform extension, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); -}); - -it("should resolve React Native .ios.tsx and .android.tsx platform variants as reachable", async () => { - const result = await scanFixture("rn-platform"); - const fixtureDir = resolve(FIXTURES_DIR, "rn-platform"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/button.tsx"), - `button.tsx should be reachable as the default platform variant, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/button.ios.tsx"), - `button.ios.tsx should be reachable as iOS platform variant, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/button.android.tsx"), - `button.android.tsx should be reachable as Android platform variant, got unused: ${unusedFilePaths}`, - ); -}); - -it("should detect cra-rewired as CRA variant and use src/index as entry", async () => { - const result = await scanFixture("cra-rewired"); - const fixtureDir = resolve(FIXTURES_DIR, "cra-rewired"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/index.tsx"), - `src/index.tsx should be reachable as CRA entry point, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/App.tsx"), - `src/App.tsx should be reachable from CRA entry, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/components/Header.tsx"), - `Header.tsx should be reachable from App import chain, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); -}); - -it("should resolve CRA src-root bare imports without jsconfig", async () => { - const result = await scanFixture("cra-src-baseurl"); - const fixtureDir = resolve(FIXTURES_DIR, "cra-src-baseurl"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/App.tsx"), - `App.tsx should be reachable via CRA src module root, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/components/Header.tsx"), - `Header.tsx should be reachable via CRA src module root, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); -}); - -it("should scope CRA src-root resolution to packages that declare CRA", async () => { - const result = await scanFixture("cra-monorepo-scope"); - const fixtureDir = resolve(FIXTURES_DIR, "cra-monorepo-scope"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("packages/app/src/App.ts"), - `app App.ts should resolve via its CRA dependency, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("packages/lib/src/RootOnly.ts"), - `lib RootOnly.ts should stay unused because lib is not CRA, got: ${unusedFilePaths}`, - ); -}); - -it("should resolve Storybook MDX imports from story files", async () => { - const result = await scanFixture("storybook-mdx-import"); - const fixtureDir = resolve(FIXTURES_DIR, "storybook-mdx-import"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/components/Alert.story.tsx"), - `Alert.story.tsx should be reachable as storybook entry, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/components/Alert.mdx"), - `Alert.mdx should be reachable via story file import, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/components/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); -}); - -it("should resolve deep workspace imports like @pkg/shared/hooks/assets", async () => { - const result = await scanFixture("workspace-deep-imports"); - const fixtureDir = resolve(FIXTURES_DIR, "workspace-deep-imports"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("packages/shared/src/hooks/assets.ts"), - `hooks/assets.ts should be reachable via deep workspace import, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("packages/shared/src/components/button.ts"), - `components/button.ts should be reachable via deep workspace import, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("packages/shared/src/components/orphan.ts"), - `orphan.ts should be unused since it is not imported, got: ${unusedFilePaths}`, - ); -}); - -it("should mark config files in non-workspace directories as always used via global patterns", async () => { - const result = await scanFixture("config-global-scope"); - const fixtureDir = resolve(FIXTURES_DIR, "config-global-scope"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("templates/next-app/postcss.config.mjs"), - `postcss.config.mjs should be always used via global pattern, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("templates/next-app/eslint.config.js"), - `eslint.config.js should be always used via global pattern, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("templates/next-app/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); -}); - -it("should resolve dynamic imports with template literals as glob patterns", async () => { - const result = await scanFixture("import-dynamic-template"); - const fixtureDir = resolve(FIXTURES_DIR, "import-dynamic-template"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/locales/en/core.js"), - `en/core.js should be reachable via template literal glob, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/locales/fr/core.js"), - `fr/core.js should be reachable via template literal glob, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/locales/de/core.js"), - `de/core.js should be reachable via template literal glob, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); -}); - -it("should resolve package.json exports pointing to .ts files that only exist as .tsx", async () => { - const result = await scanFixture("cross-ext-ts-tsx"); - const fixtureDir = resolve(FIXTURES_DIR, "cross-ext-ts-tsx"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/components/Button.tsx"), - `Button.tsx should be an entry (exported as Button.ts -> .tsx fallback), got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); -}); - -it("should resolve package.json exports pointing to .js files that only exist as .ts", async () => { - const result = await scanFixture("cross-ext-js-ts"); - const fixtureDir = resolve(FIXTURES_DIR, "cross-ext-js-ts"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("generators.ts"), - `generators.ts should be an entry (exported as ./generators.js), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("plugin.ts"), - `plugin.ts should be an entry (exported as ./plugin.js), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/utils/index.ts"), - `src/utils/index.ts should be an entry (exported as ./src/utils/index.js), got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); -}); - -it("should detect script files referenced in GitHub Actions workflow files", async () => { - const result = await scanFixture("ci-scripts"); - const fixtureDir = resolve(FIXTURES_DIR, "ci-scripts"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("scripts/deploy.mjs"), - `deploy.mjs should be detected from CI workflow run step, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("scripts/build-release.ts"), - `build-release.ts should be detected from CI workflow run step, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); -}); - -it("should detect next.config files in non-workspace directories via global alwaysUsed", async () => { - const result = await scanFixture("next-config-scope"); - const fixtureDir = resolve(FIXTURES_DIR, "next-config-scope"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("examples/my-app/next.config.mjs"), - `next.config.mjs in examples should be detected via global alwaysUsed, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); -}); - -it("should mark files matched by glob patterns in package.json scripts as entry points", async () => { - const result = await scanFixture("script-globs"); - const fixtureDir = resolve(FIXTURES_DIR, "script-globs"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("styles/themes/dark.css"), - `dark.css should be marked as entry via script glob (postcss styles/themes/*.css), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("styles/themes/light.css"), - `light.css should be marked as entry via script glob, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should still be flagged as unused, got: ${unusedFilePaths}`, - ); -}); - -it("should exclude config files from unused file detection", async () => { - const result = await scanFixture("config-exclusion"); - const fixtureDir = resolve(FIXTURES_DIR, "config-exclusion"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("vitest.config.ts"), - `vitest.config.ts should be excluded as config file, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("sanity.config.ts"), - `sanity.config.ts should be excluded as config file, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("sanity.cli.ts"), - `sanity.cli.ts should be unused (only excluded by sanity plugin, not global config), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("playwright.smoke.config.mjs"), - `playwright.smoke.config.mjs should be excluded via script -c flag, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should still be flagged as unused, got: ${unusedFilePaths}`, - ); -}); - -it("should activate tooling plugins from optionalDependencies", async () => { - const result = await scanFixture("optional-deps"); - const fixtureDir = resolve(FIXTURES_DIR, "optional-deps"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("sanity.config.ts"), - `sanity.config.ts should be excluded (sanity in optionalDependencies), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("sanity.cli.ts"), - `sanity.cli.ts should be excluded (sanity plugin activated via optionalDependencies), got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("orphan.ts"), - `orphan.ts should still be flagged as unused, got: ${unusedFilePaths}`, - ); -}); - -it("should extract entry points from tsdown/tsup config files", async () => { - const result = await scanFixture("tsdown-entry"); - const fixtureDir = resolve(FIXTURES_DIR, "tsdown-entry"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/main.ts"), - `src/main.ts should be reachable (entry in tsdown.config.ts), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/preload.ts"), - `src/preload.ts should be reachable (entry in tsdown.config.ts), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/utils.ts"), - `src/utils.ts should be reachable (imported by src/main.ts), got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/unused.ts"), - `src/unused.ts should be flagged as unused, got: ${unusedFilePaths}`, - ); -}); - -it("should not exclude source directories named build from scanning", async () => { - const result = await scanFixture("src-build-dir"); - const fixtureDir = resolve(FIXTURES_DIR, "src-build-dir"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/build/plugins.ts"), - `src/build/plugins.ts should be reachable (imported by src/index.ts), got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/build/helpers.ts"), - `src/build/helpers.ts should be reachable (imported by src/build/plugins.ts), got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be flagged as unused, got: ${unusedFilePaths}`, - ); -}); - -it("should treat files referenced via vi.mock/jest.mock as reachable (test imports create edges)", async () => { - const result = await scanFixture("test-mock-import"); - const fixtureDir = resolve(FIXTURES_DIR, "test-mock-import"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/mocked-util.ts"), - `mocked-util.ts should be reachable (imported via vi.mock from test entry), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should still be flagged as unused, got: ${unusedFilePaths}`, - ); -}); - -test("should not treat all .github files as entries, only CI-referenced scripts", async () => { - const result = await scanFixture("gh-actions-scripts"); - const fixtureDir = resolve(FIXTURES_DIR, "gh-actions-scripts"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith(".github/actions/deploy/run.js")), - `run.js should NOT be unused (referenced in CI workflow), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.some((filePath) => - filePath.endsWith(".github/actions/deploy/unused-helper.js"), - ), - `unused-helper.js should be unused (not referenced anywhere), got: ${unusedFilePaths}`, - ); -}); - -test("should resolve workspace dist paths to source and not mark dist as entries", async () => { - const result = await scanFixture("workspace-dist-resolve"); - const fixtureDir = resolve(FIXTURES_DIR, "workspace-dist-resolve"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.includes("dist/")), - `dist/ files should not appear in unused files (ignored), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.some((filePath) => filePath.endsWith("packages/utils/src/orphan.ts")), - `orphan.ts should be unused (not imported by anyone), got: ${unusedFilePaths}`, - ); -}); - -test("should exclude .gen.ts files from test entry patterns", async () => { - const result = await scanFixture("generated-specs"); - const fixtureDir = resolve(FIXTURES_DIR, "generated-specs"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("types.spec.gen.ts")), - `files matching .spec. pattern are excluded from unused-file detection, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.some((filePath) => filePath.endsWith("schema.gen.ts")), - `schema.gen.ts should be unused (generated file, not imported), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("index.test.ts")), - `index.test.ts should be an entry point (jest detected), got: ${unusedFilePaths}`, - ); -}); - -test("should not treat formatter/linter glob targets as entry points", async () => { - const result = await scanFixture("script-glob-formatter"); - const fixtureDir = resolve(FIXTURES_DIR, "script-glob-formatter"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.some((filePath) => filePath.endsWith("src/orphan.ts")), - `orphan.ts should be unused (not imported by anyone), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("scripts/build.ts")), - `build.ts should NOT be unused (referenced in build script), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith("src/helper.ts")), - `helper.ts should NOT be unused (imported by index.ts), got: ${unusedFilePaths}`, - ); -}); - -test("should not treat pages/app directories as entry points without framework dependency", async () => { - const result = await scanFixture("framework-gate/no-framework"); - const fixtureDir = resolve(FIXTURES_DIR, "framework-gate/no-framework"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.some((filePath) => filePath === "app/dashboard/page.tsx"), - `app/dashboard/page.tsx should be unused without next dependency, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.some((filePath) => filePath === "src/routes/index.tsx"), - `src/routes/index.tsx should be unused without router dependency, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.some((filePath) => filePath === "resources/js/Pages/dashboard.tsx"), - `resources/js/Pages/dashboard.tsx should be unused without inertia dependency, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath === "pages/index.tsx"), - `pages/index.tsx should NOT be unused (imported by index.ts), got: ${unusedFilePaths}`, - ); -}); - -test("should treat pages/app as entry points when next is a dependency", async () => { - const result = await scanFixture("framework-gate/with-nextjs"); - const fixtureDir = resolve(FIXTURES_DIR, "framework-gate/with-nextjs"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.some((filePath) => filePath === "unused.tsx"), - `unused.tsx should be unused, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath === "pages/index.tsx"), - `pages/index.tsx should NOT be unused (Next.js pages entry), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath === "app/dashboard/page.tsx"), - `app/dashboard/page.tsx should NOT be unused (Next.js app entry), got: ${unusedFilePaths}`, - ); -}); - -test("should treat app/routes as entry points when @react-router/dev is a dependency and read appDirectory from config", async () => { - const result = await scanFixture("framework-gate/with-react-router"); - const fixtureDir = resolve(FIXTURES_DIR, "framework-gate/with-react-router"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.some((filePath) => filePath === "unused.tsx"), - `unused.tsx should be unused, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath === "src/root.tsx"), - `src/root.tsx should NOT be unused (React Router entry with appDirectory=src), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath === "src/routes/home.tsx"), - `src/routes/home.tsx should NOT be unused (React Router route with appDirectory=src), got: ${unusedFilePaths}`, - ); -}); - -test("should activate hoisted framework dependencies from package-local scripts when scanning a package directly", async () => { - const fixtureDir = resolve(FIXTURES_DIR, "framework-hoisted-script-entry/packages/app"); - const result = await analyze(defineConfig({ rootDir: fixtureDir })); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.some((filePath) => filePath === "orphan.tsx"), - `orphan.tsx should be unused, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath === "pages/index.tsx"), - `pages/index.tsx should NOT be unused (Next script with hoisted dependency), got: ${unusedFilePaths}`, - ); -}); - -test("should activate React Router and Remix entries from hoisted script dependencies", async () => { - for (const frameworkApp of [ - { fixtureName: "react-router-app", frameworkName: "React Router" }, - { fixtureName: "remix-app", frameworkName: "Remix" }, - ]) { - const fixtureDir = resolve( - FIXTURES_DIR, - "framework-hoisted-router-scripts/packages", - frameworkApp.fixtureName, - ); - const result = await analyze(defineConfig({ rootDir: fixtureDir })); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.some((filePath) => filePath === "orphan.tsx"), - `orphan.tsx should be unused for ${frameworkApp.frameworkName}, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath === "app/root.tsx"), - `app/root.tsx should NOT be unused (${frameworkApp.frameworkName} script with hoisted dependency), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath === "app/routes/home.tsx"), - `app/routes/home.tsx should NOT be unused (${frameworkApp.frameworkName} script with hoisted dependency), got: ${unusedFilePaths}`, - ); - } -}); - -test("should treat Inertia app and pages as entry points when Inertia is a dependency", async () => { - const result = await scanFixture("framework-gate/with-inertia"); - const fixtureDir = resolve(FIXTURES_DIR, "framework-gate/with-inertia"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.some((filePath) => filePath === "resources/js/orphan.tsx"), - `resources/js/orphan.tsx should be unused, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath === "resources/js/app.tsx"), - `resources/js/app.tsx should NOT be unused (Inertia app entry), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath === "resources/js/Pages/Admin/index.tsx"), - `resources/js/Pages/Admin/index.tsx should NOT be unused (Inertia page entry), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath === "resources/js/components/page-title.tsx"), - `resources/js/components/page-title.tsx should NOT be unused (imported by Inertia page), got: ${unusedFilePaths}`, - ); -}); - -test("should not activate Redwood page entries for non-router Redwood packages", async () => { - const result = await scanFixture("framework-gate/with-redwood-non-router-package"); - const fixtureDir = resolve(FIXTURES_DIR, "framework-gate/with-redwood-non-router-package"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.some((filePath) => filePath === "web/src/pages/home.tsx"), - `web/src/pages/home.tsx should be unused without @redwoodjs/router or @redwoodjs/web, got: ${unusedFilePaths}`, - ); -}); - -test("should treat additional dependency-gated framework page conventions as entry points", async () => { - const result = await scanFixture("framework-gate/with-additional-framework-pages"); - const fixtureDir = resolve(FIXTURES_DIR, "framework-gate/with-additional-framework-pages"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.some((filePath) => filePath === "src/orphan.ts"), - `src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - - for (const expectedReachableFile of [ - "web/src/pages/home.tsx", - "web/src/layouts/main.tsx", - "web/src/Routes.tsx", - "src/pages/blog/index.page.tsx", - "src/renderer/on-render-client.tsx", - "src/routes/dashboard/index.tsx", - "src/waku.client.tsx", - "module-federation.config.ts", - "src/remote-entry.ts", - ]) { - assert.ok( - !unusedFilePaths.some((filePath) => filePath === expectedReachableFile), - `${expectedReachableFile} should NOT be unused (framework entry convention), got: ${unusedFilePaths}`, - ); - } -}); - -describe("subproject-workspace", () => { - it("should not activate framework detection for sub-project children", async () => { - const result = await scanFixture("subproject-workspace"); - const fixtureDir = resolve(FIXTURES_DIR, "subproject-workspace"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("app/packages/core/app/page.ts"), - `app/packages/core/app/page.ts should be unused (Next.js detection should not activate for sub-project children), got: ${unusedFilePaths}`, - ); - }); - - it("should not add sub-project child package entry files as global entries", async () => { - const result = await scanFixture("subproject-workspace"); - const fixtureDir = resolve(FIXTURES_DIR, "subproject-workspace"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("app/packages/icons/src/index.ts"), - `app/packages/icons/src/index.ts should be unused (not an entry when root has no workspace patterns), got: ${unusedFilePaths}`, - ); - }); - - it("should still detect files under sub-project children as unused", async () => { - const result = await scanFixture("subproject-workspace"); - const fixtureDir = resolve(FIXTURES_DIR, "subproject-workspace"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("app/packages/core/src/unused-util.ts"), - `app/packages/core/src/unused-util.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("tanstack-app", () => { - it("should treat src/routes and src/server as entry points", async () => { - const result = await scanFixture("tanstack-app"); - const fixtureDir = resolve(FIXTURES_DIR, "tanstack-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/routes/index.tsx"), - `src/routes/index.tsx should be reachable via TanStack Start route, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/routes/about.tsx"), - `src/routes/about.tsx should be reachable via TanStack Start route, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/server.ts"), - `src/server.ts should be reachable as TanStack Start server entry, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("cloudflare-worker", () => { - it("should treat src/index.ts as entry point when wrangler is present", async () => { - const result = await scanFixture("cloudflare-worker"); - const fixtureDir = resolve(FIXTURES_DIR, "cloudflare-worker"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/index.ts"), - `src/index.ts should be reachable as Wrangler worker entry, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("config-entry-seed", () => { - it("should exclude config files from unused reporting but not propagate reachability", async () => { - const result = await scanFixture("config-entry-seed"); - const fixtureDir = resolve(FIXTURES_DIR, "config-entry-seed"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("vite.config.ts"), - `vite.config.ts should be excluded from unused (config file), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/vite-plugin.ts"), - `src/vite-plugin.ts should be unused (only imported from config file), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/index.ts"), - `src/index.ts should be reachable as main entry, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/helper.ts"), - `src/helper.ts should be reachable via index.ts, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("config-imports", () => { - it("should propagate reachability from config entry points when plugin activates", async () => { - const result = await scanFixture("config-imports"); - const fixtureDir = resolve(FIXTURES_DIR, "config-imports"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("vite.config.ts"), - `vite.config.ts should be excluded (config file), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("my-vite-plugin.ts"), - `my-vite-plugin.ts should be reachable (config file is entry point when vite plugin activates), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/shared-util.ts"), - `src/shared-util.ts should be reachable (imported from both config and app), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/index.ts"), - `src/index.ts should be reachable as main entry, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("webpack-path", () => { - it("should resolve path.join(__dirname, .., app/index) webpack entries", async () => { - const result = await scanFixture("webpack-path"); - const fixtureDir = resolve(FIXTURES_DIR, "webpack-path"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("app/index.js"), - `app/index.js should be reachable via webpack path.join entry, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("app/renderer.js"), - `app/renderer.js should be reachable via app/index.js, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("app/orphan.js"), - `app/orphan.js should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("html-entry-scope", () => { - it("should only discover HTML script entries from root-level HTML files, not nested subdirectories", async () => { - const result = await scanFixture("html-entry-scope"); - const fixtureDir = resolve(FIXTURES_DIR, "html-entry-scope"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("packages/app/src/main.tsx"), - `packages/app/src/main.tsx should be reachable via workspace root index.html, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("packages/app/src/helper.ts"), - `packages/app/src/helper.ts should be reachable (imported by main.tsx), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("packages/app/sample/demo.tsx"), - `packages/app/sample/demo.tsx should be unused (nested HTML not scanned), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("packages/lib/src/orphan.ts"), - `packages/lib/src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("i18n-glob-skip", () => { - it("should not treat formatjs extract glob arguments as entry points", async () => { - const result = await scanFixture("i18n-glob-skip"); - const fixtureDir = resolve(FIXTURES_DIR, "i18n-glob-skip"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be unused because formatjs extract globs should not seed entries, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("remark-glob-skip", () => { - it("should not treat remark and cspell glob arguments as entry points", async () => { - const result = await scanFixture("remark-glob-skip"); - const fixtureDir = resolve(FIXTURES_DIR, "remark-glob-skip"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("docs/intro.mdx"), - `docs/intro.mdx should be excluded (MDX files are excluded from unused-file by default)`, - ); - assert.ok( - !unusedFilePaths.includes("docs/guide.mdx"), - `docs/guide.mdx should be excluded (MDX files are excluded from unused-file by default)`, - ); - }); -}); - -describe("extensionless-relative-import", () => { - it("should resolve extensionless relative imports to sibling source files", async () => { - const result = await scanFixture("extensionless-relative-import"); - const fixtureDir = resolve(FIXTURES_DIR, "extensionless-relative-import"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/Radio.tsx"), - `Radio.tsx should be reachable via extensionless import, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/App.tsx"), - `App.tsx should be reachable via index entry, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("jest-setup-config", () => { - it("should treat jest setupFilesAfterEnv and moduleNameMapper paths as entry points", async () => { - const result = await scanFixture("jest-setup-config"); - const fixtureDir = resolve(FIXTURES_DIR, "jest-setup-config"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("jest.setup.ts"), - `jest.setup.ts should be reachable via setupFilesAfterEnv, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/setup-helper.ts"), - `setup-helper.ts should be reachable via jest.setup.ts, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("__mocks__/styleMock.js"), - `styleMock.js should be reachable via moduleNameMapper, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("remark-config-deps", () => { - it("should keep remark plugins declared in .remarkrc used", async () => { - const result = await scanFixture("remark-config-deps"); - const unusedDependencyNames = staleDependencyNames(result); - assert.ok( - !unusedDependencyNames.includes("remark-gfm"), - `remark-gfm should be used via .remarkrc, got unused deps: ${unusedDependencyNames}`, - ); - assert.ok( - !unusedDependencyNames.includes("remark-cli"), - `remark-cli should be used via npm script, got unused deps: ${unusedDependencyNames}`, - ); - }); -}); - -describe("flow-js-app", () => { - it("should parse Flow and JSX in .js files and follow import chains", async () => { - const result = await scanFixture("flow-js-app"); - const fixtureDir = resolve(FIXTURES_DIR, "flow-js-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/Widget.js"), - `Widget.js should be reachable from main.dev.js, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/actions/helper.js"), - `helper.js should be reachable from Widget.js, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.js"), - `orphan.js should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("side-effects-glob", () => { - it("should treat package.json sideEffects globs as production entries", async () => { - const result = await scanFixture("side-effects-glob"); - const fixtureDir = resolve(FIXTURES_DIR, "side-effects-glob"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/foo/widget/style.ts"), - `style.ts should be reachable via sideEffects glob, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("cra-jest-transforms", () => { - it("should treat jest transform paths in createJestConfig as entry points", async () => { - const result = await scanFixture("cra-jest-transforms"); - const fixtureDir = resolve(FIXTURES_DIR, "cra-jest-transforms"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("config/jest/babelTransform.js"), - `babelTransform.js should be reachable via createJestConfig, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("config/jest/cssTransform.js"), - `cssTransform.js should be reachable via createJestConfig, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("config/jest/fileTransform.js"), - `fileTransform.js should be reachable via createJestConfig, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("config/jest/orphanTransform.js"), - `orphanTransform.js should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("default-import-named-export", () => { - it("should treat default imports as using same-named named exports", async () => { - const result = await scanFixture("default-import-named-export"); - const fixtureDir = resolve(FIXTURES_DIR, "default-import-named-export"); - const exportsByFile = deadExportsByFile(result, fixtureDir); - assert.ok( - !exportsByFile["src/settings-panel.tsx"]?.includes("SettingsPanel"), - `SettingsPanel should not be flagged when default-imported from test, got: ${JSON.stringify(exportsByFile["src/settings-panel.tsx"])}`, - ); - }); -}); - -describe("hoc-wrapped-default-export", () => { - it("should treat HOC-wrapped default exports as using the wrapped named export", async () => { - const result = await scanFixture("hoc-wrapped-default-export"); - const fixtureDir = resolve(FIXTURES_DIR, "hoc-wrapped-default-export"); - const exportsByFile = deadExportsByFile(result, fixtureDir); - assert.ok( - !exportsByFile["src/apps-badge.tsx"]?.includes("AppsBadge"), - `AppsBadge should not be flagged when used by default export wrapper, got: ${JSON.stringify(exportsByFile["src/apps-badge.tsx"])}`, - ); - }); -}); - -describe("jest-config-cts", () => { - it("should treat jest.config.cts setup file references as production entries", async () => { - const result = await scanFixture("jest-config-cts"); - const fixtureDir = resolve(FIXTURES_DIR, "jest-config-cts"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("test-setup.ts"), - `test-setup.ts should be reachable via jest.config.cts, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("electron-builder-files", () => { - it("should treat electron-builder build.files entries as production entries", async () => { - const result = await scanFixture("electron-builder-files"); - const fixtureDir = resolve(FIXTURES_DIR, "electron-builder-files"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/preload.ts"), - `preload.ts should be reachable via electron-builder files, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/worker.ts"), - `worker.ts should be reachable via electron-builder files, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("internal-export-usage", () => { - it("should not flag exports referenced within the same module", async () => { - const result = await scanFixture("internal-export-usage"); - const fixtureDir = resolve(FIXTURES_DIR, "internal-export-usage"); - const exportsByFile = deadExportsByFile(result, fixtureDir); - assert.ok( - !exportsByFile["src/service.module.ts"]?.includes("serviceModule"), - `serviceModule should not be flagged when used in same file, got: ${JSON.stringify(exportsByFile["src/service.module.ts"])}`, - ); - }); -}); - -describe("vitest-custom", () => { - it("should use custom include patterns from vitest.config.ts", async () => { - const result = await scanFixture("vitest-custom"); - const fixtureDir = resolve(FIXTURES_DIR, "vitest-custom"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("spec/utils-spec.ts"), - `utils-spec.ts should be an entry (matched by vitest include pattern), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("workspace-no-main", () => { - it("should fall back to index.js for workspace packages without a main field", async () => { - const result = await scanFixture("workspace-no-main"); - const fixtureDir = resolve(FIXTURES_DIR, "workspace-no-main"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("packages/lib-a/index.js"), - `index.js should NOT be unused (default entry for package without main), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("packages/lib-a/helper.js"), - `helper.js should NOT be unused (imported by index.js), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("packages/lib-a/orphan.js"), - `orphan.js should be unused (not imported by anything), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("style-export-map", () => { - it("should resolve CSS files exported via package.json exports map through dist→src heuristic", async () => { - const result = await scanFixture("style-export-map"); - const fixtureDir = resolve(FIXTURES_DIR, "style-export-map"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/style.css"), - `src/style.css should NOT be unused (exported via package.json exports), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/orphan.css"), - `CSS files are excluded from unused-file detection, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("playwright-ext", () => { - it("should NOT treat .pw.ts files as test entries", async () => { - const result = await scanFixture("playwright-ext"); - const fixtureDir = resolve(FIXTURES_DIR, "playwright-ext"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("my-test.pw.ts"), - `my-test.pw.ts should be unused (.pw.ts is not a standard test pattern), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("playwright-lib", () => { - it("should NOT treat lib/ and support/ directories as Playwright test entry points", async () => { - const result = await scanFixture("playwright-lib"); - const fixtureDir = resolve(FIXTURES_DIR, "playwright-lib"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - unusedFilePaths.includes("lib/helpers.ts"), - `lib/helpers.ts should be unused (lib/ is not a Playwright entry pattern), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("support/commands.ts"), - `support/commands.ts should be unused (support/ is not a Playwright entry pattern), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("e2e/login.spec.ts"), - `e2e/login.spec.ts should be a test entry point, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("tests/smoke.spec.ts"), - `tests/smoke.spec.ts should be a test entry point, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("env-wrapper", () => { - it("should see through cross-env wrapper to find real binary and file arguments", async () => { - const result = await scanFixture("env-wrapper"); - const fixtureDir = resolve(FIXTURES_DIR, "env-wrapper"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/main.js"), - `src/main.js should NOT be unused (entry via cross-env node), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/dev-entry.js"), - `src/dev-entry.js should NOT be unused (entry via cross-env node), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/helper.js"), - `src/helper.js should NOT be unused (imported by entries), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("orphan.js"), - `orphan.js should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("jest-mock-entry", () => { - it("should treat __mocks__ files as entry points in jest projects", async () => { - const result = await scanFixture("jest-mock-entry"); - const fixtureDir = resolve(FIXTURES_DIR, "jest-mock-entry"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/__mocks__/fs.ts"), - `src/__mocks__/fs.ts should be reachable as Jest __mocks__ entry, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/__mocks__/axios.ts"), - `src/__mocks__/axios.ts should be reachable as Jest __mocks__ entry, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("__mocks__/some-lib.js"), - `__mocks__/some-lib.js should be reachable as Jest __mocks__ entry, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("orphan.ts"), - `orphan.ts should be unused (jest-mock-entry), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("mdx-import", () => { - it("should trace imports from MDX entry points in Docusaurus projects", async () => { - const result = await scanFixture("mdx-import"); - const fixtureDir = resolve(FIXTURES_DIR, "mdx-import"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/components/Chart.tsx"), - `Chart.tsx should NOT be unused (imported by MDX entry), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/components/Unused.tsx"), - `Unused.tsx should be unused (not imported by any MDX), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("orphan.ts"), - `orphan.ts should be unused (mdx-import), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("vitest-coverage", () => { - it("should not confuse coverage.include with test.include patterns", async () => { - const result = await scanFixture("vitest-coverage"); - const fixtureDir = resolve(FIXTURES_DIR, "vitest-coverage"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("tests/core.test.ts"), - `core.test.ts should NOT be unused (vitest test file), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("orphan.ts"), - `orphan.ts should be unused (vitest-coverage), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/utils.ts"), - `src/utils.ts should be unused (not imported by any test or entry), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("dts-imports", () => { - it("should follow imports from .d.ts files to mark dependencies as reachable", async () => { - const result = await scanFixture("dts-imports"); - const fixtureDir = resolve(FIXTURES_DIR, "dts-imports"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/helper.ts"), - `src/helper.ts should NOT be unused (imported by types.d.ts which is reachable), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.some((filePath) => filePath.endsWith(".d.ts")), - `.d.ts files should NOT appear in unused files report, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("astro-mw", () => { - it("should treat src/middleware.ts as an Astro entry point", async () => { - const result = await scanFixture("astro-mw"); - const fixtureDir = resolve(FIXTURES_DIR, "astro-mw"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/middleware.ts"), - `src/middleware.ts should NOT be unused (Astro middleware entry), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("orphan.ts"), - `orphan.ts should be unused (astro-mw), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("astro-frontmatter-return", () => { - it("collects frontmatter imports even when frontmatter uses top-level return", async () => { - const result = await scanFixture("astro-frontmatter-return"); - const fixtureDir = resolve(FIXTURES_DIR, "astro-frontmatter-return"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/components/Greeting.tsx"), - `Greeting.tsx should NOT be unused (imported from .astro frontmatter), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/scripts/analytics.ts"), - `analytics.ts should NOT be unused (referenced via self-closing <script src />), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/scripts/inline-helper.ts"), - `inline-helper.ts should NOT be unused (imported from inline <script> after self-closing <script />), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/components/orphan.ts"), - `orphan.ts should be unused (never imported), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("next-middleware", () => { - it("should treat middleware, proxy, and instrumentation as Next.js entry points", async () => { - const result = await scanFixture("next-middleware"); - const fixtureDir = resolve(FIXTURES_DIR, "next-middleware"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/middleware.ts"), - `src/middleware.ts should NOT be unused (Next.js middleware entry), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/auth.ts"), - `src/auth.ts should NOT be unused (imported by middleware), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("proxy.ts"), - `proxy.ts should NOT be unused (Next.js proxy entry), got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("instrumentation.ts"), - `instrumentation.ts should NOT be unused (Next.js instrumentation entry), got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("orphan.ts"), - `orphan.ts should be unused (next-middleware), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("reexport-file-variants", () => { - it("should exempt star-re-export barrels but not named-re-export barrels", async () => { - const result = await scanFixture("reexport-file-variants"); - const fixtureDir = resolve(FIXTURES_DIR, "reexport-file-variants"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - assert.ok( - !unusedFilePaths.includes("src/star-barrel.ts"), - `src/star-barrel.ts should NOT be unused (star re-export barrel with reachable sources), got: ${unusedFilePaths}`, - ); - - assert.ok( - unusedFilePaths.includes("src/named-barrel.ts"), - `src/named-barrel.ts SHOULD be unused (named re-export barrel is reported as unused), got: ${unusedFilePaths}`, - ); - - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be unused (reexport-file-variants), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("ci-yaml-non-run", () => { - it("should only extract entries from run: blocks, not arbitrary YAML values", async () => { - const result = await scanFixture("ci-yaml-non-run"); - const fixtureDir = resolve(FIXTURES_DIR, "ci-yaml-non-run"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - assert.ok( - !unusedFilePaths.includes("scripts/deploy.mjs"), - `scripts/deploy.mjs should NOT be unused (referenced in run: block), got: ${unusedFilePaths}`, - ); - - assert.ok( - unusedFilePaths.includes(".github/changelog/changelog.js"), - `.github/changelog/changelog.js SHOULD be unused (only referenced in YAML with: block, not run:), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("workspace-dist-src", () => { - it("should resolve workspace deep imports through export maps via dist→src fallback", async () => { - const result = await scanFixture("workspace-dist-src"); - const fixtureDir = resolve(FIXTURES_DIR, "workspace-dist-src"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - assert.ok( - !unusedFilePaths.includes("packages/core/src/visualdebug.ts"), - `packages/core/src/visualdebug.ts should NOT be unused (imported via @test/core/visualdebug), got: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("packages/core/src/index.ts"), - `packages/core/src/index.ts should NOT be unused (imported via @test/core), got: ${unusedFilePaths}`, - ); - - assert.ok( - unusedFilePaths.includes("packages/core/src/orphan.ts"), - `packages/core/src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("bun-test", () => { - it("should detect bun test runner and treat test files as entry points", async () => { - const result = await scanFixture("bun-test"); - const fixtureDir = resolve(FIXTURES_DIR, "bun-test"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - assert.ok( - !unusedFilePaths.includes("src/__tests__/build-output.test.ts"), - `src/__tests__/build-output.test.ts should be reachable via bun test runner, got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("src/add.test.ts"), - `src/add.test.ts should be reachable via bun test runner, got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("__tests__/integration.test.ts"), - `__tests__/integration.test.ts should be reachable via bun test runner, got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("src/utils_test.ts"), - `src/utils_test.ts should be reachable via bun _test pattern, got unused: ${unusedFilePaths}`, - ); - - assert.ok( - unusedFilePaths.includes("orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("zx-scripts", () => { - it("should detect zx as a script runner and mark referenced files as entry points", async () => { - const result = await scanFixture("zx-scripts"); - const fixtureDir = resolve(FIXTURES_DIR, "zx-scripts"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - assert.ok( - !unusedFilePaths.includes("scripts/build-image.mjs"), - `scripts/build-image.mjs should NOT be unused (referenced via zx in package.json scripts), got: ${unusedFilePaths}`, - ); - - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("polyrepo", () => { - it("should extract entry points from all sub-project package.json files without root workspace patterns", async () => { - const result = await scanFixture("polyrepo"); - const fixtureDir = resolve(FIXTURES_DIR, "polyrepo"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - assert.ok( - !unusedFilePaths.includes("project-a/src/index.ts"), - `project-a/src/index.ts should be reachable via lib/index.js main entry fallback`, - ); - - assert.ok( - !unusedFilePaths.includes("project-a/src/helper.ts"), - `project-a/src/helper.ts should be reachable via import from index.ts`, - ); - - assert.ok( - !unusedFilePaths.includes("project-b/src/index.ts"), - `project-b/src/index.ts should be reachable via dist/index.js main entry fallback`, - ); - - assert.ok( - unusedFilePaths.includes("project-a/src/orphan.ts"), - `project-a/src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - - assert.ok( - unusedFilePaths.includes("project-b/src/unused.ts"), - `project-b/src/unused.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("build-root-fallback", () => { - it("should only resolve build output to src/ directory, not root-level fallback", async () => { - const result = await scanFixture("build-root-fallback"); - const fixtureDir = resolve(FIXTURES_DIR, "build-root-fallback"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - assert.ok( - unusedFilePaths.includes("bin/server.js"), - `bin/server.js should be unused — build/bin/server.js only resolves to src/bin/ not root bin/, got: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("src/app.ts"), - `src/app.ts should be reachable via build/app.js → src/app.ts, got: ${unusedFilePaths}`, - ); - - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("vitest-automock", () => { - it("should treat __mocks__ sibling as reachable when vi.mock has no factory", async () => { - const result = await scanFixture("vitest-automock"); - const fixtureDir = resolve(FIXTURES_DIR, "vitest-automock"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - assert.ok( - !unusedFilePaths.includes("src/server/__mocks__/api.ts"), - `__mocks__/api.ts should be reachable via vi.mock auto-mock sibling, got unused: ${unusedFilePaths}`, - ); - - assert.ok( - unusedFilePaths.includes("src/utils/__mocks__/helper.ts"), - `__mocks__/helper.ts should be unused when vi.mock has a factory, got unused: ${unusedFilePaths}`, - ); - - assert.ok( - unusedFilePaths.includes("src/server/unused.ts"), - `src/server/unused.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("react-router", () => { - it("should treat files referenced by route/layout/index calls in routes.ts as entry points", async () => { - const result = await scanFixture("react-router"); - const fixtureDir = resolve(FIXTURES_DIR, "react-router"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - assert.ok( - unusedFilePaths.includes("app/components/unused-widget.tsx"), - `unused-widget.tsx should be unused, got: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("app/dashboard/page.tsx"), - `app/dashboard/page.tsx should be reachable via index() in routes.ts, got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("app/dashboard/layout.tsx"), - `app/dashboard/layout.tsx should be reachable via layout() in routes.ts, got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("app/routes/home.tsx"), - `app/routes/home.tsx should be reachable via route() in routes.ts, got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("app/routes/about.tsx"), - `app/routes/about.tsx should be reachable via route() in routes.ts, got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("app/root.tsx"), - `app/root.tsx should be reachable as root entry, got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("app/components/header.tsx"), - `header.tsx should be reachable (imported by root.tsx and home.tsx), got unused: ${unusedFilePaths}`, - ); - }); -}); - -describe("script-no-extension", () => { - it("should resolve script file references without extensions to their source files", async () => { - const result = await scanFixture("script-no-extension"); - const fixtureDir = resolve(FIXTURES_DIR, "script-no-extension"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - assert.ok( - unusedFilePaths.includes("orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("scripts/build-data.ts"), - `scripts/build-data.ts should be reachable via 'tsx ./scripts/build-data' (extensionless), got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("scripts/lint-code.js"), - `scripts/lint-code.js should be reachable via 'node scripts/lint-code' (extensionless), got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("scripts/process-items.ts"), - `scripts/process-items.ts should be reachable via 'ts-node ./scripts/process-items' (extensionless), got unused: ${unusedFilePaths}`, - ); - }); -}); - -describe("rspack-app", () => { - it("should treat rspack config files as always-used entry points", async () => { - const result = await scanFixture("rspack-app"); - const fixtureDir = resolve(FIXTURES_DIR, "rspack-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - assert.ok( - !unusedFilePaths.includes("rspack.config.js"), - `rspack.config.js should be always-used (rspack config), got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("rspack.dev.config.js"), - `rspack.dev.config.js should be always-used (rspack wildcard config), got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("src/index.ts"), - `src/index.ts should be reachable via rspack entry, got unused: ${unusedFilePaths}`, - ); - - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be unused, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("astro-content", () => { - it("should treat astro content config files as always-used", async () => { - const result = await scanFixture("astro-content"); - const fixtureDir = resolve(FIXTURES_DIR, "astro-content"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - assert.ok( - !unusedFilePaths.includes("astro.config.ts"), - `astro.config.ts should be always-used, got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("src/content.config.ts"), - `src/content.config.ts should be always-used (astro content config), got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("src/content/config.ts"), - `src/content/config.ts should be always-used (astro content config), got unused: ${unusedFilePaths}`, - ); - - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be unused (astro-content), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("astro-live-config", () => { - it("should treat astro live collections config as always-used and trace its imports", async () => { - const result = await scanFixture("astro-live-config"); - const fixtureDir = resolve(FIXTURES_DIR, "astro-live-config"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - assert.ok( - !unusedFilePaths.includes("src/live.config.ts"), - `src/live.config.ts should be always-used (astro live collections config), got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("src/loaders/wordpress-loader.ts"), - `src/loaders/wordpress-loader.ts should be reachable (imported by live.config.ts), got unused: ${unusedFilePaths}`, - ); - - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `src/orphan.ts should be unused (astro-live-config), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("gatsby-app", () => { - it("should flag unused components but not pages, templates, or api routes", async () => { - const result = await scanFixture("gatsby-app"); - const fixtureDir = resolve(FIXTURES_DIR, "gatsby-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - assert.ok( - unusedFilePaths.includes("src/components/unused.tsx"), - `src/components/unused.tsx should be unused (Gatsby does not auto-discover components), got: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("src/pages/index.tsx"), - `src/pages/index.tsx should be reachable (Gatsby page), got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("src/templates/post.tsx"), - `src/templates/post.tsx should be reachable (Gatsby template), got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("src/api/hello.ts"), - `src/api/hello.ts should be reachable (Gatsby API route), got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("src/components/used.tsx"), - `src/components/used.tsx should be reachable (imported by page), got unused: ${unusedFilePaths}`, - ); - }); -}); - -describe("rn-app", () => { - it("should detect React Native entry points and flag orphan screens", async () => { - const result = await scanFixture("rn-app"); - const fixtureDir = resolve(FIXTURES_DIR, "rn-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - - assert.ok( - !unusedFilePaths.includes("index.js"), - `index.js should be reachable (React Native entry), got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("App.tsx"), - `App.tsx should be reachable (React Native entry), got unused: ${unusedFilePaths}`, - ); - - assert.ok( - !unusedFilePaths.includes("src/screens/used.tsx"), - `src/screens/used.tsx should be reachable (imported by App), got unused: ${unusedFilePaths}`, - ); - - assert.ok( - unusedFilePaths.includes("src/screens/orphan.tsx"), - `src/screens/orphan.tsx should be unused (rn-app), got: ${unusedFilePaths}`, - ); - }); -}); - -describe("expo-router", () => { - it("should treat src/app filesystem routes as entry points (no false-positive unused files)", async () => { - const result = await scanFixture("expo-router-src-app"); - const fixtureDir = resolve(FIXTURES_DIR, "expo-router-src-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.deepStrictEqual( - unusedFilePaths, - [], - `Expo Router src/app routes are filesystem-discovered entries and must not be flagged unused, got: ${unusedFilePaths}`, - ); - }); - - it("should still report genuinely orphaned modules outside the src/app routes directory", async () => { - const result = await scanFixture("expo-router-src-app-orphan"); - const fixtureDir = resolve(FIXTURES_DIR, "expo-router-src-app-orphan"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.deepStrictEqual( - unusedFilePaths, - ["src/utils/orphan.ts"], - `only src/utils/orphan.ts (outside src/app) should be unused, got: ${unusedFilePaths}`, - ); - }); - - it("should keep treating non-src app/ filesystem routes as entry points", async () => { - const result = await scanFixture("expo-router-app"); - const fixtureDir = resolve(FIXTURES_DIR, "expo-router-app"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.deepStrictEqual( - unusedFilePaths, - [], - `Expo Router app/ routes must remain entry points (regression guard), got: ${unusedFilePaths}`, - ); - }); -}); - -it("should detect webpack.config.js entry points and mark imported files reachable", async () => { - const result = await scanFixture("webpack-entries"); - const fixtureDir = resolve(FIXTURES_DIR, "webpack-entries"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/index.js"), - `src/index.js should be reachable as webpack entry, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/vendor.js"), - `src/vendor.js should be reachable as webpack entry, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/components/App.js"), - `App.js should be reachable via import from webpack entry, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/components/Vendor.js"), - `Vendor.js should be reachable via import from webpack entry, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.js"), - `orphan.js should be unused, got: ${unusedFilePaths}`, - ); -}); - -it("should not treat CSS files as entry points when wildcard export map expands to all files", async () => { - const result = await scanFixture("wildcard-css"); - const fixtureDir = resolve(FIXTURES_DIR, "wildcard-css"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/components/Button.css"), - `CSS files are excluded from unused-file detection, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/components/Button.ts"), - `Button.ts should be reachable via export, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); -}); - -it("should detect Electron main/preload entries and mark imported files reachable", async () => { - const result = await scanFixture("electron-detection"); - const fixtureDir = resolve(FIXTURES_DIR, "electron-detection"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/main.ts"), - `src/main.ts should be reachable as Electron main entry, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/preload/index.ts"), - `src/preload/index.ts should be reachable as Electron preload entry, got unused: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("src/window.ts"), - `src/window.ts should be reachable via import from main, got unused: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `orphan.ts should be unused, got: ${unusedFilePaths}`, - ); -}); - -describe("cycle-simple", () => { - it("should detect a simple A→B→A circular dependency", async () => { - const result = await scanFixture("cycle-simple"); - assert.ok(result.circularDependencies.length > 0, "should find at least one cycle"); - const cyclePaths = result.circularDependencies.map((cycle) => - cycle.files.map((filePath) => { - const fixtureDir = resolve(FIXTURES_DIR, "cycle-simple"); - return relative(fixtureDir, filePath); - }), - ); - const hasCycle = cyclePaths.some( - (paths) => paths.includes("src/a.ts") && paths.includes("src/b.ts"), - ); - assert.ok( - hasCycle, - `should find cycle between a.ts and b.ts, got: ${JSON.stringify(cyclePaths)}`, - ); - }); -}); - -describe("cycle-type-only", () => { - it("should not detect circular dependencies when imports are type-only", async () => { - const result = await scanFixture("cycle-type-only"); - assert.equal( - result.circularDependencies.length, - 0, - `type-only imports should not create cycles, got: ${JSON.stringify(result.circularDependencies)}`, - ); - }); -}); - -describe("cycle-interface-value-import", () => { - it("does not report a cycle whose back edge imports only an interface via a value-form import", async () => { - const result = await scanFixture("cycle-interface-value-import"); - assert.equal( - result.circularDependencies.length, - 0, - `the interface-only back edge is erased at compile time, got: ${JSON.stringify(result.circularDependencies)}`, - ); - }); -}); - -describe("default-export-alias-of-used-named", () => { - it("does not flag a default export aliasing a named export that is consumed", async () => { - const result = await scanFixture("default-export-alias-of-used-named"); - assert.deepEqual( - deadExportNames(result), - [], - `the named Page usage disproves the default alias being dead, got: ${JSON.stringify(result.unusedExports)}`, - ); - }); -}); - -describe("github-workflow-script", () => { - it("does not flag scripts run via a vendored .github tool package's npm scripts", async () => { - const result = await scanFixture("github-workflow-script"); - assert.deepEqual( - result.unusedFiles.map((unusedFile) => unusedFile.path), - [], - `build.js is executed by the vendored bundle-size package's npm run build`, - ); - }); -}); - -describe("namespace-destructure-exports", () => { - it("should track members read via destructuring of a namespace import", async () => { - const result = await scanFixture("namespace-destructure-exports"); - const allUnusedNames = deadExportNames(result); - assert.ok( - !allUnusedNames.includes("noFocalPath"), - `noFocalPath is read via \`const { noFocalPath } = testResources\`, got: ${allUnusedNames}`, - ); - assert.ok( - allUnusedNames.includes("orphanResource"), - `orphanResource is never read from the namespace, got: ${allUnusedNames}`, - ); - }); -}); - -describe("local-use-in-exported-declaration", () => { - it("should count references made inside other exported declarations as local use", async () => { - const result = await scanFixture("local-use-in-exported-declaration"); - const allUnusedNames = deadExportNames(result); - assert.ok( - !allUnusedNames.includes("isLikelyBookTitleAuthorResult"), - `isLikelyBookTitleAuthorResult is called inside exported splitAuthorSearchResults, got: ${allUnusedNames}`, - ); - assert.ok( - allUnusedNames.includes("neverReferencedAnywhere"), - `neverReferencedAnywhere has no local or external reference, got: ${allUnusedNames}`, - ); - }); -}); - -describe("runner-convention-files", () => { - it("should not flag files consumed by runner/deployment convention as unused", async () => { - const result = await scanFixture("runner-convention-files"); - const fixtureDir = resolve(FIXTURES_DIR, "runner-convention-files"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("src/render-element.test-d.ts"), - `*.test-d.ts is consumed by the typecheck runner glob, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("public/theme-init.js"), - `public/ assets are script-src loaded, not imported, got: ${unusedFilePaths}`, - ); - assert.ok( - !unusedFilePaths.includes("example-ui.config.console-analytics.js"), - `config variants are consumed by deployment convention, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `a genuine orphan must still be flagged, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("jest-custom-testmatch-mocks", () => { - it("should keep __mocks__ entries alive when jest testMatch is customized", async () => { - const result = await scanFixture("jest-custom-testmatch-mocks"); - const fixtureDir = resolve(FIXTURES_DIR, "jest-custom-testmatch-mocks"); - const unusedFilePaths = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFilePaths.includes("__mocks__/axios.ts"), - `Jest automock consumes __mocks__ regardless of testMatch, got: ${unusedFilePaths}`, - ); - assert.ok( - unusedFilePaths.includes("src/orphan.ts"), - `a genuine orphan must still be flagged, got: ${unusedFilePaths}`, - ); - }); -}); - -describe("cycle-lazy-import", () => { - it("should not report cycles closed only by a dynamic import() edge", async () => { - const result = await scanFixture("cycle-lazy-import"); - assert.equal( - result.circularDependencies.length, - 0, - `lazy import() back edges should not create cycles, got: ${JSON.stringify(result.circularDependencies)}`, - ); - }); -}); - -describe("cycle-function-only", () => { - it("should not report cycles whose back edge is only dereferenced inside function bodies", async () => { - const result = await scanFixture("cycle-function-only"); - assert.equal( - result.circularDependencies.length, - 0, - `function-body-only back edges run after module init, got: ${JSON.stringify(result.circularDependencies)}`, - ); - }); -}); - -describe("cycle-chain", () => { - it("should detect A→B→C→A circular dependency chain", async () => { - const result = await scanFixture("cycle-chain"); - assert.ok(result.circularDependencies.length > 0, "should find at least one cycle"); - const cyclePaths = result.circularDependencies.map((cycle) => - cycle.files.map((filePath) => { - const fixtureDir = resolve(FIXTURES_DIR, "cycle-chain"); - return relative(fixtureDir, filePath); - }), - ); - const hasThreeNodeCycle = cyclePaths.some( - (paths) => - paths.length === 3 && - paths.includes("src/a.ts") && - paths.includes("src/b.ts") && - paths.includes("src/c.ts"), - ); - assert.ok( - hasThreeNodeCycle, - `should find 3-node cycle between a.ts, b.ts, c.ts, got: ${JSON.stringify(cyclePaths)}`, - ); - }); -}); - -describe("cycle-none", () => { - it("should not detect any circular dependencies in a linear dependency graph", async () => { - const result = await scanFixture("cycle-none"); - assert.equal(result.circularDependencies.length, 0); - }); -}); - -describe("reexport-default-named", () => { - it("should track default-as-named re-exports and detect unused files", async () => { - const result = await scanFixture("reexport-default-named"); - const fixtureDir = resolve(FIXTURES_DIR, "reexport-default-named"); - const unusedFiles = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFiles.includes("widget.ts"), - "widget.ts should be reachable via re-export { default as Widget }", - ); - assert.ok( - !unusedFiles.includes("gadget.ts"), - "gadget.ts should be reachable via re-export { default as Gadget }", - ); - assert.ok(!unusedFiles.includes("index.ts"), "index.ts should be reachable as entry"); - assert.ok( - unusedFiles.includes("consumer.ts"), - "consumer.ts should be unused (not an entry point, not imported by entry)", - ); - }); - - it("should detect unused exports in re-exported modules", async () => { - const result = await scanFixture("reexport-default-named"); - const exportNames = deadExportNames(result); - assert.ok( - exportNames.includes("widgetHelper"), - "widgetHelper should be unused (not re-exported or consumed)", - ); - assert.ok( - exportNames.includes("gadgetHelper"), - "gadgetHelper should be unused (not re-exported or consumed)", - ); - }); -}); - -describe("import-mixed", () => { - it("should handle combined default, named, and namespace imports", async () => { - const result = await scanFixture("import-mixed"); - const fixtureDir = resolve(FIXTURES_DIR, "import-mixed"); - const unusedFiles = orphanPaths(result, fixtureDir); - assert.ok(unusedFiles.includes("orphan.ts"), "orphan.ts should be unused (not imported)"); - assert.ok(!unusedFiles.includes("lib.ts"), "lib.ts should be reachable via mixed import"); - assert.ok( - !unusedFiles.includes("utils.ts"), - "utils.ts should be reachable via namespace import", - ); - }); - - it("should detect unused exports across import patterns", async () => { - const result = await scanFixture("import-mixed"); - const exportNames = deadExportNames(result); - assert.ok(exportNames.includes("unused"), "unused export from lib.ts should be detected"); - assert.ok( - exportNames.includes("unusedUtil"), - "unusedUtil export from utils.ts should be detected", - ); - }); -}); - -describe("ns-chain", () => { - it("should track namespace import that is re-exported", async () => { - const result = await scanFixture("ns-chain"); - const fixtureDir = resolve(FIXTURES_DIR, "ns-chain"); - const unusedFiles = orphanPaths(result, fixtureDir); - assert.ok(unusedFiles.includes("unused-module.ts"), "unused-module.ts should be unused"); - assert.ok( - !unusedFiles.includes("helpers.ts"), - "helpers.ts should be reachable via namespace re-export chain", - ); - assert.ok( - unusedFiles.includes("consumer.ts"), - "consumer.ts should be unused (not an entry point)", - ); - }); -}); - -describe("type-reexport-filter", () => { - it("should not report type-only re-exports as unused by default", async () => { - const result = await scanFixture("type-reexport-filter"); - const fixtureDir = resolve(FIXTURES_DIR, "type-reexport-filter"); - const unusedFiles = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFiles.includes("types.ts"), - "types.ts should be reachable via type-only re-export", - ); - assert.ok(!unusedFiles.includes("user.ts"), "user.ts should be reachable via named re-export"); - assert.ok( - unusedFiles.includes("consumer.ts"), - "consumer.ts should be unused (not an entry point)", - ); - }); - - it("should detect unused exports even with type re-exports", async () => { - const result = await scanFixture("type-reexport-filter"); - const exportNames = deadExportNames(result); - assert.ok( - exportNames.includes("deleteUser"), - "deleteUser should be unused (not re-exported or imported)", - ); - }); -}); - -describe("cycle-with-orphans", () => { - it("should detect circular dependency between module-a and module-b", async () => { - const result = await scanFixture("cycle-with-orphans"); - assert.ok( - result.circularDependencies.length > 0, - "should find circular dependency between module-a and module-b", - ); - }); - - it("should detect unused files alongside circular deps", async () => { - const result = await scanFixture("cycle-with-orphans"); - const fixtureDir = resolve(FIXTURES_DIR, "cycle-with-orphans"); - const unusedFiles = orphanPaths(result, fixtureDir); - assert.ok( - unusedFiles.includes("orphan.ts"), - "orphan.ts should be unused despite circular deps in other files", - ); - }); - - it("should detect unused exports in circular dependency modules", async () => { - const result = await scanFixture("cycle-with-orphans"); - const exportNames = deadExportNames(result); - assert.ok( - exportNames.includes("unusedFromA"), - "unusedFromA should be detected as unused despite circular dep", - ); - assert.ok( - exportNames.includes("unusedFromB"), - "unusedFromB should be detected as unused despite circular dep", - ); - }); -}); - -describe("deep-reexport-chain", () => { - it("should propagate usage through 4-level re-export chain", async () => { - const result = await scanFixture("deep-reexport-chain"); - const fixtureDir = resolve(FIXTURES_DIR, "deep-reexport-chain"); - const unusedFiles = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFiles.includes("level-3.ts"), - "level-3.ts should be reachable through deep re-export chain", - ); - assert.ok( - !unusedFiles.includes("level-2.ts"), - "level-2.ts should be reachable through re-export chain", - ); - assert.ok( - !unusedFiles.includes("level-1.ts"), - "level-1.ts should be reachable through re-export chain", - ); - assert.ok( - unusedFiles.includes("consumer.ts"), - "consumer.ts should be unused (not an entry point)", - ); - }); - - it("should detect exports that are not propagated through the chain", async () => { - const result = await scanFixture("deep-reexport-chain"); - const exportNames = deadExportNames(result); - assert.ok( - exportNames.includes("delta"), - "delta should be unused (not re-exported past level-2)", - ); - assert.ok( - exportNames.includes("gamma"), - "gamma should be unused (not re-exported past level-1 to index)", - ); - }); -}); - -describe("enum-export", () => { - it("should detect unused enum exports", async () => { - const result = await scanFixture("enum-export"); - const exportNames = deadExportNames(result); - assert.ok(exportNames.includes("UnusedEnum"), "UnusedEnum should be detected as unused"); - assert.ok( - !exportNames.includes("Status"), - "Status should NOT be detected as unused (it is imported)", - ); - }); -}); - -describe("alias-named-exports", () => { - it("should track aliased re-exports correctly", async () => { - const result = await scanFixture("alias-named-exports"); - const fixtureDir = resolve(FIXTURES_DIR, "alias-named-exports"); - const unusedFiles = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFiles.includes("greetings.ts"), - "greetings.ts should be reachable via aliased re-export", - ); - }); - - it("should detect unused exports with aliased names", async () => { - const result = await scanFixture("alias-named-exports"); - const exportNames = deadExportNames(result); - assert.ok( - exportNames.includes("unusedGreeting"), - "unusedGreeting should be unused (not re-exported)", - ); - }); -}); - -describe("module-side-effect", () => { - it("should keep side-effect imported files as reachable", async () => { - const result = await scanFixture("module-side-effect"); - const fixtureDir = resolve(FIXTURES_DIR, "module-side-effect"); - const unusedFiles = orphanPaths(result, fixtureDir); - assert.ok( - !unusedFiles.includes("polyfill.ts"), - "polyfill.ts should be reachable via side-effect import", - ); - assert.ok( - !unusedFiles.includes("register.ts"), - "register.ts should be reachable via side-effect import", - ); - assert.ok(unusedFiles.includes("orphan.ts"), "orphan.ts should be unused"); - }); -}); - -describe("reexport-star-named", () => { - it("should handle mixed star and named re-exports", async () => { - const result = await scanFixture("reexport-star-named"); - const fixtureDir = resolve(FIXTURES_DIR, "reexport-star-named"); - const unusedFiles = orphanPaths(result, fixtureDir); - assert.ok(!unusedFiles.includes("utils.ts"), "utils.ts should be reachable via star re-export"); - assert.ok( - !unusedFiles.includes("special.ts"), - "special.ts should be reachable via named re-export", - ); - assert.ok( - unusedFiles.includes("consumer.ts"), - "consumer.ts should be unused (not an entry point)", - ); - }); - - it("should detect unused exports from modules included via star", async () => { - const result = await scanFixture("reexport-star-named"); - const exportNames = deadExportNames(result); - assert.ok( - exportNames.includes("notReExported"), - "notReExported should be unused (not consumed via star or named re-export)", - ); - }); -}); - -describe("cross-file-duplicate-exports", () => { - it("should flag the same exported name in 2+ files that share an importer", async () => { - const result = await scanFixture("cross-file-duplicate-exports"); - const findings = result.crossFileDuplicateExports; - const sharedFinding = findings.find((finding) => finding.name === "sharedThing"); - assert.ok( - sharedFinding, - `expected a cross-file duplicate for "sharedThing", got: ${JSON.stringify(findings.map((finding) => finding.name))}`, - ); - assert.equal(sharedFinding.locations.length, 2); - assert.equal(sharedFinding.confidence, "medium"); - }); - - it("should not flag unique exports", async () => { - const result = await scanFixture("cross-file-duplicate-exports"); - const onlyHere = result.crossFileDuplicateExports.find( - (finding) => finding.name === "onlyHere", - ); - assert.equal(onlyHere, undefined, "onlyHere appears in only one file and must not be flagged"); - }); - - it("should not flag entry-point modules whose duplicates are part of the public API surface", async () => { - const result = await scanFixture("cross-file-duplicate-exports-unrelated"); - const handlerFinding = result.crossFileDuplicateExports.find( - (finding) => finding.name === "handler", - ); - assert.equal( - handlerFinding, - undefined, - "package.json-declared route entry points are part of the API surface, not actionable duplicates", - ); - }); -}); - -describe("code-clones", () => { - it("can be disabled via duplicateBlocks: { enabled: false }", async () => { - const result = await scanFixture("duplicate-blocks-basic", { - duplicateBlocks: { enabled: false }, - }); - assert.deepEqual(result.duplicateBlocks, []); - assert.deepEqual(result.duplicateBlockClusters, []); - assert.deepEqual(result.shadowedDirectoryPairs, []); - }); - - it("detects structurally-identical functions in semantic mode", async () => { - const result = await scanFixture("duplicate-blocks-basic", { - duplicateBlocks: { enabled: true, mode: "semantic", minTokens: 30, minLines: 3 }, - }); - assert.ok( - result.duplicateBlocks.length > 0, - `expected at least one duplicate block, got: ${JSON.stringify(result.duplicateBlocks, null, 2)}`, - ); - const ordersInvoicesClone = result.duplicateBlocks.find( - (duplicateBlock) => - duplicateBlock.instances.some((instance) => instance.path.endsWith("orders.ts")) && - duplicateBlock.instances.some((instance) => instance.path.endsWith("invoices.ts")), - ); - assert.ok( - ordersInvoicesClone, - `expected a clone spanning orders.ts and invoices.ts, got files: ${result.duplicateBlocks - .map((duplicateBlock) => - duplicateBlock.instances.map((instance) => instance.path).join(","), - ) - .join("|")}`, - ); - }); - - it("groups clones from the same file pair into a family", async () => { - const result = await scanFixture("duplicate-blocks-basic", { - duplicateBlocks: { enabled: true, mode: "semantic", minTokens: 30, minLines: 3 }, - }); - if (result.duplicateBlocks.length === 0) return; - assert.ok( - result.duplicateBlockClusters.length > 0, - "expected at least one duplicate-block cluster when clones are present", - ); - for (const family of result.duplicateBlockClusters) { - assert.ok(family.files.length >= 2, "family must span 2+ files"); - assert.ok(family.suggestions.length > 0, "family must produce a refactoring suggestion"); - } - }); - - it("respects skipLocal: true and drops within-directory clones", async () => { - const result = await scanFixture("duplicate-blocks-basic", { - duplicateBlocks: { - enabled: true, - mode: "semantic", - minTokens: 30, - minLines: 3, - skipLocal: true, - }, - }); - for (const duplicateBlock of result.duplicateBlocks) { - const directories = new Set( - duplicateBlock.instances.map((instance) => instance.path.replace(/\/[^/]*$/, "")), - ); - assert.ok( - directories.size >= 2, - "skipLocal should remove within-directory clones from the report", - ); - } - }); - - it("does not flag dissimilar files", async () => { - const result = await scanFixture("simple-app", { - duplicateBlocks: { enabled: true, mode: "semantic", minTokens: 50, minLines: 5 }, - }); - for (const duplicateBlock of result.duplicateBlocks) { - assert.ok( - duplicateBlock.tokenCount >= 50, - `every reported clone must satisfy minTokens, got ${duplicateBlock.tokenCount}`, - ); - } - }); -}); - -describe("re-export-cycles", () => { - it("detects multi-node re-export cycles", async () => { - const result = await scanFixture("re-export-cycle"); - assert.ok( - result.reExportCycles.length > 0, - `expected at least one re-export cycle, got ${JSON.stringify(result.reExportCycles)}`, - ); - const multiNodeCycle = result.reExportCycles.find((cycle) => cycle.kind === "multi-node"); - assert.ok(multiNodeCycle, "expected a multi-node re-export cycle for barrel <-> other"); - assert.equal(multiNodeCycle.confidence, "high"); - }); -}); - -describe("feature-flags", () => { - it("can be disabled via featureFlags: { enabled: false }", async () => { - const result = await scanFixture("feature-flags-basic", { - featureFlags: { enabled: false }, - }); - assert.deepEqual(result.featureFlags, []); - }); - - it("detects env var, SDK, and provider attribution", async () => { - const result = await scanFixture("feature-flags-basic", { - featureFlags: { enabled: true }, - }); - const envVarFlag = result.featureFlags.find((flag) => flag.kind === "env-var"); - assert.ok( - envVarFlag, - `expected an env-var flag finding, got: ${JSON.stringify(result.featureFlags)}`, - ); - assert.equal(envVarFlag.name, "FEATURE_NEW_CHECKOUT"); - - const statsigFlag = result.featureFlags.find((flag) => flag.sdkProvider === "Statsig"); - assert.ok(statsigFlag, "expected Statsig sdkProvider attribution"); - assert.equal(statsigFlag.name, "legacy_billing"); - - const launchDarklyFlag = result.featureFlags.find( - (flag) => flag.sdkProvider === "LaunchDarkly", - ); - assert.ok(launchDarklyFlag, "expected LaunchDarkly sdkProvider attribution"); - assert.equal(launchDarklyFlag.name, "payments-flag"); - }); -}); - -describe("private-type-leaks", () => { - it("flags exports whose signatures reference unexported local types", async () => { - const result = await scanFixture("private-type-leak"); - const initializeLeak = result.privateTypeLeaks.find( - (leak) => leak.exportName === "initialize" && leak.typeName === "InternalConfig", - ); - assert.ok( - initializeLeak, - `expected initialize -> InternalConfig leak, got: ${JSON.stringify(result.privateTypeLeaks)}`, - ); - assert.equal(initializeLeak.confidence, "high"); - - const teardownLeak = result.privateTypeLeaks.find( - (leak) => leak.exportName === "teardown" && leak.typeName === "InternalConfig", - ); - assert.ok(teardownLeak, "expected teardown -> InternalConfig leak"); - }); -}); - -describe("complex-functions", () => { - it("can be disabled via complexity: { enabled: false }", async () => { - const result = await scanFixture("complex-functions", { - complexity: { enabled: false }, - }); - assert.deepEqual(result.complexFunctions, []); - }); - - it("flags only functions that breach a threshold", async () => { - const result = await scanFixture("complex-functions", { - complexity: { - enabled: true, - cyclomaticThreshold: 5, - cognitiveThreshold: 5, - paramCountThreshold: 4, - functionLineThreshold: 10, - }, - }); - const tangled = result.complexFunctions.find((finding) => finding.functionName === "tangledFn"); - assert.ok(tangled, "tangledFn should be flagged"); - assert.ok(tangled.cyclomatic >= 5, `tangledFn cyclomatic ${tangled.cyclomatic} should be >= 5`); - const simple = result.complexFunctions.find((finding) => finding.functionName === "simpleFn"); - assert.equal(simple, undefined, "simpleFn must not be flagged"); - }); -}); - -describe("typescript-smells", () => { - it("flags redundant double assertions like `x as unknown as T`", async () => { - const result = await scanFixture("typescript-smells"); - const doubleAssertion = result.unnecessaryAssertions.find( - (finding) => finding.kind === "redundant-double-assertion", - ); - assert.ok( - doubleAssertion, - `expected a redundant-double-assertion finding, got: ${JSON.stringify(result.unnecessaryAssertions.map((finding) => finding.kind))}`, - ); - assert.equal(doubleAssertion.confidence, "high"); - }); - - it("flags `as any`", async () => { - const result = await scanFixture("typescript-smells"); - const asAny = result.unnecessaryAssertions.find( - (finding) => finding.kind === "assertion-to-any", - ); - assert.ok(asAny, "expected an assertion-to-any finding"); - }); - - it("flags non-null assertion on a literal", async () => { - const result = await scanFixture("typescript-smells"); - const onLiteral = result.unnecessaryAssertions.find( - (finding) => finding.kind === "redundant-non-null-on-literal", - ); - assert.ok(onLiteral, "expected a redundant-non-null-on-literal finding"); - assert.equal(onLiteral.confidence, "high"); - }); - - it("flags double non-null assertions `x!!`", async () => { - const result = await scanFixture("typescript-smells"); - const doubleNonNull = result.unnecessaryAssertions.find( - (finding) => finding.kind === "double-non-null", - ); - assert.ok(doubleNonNull, "expected a double-non-null finding"); - }); - - it("flags `<T>x` angle-bracket assertions", async () => { - const result = await scanFixture("typescript-smells"); - const angleBracket = result.unnecessaryAssertions.find( - (finding) => finding.kind === "angle-bracket-assertion", - ); - assert.ok(angleBracket, "expected an angle-bracket-assertion finding"); - }); - - it("flags top-level `await import()` and `import().then()`", async () => { - const result = await scanFixture("typescript-smells"); - const awaitImport = result.lazyImportsAtTopLevel.find( - (finding) => finding.kind === "top-level-await-import", - ); - assert.ok(awaitImport, "expected a top-level-await-import finding"); - assert.ok( - awaitImport.specifier.endsWith("alpha.js"), - `expected alpha.js specifier, got ${awaitImport.specifier}`, - ); - const thenImport = result.lazyImportsAtTopLevel.find( - (finding) => finding.kind === "top-level-then-import", - ); - assert.ok(thenImport, "expected a top-level-then-import finding"); - }); - - it("flags `require()` and `module.exports` / `exports.x` in ESM modules", async () => { - const result = await scanFixture("typescript-smells"); - const requireFinding = result.commonjsInEsm.find((finding) => finding.kind === "require"); - assert.ok(requireFinding, "expected a require() finding in this ESM (`type: module`) fixture"); - const moduleExportsFinding = result.commonjsInEsm.find( - (finding) => finding.kind === "module-exports", - ); - assert.ok(moduleExportsFinding, "expected a module.exports finding"); - const exportsAssignmentFinding = result.commonjsInEsm.find( - (finding) => finding.kind === "exports-assignment", - ); - assert.ok(exportsAssignmentFinding, "expected an exports.x = ... finding"); - }); - - it("flags `@ts-ignore` and `@ts-nocheck` comments", async () => { - const result = await scanFixture("typescript-smells"); - const tsIgnoreFindings = result.typeScriptEscapeHatches.filter( - (finding) => finding.kind === "ts-ignore", - ); - assert.equal( - tsIgnoreFindings.length, - 2, - `expected the bare @ts-ignore and the mid-ternary @ts-ignore-with-trailing-text to both fire, got ${JSON.stringify(tsIgnoreFindings)}`, - ); - for (const finding of tsIgnoreFindings) assert.equal(finding.confidence, "high"); - }); - - it("flags `@ts-expect-error` without an explanation, but allows it when the comment carries a justification", async () => { - const result = await scanFixture("typescript-smells"); - const expectErrorFindings = result.typeScriptEscapeHatches.filter( - (finding) => finding.kind === "ts-expect-error-without-explanation", - ); - assert.equal( - expectErrorFindings.length, - 1, - `expected exactly one ts-expect-error-without-explanation finding, got ${expectErrorFindings.length}: ${JSON.stringify(expectErrorFindings)}`, - ); - }); -}); - -describe("jsx-namespace-member (issue #875)", () => { - it("treats a namespace member used only in JSX (<S.Custom/>) as used", async () => { - const result = await scanFixture("jsx-namespace-member"); - const dead = deadExportNames(result); - assert.ok( - !dead.includes("Custom"), - `Custom is used via <S.Custom/> and must not be flagged unused, got: ${dead}`, - ); - // `Plain` is genuinely unused — proves the scan ran and isn't passing vacuously. - assert.ok(dead.includes("Plain"), `Plain is unused and should still be flagged, got: ${dead}`); - }); -}); diff --git a/packages/deslop-js/tests/build-module-link-inputs.test.ts b/packages/deslop-js/tests/build-module-link-inputs.test.ts deleted file mode 100644 index 16fef84cee..0000000000 --- a/packages/deslop-js/tests/build-module-link-inputs.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import { join } from "node:path"; -import { after, describe, it } from "node:test"; -import { parseSourceFile } from "../src/collect/parse.js"; -import { buildModuleLinkInputs } from "../src/linker/build-module-link-inputs.js"; - -const temporaryRoot = mkdtempSync(join(os.tmpdir(), "deslop-module-link-inputs-")); - -after(() => { - rmSync(temporaryRoot, { recursive: true, force: true }); -}); - -describe("buildModuleLinkInputs", () => { - it("preserves source ordering while discovering sorted and transitive style imports", () => { - const projectDirectory = join(temporaryRoot, "style-discovery"); - const sourceFilePath = join(projectDirectory, "src", "index.ts"); - const firstStyleFilePath = join(projectDirectory, "styles", "a.css"); - const secondStyleFilePath = join(projectDirectory, "styles", "z.css"); - const nestedStyleFilePath = join(projectDirectory, "styles", "nested.css"); - const sourceExternalStyleFilePath = join(projectDirectory, "styles", "source-external.css"); - const nestedExternalStyleFilePath = join(projectDirectory, "styles", "nested-external.css"); - mkdirSync(join(projectDirectory, "src"), { recursive: true }); - mkdirSync(join(projectDirectory, "styles"), { recursive: true }); - writeFileSync( - sourceFilePath, - 'import "../styles/z.css";\nimport "../styles/a.css";\nimport "source-external";\nexport { missing } from "./missing.js";\n', - ); - writeFileSync(firstStyleFilePath, '@import "./nested.css";\n'); - writeFileSync(secondStyleFilePath, ".second {}\n"); - writeFileSync(nestedStyleFilePath, '@import "./broken.css";\n@import "nested-external";\n'); - writeFileSync(sourceExternalStyleFilePath, ".source-external {}\n"); - writeFileSync(nestedExternalStyleFilePath, ".nested-external {}\n"); - - const resolvedPaths = new Map<string, string>([ - [`${sourceFilePath}:../styles/a.css`, firstStyleFilePath], - [`${sourceFilePath}:../styles/z.css`, secondStyleFilePath], - [`${sourceFilePath}:source-external`, sourceExternalStyleFilePath], - [`${firstStyleFilePath}:./nested.css`, nestedStyleFilePath], - [`${nestedStyleFilePath}:nested-external`, nestedExternalStyleFilePath], - ]); - const result = buildModuleLinkInputs({ - files: [{ index: 0, path: sourceFilePath }], - parsedModules: [parseSourceFile(sourceFilePath)], - resolvedEntries: { - productionEntries: [sourceFilePath], - testEntries: [], - alwaysUsedFiles: [], - }, - gitIgnoredFilePaths: new Set([nestedStyleFilePath]), - resolveModule: (specifier, fromFilePath) => { - const resolvedPath = resolvedPaths.get(`${fromFilePath}:${specifier}`); - if (!resolvedPath) throw new Error(`could not resolve ${specifier}`); - return { - resolvedPath, - isExternal: specifier === "source-external" || specifier === "nested-external", - packageName: undefined, - }; - }, - }); - - assert.deepEqual( - result.graphInputs.map((graphInput) => graphInput.fileId), - [ - sourceFilePath, - firstStyleFilePath, - secondStyleFilePath, - nestedStyleFilePath, - nestedExternalStyleFilePath, - ].map((filePath, index) => ({ index, path: filePath })), - ); - assert.equal(result.graphInputs[0].isEntryPoint, true); - assert.equal(result.graphInputs[3].isGitIgnored, true); - assert.deepEqual( - result.errors.map((error) => ({ message: error.message, path: error.path })), - [ - { - message: 'moduleResolver.resolveModule threw on specifier "./missing.js"', - path: sourceFilePath, - }, - { - message: 'moduleResolver.resolveModule threw on style import "./broken.css"', - path: nestedStyleFilePath, - }, - ], - ); - }); -}); diff --git a/packages/deslop-js/tests/collect-git-ignored-paths.test.ts b/packages/deslop-js/tests/collect-git-ignored-paths.test.ts deleted file mode 100644 index 2f87e29c45..0000000000 --- a/packages/deslop-js/tests/collect-git-ignored-paths.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { collectGitIgnoredPaths } from "../src/utils/collect-git-ignored-paths.js"; -import { toPosixPath } from "../src/utils/to-posix-path.js"; - -const createTempProject = (): string => mkdtempSync(join(tmpdir(), "deslop-gitignore-")); - -const candidatePath = (projectDir: string, relativePath: string): string => - toPosixPath(resolve(projectDir, relativePath)); - -describe("collectGitIgnoredPaths", () => { - it("returns only the gitignored subset of the given paths inside a git work tree", () => { - const projectDir = createTempProject(); - try { - execFileSync("git", ["init", "-q"], { cwd: projectDir }); - writeFileSync(join(projectDir, ".gitignore"), "generated/\n"); - mkdirSync(join(projectDir, "generated")); - writeFileSync(join(projectDir, "generated", "output.ts"), "export const generated = 1;\n"); - writeFileSync(join(projectDir, "index.ts"), "export const entry = 2;\n"); - - const ignoredPath = candidatePath(projectDir, "generated/output.ts"); - const keptPath = candidatePath(projectDir, "index.ts"); - const result = collectGitIgnoredPaths(projectDir, [ignoredPath, keptPath]); - - assert.equal(result.gitUnavailable, false); - assert.ok(result.ignoredPaths.has(ignoredPath), "gitignored file should be reported"); - assert.ok(!result.ignoredPaths.has(keptPath), "non-ignored source must not be reported"); - } finally { - rmSync(projectDir, { recursive: true, force: true }); - } - }); - - it("treats a non-git directory as available-but-empty, not a git failure", () => { - const projectDir = createTempProject(); - try { - writeFileSync(join(projectDir, "index.ts"), "export const entry = 2;\n"); - const result = collectGitIgnoredPaths(projectDir, [candidatePath(projectDir, "index.ts")]); - - assert.equal(result.gitUnavailable, false); - assert.equal(result.ignoredPaths.size, 0); - } finally { - rmSync(projectDir, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/deslop-js/tests/dependency-utils.test.ts b/packages/deslop-js/tests/dependency-utils.test.ts deleted file mode 100644 index 49d8fc3708..0000000000 --- a/packages/deslop-js/tests/dependency-utils.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { collectOverrideMappingsFromRecord } from "../src/utils/collect-override-mappings-from-record.js"; -import { collectPnpmWorkspaceOverrideMappings } from "../src/utils/parse-pnpm-workspace-overrides.js"; -import { matchesPackageImportReference } from "../src/utils/matches-package-import-reference.js"; -import { matchesPackageTokenReference } from "../src/utils/matches-package-token-reference.js"; -import { resolve } from "node:path"; - -describe("collectOverrideMappingsFromRecord", () => { - it("should flatten nested override records", () => { - const mappings = collectOverrideMappingsFromRecord({ - vite: "npm:@voidzero-dev/vite-plus-core@^0.1.20", - "eslint-config-custom@1.0.0": { - typescript: "npm:@typescript/native-preview", - }, - }); - - assert.deepEqual(mappings, [ - { fromPackage: "vite", toPackage: "@voidzero-dev/vite-plus-core" }, - { fromPackage: "typescript", toPackage: "@typescript/native-preview" }, - ]); - }); -}); - -describe("collectPnpmWorkspaceOverrideMappings", () => { - it("should parse overrides under pnpm blocks", () => { - const fixtureDir = resolve(import.meta.dirname, "fixtures/vitest-override-target"); - const mappings = collectPnpmWorkspaceOverrideMappings(fixtureDir); - - assert.deepEqual(mappings, [ - { fromPackage: "vitest", toPackage: "@voidzero-dev/vite-plus-test" }, - ]); - }); - - it("should parse nested overrides from workspace yaml", () => { - const fixtureDir = resolve(import.meta.dirname, "fixtures/pnpm-nested-overrides"); - const mappings = collectPnpmWorkspaceOverrideMappings(fixtureDir); - - assert.deepEqual(mappings, [ - { fromPackage: "typescript", toPackage: "@typescript/native-preview" }, - ]); - }); -}); - -describe("matchesPackageImportReference", () => { - it("should match import and require usage", () => { - const source = [ - "import foo from 'used-package/subpath'", - 'const bar = require("used-package")', - 'const names = ["unused-string-only-package"]', - ].join("\n"); - - assert.equal(matchesPackageImportReference(source, "used-package"), true); - assert.equal(matchesPackageImportReference(source, "unused-string-only-package"), false); - assert.equal( - matchesPackageImportReference( - "const icon = require(`flag-icons/flags/4x3/${code}.svg`);", - "flag-icons", - ), - true, - ); - }); -}); - -describe("matchesPackageTokenReference", () => { - it("should match a dep passed as a flag argument", () => { - assert.equal( - matchesPackageTokenReference( - "jest --coverage --testResultsProcessor jest-sonar-reporter", - "jest-sonar-reporter", - ), - true, - ); - }); - - it("should match a dep passed as an `=`-joined flag value", () => { - assert.equal(matchesPackageTokenReference("jest --reporters=jest-junit", "jest-junit"), true); - }); - - it("should match a scoped dep and a dep with a /subpath", () => { - assert.equal(matchesPackageTokenReference("node @org/cli build", "@org/cli"), true); - assert.equal(matchesPackageTokenReference("node some-pkg/register app.js", "some-pkg"), true); - }); - - it("should not match a token that merely contains the name", () => { - assert.equal( - matchesPackageTokenReference("run my-jest-sonar-reporter-extra", "jest-sonar-reporter"), - false, - ); - }); -}); diff --git a/packages/deslop-js/tests/errors.test.ts b/packages/deslop-js/tests/errors.test.ts deleted file mode 100644 index bb19938348..0000000000 --- a/packages/deslop-js/tests/errors.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { resolve } from "node:path"; -import { analyze, defineConfig } from "../src/index.js"; -import { - ConfigError, - DeslopError, - DetectorError, - FileReadError, - ParseError, - ResolverError, - TypeScriptError, - WorkspaceError, -} from "../src/errors.js"; -import { FIXTURES_DIR } from "./helpers/fixtures-dir.js"; - -describe("errors / DeslopError class hierarchy", () => { - it("DeslopError is an Error subclass with structured fields", () => { - const error = new DeslopError({ - code: "file-read-failed", - module: "parse", - message: "boom", - path: "/tmp/x", - detail: "EACCES", - }); - assert.ok(error instanceof Error); - assert.ok(error instanceof DeslopError); - assert.equal(error.code, "file-read-failed"); - assert.equal(error.module, "parse"); - assert.equal(error.severity, "warning"); - assert.equal(error.message, "boom"); - assert.equal(error.path, "/tmp/x"); - assert.equal(error.detail, "EACCES"); - }); - - it("subclasses pre-fill module and narrow code", () => { - const fileError = new FileReadError({ code: "file-empty", message: "empty", path: "/x" }); - assert.ok(fileError instanceof DeslopError); - assert.ok(fileError instanceof FileReadError); - assert.equal(fileError.module, "parse"); - assert.equal(fileError.code, "file-empty"); - - const parseError = new ParseError({ code: "parse-failed", message: "bad" }); - assert.equal(parseError.module, "parse"); - - const tsError = new TypeScriptError({ code: "tsconfig-not-found", message: "no config" }); - assert.equal(tsError.module, "semantic"); - - const wsError = new WorkspaceError({ code: "workspace-discovery-failed", message: "ws" }); - assert.equal(wsError.module, "collect"); - - const configError = new ConfigError({ message: "bad config" }); - assert.equal(configError.module, "config"); - assert.equal(configError.severity, "fatal"); - - const resolverError = new ResolverError({ message: "resolver kapow" }); - assert.equal(resolverError.module, "resolver"); - assert.equal(resolverError.severity, "fatal"); - - const detectorError = new DetectorError({ message: "detector kapow" }); - assert.equal(detectorError.module, "report"); - }); - - it("DeslopError.fromCaught serializes the caught value into `detail`", () => { - const caughtError = new Error("upstream went bad"); - const wrapped = DeslopError.fromCaught({ - code: "parse-failed", - module: "parse", - message: "wrap", - caught: caughtError, - }); - assert.equal(wrapped.detail, "upstream went bad"); - }); - - it("toJSON produces a stable plain object suitable for JSON.stringify", () => { - const error = new ParseError({ code: "ast-walk-failed", message: "boom", path: "/p" }); - const serialized = JSON.parse(JSON.stringify(error)); - assert.equal(serialized.name, "ParseError"); - assert.equal(serialized.code, "ast-walk-failed"); - assert.equal(serialized.module, "parse"); - assert.equal(serialized.severity, "warning"); - assert.equal(serialized.message, "boom"); - assert.equal(serialized.path, "/p"); - }); -}); - -describe("errors / analyze() returns DeslopErrors instead of throwing", () => { - it("invalid rootDir yields a single fatal ConfigError and no crash", async () => { - const result = await analyze( - defineConfig({ rootDir: "/this/path/should/never/exist/__abc__" }), - ); - assert.equal(result.totalFiles, 0); - assert.equal(result.analysisErrors.length, 1); - const onlyError = result.analysisErrors[0]; - assert.equal(onlyError.code, "config-invalid"); - assert.equal(onlyError.severity, "fatal"); - assert.equal(onlyError.module, "config"); - }); - - it("empty .ts file emits an info-level file-empty error and does not crash analysis", async () => { - const result = await analyze( - defineConfig({ rootDir: resolve(FIXTURES_DIR, "empty-and-binary-files") }), - ); - const emptyErrors = result.analysisErrors.filter( - (entry) => entry.code === "file-empty" && entry.path?.endsWith("empty-file.ts"), - ); - assert.equal(emptyErrors.length, 1); - assert.equal(emptyErrors[0].severity, "info"); - assert.ok(result.totalFiles > 0, "analysis still processed the rest of the package"); - }); - - it("binary .ts file emits a file-binary error and is skipped", async () => { - const result = await analyze( - defineConfig({ rootDir: resolve(FIXTURES_DIR, "empty-and-binary-files") }), - ); - const binaryErrors = result.analysisErrors.filter( - (entry) => entry.code === "file-binary" && entry.path?.endsWith("binary-file.ts"), - ); - assert.equal(binaryErrors.length, 1); - }); - - it("minified bundle emits an info-level file-minified error and skips redundancy findings", async () => { - const result = await analyze( - defineConfig({ rootDir: resolve(FIXTURES_DIR, "empty-and-binary-files") }), - ); - const minifiedErrors = result.analysisErrors.filter( - (entry) => entry.code === "file-minified" && entry.path?.endsWith("minified-bundle.js"), - ); - assert.equal(minifiedErrors.length, 1); - assert.equal(minifiedErrors[0].severity, "info"); - const findingsInsideBundle = [ - ...result.simplifiableExpressions, - ...result.simplifiableFunctions, - ...result.duplicateImports, - ...result.redundantTypePatterns, - ].filter((entry) => entry.path.endsWith("minified-bundle.js")); - assert.deepEqual(findingsInsideBundle, []); - }); - - it("broken tsconfig with semantic enabled emits tsconfig-parse-failed instead of throwing", async () => { - const result = await analyze( - defineConfig({ - rootDir: resolve(FIXTURES_DIR, "broken-tsconfig"), - semantic: { enabled: true }, - }), - ); - const tsconfigErrors = result.analysisErrors.filter( - (entry) => entry.code === "tsconfig-parse-failed", - ); - assert.equal(tsconfigErrors.length, 1); - assert.equal(tsconfigErrors[0].module, "semantic"); - assert.deepEqual(result.unusedTypes, []); - }); - - it("missing tsconfig with semantic enabled emits an info-level tsconfig-not-found", async () => { - const result = await analyze( - defineConfig({ - rootDir: resolve(FIXTURES_DIR, "simple-app"), - semantic: { enabled: true }, - }), - ); - const notFoundErrors = result.analysisErrors.filter( - (entry) => entry.code === "tsconfig-not-found", - ); - assert.equal(notFoundErrors.length, 1); - assert.equal(notFoundErrors[0].severity, "info"); - }); - - it("scans with semantic disabled report no semantic errors", async () => { - const result = await analyze( - defineConfig({ - rootDir: resolve(FIXTURES_DIR, "simple-app"), - semantic: { enabled: false }, - }), - ); - const semanticErrors = result.analysisErrors.filter((entry) => entry.module === "semantic"); - assert.equal(semanticErrors.length, 0); - }); -}); diff --git a/packages/deslop-js/tests/find-strongly-connected-components.test.ts b/packages/deslop-js/tests/find-strongly-connected-components.test.ts deleted file mode 100644 index 99ade13167..0000000000 --- a/packages/deslop-js/tests/find-strongly-connected-components.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { findStronglyConnectedComponents } from "../src/utils/find-strongly-connected-components.js"; - -describe("findStronglyConnectedComponents", () => { - it("preserves depth-first component and node emission order", () => { - const adjacencyList = [[1], [2, 3], [0], [4], [3], [], [6]]; - - assert.deepEqual(findStronglyConnectedComponents(adjacencyList), [[4, 3], [2, 1, 0], [5], [6]]); - }); -}); diff --git a/packages/deslop-js/tests/fixtures/alias-mixed-exports/package.json b/packages/deslop-js/tests/fixtures/alias-mixed-exports/package.json deleted file mode 100644 index 7677938118..0000000000 --- a/packages/deslop-js/tests/fixtures/alias-mixed-exports/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "path-aliases-mixed-exports", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/alias-mixed-exports/src/helpers.ts b/packages/deslop-js/tests/fixtures/alias-mixed-exports/src/helpers.ts deleted file mode 100644 index 6cf1b5b973..0000000000 --- a/packages/deslop-js/tests/fixtures/alias-mixed-exports/src/helpers.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const usedHelper = () => "used"; -export const unusedHelper = () => "unused"; diff --git a/packages/deslop-js/tests/fixtures/alias-mixed-exports/src/index.ts b/packages/deslop-js/tests/fixtures/alias-mixed-exports/src/index.ts deleted file mode 100644 index 9a8a98d4f0..0000000000 --- a/packages/deslop-js/tests/fixtures/alias-mixed-exports/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { usedExport } from "@/types"; -import { usedHelper } from "@/helpers"; - -console.log(usedExport, usedHelper); diff --git a/packages/deslop-js/tests/fixtures/alias-mixed-exports/src/orphan.ts b/packages/deslop-js/tests/fixtures/alias-mixed-exports/src/orphan.ts deleted file mode 100644 index 968ebc9338..0000000000 --- a/packages/deslop-js/tests/fixtures/alias-mixed-exports/src/orphan.ts +++ /dev/null @@ -1,2 +0,0 @@ -// This file is truly unused — no imports reference it -export const orphanedValue = 42; diff --git a/packages/deslop-js/tests/fixtures/alias-mixed-exports/src/types.ts b/packages/deslop-js/tests/fixtures/alias-mixed-exports/src/types.ts deleted file mode 100644 index 88da4e62de..0000000000 --- a/packages/deslop-js/tests/fixtures/alias-mixed-exports/src/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const usedExport = "used"; -export const unusedExport = "unused"; -export type UsedType = string; -export type UnusedType = number; diff --git a/packages/deslop-js/tests/fixtures/alias-mixed-exports/tsconfig.json b/packages/deslop-js/tests/fixtures/alias-mixed-exports/tsconfig.json deleted file mode 100644 index 2c8ee2bb01..0000000000 --- a/packages/deslop-js/tests/fixtures/alias-mixed-exports/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - } - } -} diff --git a/packages/deslop-js/tests/fixtures/alias-named-exports/barrel.ts b/packages/deslop-js/tests/fixtures/alias-named-exports/barrel.ts deleted file mode 100644 index 8b9d5dcf9f..0000000000 --- a/packages/deslop-js/tests/fixtures/alias-named-exports/barrel.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { sayHello as greet } from "./greetings"; -export { farewell as goodbye } from "./greetings"; diff --git a/packages/deslop-js/tests/fixtures/alias-named-exports/greetings.ts b/packages/deslop-js/tests/fixtures/alias-named-exports/greetings.ts deleted file mode 100644 index 764184725b..0000000000 --- a/packages/deslop-js/tests/fixtures/alias-named-exports/greetings.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const sayHello = (name: string) => `Hello, ${name}!`; -export const farewell = (name: string) => `Goodbye, ${name}!`; -export const unusedGreeting = () => "not used anywhere"; diff --git a/packages/deslop-js/tests/fixtures/alias-named-exports/index.ts b/packages/deslop-js/tests/fixtures/alias-named-exports/index.ts deleted file mode 100644 index 70cce87d25..0000000000 --- a/packages/deslop-js/tests/fixtures/alias-named-exports/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { greet } from "./barrel"; - -greet("world"); diff --git a/packages/deslop-js/tests/fixtures/alias-named-exports/package.json b/packages/deslop-js/tests/fixtures/alias-named-exports/package.json deleted file mode 100644 index c74fae656d..0000000000 --- a/packages/deslop-js/tests/fixtures/alias-named-exports/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "aliased-named-exports", - "main": "index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/alias-paths/package.json b/packages/deslop-js/tests/fixtures/alias-paths/package.json deleted file mode 100644 index aca8d6f10f..0000000000 --- a/packages/deslop-js/tests/fixtures/alias-paths/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "path-aliases", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/alias-paths/src/index.ts b/packages/deslop-js/tests/fixtures/alias-paths/src/index.ts deleted file mode 100644 index f8ab649772..0000000000 --- a/packages/deslop-js/tests/fixtures/alias-paths/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { helper } from "@/utils"; - -console.log(helper); diff --git a/packages/deslop-js/tests/fixtures/alias-paths/src/utils.ts b/packages/deslop-js/tests/fixtures/alias-paths/src/utils.ts deleted file mode 100644 index e94ac33a66..0000000000 --- a/packages/deslop-js/tests/fixtures/alias-paths/src/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = 1; diff --git a/packages/deslop-js/tests/fixtures/alias-paths/tsconfig.json b/packages/deslop-js/tests/fixtures/alias-paths/tsconfig.json deleted file mode 100644 index 2c8ee2bb01..0000000000 --- a/packages/deslop-js/tests/fixtures/alias-paths/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - } - } -} diff --git a/packages/deslop-js/tests/fixtures/angular-workspace/angular.json b/packages/deslop-js/tests/fixtures/angular-workspace/angular.json deleted file mode 100644 index bc80083491..0000000000 --- a/packages/deslop-js/tests/fixtures/angular-workspace/angular.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "version": 1, - "projects": { - "demo": { - "root": "projects/demo", - "sourceRoot": "projects/demo/src", - "architect": { - "build": { - "builder": "@angular-devkit/build-angular:browser", - "options": { - "main": "projects/demo/src/main.ts", - "polyfills": "projects/demo/src/polyfills.ts", - "styles": ["projects/demo/src/styles.css"] - } - }, - "test": { - "builder": "@angular-devkit/build-angular:karma", - "options": { - "main": "projects/demo/src/test.ts" - } - } - } - } - } -} diff --git a/packages/deslop-js/tests/fixtures/angular-workspace/orphan.ts b/packages/deslop-js/tests/fixtures/angular-workspace/orphan.ts deleted file mode 100644 index b79aa99b63..0000000000 --- a/packages/deslop-js/tests/fixtures/angular-workspace/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = true; diff --git a/packages/deslop-js/tests/fixtures/angular-workspace/package.json b/packages/deslop-js/tests/fixtures/angular-workspace/package.json deleted file mode 100644 index d7dd77e978..0000000000 --- a/packages/deslop-js/tests/fixtures/angular-workspace/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "angular-workspace", - "private": true, - "dependencies": { - "@angular/core": "^17.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/app/app.component.css b/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/app/app.component.css deleted file mode 100644 index b53e19ad64..0000000000 --- a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/app/app.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.app { - color: red; -} diff --git a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/app/app.component.html b/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/app/app.component.html deleted file mode 100644 index a0f878c1ee..0000000000 --- a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/app/app.component.html +++ /dev/null @@ -1 +0,0 @@ -<p>App works!</p> diff --git a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/app/app.component.ts b/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/app/app.component.ts deleted file mode 100644 index b9b0782016..0000000000 --- a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/app/app.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-root", - templateUrl: "./app.component.html", - styleUrls: ["./app.component.css"], -}) -export class AppComponent { - title = "demo"; -} diff --git a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/app/app.module.ts b/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/app/app.module.ts deleted file mode 100644 index 2c24b5a010..0000000000 --- a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/app/app.module.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { AppComponent } from "./app.component"; -export const AppModule = { declarations: [AppComponent] }; diff --git a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/app/orphan.css b/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/app/orphan.css deleted file mode 100644 index b5c0e469e6..0000000000 --- a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/app/orphan.css +++ /dev/null @@ -1,3 +0,0 @@ -.unused { - display: none; -} diff --git a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/environments/environment.ts b/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/environments/environment.ts deleted file mode 100644 index 60007621a2..0000000000 --- a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/environments/environment.ts +++ /dev/null @@ -1 +0,0 @@ -export const environment = { production: false }; diff --git a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/main.ts b/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/main.ts deleted file mode 100644 index b901fa2c34..0000000000 --- a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/main.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { AppModule } from "./app/app.module"; -console.log(AppModule); diff --git a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/polyfills.ts b/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/polyfills.ts deleted file mode 100644 index a201b2c3d3..0000000000 --- a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/polyfills.ts +++ /dev/null @@ -1 +0,0 @@ -import "zone.js"; diff --git a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/styles.css b/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/styles.css deleted file mode 100644 index 293d3b1f13..0000000000 --- a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/styles.css +++ /dev/null @@ -1,3 +0,0 @@ -body { - margin: 0; -} diff --git a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/test.ts b/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/test.ts deleted file mode 100644 index 009d1e049c..0000000000 --- a/packages/deslop-js/tests/fixtures/angular-workspace/projects/demo/src/test.ts +++ /dev/null @@ -1 +0,0 @@ -import "jasmine-core"; diff --git a/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/package.json b/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/package.json deleted file mode 100644 index 7249814f5e..0000000000 --- a/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "arrow-wrapped-dynamic-imports", - "main": "src/index.tsx" -} diff --git a/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/Bar.tsx b/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/Bar.tsx deleted file mode 100644 index b6a1a0aa22..0000000000 --- a/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/Bar.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Bar() { - return null; -} diff --git a/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/Baz.tsx b/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/Baz.tsx deleted file mode 100644 index 9aa1d572dd..0000000000 --- a/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/Baz.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Baz() { - return null; -} diff --git a/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/Foo.tsx b/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/Foo.tsx deleted file mode 100644 index a41db702aa..0000000000 --- a/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/Foo.tsx +++ /dev/null @@ -1,4 +0,0 @@ -export default function Foo() { - return null; -} -export const unusedNamedExport = "unused"; diff --git a/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/feature.routes.ts b/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/feature.routes.ts deleted file mode 100644 index 22a587edec..0000000000 --- a/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/feature.routes.ts +++ /dev/null @@ -1,4 +0,0 @@ -const featureRoutes = [{ path: "", component: "PlaceholderComponent" }]; - -export default featureRoutes; -export const unusedRouteHelper = "unused"; diff --git a/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/index.tsx b/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/index.tsx deleted file mode 100644 index 94f4f6d63b..0000000000 --- a/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/index.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React, { lazy } from "react"; - -const Foo = React.lazy(() => import("./Foo")); -const Bar = lazy(() => import("./Bar")); -const Baz = import("./Baz"); -const route = { - path: "feature", - loadChildren: () => import("./feature.routes"), -}; - -export { Foo, Bar, Baz, route }; diff --git a/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/orphan.ts b/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/orphan.ts deleted file mode 100644 index bddae4ee9b..0000000000 --- a/packages/deslop-js/tests/fixtures/arrow-wrapped-import-dynamic/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = true; diff --git a/packages/deslop-js/tests/fixtures/astro-app/package.json b/packages/deslop-js/tests/fixtures/astro-app/package.json deleted file mode 100644 index e793387419..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-app/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "astro-app", - "dependencies": { - "astro": "^7.1.5", - "sharp": "^0.34.0", - "unused-dep": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/astro-app/src/pages/index.astro b/packages/deslop-js/tests/fixtures/astro-app/src/pages/index.astro deleted file mode 100644 index 4a222f1470..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-app/src/pages/index.astro +++ /dev/null @@ -1 +0,0 @@ -<h1>Astro</h1> diff --git a/packages/deslop-js/tests/fixtures/astro-content/astro.config.ts b/packages/deslop-js/tests/fixtures/astro-content/astro.config.ts deleted file mode 100644 index 2359d42bb8..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-content/astro.config.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { defineConfig } from "astro/config"; -export default defineConfig({}); diff --git a/packages/deslop-js/tests/fixtures/astro-content/package.json b/packages/deslop-js/tests/fixtures/astro-content/package.json deleted file mode 100644 index 324b980920..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-content/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "astro-content-config", - "dependencies": { - "astro": "^4.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/astro-content/src/content.config.ts b/packages/deslop-js/tests/fixtures/astro-content/src/content.config.ts deleted file mode 100644 index 6e6c399bfb..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-content/src/content.config.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { defineCollection, z } from "astro:content"; -export const collections = { blog: defineCollection({ schema: z.object({}) }) }; diff --git a/packages/deslop-js/tests/fixtures/astro-content/src/content/config.ts b/packages/deslop-js/tests/fixtures/astro-content/src/content/config.ts deleted file mode 100644 index 28318af521..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-content/src/content/config.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { defineCollection } from "astro:content"; -export const collections = {}; diff --git a/packages/deslop-js/tests/fixtures/astro-content/src/orphan.ts b/packages/deslop-js/tests/fixtures/astro-content/src/orphan.ts deleted file mode 100644 index bddae4ee9b..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-content/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = true; diff --git a/packages/deslop-js/tests/fixtures/astro-content/src/pages/index.astro b/packages/deslop-js/tests/fixtures/astro-content/src/pages/index.astro deleted file mode 100644 index 0a2d9fab56..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-content/src/pages/index.astro +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- -<html><body>Hello</body></html> diff --git a/packages/deslop-js/tests/fixtures/astro-frontmatter-return/package.json b/packages/deslop-js/tests/fixtures/astro-frontmatter-return/package.json deleted file mode 100644 index d8e7da5c7f..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-frontmatter-return/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "astro-frontmatter-return", - "version": "1.0.0", - "dependencies": { - "astro": "^4.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/astro-frontmatter-return/src/components/Greeting.tsx b/packages/deslop-js/tests/fixtures/astro-frontmatter-return/src/components/Greeting.tsx deleted file mode 100644 index 2cda2b3938..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-frontmatter-return/src/components/Greeting.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export const Greeting = ({ name }: { name: string }) => { - return <p>Hello, {name}!</p>; -}; diff --git a/packages/deslop-js/tests/fixtures/astro-frontmatter-return/src/components/orphan.ts b/packages/deslop-js/tests/fixtures/astro-frontmatter-return/src/components/orphan.ts deleted file mode 100644 index cff36d6881..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-frontmatter-return/src/components/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const neverImported = true; diff --git a/packages/deslop-js/tests/fixtures/astro-frontmatter-return/src/pages/[...slug].astro b/packages/deslop-js/tests/fixtures/astro-frontmatter-return/src/pages/[...slug].astro deleted file mode 100644 index 05ea6a369a..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-frontmatter-return/src/pages/[...slug].astro +++ /dev/null @@ -1,26 +0,0 @@ ---- -import { Greeting } from "../components/Greeting.tsx"; - -const { slug } = Astro.params; - -if (!slug) { - return new Response(null, { status: 404 }); -} - -if (slug === "redirect") { - return Astro.redirect("/"); -} ---- - -<html> - <body> - <Greeting client:load name={slug} /> - <script src="../scripts/analytics.ts" /> - <script> - import { recordEvent } from "../scripts/inline-helper.ts"; - window.addEventListener("load", () => { - recordEvent("loaded"); - }); - </script> - </body> -</html> diff --git a/packages/deslop-js/tests/fixtures/astro-frontmatter-return/src/scripts/analytics.ts b/packages/deslop-js/tests/fixtures/astro-frontmatter-return/src/scripts/analytics.ts deleted file mode 100644 index 254f9bad64..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-frontmatter-return/src/scripts/analytics.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const trackPageView = () => { - window.dispatchEvent(new CustomEvent("pageview")); -}; diff --git a/packages/deslop-js/tests/fixtures/astro-frontmatter-return/src/scripts/inline-helper.ts b/packages/deslop-js/tests/fixtures/astro-frontmatter-return/src/scripts/inline-helper.ts deleted file mode 100644 index 6c67efc4e3..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-frontmatter-return/src/scripts/inline-helper.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const recordEvent = (name: string): void => { - window.dispatchEvent(new CustomEvent(name)); -}; diff --git a/packages/deslop-js/tests/fixtures/astro-live-config/astro.config.ts b/packages/deslop-js/tests/fixtures/astro-live-config/astro.config.ts deleted file mode 100644 index 2359d42bb8..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-live-config/astro.config.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { defineConfig } from "astro/config"; -export default defineConfig({}); diff --git a/packages/deslop-js/tests/fixtures/astro-live-config/package.json b/packages/deslop-js/tests/fixtures/astro-live-config/package.json deleted file mode 100644 index bf3ec8f10a..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-live-config/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "astro-live-config", - "dependencies": { - "astro": "^5.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/astro-live-config/src/live.config.ts b/packages/deslop-js/tests/fixtures/astro-live-config/src/live.config.ts deleted file mode 100644 index 20a0c42a67..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-live-config/src/live.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineLiveCollection } from "astro:content"; -import { createWordPressLoader } from "./loaders/wordpress-loader"; - -const pages = defineLiveCollection({ - loader: createWordPressLoader({ pageType: "generic" }), -}); - -export const collections = { pages }; diff --git a/packages/deslop-js/tests/fixtures/astro-live-config/src/loaders/wordpress-loader.ts b/packages/deslop-js/tests/fixtures/astro-live-config/src/loaders/wordpress-loader.ts deleted file mode 100644 index 1e0bd9a230..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-live-config/src/loaders/wordpress-loader.ts +++ /dev/null @@ -1,8 +0,0 @@ -interface WordPressLoaderOptions { - pageType: string; -} - -export const createWordPressLoader = (options: WordPressLoaderOptions) => ({ - name: "wordpress-loader", - loadCollection: async () => ({ entries: [], pageType: options.pageType }), -}); diff --git a/packages/deslop-js/tests/fixtures/astro-live-config/src/orphan.ts b/packages/deslop-js/tests/fixtures/astro-live-config/src/orphan.ts deleted file mode 100644 index bddae4ee9b..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-live-config/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = true; diff --git a/packages/deslop-js/tests/fixtures/astro-live-config/src/pages/index.astro b/packages/deslop-js/tests/fixtures/astro-live-config/src/pages/index.astro deleted file mode 100644 index 0a2d9fab56..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-live-config/src/pages/index.astro +++ /dev/null @@ -1,3 +0,0 @@ ---- ---- -<html><body>Hello</body></html> diff --git a/packages/deslop-js/tests/fixtures/astro-mw/orphan.ts b/packages/deslop-js/tests/fixtures/astro-mw/orphan.ts deleted file mode 100644 index b79aa99b63..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-mw/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = true; diff --git a/packages/deslop-js/tests/fixtures/astro-mw/package.json b/packages/deslop-js/tests/fixtures/astro-mw/package.json deleted file mode 100644 index 3210fecd72..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-mw/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "astro-middleware", - "version": "1.0.0", - "dependencies": { - "astro": "^4.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/astro-mw/src/middleware.ts b/packages/deslop-js/tests/fixtures/astro-mw/src/middleware.ts deleted file mode 100644 index 81cf6a6130..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-mw/src/middleware.ts +++ /dev/null @@ -1 +0,0 @@ -export const onRequest = (context: unknown, next: () => void) => next(); diff --git a/packages/deslop-js/tests/fixtures/astro-mw/src/pages/index.astro b/packages/deslop-js/tests/fixtures/astro-mw/src/pages/index.astro deleted file mode 100644 index 8f884d1624..0000000000 --- a/packages/deslop-js/tests/fixtures/astro-mw/src/pages/index.astro +++ /dev/null @@ -1,4 +0,0 @@ ---- -const title = "Home"; ---- -<html><body><h1>{title}</h1></body></html> diff --git a/packages/deslop-js/tests/fixtures/ava-app/package.json b/packages/deslop-js/tests/fixtures/ava-app/package.json deleted file mode 100644 index 04b807a4e6..0000000000 --- a/packages/deslop-js/tests/fixtures/ava-app/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "ava-fixture", - "private": true, - "devDependencies": { - "ava": "^6.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/ava-app/src/index.ts b/packages/deslop-js/tests/fixtures/ava-app/src/index.ts deleted file mode 100644 index 9f213e7a19..0000000000 --- a/packages/deslop-js/tests/fixtures/ava-app/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { add } from "./math"; - -export const result = add(1, 2); diff --git a/packages/deslop-js/tests/fixtures/ava-app/src/math.ts b/packages/deslop-js/tests/fixtures/ava-app/src/math.ts deleted file mode 100644 index 18ef955fc7..0000000000 --- a/packages/deslop-js/tests/fixtures/ava-app/src/math.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const add = (a: number, b: number) => a + b; -export const subtract = (a: number, b: number) => a - b; diff --git a/packages/deslop-js/tests/fixtures/ava-app/src/orphan.ts b/packages/deslop-js/tests/fixtures/ava-app/src/orphan.ts deleted file mode 100644 index e12315b1e5..0000000000 --- a/packages/deslop-js/tests/fixtures/ava-app/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphanFn = () => "not imported by anything"; diff --git a/packages/deslop-js/tests/fixtures/ava-app/test/math.test.ts b/packages/deslop-js/tests/fixtures/ava-app/test/math.test.ts deleted file mode 100644 index 54b7ce5d2d..0000000000 --- a/packages/deslop-js/tests/fixtures/ava-app/test/math.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import test from "ava"; -import { add } from "../src/math"; - -test("add works", (t) => { - t.is(add(1, 2), 3); -}); diff --git a/packages/deslop-js/tests/fixtures/babel-module-resolver/babel.config.js b/packages/deslop-js/tests/fixtures/babel-module-resolver/babel.config.js deleted file mode 100644 index c3215f4cb3..0000000000 --- a/packages/deslop-js/tests/fixtures/babel-module-resolver/babel.config.js +++ /dev/null @@ -1,13 +0,0 @@ -module.exports = { - plugins: [ - [ - "module-resolver", - { - root: ["./src"], - alias: { - "@components": "./src/components", - }, - }, - ], - ], -}; diff --git a/packages/deslop-js/tests/fixtures/babel-module-resolver/package.json b/packages/deslop-js/tests/fixtures/babel-module-resolver/package.json deleted file mode 100644 index 58844d6126..0000000000 --- a/packages/deslop-js/tests/fixtures/babel-module-resolver/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "babel-module-resolver", - "version": "1.0.0", - "private": true, - "main": "src/index.ts", - "devDependencies": { - "@babel/core": "^7.0.0", - "babel-plugin-module-resolver": "^5.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/babel-module-resolver/src/components/button.ts b/packages/deslop-js/tests/fixtures/babel-module-resolver/src/components/button.ts deleted file mode 100644 index 690f92e65a..0000000000 --- a/packages/deslop-js/tests/fixtures/babel-module-resolver/src/components/button.ts +++ /dev/null @@ -1 +0,0 @@ -export const Button = (): string => "button"; diff --git a/packages/deslop-js/tests/fixtures/babel-module-resolver/src/components/orphan.ts b/packages/deslop-js/tests/fixtures/babel-module-resolver/src/components/orphan.ts deleted file mode 100644 index 3ec8e38a8b..0000000000 --- a/packages/deslop-js/tests/fixtures/babel-module-resolver/src/components/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const UnusedComponent = (): string => "unused"; diff --git a/packages/deslop-js/tests/fixtures/babel-module-resolver/src/index.ts b/packages/deslop-js/tests/fixtures/babel-module-resolver/src/index.ts deleted file mode 100644 index d491fa2c77..0000000000 --- a/packages/deslop-js/tests/fixtures/babel-module-resolver/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Button } from "@components/button"; - -export const app = (): string => Button(); diff --git a/packages/deslop-js/tests/fixtures/broken-tsconfig/package.json b/packages/deslop-js/tests/fixtures/broken-tsconfig/package.json deleted file mode 100644 index fde8842946..0000000000 --- a/packages/deslop-js/tests/fixtures/broken-tsconfig/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "broken-tsconfig", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/broken-tsconfig/src/index.ts b/packages/deslop-js/tests/fixtures/broken-tsconfig/src/index.ts deleted file mode 100644 index de0ddf3e79..0000000000 --- a/packages/deslop-js/tests/fixtures/broken-tsconfig/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const value = "broken-tsconfig-fixture"; -console.log(value); diff --git a/packages/deslop-js/tests/fixtures/broken-tsconfig/tsconfig.json b/packages/deslop-js/tests/fixtures/broken-tsconfig/tsconfig.json deleted file mode 100644 index 4d0124c571..0000000000 --- a/packages/deslop-js/tests/fixtures/broken-tsconfig/tsconfig.json +++ /dev/null @@ -1 +0,0 @@ -{ "compilerOptions": { invalid json,, } diff --git a/packages/deslop-js/tests/fixtures/build-root-fallback/bin/server.js b/packages/deslop-js/tests/fixtures/build-root-fallback/bin/server.js deleted file mode 100644 index b0e7053352..0000000000 --- a/packages/deslop-js/tests/fixtures/build-root-fallback/bin/server.js +++ /dev/null @@ -1,2 +0,0 @@ -const { startApp } = require("../src/app"); -startApp(); diff --git a/packages/deslop-js/tests/fixtures/build-root-fallback/package.json b/packages/deslop-js/tests/fixtures/build-root-fallback/package.json deleted file mode 100644 index d5e0a64704..0000000000 --- a/packages/deslop-js/tests/fixtures/build-root-fallback/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "build-output-root-fallback", - "bin": { - "server": "./build/bin/server.js" - }, - "scripts": { - "start": "node build/app.js" - } -} diff --git a/packages/deslop-js/tests/fixtures/build-root-fallback/src/app.ts b/packages/deslop-js/tests/fixtures/build-root-fallback/src/app.ts deleted file mode 100644 index 8a1ae1d96f..0000000000 --- a/packages/deslop-js/tests/fixtures/build-root-fallback/src/app.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { db } from "./db"; -export const startApp = () => db.connect(); diff --git a/packages/deslop-js/tests/fixtures/build-root-fallback/src/db.ts b/packages/deslop-js/tests/fixtures/build-root-fallback/src/db.ts deleted file mode 100644 index 21bb297d97..0000000000 --- a/packages/deslop-js/tests/fixtures/build-root-fallback/src/db.ts +++ /dev/null @@ -1 +0,0 @@ -export const db = { connect: () => {} }; diff --git a/packages/deslop-js/tests/fixtures/build-root-fallback/src/orphan.ts b/packages/deslop-js/tests/fixtures/build-root-fallback/src/orphan.ts deleted file mode 100644 index af0a341bc7..0000000000 --- a/packages/deslop-js/tests/fixtures/build-root-fallback/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "not imported"; diff --git a/packages/deslop-js/tests/fixtures/build-script-map/package.json b/packages/deslop-js/tests/fixtures/build-script-map/package.json deleted file mode 100644 index 00b63243e5..0000000000 --- a/packages/deslop-js/tests/fixtures/build-script-map/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "build-script-source-map", - "version": "1.0.0", - "main": "src/index.ts", - "scripts": { - "migrate": "node build/scripts/migrate.js", - "health": "node build/scripts/health-check.js" - }, - "dependencies": { - "express": "^4.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/build-script-map/src/index.ts b/packages/deslop-js/tests/fixtures/build-script-map/src/index.ts deleted file mode 100644 index 40423bded3..0000000000 --- a/packages/deslop-js/tests/fixtures/build-script-map/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const app = "main entry"; diff --git a/packages/deslop-js/tests/fixtures/build-script-map/src/orphan.ts b/packages/deslop-js/tests/fixtures/build-script-map/src/orphan.ts deleted file mode 100644 index 2e2026e157..0000000000 --- a/packages/deslop-js/tests/fixtures/build-script-map/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "orphan"; diff --git a/packages/deslop-js/tests/fixtures/build-script-map/src/scripts/health-check.ts b/packages/deslop-js/tests/fixtures/build-script-map/src/scripts/health-check.ts deleted file mode 100644 index 46ed9434d9..0000000000 --- a/packages/deslop-js/tests/fixtures/build-script-map/src/scripts/health-check.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("health check"); diff --git a/packages/deslop-js/tests/fixtures/build-script-map/src/scripts/migrate.ts b/packages/deslop-js/tests/fixtures/build-script-map/src/scripts/migrate.ts deleted file mode 100644 index 1ce311cc2f..0000000000 --- a/packages/deslop-js/tests/fixtures/build-script-map/src/scripts/migrate.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("running migrations"); diff --git a/packages/deslop-js/tests/fixtures/bun-test/__tests__/integration.test.ts b/packages/deslop-js/tests/fixtures/bun-test/__tests__/integration.test.ts deleted file mode 100644 index 87150000cf..0000000000 --- a/packages/deslop-js/tests/fixtures/bun-test/__tests__/integration.test.ts +++ /dev/null @@ -1 +0,0 @@ -import { add } from "../src/index"; diff --git a/packages/deslop-js/tests/fixtures/bun-test/orphan.ts b/packages/deslop-js/tests/fixtures/bun-test/orphan.ts deleted file mode 100644 index b79aa99b63..0000000000 --- a/packages/deslop-js/tests/fixtures/bun-test/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = true; diff --git a/packages/deslop-js/tests/fixtures/bun-test/package.json b/packages/deslop-js/tests/fixtures/bun-test/package.json deleted file mode 100644 index 57fa3c3421..0000000000 --- a/packages/deslop-js/tests/fixtures/bun-test/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "bun-test-runner", - "scripts": { - "test": "bun test --exit", - "dev": "bun run src/index.ts" - } -} diff --git a/packages/deslop-js/tests/fixtures/bun-test/src/__tests__/build-output.test.ts b/packages/deslop-js/tests/fixtures/bun-test/src/__tests__/build-output.test.ts deleted file mode 100644 index 094009ef2d..0000000000 --- a/packages/deslop-js/tests/fixtures/bun-test/src/__tests__/build-output.test.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { add } from "../index"; -console.log(add(1, 2)); diff --git a/packages/deslop-js/tests/fixtures/bun-test/src/add.test.ts b/packages/deslop-js/tests/fixtures/bun-test/src/add.test.ts deleted file mode 100644 index e5779a3f6a..0000000000 --- a/packages/deslop-js/tests/fixtures/bun-test/src/add.test.ts +++ /dev/null @@ -1 +0,0 @@ -import { add } from "./index"; diff --git a/packages/deslop-js/tests/fixtures/bun-test/src/index.ts b/packages/deslop-js/tests/fixtures/bun-test/src/index.ts deleted file mode 100644 index bc81dd56de..0000000000 --- a/packages/deslop-js/tests/fixtures/bun-test/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const add = (a: number, b: number) => a + b; diff --git a/packages/deslop-js/tests/fixtures/bun-test/src/orphan.ts b/packages/deslop-js/tests/fixtures/bun-test/src/orphan.ts deleted file mode 100644 index 3dfbaae80e..0000000000 --- a/packages/deslop-js/tests/fixtures/bun-test/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "this is not imported by anything"; diff --git a/packages/deslop-js/tests/fixtures/bun-test/src/utils_test.ts b/packages/deslop-js/tests/fixtures/bun-test/src/utils_test.ts deleted file mode 100644 index e5779a3f6a..0000000000 --- a/packages/deslop-js/tests/fixtures/bun-test/src/utils_test.ts +++ /dev/null @@ -1 +0,0 @@ -import { add } from "./index"; diff --git a/packages/deslop-js/tests/fixtures/ci-scripts/.github/workflows/release.yml b/packages/deslop-js/tests/fixtures/ci-scripts/.github/workflows/release.yml deleted file mode 100644 index c09aa02f4f..0000000000 --- a/packages/deslop-js/tests/fixtures/ci-scripts/.github/workflows/release.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: Release -on: - push: - branches: [main] -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Deploy - run: node scripts/deploy.mjs - - name: Build - run: | - echo "Building..." - node scripts/build-release.ts diff --git a/packages/deslop-js/tests/fixtures/ci-scripts/package.json b/packages/deslop-js/tests/fixtures/ci-scripts/package.json deleted file mode 100644 index d8b2a37ca1..0000000000 --- a/packages/deslop-js/tests/fixtures/ci-scripts/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "ci-workflow-scripts", - "version": "1.0.0" -} diff --git a/packages/deslop-js/tests/fixtures/ci-scripts/scripts/build-release.ts b/packages/deslop-js/tests/fixtures/ci-scripts/scripts/build-release.ts deleted file mode 100644 index 397e84de0f..0000000000 --- a/packages/deslop-js/tests/fixtures/ci-scripts/scripts/build-release.ts +++ /dev/null @@ -1 +0,0 @@ -export const buildRelease = () => console.log("building release"); diff --git a/packages/deslop-js/tests/fixtures/ci-scripts/scripts/deploy.mjs b/packages/deslop-js/tests/fixtures/ci-scripts/scripts/deploy.mjs deleted file mode 100644 index 579c79f9f8..0000000000 --- a/packages/deslop-js/tests/fixtures/ci-scripts/scripts/deploy.mjs +++ /dev/null @@ -1 +0,0 @@ -console.log("deploying..."); diff --git a/packages/deslop-js/tests/fixtures/ci-scripts/src/index.ts b/packages/deslop-js/tests/fixtures/ci-scripts/src/index.ts deleted file mode 100644 index 2d1d06a162..0000000000 --- a/packages/deslop-js/tests/fixtures/ci-scripts/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = () => "main"; diff --git a/packages/deslop-js/tests/fixtures/ci-scripts/src/orphan.ts b/packages/deslop-js/tests/fixtures/ci-scripts/src/orphan.ts deleted file mode 100644 index bd93db0a49..0000000000 --- a/packages/deslop-js/tests/fixtures/ci-scripts/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "unused"; diff --git a/packages/deslop-js/tests/fixtures/ci-yaml-non-run/.github/changelog/changelog.js b/packages/deslop-js/tests/fixtures/ci-yaml-non-run/.github/changelog/changelog.js deleted file mode 100644 index 01c1896d3e..0000000000 --- a/packages/deslop-js/tests/fixtures/ci-yaml-non-run/.github/changelog/changelog.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = { types: [{ types: ["feat"], label: "Features" }] }; diff --git a/packages/deslop-js/tests/fixtures/ci-yaml-non-run/.github/workflows/release.yml b/packages/deslop-js/tests/fixtures/ci-yaml-non-run/.github/workflows/release.yml deleted file mode 100644 index f590c7d2ad..0000000000 --- a/packages/deslop-js/tests/fixtures/ci-yaml-non-run/.github/workflows/release.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: Release -on: - push: - branches: [main] -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Deploy - run: node scripts/deploy.mjs - - name: Changelog - uses: some-action/changelog@v1 - with: - config_file: .github/changelog/changelog.js diff --git a/packages/deslop-js/tests/fixtures/ci-yaml-non-run/package.json b/packages/deslop-js/tests/fixtures/ci-yaml-non-run/package.json deleted file mode 100644 index 8d4a05917c..0000000000 --- a/packages/deslop-js/tests/fixtures/ci-yaml-non-run/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "ci-yaml-non-run-values", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/ci-yaml-non-run/scripts/deploy.mjs b/packages/deslop-js/tests/fixtures/ci-yaml-non-run/scripts/deploy.mjs deleted file mode 100644 index 29db3224d7..0000000000 --- a/packages/deslop-js/tests/fixtures/ci-yaml-non-run/scripts/deploy.mjs +++ /dev/null @@ -1 +0,0 @@ -console.log("deploying"); diff --git a/packages/deslop-js/tests/fixtures/ci-yaml-non-run/src/index.ts b/packages/deslop-js/tests/fixtures/ci-yaml-non-run/src/index.ts deleted file mode 100644 index c5961da749..0000000000 --- a/packages/deslop-js/tests/fixtures/ci-yaml-non-run/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/cloudflare-worker/package.json b/packages/deslop-js/tests/fixtures/cloudflare-worker/package.json deleted file mode 100644 index 5a6d9d7157..0000000000 --- a/packages/deslop-js/tests/fixtures/cloudflare-worker/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "my-worker", - "version": "1.0.0", - "devDependencies": { - "wrangler": "^3.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/cloudflare-worker/src/index.ts b/packages/deslop-js/tests/fixtures/cloudflare-worker/src/index.ts deleted file mode 100644 index a97351d286..0000000000 --- a/packages/deslop-js/tests/fixtures/cloudflare-worker/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export default { fetch: () => new Response("hello") }; diff --git a/packages/deslop-js/tests/fixtures/cloudflare-worker/src/orphan.ts b/packages/deslop-js/tests/fixtures/cloudflare-worker/src/orphan.ts deleted file mode 100644 index 027ea325f2..0000000000 --- a/packages/deslop-js/tests/fixtures/cloudflare-worker/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const helperUtil = () => "helper"; diff --git a/packages/deslop-js/tests/fixtures/commonjs-app/package.json b/packages/deslop-js/tests/fixtures/commonjs-app/package.json deleted file mode 100644 index 88eb166b80..0000000000 --- a/packages/deslop-js/tests/fixtures/commonjs-app/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "cjs-project", - "main": "src/index.js" -} diff --git a/packages/deslop-js/tests/fixtures/commonjs-app/src/index.js b/packages/deslop-js/tests/fixtures/commonjs-app/src/index.js deleted file mode 100644 index a9702f3b46..0000000000 --- a/packages/deslop-js/tests/fixtures/commonjs-app/src/index.js +++ /dev/null @@ -1,2 +0,0 @@ -const utils = require("./utils"); -console.log(utils.greet()); diff --git a/packages/deslop-js/tests/fixtures/commonjs-app/src/orphan.js b/packages/deslop-js/tests/fixtures/commonjs-app/src/orphan.js deleted file mode 100644 index cd7b1d2e61..0000000000 --- a/packages/deslop-js/tests/fixtures/commonjs-app/src/orphan.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = { orphan: true }; diff --git a/packages/deslop-js/tests/fixtures/commonjs-app/src/utils.js b/packages/deslop-js/tests/fixtures/commonjs-app/src/utils.js deleted file mode 100644 index 939a372eaa..0000000000 --- a/packages/deslop-js/tests/fixtures/commonjs-app/src/utils.js +++ /dev/null @@ -1,6 +0,0 @@ -exports.greet = function () { - return "hello"; -}; -exports.unused = function () { - return "unused"; -}; diff --git a/packages/deslop-js/tests/fixtures/complex-functions/package.json b/packages/deslop-js/tests/fixtures/complex-functions/package.json deleted file mode 100644 index 08f40247c9..0000000000 --- a/packages/deslop-js/tests/fixtures/complex-functions/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "complex-functions", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/complex-functions/src/index.ts b/packages/deslop-js/tests/fixtures/complex-functions/src/index.ts deleted file mode 100644 index fbbc7851b9..0000000000 --- a/packages/deslop-js/tests/fixtures/complex-functions/src/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const simpleFn = (a: number): number => a + 1; - -export const tangledFn = (a: number, b: number, c: number, d: number, e: number): number => { - let result = 0; - if (a > 0) { - if (b > 0) { - for (let index = 0; index < c; index++) { - if (d > 0 && e > 0) { - result += index * 2; - } else if (d > 0 || e > 0) { - result += index; - } - } - } - } else if (a < -10) { - while (b < 0) { - result -= 1; - if (c > 0 && d > 0) result += 1; - } - } else { - try { - result = a / b; - } catch (error) { - if (error) result = -1; - } - } - return result; -}; diff --git a/packages/deslop-js/tests/fixtures/config-compound-name/cypress.config.contract.js b/packages/deslop-js/tests/fixtures/config-compound-name/cypress.config.contract.js deleted file mode 100644 index 984bf124bb..0000000000 --- a/packages/deslop-js/tests/fixtures/config-compound-name/cypress.config.contract.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - e2e: { - baseUrl: "http://localhost:3000", - }, -}; diff --git a/packages/deslop-js/tests/fixtures/config-compound-name/package.json b/packages/deslop-js/tests/fixtures/config-compound-name/package.json deleted file mode 100644 index 93c12bc1b9..0000000000 --- a/packages/deslop-js/tests/fixtures/config-compound-name/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "config-multi-segment-fixture", - "private": true, - "devDependencies": { - "cypress": "^13.0.0", - "vitest": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/config-compound-name/src/index.ts b/packages/deslop-js/tests/fixtures/config-compound-name/src/index.ts deleted file mode 100644 index c5961da749..0000000000 --- a/packages/deslop-js/tests/fixtures/config-compound-name/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/config-compound-name/src/orphan.ts b/packages/deslop-js/tests/fixtures/config-compound-name/src/orphan.ts deleted file mode 100644 index 2e65a50551..0000000000 --- a/packages/deslop-js/tests/fixtures/config-compound-name/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "never imported"; diff --git a/packages/deslop-js/tests/fixtures/config-compound-name/vitest.config.unit.ts b/packages/deslop-js/tests/fixtures/config-compound-name/vitest.config.unit.ts deleted file mode 100644 index 9733eb62fc..0000000000 --- a/packages/deslop-js/tests/fixtures/config-compound-name/vitest.config.unit.ts +++ /dev/null @@ -1 +0,0 @@ -export default { test: { globals: true } }; diff --git a/packages/deslop-js/tests/fixtures/config-detection/.desloprc.json b/packages/deslop-js/tests/fixtures/config-detection/.desloprc.json deleted file mode 100644 index 2ad75c9218..0000000000 --- a/packages/deslop-js/tests/fixtures/config-detection/.desloprc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "rules": { - "unused-files": "warn" - } -} diff --git a/packages/deslop-js/tests/fixtures/config-detection/package.json b/packages/deslop-js/tests/fixtures/config-detection/package.json deleted file mode 100644 index 285fb2c64c..0000000000 --- a/packages/deslop-js/tests/fixtures/config-detection/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "config-file-project", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/config-detection/src/index.ts b/packages/deslop-js/tests/fixtures/config-detection/src/index.ts deleted file mode 100644 index 64b5f68e21..0000000000 --- a/packages/deslop-js/tests/fixtures/config-detection/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { usedFunction } from "./utils"; -usedFunction(); diff --git a/packages/deslop-js/tests/fixtures/config-detection/src/orphan.ts b/packages/deslop-js/tests/fixtures/config-detection/src/orphan.ts deleted file mode 100644 index a7e8121db2..0000000000 --- a/packages/deslop-js/tests/fixtures/config-detection/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphaned = "nobody imports me"; diff --git a/packages/deslop-js/tests/fixtures/config-detection/src/utils.ts b/packages/deslop-js/tests/fixtures/config-detection/src/utils.ts deleted file mode 100644 index 0fedfbb7ed..0000000000 --- a/packages/deslop-js/tests/fixtures/config-detection/src/utils.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const usedFunction = () => 42; -export const unusedFunction = () => "not used"; diff --git a/packages/deslop-js/tests/fixtures/config-entry-seed/package.json b/packages/deslop-js/tests/fixtures/config-entry-seed/package.json deleted file mode 100644 index 057a92111c..0000000000 --- a/packages/deslop-js/tests/fixtures/config-entry-seed/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "config-always-used", - "main": "src/index.ts", - "devDependencies": { - "vitest": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/config-entry-seed/src/helper.ts b/packages/deslop-js/tests/fixtures/config-entry-seed/src/helper.ts deleted file mode 100644 index 613e5ee576..0000000000 --- a/packages/deslop-js/tests/fixtures/config-entry-seed/src/helper.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = () => "used"; diff --git a/packages/deslop-js/tests/fixtures/config-entry-seed/src/index.ts b/packages/deslop-js/tests/fixtures/config-entry-seed/src/index.ts deleted file mode 100644 index b9d617ab01..0000000000 --- a/packages/deslop-js/tests/fixtures/config-entry-seed/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { helper } from "./helper"; -export const main = () => helper(); diff --git a/packages/deslop-js/tests/fixtures/config-entry-seed/src/orphan.ts b/packages/deslop-js/tests/fixtures/config-entry-seed/src/orphan.ts deleted file mode 100644 index f847ac5f14..0000000000 --- a/packages/deslop-js/tests/fixtures/config-entry-seed/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "not imported by anything"; diff --git a/packages/deslop-js/tests/fixtures/config-entry-seed/src/vite-plugin.ts b/packages/deslop-js/tests/fixtures/config-entry-seed/src/vite-plugin.ts deleted file mode 100644 index 6d1c8bb5a2..0000000000 --- a/packages/deslop-js/tests/fixtures/config-entry-seed/src/vite-plugin.ts +++ /dev/null @@ -1 +0,0 @@ -export const myPlugin = () => ({ name: "my-plugin" }); diff --git a/packages/deslop-js/tests/fixtures/config-entry-seed/vite.config.ts b/packages/deslop-js/tests/fixtures/config-entry-seed/vite.config.ts deleted file mode 100644 index cdefdb334f..0000000000 --- a/packages/deslop-js/tests/fixtures/config-entry-seed/vite.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from "vite"; -import { myPlugin } from "./src/vite-plugin"; - -export default defineConfig({ - plugins: [myPlugin()], -}); diff --git a/packages/deslop-js/tests/fixtures/config-exclusion/package.json b/packages/deslop-js/tests/fixtures/config-exclusion/package.json deleted file mode 100644 index 8f54ed7983..0000000000 --- a/packages/deslop-js/tests/fixtures/config-exclusion/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "config-file-exclusion", - "version": "1.0.0", - "main": "src/index.ts", - "scripts": { - "test:smoke": "playwright test -c playwright.smoke.config.mjs" - } -} diff --git a/packages/deslop-js/tests/fixtures/config-exclusion/playwright.smoke.config.mjs b/packages/deslop-js/tests/fixtures/config-exclusion/playwright.smoke.config.mjs deleted file mode 100644 index effddb62a9..0000000000 --- a/packages/deslop-js/tests/fixtures/config-exclusion/playwright.smoke.config.mjs +++ /dev/null @@ -1 +0,0 @@ -export default { timeout: 30000 }; diff --git a/packages/deslop-js/tests/fixtures/config-exclusion/sanity.cli.ts b/packages/deslop-js/tests/fixtures/config-exclusion/sanity.cli.ts deleted file mode 100644 index 8283d0d04d..0000000000 --- a/packages/deslop-js/tests/fixtures/config-exclusion/sanity.cli.ts +++ /dev/null @@ -1 +0,0 @@ -export default { api: {} }; diff --git a/packages/deslop-js/tests/fixtures/config-exclusion/sanity.config.ts b/packages/deslop-js/tests/fixtures/config-exclusion/sanity.config.ts deleted file mode 100644 index bfc40ce70e..0000000000 --- a/packages/deslop-js/tests/fixtures/config-exclusion/sanity.config.ts +++ /dev/null @@ -1 +0,0 @@ -export default { projectId: "abc" }; diff --git a/packages/deslop-js/tests/fixtures/config-exclusion/src/index.ts b/packages/deslop-js/tests/fixtures/config-exclusion/src/index.ts deleted file mode 100644 index 2062d509ef..0000000000 --- a/packages/deslop-js/tests/fixtures/config-exclusion/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const app = "hello"; diff --git a/packages/deslop-js/tests/fixtures/config-exclusion/src/orphan.ts b/packages/deslop-js/tests/fixtures/config-exclusion/src/orphan.ts deleted file mode 100644 index bddae4ee9b..0000000000 --- a/packages/deslop-js/tests/fixtures/config-exclusion/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = true; diff --git a/packages/deslop-js/tests/fixtures/config-exclusion/vitest.config.ts b/packages/deslop-js/tests/fixtures/config-exclusion/vitest.config.ts deleted file mode 100644 index 720aeb7176..0000000000 --- a/packages/deslop-js/tests/fixtures/config-exclusion/vitest.config.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { defineConfig } from "vitest/config"; -export default defineConfig({}); diff --git a/packages/deslop-js/tests/fixtures/config-global-scope/package.json b/packages/deslop-js/tests/fixtures/config-global-scope/package.json deleted file mode 100644 index c87839163d..0000000000 --- a/packages/deslop-js/tests/fixtures/config-global-scope/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "global-config-test", - "version": "1.0.0", - "workspaces": [ - "packages/*" - ], - "main": "src/index.ts", - "devDependencies": { - "eslint": "^9.0.0", - "postcss": "^8.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/config-global-scope/src/index.ts b/packages/deslop-js/tests/fixtures/config-global-scope/src/index.ts deleted file mode 100644 index 2d1d06a162..0000000000 --- a/packages/deslop-js/tests/fixtures/config-global-scope/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = () => "main"; diff --git a/packages/deslop-js/tests/fixtures/config-global-scope/templates/next-app/eslint.config.js b/packages/deslop-js/tests/fixtures/config-global-scope/templates/next-app/eslint.config.js deleted file mode 100644 index f053ebf797..0000000000 --- a/packages/deslop-js/tests/fixtures/config-global-scope/templates/next-app/eslint.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/packages/deslop-js/tests/fixtures/config-global-scope/templates/next-app/orphan.ts b/packages/deslop-js/tests/fixtures/config-global-scope/templates/next-app/orphan.ts deleted file mode 100644 index 9a2a4ec39c..0000000000 --- a/packages/deslop-js/tests/fixtures/config-global-scope/templates/next-app/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = () => "orphan"; diff --git a/packages/deslop-js/tests/fixtures/config-global-scope/templates/next-app/postcss.config.mjs b/packages/deslop-js/tests/fixtures/config-global-scope/templates/next-app/postcss.config.mjs deleted file mode 100644 index cdda25ff5b..0000000000 --- a/packages/deslop-js/tests/fixtures/config-global-scope/templates/next-app/postcss.config.mjs +++ /dev/null @@ -1 +0,0 @@ -export default { plugins: {} }; diff --git a/packages/deslop-js/tests/fixtures/config-imports/my-vite-plugin.ts b/packages/deslop-js/tests/fixtures/config-imports/my-vite-plugin.ts deleted file mode 100644 index aa5e04af7c..0000000000 --- a/packages/deslop-js/tests/fixtures/config-imports/my-vite-plugin.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const myVitePlugin = () => ({ - name: "my-plugin", - transform(code: string) { - return code; - }, -}); diff --git a/packages/deslop-js/tests/fixtures/config-imports/package.json b/packages/deslop-js/tests/fixtures/config-imports/package.json deleted file mode 100644 index 0047799375..0000000000 --- a/packages/deslop-js/tests/fixtures/config-imports/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "config-file-imports", - "main": "src/index.ts", - "dependencies": { - "vite": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/config-imports/src/app.ts b/packages/deslop-js/tests/fixtures/config-imports/src/app.ts deleted file mode 100644 index d651dcbc1b..0000000000 --- a/packages/deslop-js/tests/fixtures/config-imports/src/app.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { sharedUtil } from "./shared-util"; - -console.log(sharedUtil()); diff --git a/packages/deslop-js/tests/fixtures/config-imports/src/index.ts b/packages/deslop-js/tests/fixtures/config-imports/src/index.ts deleted file mode 100644 index 31538b7a92..0000000000 --- a/packages/deslop-js/tests/fixtures/config-imports/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { sharedUtil } from "./shared-util"; - -export const app = sharedUtil(); diff --git a/packages/deslop-js/tests/fixtures/config-imports/src/shared-util.ts b/packages/deslop-js/tests/fixtures/config-imports/src/shared-util.ts deleted file mode 100644 index 85ea329efb..0000000000 --- a/packages/deslop-js/tests/fixtures/config-imports/src/shared-util.ts +++ /dev/null @@ -1 +0,0 @@ -export const sharedUtil = () => "used by both config and app"; diff --git a/packages/deslop-js/tests/fixtures/config-imports/vite.config.ts b/packages/deslop-js/tests/fixtures/config-imports/vite.config.ts deleted file mode 100644 index fd4d87f178..0000000000 --- a/packages/deslop-js/tests/fixtures/config-imports/vite.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { myVitePlugin } from "./my-vite-plugin"; -import { sharedUtil } from "./src/shared-util"; - -export default { - plugins: [myVitePlugin()], - define: { - __UTIL__: sharedUtil(), - }, -}; diff --git a/packages/deslop-js/tests/fixtures/config-mixed-formats/lage.config.cjs b/packages/deslop-js/tests/fixtures/config-mixed-formats/lage.config.cjs deleted file mode 100644 index d20eaa5148..0000000000 --- a/packages/deslop-js/tests/fixtures/config-mixed-formats/lage.config.cjs +++ /dev/null @@ -1 +0,0 @@ -module.exports = { pipeline: { build: ["^build"] } }; diff --git a/packages/deslop-js/tests/fixtures/config-mixed-formats/package.json b/packages/deslop-js/tests/fixtures/config-mixed-formats/package.json deleted file mode 100644 index 4c919353e1..0000000000 --- a/packages/deslop-js/tests/fixtures/config-mixed-formats/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "config-files-cjs-mjs", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "prettier": "^3.0.0", - "vitest": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/config-mixed-formats/prettier.config.mjs b/packages/deslop-js/tests/fixtures/config-mixed-formats/prettier.config.mjs deleted file mode 100644 index 4713553cdc..0000000000 --- a/packages/deslop-js/tests/fixtures/config-mixed-formats/prettier.config.mjs +++ /dev/null @@ -1 +0,0 @@ -export default { semi: true, singleQuote: true }; diff --git a/packages/deslop-js/tests/fixtures/config-mixed-formats/src/index.ts b/packages/deslop-js/tests/fixtures/config-mixed-formats/src/index.ts deleted file mode 100644 index 4124a4c154..0000000000 --- a/packages/deslop-js/tests/fixtures/config-mixed-formats/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = "hello"; diff --git a/packages/deslop-js/tests/fixtures/config-mixed-formats/src/orphan.ts b/packages/deslop-js/tests/fixtures/config-mixed-formats/src/orphan.ts deleted file mode 100644 index bd93db0a49..0000000000 --- a/packages/deslop-js/tests/fixtures/config-mixed-formats/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "unused"; diff --git a/packages/deslop-js/tests/fixtures/config-mixed-formats/vitest.config.mts b/packages/deslop-js/tests/fixtures/config-mixed-formats/vitest.config.mts deleted file mode 100644 index 9733eb62fc..0000000000 --- a/packages/deslop-js/tests/fixtures/config-mixed-formats/vitest.config.mts +++ /dev/null @@ -1 +0,0 @@ -export default { test: { globals: true } }; diff --git a/packages/deslop-js/tests/fixtures/config-paths-only/lib/orphan.ts b/packages/deslop-js/tests/fixtures/config-paths-only/lib/orphan.ts deleted file mode 100644 index b78453820d..0000000000 --- a/packages/deslop-js/tests/fixtures/config-paths-only/lib/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unusedThing = (): number => 0; diff --git a/packages/deslop-js/tests/fixtures/config-paths-only/lib/thing.ts b/packages/deslop-js/tests/fixtures/config-paths-only/lib/thing.ts deleted file mode 100644 index 015f01499c..0000000000 --- a/packages/deslop-js/tests/fixtures/config-paths-only/lib/thing.ts +++ /dev/null @@ -1 +0,0 @@ -export const thing = (): number => 1; diff --git a/packages/deslop-js/tests/fixtures/config-paths-only/package.json b/packages/deslop-js/tests/fixtures/config-paths-only/package.json deleted file mode 100644 index b77069262a..0000000000 --- a/packages/deslop-js/tests/fixtures/config-paths-only/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "config-paths-only", - "version": "1.0.0", - "private": true, - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/config-paths-only/src/index.ts b/packages/deslop-js/tests/fixtures/config-paths-only/src/index.ts deleted file mode 100644 index 75f0df14b7..0000000000 --- a/packages/deslop-js/tests/fixtures/config-paths-only/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { thing } from "@custom/thing"; - -export const useThing = (): number => thing(); diff --git a/packages/deslop-js/tests/fixtures/config-script-flags/db/drizzle.config.ts b/packages/deslop-js/tests/fixtures/config-script-flags/db/drizzle.config.ts deleted file mode 100644 index 1558b141e8..0000000000 --- a/packages/deslop-js/tests/fixtures/config-script-flags/db/drizzle.config.ts +++ /dev/null @@ -1 +0,0 @@ -export default { schema: "./db/schema.ts" }; diff --git a/packages/deslop-js/tests/fixtures/config-script-flags/package.json b/packages/deslop-js/tests/fixtures/config-script-flags/package.json deleted file mode 100644 index d9d98926c6..0000000000 --- a/packages/deslop-js/tests/fixtures/config-script-flags/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "config-flag-scripts", - "version": "1.0.0", - "main": "src/index.ts", - "scripts": { - "migrate": "drizzle-kit generate --config ./db/drizzle.config.ts", - "seed": "tsx scripts/seed.ts" - }, - "dependencies": { - "drizzle-orm": "^0.30.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/config-script-flags/scripts/seed.ts b/packages/deslop-js/tests/fixtures/config-script-flags/scripts/seed.ts deleted file mode 100644 index 13ff85f30a..0000000000 --- a/packages/deslop-js/tests/fixtures/config-script-flags/scripts/seed.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("seeding database"); diff --git a/packages/deslop-js/tests/fixtures/config-script-flags/src/index.ts b/packages/deslop-js/tests/fixtures/config-script-flags/src/index.ts deleted file mode 100644 index c5961da749..0000000000 --- a/packages/deslop-js/tests/fixtures/config-script-flags/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/config-script-flags/src/orphan.ts b/packages/deslop-js/tests/fixtures/config-script-flags/src/orphan.ts deleted file mode 100644 index 2e2026e157..0000000000 --- a/packages/deslop-js/tests/fixtures/config-script-flags/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "orphan"; diff --git a/packages/deslop-js/tests/fixtures/cra-jest-transforms/config/jest/babelTransform.js b/packages/deslop-js/tests/fixtures/cra-jest-transforms/config/jest/babelTransform.js deleted file mode 100644 index f053ebf797..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-jest-transforms/config/jest/babelTransform.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/packages/deslop-js/tests/fixtures/cra-jest-transforms/config/jest/cssTransform.js b/packages/deslop-js/tests/fixtures/cra-jest-transforms/config/jest/cssTransform.js deleted file mode 100644 index f053ebf797..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-jest-transforms/config/jest/cssTransform.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/packages/deslop-js/tests/fixtures/cra-jest-transforms/config/jest/fileTransform.js b/packages/deslop-js/tests/fixtures/cra-jest-transforms/config/jest/fileTransform.js deleted file mode 100644 index f053ebf797..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-jest-transforms/config/jest/fileTransform.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/packages/deslop-js/tests/fixtures/cra-jest-transforms/config/jest/orphanTransform.js b/packages/deslop-js/tests/fixtures/cra-jest-transforms/config/jest/orphanTransform.js deleted file mode 100644 index f053ebf797..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-jest-transforms/config/jest/orphanTransform.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/packages/deslop-js/tests/fixtures/cra-jest-transforms/package.json b/packages/deslop-js/tests/fixtures/cra-jest-transforms/package.json deleted file mode 100644 index 228a73e757..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-jest-transforms/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "cra-jest-transforms", - "version": "1.0.0", - "main": "src/index.js", - "dependencies": { - "react-scripts": "5.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/cra-jest-transforms/scripts/utils/createJestConfig.js b/packages/deslop-js/tests/fixtures/cra-jest-transforms/scripts/utils/createJestConfig.js deleted file mode 100644 index b1a88fd5be..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-jest-transforms/scripts/utils/createJestConfig.js +++ /dev/null @@ -1,9 +0,0 @@ -const path = require("path"); - -module.exports = (resolve) => ({ - transform: { - "^.+\\.(js|jsx)$": resolve("config/jest/babelTransform.js"), - "^.+\\.css$": resolve("config/jest/cssTransform.js"), - "^(?!.*\\.(css|json)$)": resolve("config/jest/fileTransform.js"), - }, -}); diff --git a/packages/deslop-js/tests/fixtures/cra-jest-transforms/src/index.js b/packages/deslop-js/tests/fixtures/cra-jest-transforms/src/index.js deleted file mode 100644 index c5942a576c..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-jest-transforms/src/index.js +++ /dev/null @@ -1 +0,0 @@ -export const main = () => {}; diff --git a/packages/deslop-js/tests/fixtures/cra-monorepo-scope/package.json b/packages/deslop-js/tests/fixtures/cra-monorepo-scope/package.json deleted file mode 100644 index c490895a95..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-monorepo-scope/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "cra-monorepo-scope", - "version": "1.0.0", - "private": true, - "workspaces": [ - "packages/*" - ], - "dependencies": { - "react-scripts": "^5.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/app/package.json b/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/app/package.json deleted file mode 100644 index 2fce63e9be..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/app/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@fixture/cra-app", - "version": "1.0.0", - "main": "src/index.ts", - "dependencies": { - "react-scripts": "^5.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/app/src/App.ts b/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/app/src/App.ts deleted file mode 100644 index 3ff512c6c3..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/app/src/App.ts +++ /dev/null @@ -1 +0,0 @@ -export const App = "app"; diff --git a/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/app/src/index.ts b/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/app/src/index.ts deleted file mode 100644 index 43e6bc7b4d..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/app/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { App } from "App"; - -export const app = App; diff --git a/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/lib/package.json b/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/lib/package.json deleted file mode 100644 index 094c06cd89..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/lib/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "@fixture/lib", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/lib/src/RootOnly.ts b/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/lib/src/RootOnly.ts deleted file mode 100644 index eeb6edad15..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/lib/src/RootOnly.ts +++ /dev/null @@ -1 +0,0 @@ -export const RootOnly = "root-only"; diff --git a/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/lib/src/index.ts b/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/lib/src/index.ts deleted file mode 100644 index a515dfcb59..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-monorepo-scope/packages/lib/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { RootOnly } from "RootOnly"; - -export const lib = RootOnly; diff --git a/packages/deslop-js/tests/fixtures/cra-rewired/package.json b/packages/deslop-js/tests/fixtures/cra-rewired/package.json deleted file mode 100644 index e2dee0f14c..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-rewired/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "cra-rewired-app", - "version": "1.0.0", - "scripts": { - "start": "react-app-rewired start", - "build": "react-app-rewired build" - }, - "dependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - }, - "devDependencies": { - "react-app-rewired": "^2.2.1" - } -} diff --git a/packages/deslop-js/tests/fixtures/cra-rewired/src/App.tsx b/packages/deslop-js/tests/fixtures/cra-rewired/src/App.tsx deleted file mode 100644 index 4cc46dfe2c..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-rewired/src/App.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { Header } from "./components/Header"; -export const App = () => <Header />; diff --git a/packages/deslop-js/tests/fixtures/cra-rewired/src/components/Header.tsx b/packages/deslop-js/tests/fixtures/cra-rewired/src/components/Header.tsx deleted file mode 100644 index 84beb43c05..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-rewired/src/components/Header.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Header = () => <h1>Hello</h1>; diff --git a/packages/deslop-js/tests/fixtures/cra-rewired/src/index.tsx b/packages/deslop-js/tests/fixtures/cra-rewired/src/index.tsx deleted file mode 100644 index be1e803bb7..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-rewired/src/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { App } from "./App"; -export { App }; diff --git a/packages/deslop-js/tests/fixtures/cra-rewired/src/orphan.ts b/packages/deslop-js/tests/fixtures/cra-rewired/src/orphan.ts deleted file mode 100644 index bd93db0a49..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-rewired/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "unused"; diff --git a/packages/deslop-js/tests/fixtures/cra-src-baseurl/package.json b/packages/deslop-js/tests/fixtures/cra-src-baseurl/package.json deleted file mode 100644 index b1b944d9f3..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-src-baseurl/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "cra-src-baseurl", - "version": "1.0.0", - "dependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0", - "react-scripts": "^5.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/cra-src-baseurl/src/App.tsx b/packages/deslop-js/tests/fixtures/cra-src-baseurl/src/App.tsx deleted file mode 100644 index 310d932f50..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-src-baseurl/src/App.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { Header } from "components/Header"; - -export const App = () => <Header />; diff --git a/packages/deslop-js/tests/fixtures/cra-src-baseurl/src/components/Header.tsx b/packages/deslop-js/tests/fixtures/cra-src-baseurl/src/components/Header.tsx deleted file mode 100644 index 84beb43c05..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-src-baseurl/src/components/Header.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Header = () => <h1>Hello</h1>; diff --git a/packages/deslop-js/tests/fixtures/cra-src-baseurl/src/index.tsx b/packages/deslop-js/tests/fixtures/cra-src-baseurl/src/index.tsx deleted file mode 100644 index ec54726341..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-src-baseurl/src/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { App } from "App"; - -export const root = <App />; diff --git a/packages/deslop-js/tests/fixtures/cra-src-baseurl/src/orphan.ts b/packages/deslop-js/tests/fixtures/cra-src-baseurl/src/orphan.ts deleted file mode 100644 index 88f7cf20e0..0000000000 --- a/packages/deslop-js/tests/fixtures/cra-src-baseurl/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "orphan"; diff --git a/packages/deslop-js/tests/fixtures/cross-ext-js-ts/generators.ts b/packages/deslop-js/tests/fixtures/cross-ext-js-ts/generators.ts deleted file mode 100644 index 67d261a29c..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-ext-js-ts/generators.ts +++ /dev/null @@ -1 +0,0 @@ -export const generate = () => "generated"; diff --git a/packages/deslop-js/tests/fixtures/cross-ext-js-ts/orphan.ts b/packages/deslop-js/tests/fixtures/cross-ext-js-ts/orphan.ts deleted file mode 100644 index 9a2a4ec39c..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-ext-js-ts/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = () => "orphan"; diff --git a/packages/deslop-js/tests/fixtures/cross-ext-js-ts/package.json b/packages/deslop-js/tests/fixtures/cross-ext-js-ts/package.json deleted file mode 100644 index 559cfa57dd..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-ext-js-ts/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "exports-js-to-ts", - "version": "1.0.0", - "exports": { - "./generators": "./generators.js", - "./plugin": "./plugin.js", - "./utils": "./src/utils/index.js" - } -} diff --git a/packages/deslop-js/tests/fixtures/cross-ext-js-ts/plugin.ts b/packages/deslop-js/tests/fixtures/cross-ext-js-ts/plugin.ts deleted file mode 100644 index 78c75a6e28..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-ext-js-ts/plugin.ts +++ /dev/null @@ -1 +0,0 @@ -export const plugin = () => "plugin"; diff --git a/packages/deslop-js/tests/fixtures/cross-ext-js-ts/src/utils/index.ts b/packages/deslop-js/tests/fixtures/cross-ext-js-ts/src/utils/index.ts deleted file mode 100644 index 6418cc6a23..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-ext-js-ts/src/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const util = () => "util"; diff --git a/packages/deslop-js/tests/fixtures/cross-ext-ts-tsx/package.json b/packages/deslop-js/tests/fixtures/cross-ext-ts-tsx/package.json deleted file mode 100644 index 8a02c088fe..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-ext-ts-tsx/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "exports-ts-to-tsx", - "version": "1.0.0", - "exports": { - "./Button": { - "import": "./src/components/Button.ts", - "default": "./src/components/Button.ts" - } - } -} diff --git a/packages/deslop-js/tests/fixtures/cross-ext-ts-tsx/src/App.tsx b/packages/deslop-js/tests/fixtures/cross-ext-ts-tsx/src/App.tsx deleted file mode 100644 index 5c31d30bb1..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-ext-ts-tsx/src/App.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import React from "react"; -export default function App() { - return <div>App</div>; -} diff --git a/packages/deslop-js/tests/fixtures/cross-ext-ts-tsx/src/components/Button.tsx b/packages/deslop-js/tests/fixtures/cross-ext-ts-tsx/src/components/Button.tsx deleted file mode 100644 index dddeda5ffe..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-ext-ts-tsx/src/components/Button.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import React from "react"; -export const Button = () => <button>Click</button>; diff --git a/packages/deslop-js/tests/fixtures/cross-ext-ts-tsx/src/index.ts b/packages/deslop-js/tests/fixtures/cross-ext-ts-tsx/src/index.ts deleted file mode 100644 index 272db70df3..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-ext-ts-tsx/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as App } from "./App"; diff --git a/packages/deslop-js/tests/fixtures/cross-ext-ts-tsx/src/orphan.ts b/packages/deslop-js/tests/fixtures/cross-ext-ts-tsx/src/orphan.ts deleted file mode 100644 index 1b61e3211e..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-ext-ts-tsx/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = () => "unused"; diff --git a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports-unrelated/package.json b/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports-unrelated/package.json deleted file mode 100644 index 15be36f5fb..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports-unrelated/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "cross-file-duplicate-exports-unrelated", - "exports": { - "./root": "./src/index.ts", - "./routes/alpha": "./src/routes/alpha/handler.ts", - "./routes/beta": "./src/routes/beta/handler.ts" - } -} diff --git a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports-unrelated/src/index.ts b/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports-unrelated/src/index.ts deleted file mode 100644 index 884c71f9f5..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports-unrelated/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const root = "root-only"; diff --git a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports-unrelated/src/routes/alpha/handler.ts b/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports-unrelated/src/routes/alpha/handler.ts deleted file mode 100644 index b95e290c42..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports-unrelated/src/routes/alpha/handler.ts +++ /dev/null @@ -1 +0,0 @@ -export const handler = "alpha-handler"; diff --git a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports-unrelated/src/routes/beta/handler.ts b/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports-unrelated/src/routes/beta/handler.ts deleted file mode 100644 index 14edb178d1..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports-unrelated/src/routes/beta/handler.ts +++ /dev/null @@ -1 +0,0 @@ -export const handler = "beta-handler"; diff --git a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports/package.json b/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports/package.json deleted file mode 100644 index fd8592474d..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "cross-file-duplicate-exports", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports/src/alpha.ts b/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports/src/alpha.ts deleted file mode 100644 index b48b9d9737..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports/src/alpha.ts +++ /dev/null @@ -1 +0,0 @@ -export const sharedThing = "alpha"; diff --git a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports/src/beta.ts b/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports/src/beta.ts deleted file mode 100644 index 420ea3ee0f..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports/src/beta.ts +++ /dev/null @@ -1 +0,0 @@ -export const sharedThing = "beta"; diff --git a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports/src/gamma.ts b/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports/src/gamma.ts deleted file mode 100644 index 291efaef24..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports/src/gamma.ts +++ /dev/null @@ -1 +0,0 @@ -export const onlyHere = "gamma-unique"; diff --git a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports/src/index.ts b/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports/src/index.ts deleted file mode 100644 index bdeaf1374d..0000000000 --- a/packages/deslop-js/tests/fixtures/cross-file-duplicate-exports/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { sharedThing as alpha } from "./alpha.js"; -import { sharedThing as beta } from "./beta.js"; -import { onlyHere } from "./gamma.js"; - -console.log(alpha, beta, onlyHere); diff --git a/packages/deslop-js/tests/fixtures/css-tilde-import/package.json b/packages/deslop-js/tests/fixtures/css-tilde-import/package.json deleted file mode 100644 index 65f9407c95..0000000000 --- a/packages/deslop-js/tests/fixtures/css-tilde-import/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "css-tilde-import", - "version": "1.0.0", - "main": "src/index.ts", - "dependencies": { - "bootstrap": "^5.0.0", - "unused-dep": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/css-tilde-import/src/index.ts b/packages/deslop-js/tests/fixtures/css-tilde-import/src/index.ts deleted file mode 100644 index cad69a1f0e..0000000000 --- a/packages/deslop-js/tests/fixtures/css-tilde-import/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import "./styles.scss"; - -export const value = "value"; diff --git a/packages/deslop-js/tests/fixtures/css-tilde-import/src/styles.scss b/packages/deslop-js/tests/fixtures/css-tilde-import/src/styles.scss deleted file mode 100644 index 13dc130115..0000000000 --- a/packages/deslop-js/tests/fixtures/css-tilde-import/src/styles.scss +++ /dev/null @@ -1 +0,0 @@ -@import "~bootstrap/scss/functions"; diff --git a/packages/deslop-js/tests/fixtures/cycle-chain/package.json b/packages/deslop-js/tests/fixtures/cycle-chain/package.json deleted file mode 100644 index 6d1df1e30c..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-chain/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "circular-deps-chain" -} diff --git a/packages/deslop-js/tests/fixtures/cycle-chain/src/a.ts b/packages/deslop-js/tests/fixtures/cycle-chain/src/a.ts deleted file mode 100644 index 7d30866773..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-chain/src/a.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { b } from "./b"; -export const a = b + 1; diff --git a/packages/deslop-js/tests/fixtures/cycle-chain/src/b.ts b/packages/deslop-js/tests/fixtures/cycle-chain/src/b.ts deleted file mode 100644 index 318a10c1b1..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-chain/src/b.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { c } from "./c"; -export const b = c + 1; diff --git a/packages/deslop-js/tests/fixtures/cycle-chain/src/c.ts b/packages/deslop-js/tests/fixtures/cycle-chain/src/c.ts deleted file mode 100644 index ee22bd6f1e..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-chain/src/c.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { a } from "./a"; -export const c = a + 1; diff --git a/packages/deslop-js/tests/fixtures/cycle-chain/src/index.ts b/packages/deslop-js/tests/fixtures/cycle-chain/src/index.ts deleted file mode 100644 index 042e53e107..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-chain/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { a } from "./a"; -export const main = a; diff --git a/packages/deslop-js/tests/fixtures/cycle-function-only/package.json b/packages/deslop-js/tests/fixtures/cycle-function-only/package.json deleted file mode 100644 index 037c4ef32d..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-function-only/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "circular-deps-function-only" -} diff --git a/packages/deslop-js/tests/fixtures/cycle-function-only/src/child.ts b/packages/deslop-js/tests/fixtures/cycle-function-only/src/child.ts deleted file mode 100644 index 8fc6e13dc7..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-function-only/src/child.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { parentLabel } from "./parent"; - -export const renderChild = () => parentLabel; diff --git a/packages/deslop-js/tests/fixtures/cycle-function-only/src/index.ts b/packages/deslop-js/tests/fixtures/cycle-function-only/src/index.ts deleted file mode 100644 index 50d543c4ef..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-function-only/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { renderParent } from "./parent"; - -export const main = renderParent(); diff --git a/packages/deslop-js/tests/fixtures/cycle-function-only/src/parent.ts b/packages/deslop-js/tests/fixtures/cycle-function-only/src/parent.ts deleted file mode 100644 index fe0e0d1f77..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-function-only/src/parent.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { renderChild } from "./child"; - -export const parentLabel = "parent"; - -export const renderParent = () => renderChild(); diff --git a/packages/deslop-js/tests/fixtures/cycle-interface-value-import/package.json b/packages/deslop-js/tests/fixtures/cycle-interface-value-import/package.json deleted file mode 100644 index 58f830f6ce..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-interface-value-import/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "cycle-interface-value-import" -} diff --git a/packages/deslop-js/tests/fixtures/cycle-interface-value-import/src/barrel.ts b/packages/deslop-js/tests/fixtures/cycle-interface-value-import/src/barrel.ts deleted file mode 100644 index 62ceeaf3e9..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-interface-value-import/src/barrel.ts +++ /dev/null @@ -1,9 +0,0 @@ -import Comp from "./comp"; - -export interface CompProps { - label: string; -} - -export const mapping: { [key: string]: (props: CompProps) => unknown } = { - DEFAULT: Comp, -}; diff --git a/packages/deslop-js/tests/fixtures/cycle-interface-value-import/src/comp.ts b/packages/deslop-js/tests/fixtures/cycle-interface-value-import/src/comp.ts deleted file mode 100644 index 995da2565e..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-interface-value-import/src/comp.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { CompProps } from "./barrel"; - -const Comp = (props: CompProps): unknown => props.label; - -export default Comp; diff --git a/packages/deslop-js/tests/fixtures/cycle-interface-value-import/src/index.ts b/packages/deslop-js/tests/fixtures/cycle-interface-value-import/src/index.ts deleted file mode 100644 index 3419f0121e..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-interface-value-import/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { mapping } from "./barrel"; - -console.log(mapping); diff --git a/packages/deslop-js/tests/fixtures/cycle-lazy-import/package.json b/packages/deslop-js/tests/fixtures/cycle-lazy-import/package.json deleted file mode 100644 index c885a9832d..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-lazy-import/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "circular-deps-lazy-import" -} diff --git a/packages/deslop-js/tests/fixtures/cycle-lazy-import/src/index.ts b/packages/deslop-js/tests/fixtures/cycle-lazy-import/src/index.ts deleted file mode 100644 index edff676192..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-lazy-import/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { renderRoutes } from "./routes"; - -export const main = renderRoutes(); diff --git a/packages/deslop-js/tests/fixtures/cycle-lazy-import/src/page.ts b/packages/deslop-js/tests/fixtures/cycle-lazy-import/src/page.ts deleted file mode 100644 index 88d67127a9..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-lazy-import/src/page.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const pageTitle = "page"; - -export const loadRoutes = async () => { - const routesModule = await import("./routes"); - return routesModule.renderRoutes(); -}; diff --git a/packages/deslop-js/tests/fixtures/cycle-lazy-import/src/routes.ts b/packages/deslop-js/tests/fixtures/cycle-lazy-import/src/routes.ts deleted file mode 100644 index da481e3942..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-lazy-import/src/routes.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { pageTitle } from "./page"; - -export const renderRoutes = () => pageTitle; diff --git a/packages/deslop-js/tests/fixtures/cycle-none/package.json b/packages/deslop-js/tests/fixtures/cycle-none/package.json deleted file mode 100644 index bef5d30c55..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-none/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "circular-deps-none" -} diff --git a/packages/deslop-js/tests/fixtures/cycle-none/src/helper.ts b/packages/deslop-js/tests/fixtures/cycle-none/src/helper.ts deleted file mode 100644 index d3100c980f..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-none/src/helper.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { util } from "./util"; -export const helper = () => util + " world"; diff --git a/packages/deslop-js/tests/fixtures/cycle-none/src/index.ts b/packages/deslop-js/tests/fixtures/cycle-none/src/index.ts deleted file mode 100644 index 9388e0c5d0..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-none/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { helper } from "./helper"; -export const main = helper(); diff --git a/packages/deslop-js/tests/fixtures/cycle-none/src/util.ts b/packages/deslop-js/tests/fixtures/cycle-none/src/util.ts deleted file mode 100644 index 75d54fd041..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-none/src/util.ts +++ /dev/null @@ -1 +0,0 @@ -export const util = "hello"; diff --git a/packages/deslop-js/tests/fixtures/cycle-reexport/package.json b/packages/deslop-js/tests/fixtures/cycle-reexport/package.json deleted file mode 100644 index 48f5445f57..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-reexport/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "circular-re-export", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/cycle-reexport/src/index.ts b/packages/deslop-js/tests/fixtures/cycle-reexport/src/index.ts deleted file mode 100644 index 0d23f46926..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-reexport/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { fromA } from "./module-a"; -import { fromB } from "./module-b"; - -console.log(fromA, fromB); diff --git a/packages/deslop-js/tests/fixtures/cycle-reexport/src/module-a.ts b/packages/deslop-js/tests/fixtures/cycle-reexport/src/module-a.ts deleted file mode 100644 index 93e97dd195..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-reexport/src/module-a.ts +++ /dev/null @@ -1,3 +0,0 @@ -// module-a has its own export and re-exports from module-b -export const fromA = "defined in module-a"; -export { fromB } from "./module-b"; diff --git a/packages/deslop-js/tests/fixtures/cycle-reexport/src/module-b.ts b/packages/deslop-js/tests/fixtures/cycle-reexport/src/module-b.ts deleted file mode 100644 index 780b54041a..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-reexport/src/module-b.ts +++ /dev/null @@ -1,3 +0,0 @@ -// module-b has its own export and re-exports from module-a (circular) -export const fromB = "defined in module-b"; -export { fromA } from "./module-a"; diff --git a/packages/deslop-js/tests/fixtures/cycle-simple/package.json b/packages/deslop-js/tests/fixtures/cycle-simple/package.json deleted file mode 100644 index 4dfbea1a7b..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-simple/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "circular-deps-simple" -} diff --git a/packages/deslop-js/tests/fixtures/cycle-simple/src/a.ts b/packages/deslop-js/tests/fixtures/cycle-simple/src/a.ts deleted file mode 100644 index 727ee88e66..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-simple/src/a.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { c } from "./b"; -export const b = c + 1; diff --git a/packages/deslop-js/tests/fixtures/cycle-simple/src/b.ts b/packages/deslop-js/tests/fixtures/cycle-simple/src/b.ts deleted file mode 100644 index 84424c8e9d..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-simple/src/b.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { b } from "./a"; -export const c = b + 1; diff --git a/packages/deslop-js/tests/fixtures/cycle-simple/src/index.ts b/packages/deslop-js/tests/fixtures/cycle-simple/src/index.ts deleted file mode 100644 index cd1334058c..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-simple/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { b } from "./a"; -export const main = b; diff --git a/packages/deslop-js/tests/fixtures/cycle-type-only/package.json b/packages/deslop-js/tests/fixtures/cycle-type-only/package.json deleted file mode 100644 index e7e9460ed0..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-type-only/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "circular-deps-type-only" -} diff --git a/packages/deslop-js/tests/fixtures/cycle-type-only/src/a.ts b/packages/deslop-js/tests/fixtures/cycle-type-only/src/a.ts deleted file mode 100644 index 51af7a7838..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-type-only/src/a.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { CType } from "./b"; -export const b = 1; -export interface AType { - value: CType; -} diff --git a/packages/deslop-js/tests/fixtures/cycle-type-only/src/b.ts b/packages/deslop-js/tests/fixtures/cycle-type-only/src/b.ts deleted file mode 100644 index 8a41f97e7a..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-type-only/src/b.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { AType } from "./a"; -export interface CType { - data: string; -} -export const process = (input: AType): CType => ({ data: String(input.value) }); diff --git a/packages/deslop-js/tests/fixtures/cycle-type-only/src/index.ts b/packages/deslop-js/tests/fixtures/cycle-type-only/src/index.ts deleted file mode 100644 index cd1334058c..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-type-only/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { b } from "./a"; -export const main = b; diff --git a/packages/deslop-js/tests/fixtures/cycle-with-orphans/index.ts b/packages/deslop-js/tests/fixtures/cycle-with-orphans/index.ts deleted file mode 100644 index 369f52a7c2..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-with-orphans/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { processA } from "./module-a"; - -processA(); diff --git a/packages/deslop-js/tests/fixtures/cycle-with-orphans/module-a.ts b/packages/deslop-js/tests/fixtures/cycle-with-orphans/module-a.ts deleted file mode 100644 index 251d40825c..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-with-orphans/module-a.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { processB } from "./module-b"; - -export const processA = () => { - processB(); -}; - -export const unusedFromA = () => "never used"; diff --git a/packages/deslop-js/tests/fixtures/cycle-with-orphans/module-b.ts b/packages/deslop-js/tests/fixtures/cycle-with-orphans/module-b.ts deleted file mode 100644 index 3b2370b017..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-with-orphans/module-b.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { processA } from "./module-a"; - -const fallbackProcess = processA; - -export const processB = () => { - if (Math.random() > 0.5) fallbackProcess(); -}; - -export const unusedFromB = () => "never used"; diff --git a/packages/deslop-js/tests/fixtures/cycle-with-orphans/orphan.ts b/packages/deslop-js/tests/fixtures/cycle-with-orphans/orphan.ts deleted file mode 100644 index b9ecd9f2ff..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-with-orphans/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphanedFunction = () => "this file is not imported by anything"; diff --git a/packages/deslop-js/tests/fixtures/cycle-with-orphans/package.json b/packages/deslop-js/tests/fixtures/cycle-with-orphans/package.json deleted file mode 100644 index cb50ecd515..0000000000 --- a/packages/deslop-js/tests/fixtures/cycle-with-orphans/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "circular-deps-with-unused", - "main": "index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/deep-reexport-chain/consumer.ts b/packages/deslop-js/tests/fixtures/deep-reexport-chain/consumer.ts deleted file mode 100644 index a26413975e..0000000000 --- a/packages/deslop-js/tests/fixtures/deep-reexport-chain/consumer.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { alpha } from "./index"; - -console.log(alpha()); diff --git a/packages/deslop-js/tests/fixtures/deep-reexport-chain/index.ts b/packages/deslop-js/tests/fixtures/deep-reexport-chain/index.ts deleted file mode 100644 index 5b69c2bd4f..0000000000 --- a/packages/deslop-js/tests/fixtures/deep-reexport-chain/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { alpha, beta } from "./level-1"; diff --git a/packages/deslop-js/tests/fixtures/deep-reexport-chain/level-1.ts b/packages/deslop-js/tests/fixtures/deep-reexport-chain/level-1.ts deleted file mode 100644 index 4608bd21bd..0000000000 --- a/packages/deslop-js/tests/fixtures/deep-reexport-chain/level-1.ts +++ /dev/null @@ -1 +0,0 @@ -export { alpha, beta, gamma } from "./level-2"; diff --git a/packages/deslop-js/tests/fixtures/deep-reexport-chain/level-2.ts b/packages/deslop-js/tests/fixtures/deep-reexport-chain/level-2.ts deleted file mode 100644 index 0711d4ce92..0000000000 --- a/packages/deslop-js/tests/fixtures/deep-reexport-chain/level-2.ts +++ /dev/null @@ -1 +0,0 @@ -export { alpha, beta, gamma, delta } from "./level-3"; diff --git a/packages/deslop-js/tests/fixtures/deep-reexport-chain/level-3.ts b/packages/deslop-js/tests/fixtures/deep-reexport-chain/level-3.ts deleted file mode 100644 index bdb706d134..0000000000 --- a/packages/deslop-js/tests/fixtures/deep-reexport-chain/level-3.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const alpha = () => "a"; -export const beta = () => "b"; -export const gamma = () => "c"; -export const delta = () => "d"; diff --git a/packages/deslop-js/tests/fixtures/deep-reexport-chain/package.json b/packages/deslop-js/tests/fixtures/deep-reexport-chain/package.json deleted file mode 100644 index b1c99d0175..0000000000 --- a/packages/deslop-js/tests/fixtures/deep-reexport-chain/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "deep-nested-re-export", - "main": "index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/deep-reexport-tracking/package.json b/packages/deslop-js/tests/fixtures/deep-reexport-tracking/package.json deleted file mode 100644 index 0d04c30358..0000000000 --- a/packages/deslop-js/tests/fixtures/deep-reexport-tracking/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "deep-barrel-symbol-tracking", - "private": true -} diff --git a/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/barrel-mid.ts b/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/barrel-mid.ts deleted file mode 100644 index 62a8ad4dae..0000000000 --- a/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/barrel-mid.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { usedHelper } from "./used-source"; -export { unusedHelper } from "./unused-source"; diff --git a/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/barrel-top.ts b/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/barrel-top.ts deleted file mode 100644 index afa45b8349..0000000000 --- a/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/barrel-top.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { usedHelper } from "./barrel-mid"; -export { unusedHelper } from "./barrel-mid"; diff --git a/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/index.ts b/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/index.ts deleted file mode 100644 index f16df236c4..0000000000 --- a/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { usedHelper } from "./barrel-top"; - -console.log(usedHelper); diff --git a/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/orphan.ts b/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/orphan.ts deleted file mode 100644 index cc89ca1fcc..0000000000 --- a/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "not referenced by anything"; diff --git a/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/unused-source.ts b/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/unused-source.ts deleted file mode 100644 index 63672b141d..0000000000 --- a/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/unused-source.ts +++ /dev/null @@ -1 +0,0 @@ -export const unusedHelper = "never consumed at root"; diff --git a/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/used-source.ts b/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/used-source.ts deleted file mode 100644 index 0812ba2c22..0000000000 --- a/packages/deslop-js/tests/fixtures/deep-reexport-tracking/src/used-source.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const usedHelper = "consumed through two barrel layers"; -export const usedHelperSibling = "not consumed but lives in used file"; diff --git a/packages/deslop-js/tests/fixtures/default-export-alias-of-used-named/package.json b/packages/deslop-js/tests/fixtures/default-export-alias-of-used-named/package.json deleted file mode 100644 index bdd9b9cc17..0000000000 --- a/packages/deslop-js/tests/fixtures/default-export-alias-of-used-named/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "default-export-alias-of-used-named" -} diff --git a/packages/deslop-js/tests/fixtures/default-export-alias-of-used-named/src/devtools.ts b/packages/deslop-js/tests/fixtures/default-export-alias-of-used-named/src/devtools.ts deleted file mode 100644 index 57b51d7560..0000000000 --- a/packages/deslop-js/tests/fixtures/default-export-alias-of-used-named/src/devtools.ts +++ /dev/null @@ -1 +0,0 @@ -export { Page } from "./page"; diff --git a/packages/deslop-js/tests/fixtures/default-export-alias-of-used-named/src/index.ts b/packages/deslop-js/tests/fixtures/default-export-alias-of-used-named/src/index.ts deleted file mode 100644 index 3e3fbefc70..0000000000 --- a/packages/deslop-js/tests/fixtures/default-export-alias-of-used-named/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -const load = () => import("./devtools").then((m) => ({ default: m.Page })); - -console.log(load); diff --git a/packages/deslop-js/tests/fixtures/default-export-alias-of-used-named/src/page.ts b/packages/deslop-js/tests/fixtures/default-export-alias-of-used-named/src/page.ts deleted file mode 100644 index 2b5c398a4a..0000000000 --- a/packages/deslop-js/tests/fixtures/default-export-alias-of-used-named/src/page.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function Page(): string { - return "page"; -} - -export default Page; diff --git a/packages/deslop-js/tests/fixtures/default-import-named-export/package.json b/packages/deslop-js/tests/fixtures/default-import-named-export/package.json deleted file mode 100644 index 3021163e82..0000000000 --- a/packages/deslop-js/tests/fixtures/default-import-named-export/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "default-import-named-export", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "vitest": "^2.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/default-import-named-export/src/index.ts b/packages/deslop-js/tests/fixtures/default-import-named-export/src/index.ts deleted file mode 100644 index ff0ba835bb..0000000000 --- a/packages/deslop-js/tests/fixtures/default-import-named-export/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { SettingsPanel } from "./settings-panel"; - -export const main = (): void => { - SettingsPanel(); -}; diff --git a/packages/deslop-js/tests/fixtures/default-import-named-export/src/orphan.ts b/packages/deslop-js/tests/fixtures/default-import-named-export/src/orphan.ts deleted file mode 100644 index 74448acbb5..0000000000 --- a/packages/deslop-js/tests/fixtures/default-import-named-export/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphanValue = 1; diff --git a/packages/deslop-js/tests/fixtures/default-import-named-export/src/settings-panel.test.tsx b/packages/deslop-js/tests/fixtures/default-import-named-export/src/settings-panel.test.tsx deleted file mode 100644 index f0606479cf..0000000000 --- a/packages/deslop-js/tests/fixtures/default-import-named-export/src/settings-panel.test.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import SettingsPanel from "./settings-panel"; -import { describe, it } from "vitest"; - -describe("SettingsPanel", () => { - it("loads", () => { - SettingsPanel(); - }); -}); diff --git a/packages/deslop-js/tests/fixtures/default-import-named-export/src/settings-panel.tsx b/packages/deslop-js/tests/fixtures/default-import-named-export/src/settings-panel.tsx deleted file mode 100644 index bf65639b90..0000000000 --- a/packages/deslop-js/tests/fixtures/default-import-named-export/src/settings-panel.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function SettingsPanel(): null { - return null; -} diff --git a/packages/deslop-js/tests/fixtures/dependency-tooling/.dumi/theme/common/prompt-drawer.tsx b/packages/deslop-js/tests/fixtures/dependency-tooling/.dumi/theme/common/prompt-drawer.tsx deleted file mode 100644 index e6f2c42beb..0000000000 --- a/packages/deslop-js/tests/fixtures/dependency-tooling/.dumi/theme/common/prompt-drawer.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { Drawer } from "docs-theme-widgets"; - -export const PromptDrawer = (): unknown => Drawer; diff --git a/packages/deslop-js/tests/fixtures/dependency-tooling/.eslintrc.json b/packages/deslop-js/tests/fixtures/dependency-tooling/.eslintrc.json deleted file mode 100644 index 8dafe48e32..0000000000 --- a/packages/deslop-js/tests/fixtures/dependency-tooling/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "parser": "babel-eslint" -} diff --git a/packages/deslop-js/tests/fixtures/dependency-tooling/.gitignore b/packages/deslop-js/tests/fixtures/dependency-tooling/.gitignore deleted file mode 100644 index ddf342489b..0000000000 --- a/packages/deslop-js/tests/fixtures/dependency-tooling/.gitignore +++ /dev/null @@ -1 +0,0 @@ -!node_modules/ diff --git a/packages/deslop-js/tests/fixtures/dependency-tooling/jest.config.js b/packages/deslop-js/tests/fixtures/dependency-tooling/jest.config.js deleted file mode 100644 index 5da099d967..0000000000 --- a/packages/deslop-js/tests/fixtures/dependency-tooling/jest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - testEnvironment: "node", -}; diff --git a/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/@babel/cli/package.json b/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/@babel/cli/package.json deleted file mode 100644 index 4b9fca9f91..0000000000 --- a/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/@babel/cli/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@babel/cli", - "version": "1.0.0", - "bin": { - "babel": "./bin.js" - } -} diff --git a/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/@formatjs/cli/package.json b/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/@formatjs/cli/package.json deleted file mode 100644 index 1353089ee2..0000000000 --- a/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/@formatjs/cli/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@formatjs/cli", - "version": "1.0.0", - "bin": { - "formatjs": "./bin.js" - } -} diff --git a/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/@tauri-apps/cli/package.json b/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/@tauri-apps/cli/package.json deleted file mode 100644 index 8cb5fdcda9..0000000000 --- a/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/@tauri-apps/cli/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@tauri-apps/cli", - "version": "1.0.0", - "bin": { - "tauri": "./bin.js" - } -} diff --git a/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/@tinacms/cli/package.json b/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/@tinacms/cli/package.json deleted file mode 100644 index f75ee378e6..0000000000 --- a/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/@tinacms/cli/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@tinacms/cli", - "version": "1.0.0", - "bin": { - "tinacms": "./bin.js" - } -} diff --git a/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/chokidar-cli/package.json b/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/chokidar-cli/package.json deleted file mode 100644 index daae667b04..0000000000 --- a/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/chokidar-cli/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "chokidar-cli", - "version": "1.0.0", - "peerDependencies": { - "chokidar-peer": "*" - }, - "bin": { - "chokidar": "./bin.js" - } -} diff --git a/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/jest-cli/package.json b/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/jest-cli/package.json deleted file mode 100644 index b47aeb1ec5..0000000000 --- a/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/jest-cli/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "jest-cli", - "version": "1.0.0", - "bin": { - "jest": "./bin.js" - } -} diff --git a/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/react-chartjs-2/package.json b/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/react-chartjs-2/package.json deleted file mode 100644 index 18b61db2cc..0000000000 --- a/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/react-chartjs-2/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "react-chartjs-2", - "version": "1.0.0", - "peerDependencies": { - "chart.js": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/react-redux/package.json b/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/react-redux/package.json deleted file mode 100644 index 810cca2166..0000000000 --- a/packages/deslop-js/tests/fixtures/dependency-tooling/node_modules/react-redux/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "react-redux", - "version": "1.0.0", - "peerDependencies": { - "redux": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/dependency-tooling/package.json b/packages/deslop-js/tests/fixtures/dependency-tooling/package.json deleted file mode 100644 index c18718b309..0000000000 --- a/packages/deslop-js/tests/fixtures/dependency-tooling/package.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "dependency-tooling", - "version": "1.0.0", - "main": "src/index.ts", - "scripts": { - "build": "babel src --out-dir lib && tsc-alias", - "copy-styles": "cpy 'src/**/*.css' dist", - "e2e": "tauri build", - "extract": "formatjs extract src/index.ts", - "prompt": "prompt", - "replace": "replace-in-file old new src/index.ts", - "test": "jest --config jest.config.js", - "test:browser": "playwright test", - "watch": "chokidar 'src/**' -c 'echo changed'", - "cms": "tinacms dev -c 'next dev'" - }, - "dependencies": { - "@hookform/resolvers": "^3.0.0", - "chart.js": "^4.0.0", - "react-chartjs-2": "^5.0.0", - "react-redux": "^9.0.0", - "redux": "^5.0.0", - "redux-thunk": "^3.0.0", - "unused-dep": "^1.0.0", - "zod": "^3.0.0" - }, - "devDependencies": { - "@babel/cli": "^7.0.0", - "@formatjs/cli": "^6.0.0", - "@nx/js": "^20.0.0", - "@tauri-apps/cli": "^2.0.0", - "@tinacms/cli": "^1.0.0", - "@typescript/native-preview": "^7.0.0-dev", - "chokidar-peer": "^1.0.0", - "axe-core": "^4.11.0", - "babel-eslint": "^10.0.0", - "chokidar-cli": "^3.0.0", - "cpy-cli": "^5.0.0", - "jest-cli": "^29.0.0", - "playwright-chromium": "^1.60.0", - "prompt": "^1.0.0", - "replace-in-file": "^8.0.0", - "tsc-alias": "^1.0.0", - "unused-tool": "^1.0.0", - "vitest-axe": "^0.1.0", - "docs-theme-widgets": "^1.0.0" - }, - "pnpm": { - "overrides": { - "typescript": "npm:@typescript/native-preview" - } - } -} diff --git a/packages/deslop-js/tests/fixtures/dependency-tooling/project.json b/packages/deslop-js/tests/fixtures/dependency-tooling/project.json deleted file mode 100644 index 86b2d0ea0b..0000000000 --- a/packages/deslop-js/tests/fixtures/dependency-tooling/project.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "dependency-tooling", - "targets": { - "build": { - "executor": "@nx/js:tsc" - }, - "alias": { - "command": "tsc-alias" - } - } -} diff --git a/packages/deslop-js/tests/fixtures/dependency-tooling/src/index.ts b/packages/deslop-js/tests/fixtures/dependency-tooling/src/index.ts deleted file mode 100644 index 5def9c17c4..0000000000 --- a/packages/deslop-js/tests/fixtures/dependency-tooling/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { Chart } from "react-chartjs-2"; -import { Provider } from "react-redux"; -import "vitest-axe"; - -export const schema = z.string(); -export const dependencies = [zodResolver, Chart, Provider]; diff --git a/packages/deslop-js/tests/fixtures/docusaurus-docs/blog/first-post.mdx b/packages/deslop-js/tests/fixtures/docusaurus-docs/blog/first-post.mdx deleted file mode 100644 index db61004926..0000000000 --- a/packages/deslop-js/tests/fixtures/docusaurus-docs/blog/first-post.mdx +++ /dev/null @@ -1,3 +0,0 @@ -# First Post - -Blog content diff --git a/packages/deslop-js/tests/fixtures/docusaurus-docs/docs/intro.mdx b/packages/deslop-js/tests/fixtures/docusaurus-docs/docs/intro.mdx deleted file mode 100644 index 3e2b92316f..0000000000 --- a/packages/deslop-js/tests/fixtures/docusaurus-docs/docs/intro.mdx +++ /dev/null @@ -1,3 +0,0 @@ -# Introduction - -This is a doc file diff --git a/packages/deslop-js/tests/fixtures/docusaurus-docs/docusaurus.config.ts b/packages/deslop-js/tests/fixtures/docusaurus-docs/docusaurus.config.ts deleted file mode 100644 index 5b1f54d17a..0000000000 --- a/packages/deslop-js/tests/fixtures/docusaurus-docs/docusaurus.config.ts +++ /dev/null @@ -1 +0,0 @@ -export default { title: "Test" }; diff --git a/packages/deslop-js/tests/fixtures/docusaurus-docs/package.json b/packages/deslop-js/tests/fixtures/docusaurus-docs/package.json deleted file mode 100644 index 5b691a2b0b..0000000000 --- a/packages/deslop-js/tests/fixtures/docusaurus-docs/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "docusaurus-content-fixture", - "private": true, - "dependencies": { - "@docusaurus/core": "^3.0.0", - "@docusaurus/preset-classic": "^3.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/docusaurus-docs/src/components/orphan.tsx b/packages/deslop-js/tests/fixtures/docusaurus-docs/src/components/orphan.tsx deleted file mode 100644 index 1cac5b98b5..0000000000 --- a/packages/deslop-js/tests/fixtures/docusaurus-docs/src/components/orphan.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Orphan = () => <div>Orphan</div>; diff --git a/packages/deslop-js/tests/fixtures/docusaurus-docs/src/components/used.tsx b/packages/deslop-js/tests/fixtures/docusaurus-docs/src/components/used.tsx deleted file mode 100644 index 3644653341..0000000000 --- a/packages/deslop-js/tests/fixtures/docusaurus-docs/src/components/used.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Used = () => <div>Used</div>; diff --git a/packages/deslop-js/tests/fixtures/docusaurus-docs/src/pages/index.tsx b/packages/deslop-js/tests/fixtures/docusaurus-docs/src/pages/index.tsx deleted file mode 100644 index a9a39af2e8..0000000000 --- a/packages/deslop-js/tests/fixtures/docusaurus-docs/src/pages/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { Used } from "../components/used"; -export default function Home() { - return <Used />; -} diff --git a/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/package.json b/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/package.json deleted file mode 100644 index e396d69811..0000000000 --- a/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "dry-patterns-syntactic", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/duplicate-imports.ts b/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/duplicate-imports.ts deleted file mode 100644 index e1ad2c944b..0000000000 --- a/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/duplicate-imports.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { foo } from "./helpers.js"; -import { bar } from "./helpers.js"; -import { baz } from "./helpers.js"; - -import { one } from "./other.js"; - -console.log(foo, bar, baz, one); diff --git a/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/duplicate-type-other/types.ts b/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/duplicate-type-other/types.ts deleted file mode 100644 index 368ac4b0a7..0000000000 --- a/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/duplicate-type-other/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface User { - id: string; - name: string; -} - -export interface OnlyHere { - unique: true; -} diff --git a/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/helpers.ts b/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/helpers.ts deleted file mode 100644 index 1ebedda40d..0000000000 --- a/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/helpers.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const foo = "foo"; -export const bar = "bar"; -export const baz = "baz"; diff --git a/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/index.ts b/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/index.ts deleted file mode 100644 index 2fc45edec9..0000000000 --- a/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { User } from "./types.js"; -import type { User as OtherUser } from "./duplicate-type-other/types.js"; -import "./duplicate-imports.js"; -import "./wrappers.js"; - -export const consume = (u: User, other: OtherUser): string => `${u.id}:${other.name}`; diff --git a/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/other.ts b/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/other.ts deleted file mode 100644 index d815786306..0000000000 --- a/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/other.ts +++ /dev/null @@ -1 +0,0 @@ -export const one = 1; diff --git a/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/types.ts b/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/types.ts deleted file mode 100644 index 8830d46c4b..0000000000 --- a/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/types.ts +++ /dev/null @@ -1,40 +0,0 @@ -export type IntersectWithEmpty = User & {}; - -export type SelfUnion = User | User; - -export type NestedPartial = Partial<Partial<User>>; - -export type NestedReadonly = Readonly<Readonly<User>>; - -export type PickAll = Pick<User, keyof User>; - -export type OmitNever = Omit<User, never>; - -export interface EmptyExtends extends User {} - -export interface ZodMergedSchemaShape extends ZodSchema.infer<typeof ZodMergedSchemaShape> {} -export const ZodMergedSchemaShape = { ref: 1 }; - -export interface CheckboxRootProps extends CheckboxPrimitive.Root.Props {} - -declare namespace ZodSchema { - type infer<TParsed> = TParsed; -} -declare namespace CheckboxPrimitive { - namespace Root { - interface Props { - checked: boolean; - } - } -} - -export interface User { - id: string; - name: string; -} - -export interface LegitChild extends User { - extra: boolean; -} - -export type LegitUnion = "a" | "b"; diff --git a/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/wrappers.ts b/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/wrappers.ts deleted file mode 100644 index 920f11a511..0000000000 --- a/packages/deslop-js/tests/fixtures/dry-patterns-syntactic/src/wrappers.ts +++ /dev/null @@ -1,29 +0,0 @@ -export const debugLog = (message: string) => console.log(message); - -export const triggerWith = (event: string, payload: number) => fireEvent(event, payload); - -export const variadicWrap = (...args: unknown[]) => downstream(...args); - -export const callOnly = () => bootstrap(); - -export const legitWrap = (input: string) => transform(input.toUpperCase()); - -export const legitExtra = (input: string) => transform(input, "extra"); - -export const legitDifferentOrder = (a: number, b: number) => fn(b, a); - -const fireEvent = (event: string, payload: number) => `${event}:${payload}`; -const downstream = (...args: unknown[]) => args.length; -const bootstrap = () => "ready"; -const transform = (..._args: unknown[]) => "done"; -const fn = (..._args: unknown[]) => 0; - -console.log( - debugLog("ok"), - triggerWith("e", 1), - variadicWrap(1, 2), - callOnly(), - legitWrap("hi"), - legitExtra("yo"), - legitDifferentOrder(1, 2), -); diff --git a/packages/deslop-js/tests/fixtures/dts-imports/orphan.ts b/packages/deslop-js/tests/fixtures/dts-imports/orphan.ts deleted file mode 100644 index b79aa99b63..0000000000 --- a/packages/deslop-js/tests/fixtures/dts-imports/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = true; diff --git a/packages/deslop-js/tests/fixtures/dts-imports/package.json b/packages/deslop-js/tests/fixtures/dts-imports/package.json deleted file mode 100644 index 9c4ef6f03c..0000000000 --- a/packages/deslop-js/tests/fixtures/dts-imports/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "declaration-file-imports", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/dts-imports/src/helper.ts b/packages/deslop-js/tests/fixtures/dts-imports/src/helper.ts deleted file mode 100644 index 64175b3fd5..0000000000 --- a/packages/deslop-js/tests/fixtures/dts-imports/src/helper.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface HelperUtil { - format: (input: string) => string; -} - -export const createHelper = (): HelperUtil => ({ - format: (input: string) => input.trim(), -}); diff --git a/packages/deslop-js/tests/fixtures/dts-imports/src/index.ts b/packages/deslop-js/tests/fixtures/dts-imports/src/index.ts deleted file mode 100644 index d010d6c81c..0000000000 --- a/packages/deslop-js/tests/fixtures/dts-imports/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import type { AppConfig } from "./types"; -export const run = (config: AppConfig) => config.name; diff --git a/packages/deslop-js/tests/fixtures/dts-imports/src/types.d.ts b/packages/deslop-js/tests/fixtures/dts-imports/src/types.d.ts deleted file mode 100644 index 9da63aa341..0000000000 --- a/packages/deslop-js/tests/fixtures/dts-imports/src/types.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { HelperUtil } from "./helper"; - -export interface AppConfig { - name: string; - helper: HelperUtil; -} diff --git a/packages/deslop-js/tests/fixtures/duplicate-blocks-basic/package.json b/packages/deslop-js/tests/fixtures/duplicate-blocks-basic/package.json deleted file mode 100644 index 42489e6b83..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-blocks-basic/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "duplicate-blocks-basic", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/duplicate-blocks-basic/src/index.ts b/packages/deslop-js/tests/fixtures/duplicate-blocks-basic/src/index.ts deleted file mode 100644 index d70afd3b95..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-blocks-basic/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { processOrders } from "./orders.js"; -import { processInvoices } from "./invoices.js"; - -console.log(processOrders([], 0), processInvoices([], 0)); diff --git a/packages/deslop-js/tests/fixtures/duplicate-blocks-basic/src/invoices.ts b/packages/deslop-js/tests/fixtures/duplicate-blocks-basic/src/invoices.ts deleted file mode 100644 index ee2308a6d8..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-blocks-basic/src/invoices.ts +++ /dev/null @@ -1,21 +0,0 @@ -interface Invoice { - id: number; - amount: number; - state: string; - vendor: string; - memo: string; -} - -export const processInvoices = (invoices: Invoice[], minAmount: number): Invoice[] => { - const accepted = []; - for (let cursor = 0; cursor < invoices.length; cursor++) { - const entry = invoices[cursor]; - if (entry.amount <= minAmount) continue; - if (entry.state === "cancelled") continue; - if (entry.memo.length === 0) continue; - if (entry.vendor.length === 0) continue; - accepted.push(entry); - } - accepted.sort((leftEntry, rightEntry) => rightEntry.amount - leftEntry.amount); - return accepted; -}; diff --git a/packages/deslop-js/tests/fixtures/duplicate-blocks-basic/src/orders.ts b/packages/deslop-js/tests/fixtures/duplicate-blocks-basic/src/orders.ts deleted file mode 100644 index 9858fb106e..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-blocks-basic/src/orders.ts +++ /dev/null @@ -1,21 +0,0 @@ -interface Order { - id: number; - total: number; - status: string; - customer: string; - notes: string; -} - -export const processOrders = (orders: Order[], threshold: number): Order[] => { - const filtered = []; - for (let index = 0; index < orders.length; index++) { - const current = orders[index]; - if (current.total <= threshold) continue; - if (current.status === "cancelled") continue; - if (current.notes.length === 0) continue; - if (current.customer.length === 0) continue; - filtered.push(current); - } - filtered.sort((firstItem, secondItem) => secondItem.total - firstItem.total); - return filtered; -}; diff --git a/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/package.json b/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/package.json deleted file mode 100644 index 84ef731906..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "duplicate-constants-unit-mismatch", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-cache.ts b/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-cache.ts deleted file mode 100644 index e058bd1749..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-cache.ts +++ /dev/null @@ -1 +0,0 @@ -export const CACHE_INTERVAL_MS = 2000; diff --git a/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-pixels.ts b/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-pixels.ts deleted file mode 100644 index 0ab05ed354..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-pixels.ts +++ /dev/null @@ -1 +0,0 @@ -export const SCREEN_WIDTH = 1000; diff --git a/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-poll.ts b/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-poll.ts deleted file mode 100644 index 42526b4d44..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-poll.ts +++ /dev/null @@ -1 +0,0 @@ -export const POLL_INTERVAL_MS = 2000; diff --git a/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-reconnect.ts b/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-reconnect.ts deleted file mode 100644 index 517084e3d8..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-reconnect.ts +++ /dev/null @@ -1 +0,0 @@ -export const RECONNECT_DELAY_MS = 2000; diff --git a/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-time.ts b/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-time.ts deleted file mode 100644 index c6f81c8688..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-time.ts +++ /dev/null @@ -1 +0,0 @@ -export const STEP_DELAY_MS = 1000; diff --git a/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-tokens.ts b/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-tokens.ts deleted file mode 100644 index 2aa511b0e8..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/feature-tokens.ts +++ /dev/null @@ -1 +0,0 @@ -export const MINIMUM_TOKENS = 1000; diff --git a/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/index.ts b/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/index.ts deleted file mode 100644 index a35526098a..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-constants-unit-mismatch/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { STEP_DELAY_MS } from "./feature-time"; -import { MINIMUM_TOKENS } from "./feature-tokens"; -import { SCREEN_WIDTH } from "./feature-pixels"; -import { CACHE_INTERVAL_MS } from "./feature-cache"; -import { RECONNECT_DELAY_MS } from "./feature-reconnect"; -import { POLL_INTERVAL_MS } from "./feature-poll"; - -console.log(STEP_DELAY_MS, MINIMUM_TOKENS, SCREEN_WIDTH); -console.log(CACHE_INTERVAL_MS, RECONNECT_DELAY_MS, POLL_INTERVAL_MS); diff --git a/packages/deslop-js/tests/fixtures/duplicate-constants/package.json b/packages/deslop-js/tests/fixtures/duplicate-constants/package.json deleted file mode 100644 index 3110bb0dc6..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-constants/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "duplicate-constants", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/duplicate-constants/src/feature-one.ts b/packages/deslop-js/tests/fixtures/duplicate-constants/src/feature-one.ts deleted file mode 100644 index 349416aa96..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-constants/src/feature-one.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const API_BASE_URL = "https://api.example.com"; -export const POLL_INTERVAL_MS = 5000; -export const FEATURE_ONE_SPECIFIC = "only-here"; diff --git a/packages/deslop-js/tests/fixtures/duplicate-constants/src/feature-three.ts b/packages/deslop-js/tests/fixtures/duplicate-constants/src/feature-three.ts deleted file mode 100644 index 04fda08142..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-constants/src/feature-three.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const API_BASE_URL = "https://api.example.com"; -export const SHORT_NAME = "x"; -export const SMALL_NUMBER = 42; diff --git a/packages/deslop-js/tests/fixtures/duplicate-constants/src/feature-two.ts b/packages/deslop-js/tests/fixtures/duplicate-constants/src/feature-two.ts deleted file mode 100644 index 97eb4464b5..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-constants/src/feature-two.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const API_BASE_URL = "https://api.example.com"; -export const RETRY_DELAY_MS = 5000; diff --git a/packages/deslop-js/tests/fixtures/duplicate-constants/src/index.ts b/packages/deslop-js/tests/fixtures/duplicate-constants/src/index.ts deleted file mode 100644 index 8afee6adca..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-constants/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./feature-one.js"; -export * from "./feature-two.js"; -export * from "./feature-three.js"; diff --git a/packages/deslop-js/tests/fixtures/duplicate-exports-barrel/package.json b/packages/deslop-js/tests/fixtures/duplicate-exports-barrel/package.json deleted file mode 100644 index 56ed074c1a..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-exports-barrel/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "duplicate-exports-barrel", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/duplicate-exports-barrel/src/alpha.ts b/packages/deslop-js/tests/fixtures/duplicate-exports-barrel/src/alpha.ts deleted file mode 100644 index 8223f8f3ee..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-exports-barrel/src/alpha.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const shared = "from-alpha"; -export const aOnly = "alpha-only"; diff --git a/packages/deslop-js/tests/fixtures/duplicate-exports-barrel/src/barrel.ts b/packages/deslop-js/tests/fixtures/duplicate-exports-barrel/src/barrel.ts deleted file mode 100644 index 269ce714b3..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-exports-barrel/src/barrel.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { shared } from "./alpha.js"; -export { shared } from "./beta.js"; -export { aOnly } from "./alpha.js"; -export { bOnly } from "./beta.js"; diff --git a/packages/deslop-js/tests/fixtures/duplicate-exports-barrel/src/beta.ts b/packages/deslop-js/tests/fixtures/duplicate-exports-barrel/src/beta.ts deleted file mode 100644 index 08df89155b..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-exports-barrel/src/beta.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const shared = "from-beta"; -export const bOnly = "beta-only"; diff --git a/packages/deslop-js/tests/fixtures/duplicate-exports-barrel/src/index.ts b/packages/deslop-js/tests/fixtures/duplicate-exports-barrel/src/index.ts deleted file mode 100644 index 5aa7c46c52..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-exports-barrel/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { shared, aOnly, bOnly } from "./barrel.js"; - -console.log(shared, aOnly, bOnly); diff --git a/packages/deslop-js/tests/fixtures/duplicate-import-type-value/package.json b/packages/deslop-js/tests/fixtures/duplicate-import-type-value/package.json deleted file mode 100644 index 0bc431b749..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-import-type-value/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "duplicate-import-type-value", - "version": "1.0.0", - "main": "src/consumer-split.ts" -} diff --git a/packages/deslop-js/tests/fixtures/duplicate-import-type-value/src/api.ts b/packages/deslop-js/tests/fixtures/duplicate-import-type-value/src/api.ts deleted file mode 100644 index c1f06623f3..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-import-type-value/src/api.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface PackageJson { - name: string; -} - -export interface EditStatus { - ok: boolean; -} - -export const runEdit = (status: EditStatus): boolean => status.ok; -export const helperOne = (): string => "one"; -export const helperTwo = (): string => "two"; diff --git a/packages/deslop-js/tests/fixtures/duplicate-import-type-value/src/consumer-split.ts b/packages/deslop-js/tests/fixtures/duplicate-import-type-value/src/consumer-split.ts deleted file mode 100644 index 10f5b76909..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-import-type-value/src/consumer-split.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { runEdit } from "./api"; -import type { EditStatus, PackageJson } from "./api"; - -import { helperOne } from "./api"; -import { helperTwo } from "./api"; - -export const status: EditStatus = { ok: true }; -export const pkg: PackageJson = { name: helperOne() + helperTwo() }; -export const value = runEdit(status); diff --git a/packages/deslop-js/tests/fixtures/duplicate-inline-types/package.json b/packages/deslop-js/tests/fixtures/duplicate-inline-types/package.json deleted file mode 100644 index 465452b616..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-inline-types/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "duplicate-inline-types", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/duplicate-inline-types/src/elsewhere.ts b/packages/deslop-js/tests/fixtures/duplicate-inline-types/src/elsewhere.ts deleted file mode 100644 index 8dc3425dbb..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-inline-types/src/elsewhere.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const renderProfile = (profile: { id: string; name: string; email: string }): string => - `${profile.id}:${profile.name}:${profile.email}`; diff --git a/packages/deslop-js/tests/fixtures/duplicate-inline-types/src/index.ts b/packages/deslop-js/tests/fixtures/duplicate-inline-types/src/index.ts deleted file mode 100644 index 9e6e7ee589..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-inline-types/src/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - createUser, - updateUser, - fetchProfile, - buildProfile, - localAlias, - uniqueShape, - twoPropShape, - twoPropShape2, -} from "./operations.js"; -import { renderProfile } from "./elsewhere.js"; - -createUser({ id: "1", name: "Ada", email: "ada@example.com" }); -updateUser({ id: "1", name: "Ada", email: "ada@example.com" }); -console.log( - fetchProfile(), - buildProfile(), - localAlias(), - renderProfile({ id: "1", name: "Ada", email: "ada@example.com" }), -); -uniqueShape({ onlyHere: true }); -twoPropShape({ a: 1, b: 2 }); -twoPropShape2({ a: 3, b: 4 }); diff --git a/packages/deslop-js/tests/fixtures/duplicate-inline-types/src/operations.ts b/packages/deslop-js/tests/fixtures/duplicate-inline-types/src/operations.ts deleted file mode 100644 index a2adb96942..0000000000 --- a/packages/deslop-js/tests/fixtures/duplicate-inline-types/src/operations.ts +++ /dev/null @@ -1,35 +0,0 @@ -export const createUser = (input: { id: string; name: string; email: string }): void => { - console.log(input); -}; - -export const updateUser = (patch: { id: string; name: string; email: string }): void => { - console.log(patch); -}; - -export function fetchProfile(): { id: string; name: string; email: string } { - return { id: "1", name: "Ada", email: "ada@example.com" }; -} - -export const buildProfile = (): { id: string; name: string; email: string } => ({ - id: "1", - name: "Ada", - email: "ada@example.com", -}); - -export const localAlias = (): string => { - type ProfileLocal = { id: string; name: string; email: string }; - const profile: ProfileLocal = { id: "1", name: "Ada", email: "ada@example.com" }; - return profile.name; -}; - -export const uniqueShape = (input: { onlyHere: true }): void => { - console.log(input); -}; - -export const twoPropShape = (input: { a: number; b: number }): void => { - console.log(input); -}; - -export const twoPropShape2 = (input: { a: number; b: number }): void => { - console.log(input); -}; diff --git a/packages/deslop-js/tests/fixtures/electron-app/orphan.ts b/packages/deslop-js/tests/fixtures/electron-app/orphan.ts deleted file mode 100644 index bddae4ee9b..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-app/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = true; diff --git a/packages/deslop-js/tests/fixtures/electron-app/package.json b/packages/deslop-js/tests/fixtures/electron-app/package.json deleted file mode 100644 index 492ce64731..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-app/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "electron-project-fixture", - "private": true, - "main": "dist-electron/main.cjs", - "devDependencies": { - "electron": "^30.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/electron-app/src/main/index.ts b/packages/deslop-js/tests/fixtures/electron-app/src/main/index.ts deleted file mode 100644 index 0cbcf6c3cd..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-app/src/main/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { createWindow } from "./window"; -createWindow(); diff --git a/packages/deslop-js/tests/fixtures/electron-app/src/main/window.ts b/packages/deslop-js/tests/fixtures/electron-app/src/main/window.ts deleted file mode 100644 index f9436203aa..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-app/src/main/window.ts +++ /dev/null @@ -1 +0,0 @@ -export const createWindow = () => {}; diff --git a/packages/deslop-js/tests/fixtures/electron-app/src/preload.ts b/packages/deslop-js/tests/fixtures/electron-app/src/preload.ts deleted file mode 100644 index 7d820f79fb..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-app/src/preload.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("preload"); diff --git a/packages/deslop-js/tests/fixtures/electron-app/src/preload/preload.ts b/packages/deslop-js/tests/fixtures/electron-app/src/preload/preload.ts deleted file mode 100644 index 14b4dbe774..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-app/src/preload/preload.ts +++ /dev/null @@ -1 +0,0 @@ -export const preloadInit = () => "preload script"; diff --git a/packages/deslop-js/tests/fixtures/electron-builder-files/package.json b/packages/deslop-js/tests/fixtures/electron-builder-files/package.json deleted file mode 100644 index 7d615b58d2..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-builder-files/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "electron-builder-files", - "version": "1.0.0", - "main": "src/main.ts", - "devDependencies": { - "electron": "^30.0.0", - "electron-builder": "^24.0.0" - }, - "build": { - "files": [ - "src/preload.ts", - "src/worker.ts" - ] - } -} diff --git a/packages/deslop-js/tests/fixtures/electron-builder-files/src/main.ts b/packages/deslop-js/tests/fixtures/electron-builder-files/src/main.ts deleted file mode 100644 index cb44fe1048..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-builder-files/src/main.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = (): void => {}; diff --git a/packages/deslop-js/tests/fixtures/electron-builder-files/src/orphan.ts b/packages/deslop-js/tests/fixtures/electron-builder-files/src/orphan.ts deleted file mode 100644 index 74448acbb5..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-builder-files/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphanValue = 1; diff --git a/packages/deslop-js/tests/fixtures/electron-builder-files/src/preload.ts b/packages/deslop-js/tests/fixtures/electron-builder-files/src/preload.ts deleted file mode 100644 index 8a3ba44b1d..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-builder-files/src/preload.ts +++ /dev/null @@ -1 +0,0 @@ -export const preloadApi = (): void => {}; diff --git a/packages/deslop-js/tests/fixtures/electron-builder-files/src/worker.ts b/packages/deslop-js/tests/fixtures/electron-builder-files/src/worker.ts deleted file mode 100644 index 05efa94f1e..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-builder-files/src/worker.ts +++ /dev/null @@ -1 +0,0 @@ -export const workerTask = (): void => {}; diff --git a/packages/deslop-js/tests/fixtures/electron-detection/package.json b/packages/deslop-js/tests/fixtures/electron-detection/package.json deleted file mode 100644 index f70655d4a4..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-detection/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "electron-app-test", - "version": "1.0.0", - "main": "dist/main.js", - "devDependencies": { - "electron": "^28.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/electron-detection/src/main.ts b/packages/deslop-js/tests/fixtures/electron-detection/src/main.ts deleted file mode 100644 index 0cbcf6c3cd..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-detection/src/main.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { createWindow } from "./window"; -createWindow(); diff --git a/packages/deslop-js/tests/fixtures/electron-detection/src/orphan.ts b/packages/deslop-js/tests/fixtures/electron-detection/src/orphan.ts deleted file mode 100644 index 3bc8bff37c..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-detection/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = () => console.log("unused"); diff --git a/packages/deslop-js/tests/fixtures/electron-detection/src/preload/index.ts b/packages/deslop-js/tests/fixtures/electron-detection/src/preload/index.ts deleted file mode 100644 index 36d7e159b1..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-detection/src/preload/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const preloadApi = { version: "1.0" }; diff --git a/packages/deslop-js/tests/fixtures/electron-detection/src/window.ts b/packages/deslop-js/tests/fixtures/electron-detection/src/window.ts deleted file mode 100644 index 4c79fae6ef..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-detection/src/window.ts +++ /dev/null @@ -1 +0,0 @@ -export const createWindow = () => console.log("window created"); diff --git a/packages/deslop-js/tests/fixtures/electron-entries/package.json b/packages/deslop-js/tests/fixtures/electron-entries/package.json deleted file mode 100644 index ecf2994923..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-entries/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "electron-vite-fixture", - "private": true, - "main": "dist-electron/main.cjs", - "devDependencies": { - "electron": "^30.0.0", - "vite": "^6.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/electron-entries/src/app.ts b/packages/deslop-js/tests/fixtures/electron-entries/src/app.ts deleted file mode 100644 index eb8f489725..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-entries/src/app.ts +++ /dev/null @@ -1 +0,0 @@ -export const initApp = () => {}; diff --git a/packages/deslop-js/tests/fixtures/electron-entries/src/main.ts b/packages/deslop-js/tests/fixtures/electron-entries/src/main.ts deleted file mode 100644 index afdd69a372..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-entries/src/main.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { initApp } from "./app"; -initApp(); diff --git a/packages/deslop-js/tests/fixtures/electron-entries/src/orphan.ts b/packages/deslop-js/tests/fixtures/electron-entries/src/orphan.ts deleted file mode 100644 index bddae4ee9b..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-entries/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = true; diff --git a/packages/deslop-js/tests/fixtures/electron-entries/src/preload/bridge.ts b/packages/deslop-js/tests/fixtures/electron-entries/src/preload/bridge.ts deleted file mode 100644 index 1dfbe448e9..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-entries/src/preload/bridge.ts +++ /dev/null @@ -1 +0,0 @@ -export const setupBridge = () => {}; diff --git a/packages/deslop-js/tests/fixtures/electron-entries/src/preload/index.ts b/packages/deslop-js/tests/fixtures/electron-entries/src/preload/index.ts deleted file mode 100644 index a447ca7ead..0000000000 --- a/packages/deslop-js/tests/fixtures/electron-entries/src/preload/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { setupBridge } from "./bridge"; -setupBridge(); diff --git a/packages/deslop-js/tests/fixtures/empty-and-binary-files/package.json b/packages/deslop-js/tests/fixtures/empty-and-binary-files/package.json deleted file mode 100644 index 14c045b695..0000000000 --- a/packages/deslop-js/tests/fixtures/empty-and-binary-files/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "empty-and-binary-files", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/empty-and-binary-files/src/binary-file.ts b/packages/deslop-js/tests/fixtures/empty-and-binary-files/src/binary-file.ts deleted file mode 100644 index 1bfbf71c83..0000000000 Binary files a/packages/deslop-js/tests/fixtures/empty-and-binary-files/src/binary-file.ts and /dev/null differ diff --git a/packages/deslop-js/tests/fixtures/empty-and-binary-files/src/empty-file.ts b/packages/deslop-js/tests/fixtures/empty-and-binary-files/src/empty-file.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/deslop-js/tests/fixtures/empty-and-binary-files/src/index.ts b/packages/deslop-js/tests/fixtures/empty-and-binary-files/src/index.ts deleted file mode 100644 index 14ca8ad5e2..0000000000 --- a/packages/deslop-js/tests/fixtures/empty-and-binary-files/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const helloWorld = "hello-world"; -console.log(helloWorld); diff --git a/packages/deslop-js/tests/fixtures/empty-and-binary-files/src/minified-bundle.js b/packages/deslop-js/tests/fixtures/empty-and-binary-files/src/minified-bundle.js deleted file mode 100644 index 8742ad7345..0000000000 --- a/packages/deslop-js/tests/fixtures/empty-and-binary-files/src/minified-bundle.js +++ /dev/null @@ -1,4 +0,0 @@ -var SamePreviewInjectScript=function(e){"use strict";var t=Object.defineProperty,n="bippy-0.3.16",r=Object.defineProperty,o=Object.prototype.hasOwnProperty,i=()=>{},a=e=>{try{Function.prototype.toString.call(e).indexOf("^_^")>-1&&setTimeout((()=>{throw new Error("React is running in production mode")}),0)}catch(t){}return e},s=e=>{const t=new WeakMap;return n=>{if(t.has(n))return t.get(n);const r=e(n);return t.set(n,r),r}};function p(e,t){return e==null?void 0:e[t]}function f(e,t){return e&&typeof e=="object"&&t in e}var u=()=>!0,c=(e)=>!e,d=function(t,n){const r=p(t,n);return r==null?void 0:r.toString()};var g=t=>{const r={};for(const o of Object.keys(t))r[o]=t[o];return r},h=(e,t)=>{const n=Object.assign({},e);for(const r of Object.keys(t))n[r]=t[r];return n};return{create:s,merge:h,clone:g,call:d,exists:f,truthy:u,falsy:c,get:p};var SamePreviewInjectScript=function(e){"use strict";var t=Object.defineProperty,n="bippy-0.3.16",r=Object.defineProperty,o=Object.prototype.hasOwnProperty,i=()=>{},a=e=>{try{Function.prototype.toString.call(e).indexOf("^_^")>-1&&setTimeout((()=>{throw new Error("React is running in production mode")}),0)}catch(t){}return e},s=e=>{const t=new WeakMap;return n=>{if(t.has(n))return t.get(n);const r=e(n);return t.set(n,r),r}};function p(e,t){return e==null?void 0:e[t]}function f(e,t){return e&&typeof e=="object"&&t in e}var u=()=>!0,c=(e)=>!e,d=function(t,n){const r=p(t,n);return r==null?void 0:r.toString()};var g=t=>{const r={};for(const o of Object.keys(t))r[o]=t[o];return r},h=(e,t)=>{const n=Object.assign({},e);for(const r of Object.keys(t))n[r]=t[r];return n};return{create:s,merge:h,clone:g,call:d,exists:f,truthy:u,falsy:c,get:p};var SamePreviewInjectScript=function(e){"use strict";var t=Object.defineProperty,n="bippy-0.3.16",r=Object.defineProperty,o=Object.prototype.hasOwnProperty,i=()=>{},a=e=>{try{Function.prototype.toString.call(e).indexOf("^_^")>-1&&setTimeout((()=>{throw new Error("React is running in production mode")}),0)}catch(t){}return e},s=e=>{const t=new WeakMap;return n=>{if(t.has(n))return t.get(n);const r=e(n);return t.set(n,r),r}};function p(e,t){return e==null?void 0:e[t]}function f(e,t){return e&&typeof e=="object"&&t in e}var u=()=>!0,c=(e)=>!e,d=function(t,n){const r=p(t,n);return r==null?void 0:r.toString()};var g=t=>{const r={};for(const o of Object.keys(t))r[o]=t[o];return r},h=(e,t)=>{const n=Object.assign({},e);for(const r of Object.keys(t))n[r]=t[r];return n};return{create:s,merge:h,clone:g,call:d,exists:f,truthy:u,falsy:c,get:p}; -var SamePreviewInjectScript=function(e){"use strict";var t=Object.defineProperty,n="bippy-0.3.16",r=Object.defineProperty,o=Object.prototype.hasOwnProperty,i=()=>{},a=e=>{try{Function.prototype.toString.call(e).indexOf("^_^")>-1&&setTimeout((()=>{throw new Error("React is running in production mode")}),0)}catch(t){}return e},s=e=>{const t=new WeakMap;return n=>{if(t.has(n))return t.get(n);const r=e(n);return t.set(n,r),r}};function p(e,t){return e==null?void 0:e[t]}function f(e,t){return e&&typeof e=="object"&&t in e}var u=()=>!0,c=(e)=>!e,d=function(t,n){const r=p(t,n);return r==null?void 0:r.toString()};var g=t=>{const r={};for(const o of Object.keys(t))r[o]=t[o];return r},h=(e,t)=>{const n=Object.assign({},e);for(const r of Object.keys(t))n[r]=t[r];return n};return{create:s,merge:h,clone:g,call:d,exists:f,truthy:u,falsy:c,get:p};var SamePreviewInjectScript=function(e){"use strict";var t=Object.defineProperty,n="bippy-0.3.16",r=Object.defineProperty,o=Object.prototype.hasOwnProperty,i=()=>{},a=e=>{try{Function.prototype.toString.call(e).indexOf("^_^")>-1&&setTimeout((()=>{throw new Error("React is running in production mode")}),0)}catch(t){}return e},s=e=>{const t=new WeakMap;return n=>{if(t.has(n))return t.get(n);const r=e(n);return t.set(n,r),r}};function p(e,t){return e==null?void 0:e[t]}function f(e,t){return e&&typeof e=="object"&&t in e}var u=()=>!0,c=(e)=>!e,d=function(t,n){const r=p(t,n);return r==null?void 0:r.toString()};var g=t=>{const r={};for(const o of Object.keys(t))r[o]=t[o];return r},h=(e,t)=>{const n=Object.assign({},e);for(const r of Object.keys(t))n[r]=t[r];return n};return{create:s,merge:h,clone:g,call:d,exists:f,truthy:u,falsy:c,get:p}; -var SamePreviewInjectScript=function(e){"use strict";var t=Object.defineProperty,n="bippy-0.3.16",r=Object.defineProperty,o=Object.prototype.hasOwnProperty,i=()=>{},a=e=>{try{Function.prototype.toString.call(e).indexOf("^_^")>-1&&setTimeout((()=>{throw new Error("React is running in production mode")}),0)}catch(t){}return e},s=e=>{const t=new WeakMap;return n=>{if(t.has(n))return t.get(n);const r=e(n);return t.set(n,r),r}};function p(e,t){return e==null?void 0:e[t]}function f(e,t){return e&&typeof e=="object"&&t in e}var u=()=>!0,c=(e)=>!e,d=function(t,n){const r=p(t,n);return r==null?void 0:r.toString()};var g=t=>{const r={};for(const o of Object.keys(t))r[o]=t[o];return r},h=(e,t)=>{const n=Object.assign({},e);for(const r of Object.keys(t))n[r]=t[r];return n};return{create:s,merge:h,clone:g,call:d,exists:f,truthy:u,falsy:c,get:p};var SamePreviewInjectScript=function(e){"use strict";var t=Object.defineProperty,n="bippy-0.3.16",r=Object.defineProperty,o=Object.prototype.hasOwnProperty,i=()=>{},a=e=>{try{Function.prototype.toString.call(e).indexOf("^_^")>-1&&setTimeout((()=>{throw new Error("React is running in production mode")}),0)}catch(t){}return e},s=e=>{const t=new WeakMap;return n=>{if(t.has(n))return t.get(n);const r=e(n);return t.set(n,r),r}};function p(e,t){return e==null?void 0:e[t]}function f(e,t){return e&&typeof e=="object"&&t in e}var u=()=>!0,c=(e)=>!e,d=function(t,n){const r=p(t,n);return r==null?void 0:r.toString()};var g=t=>{const r={};for(const o of Object.keys(t))r[o]=t[o];return r},h=(e,t)=>{const n=Object.assign({},e);for(const r of Object.keys(t))n[r]=t[r];return n};return{create:s,merge:h,clone:g,call:d,exists:f,truthy:u,falsy:c,get:p}; -var SamePreviewInjectScript=function(e){"use strict";var t=Object.defineProperty,n="bippy-0.3.16",r=Object.defineProperty,o=Object.prototype.hasOwnProperty,i=()=>{},a=e=>{try{Function.prototype.toString.call(e).indexOf("^_^")>-1&&setTimeout((()=>{throw new Error("React is running in production mode")}),0)}catch(t){}return e},s=e=>{const t=new WeakMap;return n=>{if(t.has(n))return t.get(n);const r=e(n);return t.set(n,r),r}};function p(e,t){return e==null?void 0:e[t]}function f(e,t){return e&&typeof e=="object"&&t in e}var u=()=>!0,c=(e)=>!e,d=function(t,n){const r=p(t,n);return r==null?void 0:r.toString()};var g=t=>{const r={};for(const o of Object.keys(t))r[o]=t[o];return r},h=(e,t)=>{const n=Object.assign({},e);for(const r of Object.keys(t))n[r]=t[r];return n};return{create:s,merge:h,clone:g,call:d,exists:f,truthy:u,falsy:c,get:p};var SamePreviewInjectScript=function(e){"use strict";var t=Object.defineProperty,n="bippy-0.3.16",r=Object.defineProperty,o=Object.prototype.hasOwnProperty,i=()=>{},a=e=>{try{Function.prototype.toString.call(e).indexOf("^_^")>-1&&setTimeout((()=>{throw new Error("React is running in production mode")}),0)}catch(t){}return e},s=e=>{const t=new WeakMap;return n=>{if(t.has(n))return t.get(n);const r=e(n);return t.set(n,r),r}};function p(e,t){return e==null?void 0:e[t]}function f(e,t){return e&&typeof e=="object"&&t in e}var u=()=>!0,c=(e)=>!e,d=function(t,n){const r=p(t,n);return r==null?void 0:r.toString()};var g=t=>{const r={};for(const o of Object.keys(t))r[o]=t[o];return r},h=(e,t)=>{const n=Object.assign({},e);for(const r of Object.keys(t))n[r]=t[r];return n};return{create:s,merge:h,clone:g,call:d,exists:f,truthy:u,falsy:c,get:p}; diff --git a/packages/deslop-js/tests/fixtures/entry-validation/package.json b/packages/deslop-js/tests/fixtures/entry-validation/package.json deleted file mode 100644 index 6819d08665..0000000000 --- a/packages/deslop-js/tests/fixtures/entry-validation/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "entry-export-validation", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/entry-validation/src/consumer.ts b/packages/deslop-js/tests/fixtures/entry-validation/src/consumer.ts deleted file mode 100644 index fdbf381a19..0000000000 --- a/packages/deslop-js/tests/fixtures/entry-validation/src/consumer.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { helper } from "./index"; -console.log(helper()); diff --git a/packages/deslop-js/tests/fixtures/entry-validation/src/index.ts b/packages/deslop-js/tests/fixtures/entry-validation/src/index.ts deleted file mode 100644 index fde8a8e345..0000000000 --- a/packages/deslop-js/tests/fixtures/entry-validation/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const meatdata = {}; -export const config = {}; -export const helper = () => {}; diff --git a/packages/deslop-js/tests/fixtures/enum-export/index.ts b/packages/deslop-js/tests/fixtures/enum-export/index.ts deleted file mode 100644 index d75f837ec3..0000000000 --- a/packages/deslop-js/tests/fixtures/enum-export/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Status } from "./status"; - -const current = Status.Active; -console.log(current); diff --git a/packages/deslop-js/tests/fixtures/enum-export/package.json b/packages/deslop-js/tests/fixtures/enum-export/package.json deleted file mode 100644 index e97d4bdc11..0000000000 --- a/packages/deslop-js/tests/fixtures/enum-export/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "export-enum-member", - "main": "index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/enum-export/status.ts b/packages/deslop-js/tests/fixtures/enum-export/status.ts deleted file mode 100644 index 14feea4d5a..0000000000 --- a/packages/deslop-js/tests/fixtures/enum-export/status.ts +++ /dev/null @@ -1,10 +0,0 @@ -export enum Status { - Active = "active", - Inactive = "inactive", - Pending = "pending", -} - -export enum UnusedEnum { - Foo = "foo", - Bar = "bar", -} diff --git a/packages/deslop-js/tests/fixtures/env-wrapper/orphan.js b/packages/deslop-js/tests/fixtures/env-wrapper/orphan.js deleted file mode 100644 index eace45e1a2..0000000000 --- a/packages/deslop-js/tests/fixtures/env-wrapper/orphan.js +++ /dev/null @@ -1 +0,0 @@ -export const unused = () => "orphan"; diff --git a/packages/deslop-js/tests/fixtures/env-wrapper/package.json b/packages/deslop-js/tests/fixtures/env-wrapper/package.json deleted file mode 100644 index 6fcace6f50..0000000000 --- a/packages/deslop-js/tests/fixtures/env-wrapper/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "cross-env-wrapper-test", - "scripts": { - "start": "cross-env NODE_ENV=production node ./src/main.js", - "dev": "cross-env HOT=1 NODE_ENV=development node ./src/dev-entry.js" - }, - "dependencies": {}, - "devDependencies": { - "cross-env": "^7.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/env-wrapper/src/dev-entry.js b/packages/deslop-js/tests/fixtures/env-wrapper/src/dev-entry.js deleted file mode 100644 index 0860eabea8..0000000000 --- a/packages/deslop-js/tests/fixtures/env-wrapper/src/dev-entry.js +++ /dev/null @@ -1,2 +0,0 @@ -import { helper } from "./helper.js"; -export const devRun = () => helper(); diff --git a/packages/deslop-js/tests/fixtures/env-wrapper/src/helper.js b/packages/deslop-js/tests/fixtures/env-wrapper/src/helper.js deleted file mode 100644 index 83d52b7862..0000000000 --- a/packages/deslop-js/tests/fixtures/env-wrapper/src/helper.js +++ /dev/null @@ -1 +0,0 @@ -export const helper = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/env-wrapper/src/main.js b/packages/deslop-js/tests/fixtures/env-wrapper/src/main.js deleted file mode 100644 index d8115f2030..0000000000 --- a/packages/deslop-js/tests/fixtures/env-wrapper/src/main.js +++ /dev/null @@ -1,2 +0,0 @@ -import { helper } from "./helper.js"; -export const run = () => helper(); diff --git a/packages/deslop-js/tests/fixtures/expo-config-plugins/app.config.ts b/packages/deslop-js/tests/fixtures/expo-config-plugins/app.config.ts deleted file mode 100644 index cebe6aa2c5..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-config-plugins/app.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -const internalToolingConfig = { - plugins: ["./plugins/false-positive-target.ts"], -}; - -const expoAppConfig = () => ({ - plugins: [ - `./plugins/template-literal-plugin.ts`, - ["./plugins/directory-index-plugin", { enabled: true }], - "./plugins/*.ts", - "/plugins/false-positive-target.ts", - ["./plugins/false-positive-placeholder.ts".replace("placeholder", "target"), { enabled: true }], - "expo-camera", - ], - extra: internalToolingConfig, -}); - -export default expoAppConfig; diff --git a/packages/deslop-js/tests/fixtures/expo-config-plugins/app.json b/packages/deslop-js/tests/fixtures/expo-config-plugins/app.json deleted file mode 100644 index 56b609ceb2..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-config-plugins/app.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "expo": { - "plugins": [["./plugins/expo-json-extensionless-plugin", { "enabled": true }], "expo-router"] - }, - "plugins": [["./plugins/root-json-plugin.ts", { "enabled": true }]] -} diff --git a/packages/deslop-js/tests/fixtures/expo-config-plugins/apps/mobile/app.config.js b/packages/deslop-js/tests/fixtures/expo-config-plugins/apps/mobile/app.config.js deleted file mode 100644 index ce774ea8bb..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-config-plugins/apps/mobile/app.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = () => ({ - plugins: ["../shared/cross-workspace-plugin"], -}); diff --git a/packages/deslop-js/tests/fixtures/expo-config-plugins/apps/mobile/package.json b/packages/deslop-js/tests/fixtures/expo-config-plugins/apps/mobile/package.json deleted file mode 100644 index 085fe90584..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-config-plugins/apps/mobile/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "expo-config-plugins-mobile", - "dependencies": { - "expo": "^56.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/expo-config-plugins/apps/shared/cross-workspace-plugin.ts b/packages/deslop-js/tests/fixtures/expo-config-plugins/apps/shared/cross-workspace-plugin.ts deleted file mode 100644 index 4874f4d113..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-config-plugins/apps/shared/cross-workspace-plugin.ts +++ /dev/null @@ -1 +0,0 @@ -export default (config: unknown): unknown => config; diff --git a/packages/deslop-js/tests/fixtures/expo-config-plugins/expo-camera.ts b/packages/deslop-js/tests/fixtures/expo-config-plugins/expo-camera.ts deleted file mode 100644 index c1cdae5570..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-config-plugins/expo-camera.ts +++ /dev/null @@ -1 +0,0 @@ -export const packageNameLookalike = true; diff --git a/packages/deslop-js/tests/fixtures/expo-config-plugins/package.json b/packages/deslop-js/tests/fixtures/expo-config-plugins/package.json deleted file mode 100644 index adf4d99f72..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-config-plugins/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "expo-config-plugins", - "workspaces": [ - "apps/*" - ], - "type": "module", - "dependencies": { - "expo": "^56.0.0", - "react": "^19.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/expo-config-plugins/plugins/directory-index-plugin/index.ts b/packages/deslop-js/tests/fixtures/expo-config-plugins/plugins/directory-index-plugin/index.ts deleted file mode 100644 index 4874f4d113..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-config-plugins/plugins/directory-index-plugin/index.ts +++ /dev/null @@ -1 +0,0 @@ -export default (config: unknown): unknown => config; diff --git a/packages/deslop-js/tests/fixtures/expo-config-plugins/plugins/expo-json-extensionless-plugin.ts b/packages/deslop-js/tests/fixtures/expo-config-plugins/plugins/expo-json-extensionless-plugin.ts deleted file mode 100644 index 4874f4d113..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-config-plugins/plugins/expo-json-extensionless-plugin.ts +++ /dev/null @@ -1 +0,0 @@ -export default (config: unknown): unknown => config; diff --git a/packages/deslop-js/tests/fixtures/expo-config-plugins/plugins/false-positive-target.ts b/packages/deslop-js/tests/fixtures/expo-config-plugins/plugins/false-positive-target.ts deleted file mode 100644 index b21674acf8..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-config-plugins/plugins/false-positive-target.ts +++ /dev/null @@ -1 +0,0 @@ -export const shouldStayUnused = true; diff --git a/packages/deslop-js/tests/fixtures/expo-config-plugins/plugins/root-json-plugin.ts b/packages/deslop-js/tests/fixtures/expo-config-plugins/plugins/root-json-plugin.ts deleted file mode 100644 index 4874f4d113..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-config-plugins/plugins/root-json-plugin.ts +++ /dev/null @@ -1 +0,0 @@ -export default (config: unknown): unknown => config; diff --git a/packages/deslop-js/tests/fixtures/expo-config-plugins/plugins/template-literal-plugin.ts b/packages/deslop-js/tests/fixtures/expo-config-plugins/plugins/template-literal-plugin.ts deleted file mode 100644 index 4874f4d113..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-config-plugins/plugins/template-literal-plugin.ts +++ /dev/null @@ -1 +0,0 @@ -export default (config: unknown): unknown => config; diff --git a/packages/deslop-js/tests/fixtures/expo-config-plugins/src/index.ts b/packages/deslop-js/tests/fixtures/expo-config-plugins/src/index.ts deleted file mode 100644 index 51c0086ed7..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-config-plugins/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const app = "expo"; diff --git a/packages/deslop-js/tests/fixtures/expo-plugin-packages-false-positive/app.config.js b/packages/deslop-js/tests/fixtures/expo-plugin-packages-false-positive/app.config.js deleted file mode 100644 index 871a603dc4..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-plugin-packages-false-positive/app.config.js +++ /dev/null @@ -1,8 +0,0 @@ -export default () => ({ - expo: { - name: "TestApp", - plugins: [ - "@react-native-firebase/app" - ] - } -}); diff --git a/packages/deslop-js/tests/fixtures/expo-plugin-packages-false-positive/app.json b/packages/deslop-js/tests/fixtures/expo-plugin-packages-false-positive/app.json deleted file mode 100644 index dbd0aa1c3c..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-plugin-packages-false-positive/app.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "expo": { - "name": "TestApp", - "plugins": [ - ["@config-plugins/detox", { "skipProguardConfig": false }] - ] - } -} diff --git a/packages/deslop-js/tests/fixtures/expo-plugin-packages-false-positive/package.json b/packages/deslop-js/tests/fixtures/expo-plugin-packages-false-positive/package.json deleted file mode 100644 index 219f7e258f..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-plugin-packages-false-positive/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "expo-plugin-packages-false-positive", - "type": "module", - "dependencies": { - "expo": "^56.0.0", - "react": "^19.0.0", - "@config-plugins/detox": "^9.0.0", - "@react-native-firebase/app": "^21.0.0", - "left-pad": "^1.3.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/expo-plugin-packages-false-positive/src/index.js b/packages/deslop-js/tests/fixtures/expo-plugin-packages-false-positive/src/index.js deleted file mode 100644 index b1737b48ed..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-plugin-packages-false-positive/src/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import { registerRootComponent } from "expo"; -import { createElement } from "react"; - -const App = () => createElement("div", null, "Hello"); - -registerRootComponent(App); diff --git a/packages/deslop-js/tests/fixtures/expo-router-app/app/(tabs)/_layout.tsx b/packages/deslop-js/tests/fixtures/expo-router-app/app/(tabs)/_layout.tsx deleted file mode 100644 index d520fa0406..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-router-app/app/(tabs)/_layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function TabsLayout() { - return null; -} diff --git a/packages/deslop-js/tests/fixtures/expo-router-app/app/(tabs)/index.tsx b/packages/deslop-js/tests/fixtures/expo-router-app/app/(tabs)/index.tsx deleted file mode 100644 index c9235bccc1..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-router-app/app/(tabs)/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function HomeScreen() { - return null; -} diff --git a/packages/deslop-js/tests/fixtures/expo-router-app/app/(tabs)/settings.tsx b/packages/deslop-js/tests/fixtures/expo-router-app/app/(tabs)/settings.tsx deleted file mode 100644 index 8afe0f1552..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-router-app/app/(tabs)/settings.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function SettingsScreen() { - return null; -} diff --git a/packages/deslop-js/tests/fixtures/expo-router-app/app/_layout.tsx b/packages/deslop-js/tests/fixtures/expo-router-app/app/_layout.tsx deleted file mode 100644 index da4cf2a2b3..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-router-app/app/_layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function RootLayout() { - return null; -} diff --git a/packages/deslop-js/tests/fixtures/expo-router-app/package.json b/packages/deslop-js/tests/fixtures/expo-router-app/package.json deleted file mode 100644 index 25aacffee7..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-router-app/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "expo-router-app", - "dependencies": { - "expo": "*", - "expo-router": "*", - "react": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/expo-router-src-app-orphan/package.json b/packages/deslop-js/tests/fixtures/expo-router-src-app-orphan/package.json deleted file mode 100644 index 7b7410239a..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-router-src-app-orphan/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "expo-router-src-app-orphan", - "dependencies": { - "expo": "*", - "expo-router": "*", - "react": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/expo-router-src-app-orphan/src/app/(tabs)/_layout.tsx b/packages/deslop-js/tests/fixtures/expo-router-src-app-orphan/src/app/(tabs)/_layout.tsx deleted file mode 100644 index d520fa0406..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-router-src-app-orphan/src/app/(tabs)/_layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function TabsLayout() { - return null; -} diff --git a/packages/deslop-js/tests/fixtures/expo-router-src-app-orphan/src/app/(tabs)/index.tsx b/packages/deslop-js/tests/fixtures/expo-router-src-app-orphan/src/app/(tabs)/index.tsx deleted file mode 100644 index c9235bccc1..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-router-src-app-orphan/src/app/(tabs)/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function HomeScreen() { - return null; -} diff --git a/packages/deslop-js/tests/fixtures/expo-router-src-app-orphan/src/app/_layout.tsx b/packages/deslop-js/tests/fixtures/expo-router-src-app-orphan/src/app/_layout.tsx deleted file mode 100644 index da4cf2a2b3..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-router-src-app-orphan/src/app/_layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function RootLayout() { - return null; -} diff --git a/packages/deslop-js/tests/fixtures/expo-router-src-app-orphan/src/utils/orphan.ts b/packages/deslop-js/tests/fixtures/expo-router-src-app-orphan/src/utils/orphan.ts deleted file mode 100644 index b3566a49b7..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-router-src-app-orphan/src/utils/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unusedUtil = () => "not imported by any route"; diff --git a/packages/deslop-js/tests/fixtures/expo-router-src-app/package.json b/packages/deslop-js/tests/fixtures/expo-router-src-app/package.json deleted file mode 100644 index 95dcfc72bf..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-router-src-app/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "expo-router-src-app", - "dependencies": { - "expo": "*", - "expo-router": "*", - "react": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/expo-router-src-app/src/app/(tabs)/_layout.tsx b/packages/deslop-js/tests/fixtures/expo-router-src-app/src/app/(tabs)/_layout.tsx deleted file mode 100644 index d520fa0406..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-router-src-app/src/app/(tabs)/_layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function TabsLayout() { - return null; -} diff --git a/packages/deslop-js/tests/fixtures/expo-router-src-app/src/app/(tabs)/index.tsx b/packages/deslop-js/tests/fixtures/expo-router-src-app/src/app/(tabs)/index.tsx deleted file mode 100644 index c9235bccc1..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-router-src-app/src/app/(tabs)/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function HomeScreen() { - return null; -} diff --git a/packages/deslop-js/tests/fixtures/expo-router-src-app/src/app/(tabs)/settings.tsx b/packages/deslop-js/tests/fixtures/expo-router-src-app/src/app/(tabs)/settings.tsx deleted file mode 100644 index 8afe0f1552..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-router-src-app/src/app/(tabs)/settings.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function SettingsScreen() { - return null; -} diff --git a/packages/deslop-js/tests/fixtures/expo-router-src-app/src/app/_layout.tsx b/packages/deslop-js/tests/fixtures/expo-router-src-app/src/app/_layout.tsx deleted file mode 100644 index da4cf2a2b3..0000000000 --- a/packages/deslop-js/tests/fixtures/expo-router-src-app/src/app/_layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function RootLayout() { - return null; -} diff --git a/packages/deslop-js/tests/fixtures/export-default/package.json b/packages/deslop-js/tests/fixtures/export-default/package.json deleted file mode 100644 index 1e38818686..0000000000 --- a/packages/deslop-js/tests/fixtures/export-default/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "default-export", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/export-default/src/component.ts b/packages/deslop-js/tests/fixtures/export-default/src/component.ts deleted file mode 100644 index c5c2f0cbec..0000000000 --- a/packages/deslop-js/tests/fixtures/export-default/src/component.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default function Component() { - return "component"; -} - -export const usedNamed = 1; diff --git a/packages/deslop-js/tests/fixtures/export-default/src/index.ts b/packages/deslop-js/tests/fixtures/export-default/src/index.ts deleted file mode 100644 index 7f9afa03bc..0000000000 --- a/packages/deslop-js/tests/fixtures/export-default/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { usedNamed } from "./component"; - -console.log(usedNamed); diff --git a/packages/deslop-js/tests/fixtures/export-default/src/unused-default.ts b/packages/deslop-js/tests/fixtures/export-default/src/unused-default.ts deleted file mode 100644 index e8e7db1920..0000000000 --- a/packages/deslop-js/tests/fixtures/export-default/src/unused-default.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default class Foo { - value = 42; -} diff --git a/packages/deslop-js/tests/fixtures/extensionless-relative-import/package.json b/packages/deslop-js/tests/fixtures/extensionless-relative-import/package.json deleted file mode 100644 index 76b81bca69..0000000000 --- a/packages/deslop-js/tests/fixtures/extensionless-relative-import/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "extensionless-relative-import", - "version": "1.0.0", - "main": "src/index.tsx" -} diff --git a/packages/deslop-js/tests/fixtures/extensionless-relative-import/src/App.tsx b/packages/deslop-js/tests/fixtures/extensionless-relative-import/src/App.tsx deleted file mode 100644 index 9fce89f6ec..0000000000 --- a/packages/deslop-js/tests/fixtures/extensionless-relative-import/src/App.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import Radio from "./Radio"; - -export const App = (): void => { - Radio(); -}; diff --git a/packages/deslop-js/tests/fixtures/extensionless-relative-import/src/Radio.tsx b/packages/deslop-js/tests/fixtures/extensionless-relative-import/src/Radio.tsx deleted file mode 100644 index bff2b37f4b..0000000000 --- a/packages/deslop-js/tests/fixtures/extensionless-relative-import/src/Radio.tsx +++ /dev/null @@ -1 +0,0 @@ -export default function Radio(): void {} diff --git a/packages/deslop-js/tests/fixtures/extensionless-relative-import/src/index.tsx b/packages/deslop-js/tests/fixtures/extensionless-relative-import/src/index.tsx deleted file mode 100644 index 300d71d03c..0000000000 --- a/packages/deslop-js/tests/fixtures/extensionless-relative-import/src/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { App } from "./App"; - -export const bootstrap = (): void => { - App(); -}; diff --git a/packages/deslop-js/tests/fixtures/extensionless-relative-import/src/orphan.ts b/packages/deslop-js/tests/fixtures/extensionless-relative-import/src/orphan.ts deleted file mode 100644 index 74448acbb5..0000000000 --- a/packages/deslop-js/tests/fixtures/extensionless-relative-import/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphanValue = 1; diff --git a/packages/deslop-js/tests/fixtures/feature-flags-basic/package.json b/packages/deslop-js/tests/fixtures/feature-flags-basic/package.json deleted file mode 100644 index cace2af69a..0000000000 --- a/packages/deslop-js/tests/fixtures/feature-flags-basic/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "feature-flags-basic", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/feature-flags-basic/src/index.ts b/packages/deslop-js/tests/fixtures/feature-flags-basic/src/index.ts deleted file mode 100644 index af66970f82..0000000000 --- a/packages/deslop-js/tests/fixtures/feature-flags-basic/src/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useGate } from "statsig-react"; -import { variation } from "launchdarkly-js-client-sdk"; - -export const handler = (): string => { - if (process.env.FEATURE_NEW_CHECKOUT === "true") { - return "new-checkout"; - } - if (useGate("legacy_billing").value) { - return "legacy"; - } - return variation("payments-flag", "default"); -}; diff --git a/packages/deslop-js/tests/fixtures/filename-registry-entries/other/nested-task.ts b/packages/deslop-js/tests/fixtures/filename-registry-entries/other/nested-task.ts deleted file mode 100644 index 75271a58b1..0000000000 --- a/packages/deslop-js/tests/fixtures/filename-registry-entries/other/nested-task.ts +++ /dev/null @@ -1 +0,0 @@ -export const unrelatedNestedTask = (): string => "unrelated"; diff --git a/packages/deslop-js/tests/fixtures/filename-registry-entries/package.json b/packages/deslop-js/tests/fixtures/filename-registry-entries/package.json deleted file mode 100644 index 61533f400e..0000000000 --- a/packages/deslop-js/tests/fixtures/filename-registry-entries/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "filename-registry-entries", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/filename-registry-entries/src/index.ts b/packages/deslop-js/tests/fixtures/filename-registry-entries/src/index.ts deleted file mode 100644 index 58ebb2c531..0000000000 --- a/packages/deslop-js/tests/fixtures/filename-registry-entries/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TOOL_REGISTRY } from "./registry"; - -export const tools = TOOL_REGISTRY; diff --git a/packages/deslop-js/tests/fixtures/filename-registry-entries/src/registry.ts b/packages/deslop-js/tests/fixtures/filename-registry-entries/src/registry.ts deleted file mode 100644 index 1944d8f760..0000000000 --- a/packages/deslop-js/tests/fixtures/filename-registry-entries/src/registry.ts +++ /dev/null @@ -1,9 +0,0 @@ -const registerTool = (path: string): string => path; - -export const TOOL_REGISTRY = [ - { name: "diagnose-user", file: "diagnose-user.ts" }, - { name: "export-data", file: "export-data.ts" }, - { name: "nested-task", file: registerTool("tools/dynamic/nested-task") }, -] as const; - -export const loadDynamicTool = () => import("tools/dynamic/dynamic-import-task"); diff --git a/packages/deslop-js/tests/fixtures/filename-registry-entries/tools/diagnose-user.ts b/packages/deslop-js/tests/fixtures/filename-registry-entries/tools/diagnose-user.ts deleted file mode 100644 index 9002b63c3d..0000000000 --- a/packages/deslop-js/tests/fixtures/filename-registry-entries/tools/diagnose-user.ts +++ /dev/null @@ -1 +0,0 @@ -export const diagnoseUser = (userId: string): string => `diagnosing ${userId}`; diff --git a/packages/deslop-js/tests/fixtures/filename-registry-entries/tools/dynamic/dynamic-import-task.ts b/packages/deslop-js/tests/fixtures/filename-registry-entries/tools/dynamic/dynamic-import-task.ts deleted file mode 100644 index bd3e3c54e3..0000000000 --- a/packages/deslop-js/tests/fixtures/filename-registry-entries/tools/dynamic/dynamic-import-task.ts +++ /dev/null @@ -1 +0,0 @@ -export const dynamicImportTask = (): string => "dynamic"; diff --git a/packages/deslop-js/tests/fixtures/filename-registry-entries/tools/dynamic/nested-task.ts b/packages/deslop-js/tests/fixtures/filename-registry-entries/tools/dynamic/nested-task.ts deleted file mode 100644 index 09494199fd..0000000000 --- a/packages/deslop-js/tests/fixtures/filename-registry-entries/tools/dynamic/nested-task.ts +++ /dev/null @@ -1 +0,0 @@ -export const nestedTask = (): string => "nested"; diff --git a/packages/deslop-js/tests/fixtures/filename-registry-entries/tools/export-data.ts b/packages/deslop-js/tests/fixtures/filename-registry-entries/tools/export-data.ts deleted file mode 100644 index 319eb8a5bc..0000000000 --- a/packages/deslop-js/tests/fixtures/filename-registry-entries/tools/export-data.ts +++ /dev/null @@ -1 +0,0 @@ -export const exportData = (): string => "data"; diff --git a/packages/deslop-js/tests/fixtures/filename-registry-entries/tools/genuinely-dead.ts b/packages/deslop-js/tests/fixtures/filename-registry-entries/tools/genuinely-dead.ts deleted file mode 100644 index bb7688e601..0000000000 --- a/packages/deslop-js/tests/fixtures/filename-registry-entries/tools/genuinely-dead.ts +++ /dev/null @@ -1 +0,0 @@ -export const dead = (): string => "no one references me anywhere"; diff --git a/packages/deslop-js/tests/fixtures/flow-js-app/package.json b/packages/deslop-js/tests/fixtures/flow-js-app/package.json deleted file mode 100644 index 5f3253ae63..0000000000 --- a/packages/deslop-js/tests/fixtures/flow-js-app/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "flow-js-app", - "version": "1.0.0", - "main": "src/main.dev.js" -} diff --git a/packages/deslop-js/tests/fixtures/flow-js-app/src/Widget.js b/packages/deslop-js/tests/fixtures/flow-js-app/src/Widget.js deleted file mode 100644 index a2b4731723..0000000000 --- a/packages/deslop-js/tests/fixtures/flow-js-app/src/Widget.js +++ /dev/null @@ -1,11 +0,0 @@ -// @flow -import React from "react"; -import { helper } from "./actions/helper"; - -export const renderWidget = (): void => { - helper(); -}; - -const Widget = () => <div />; - -export default Widget; diff --git a/packages/deslop-js/tests/fixtures/flow-js-app/src/actions/helper.js b/packages/deslop-js/tests/fixtures/flow-js-app/src/actions/helper.js deleted file mode 100644 index 3fecc19fb6..0000000000 --- a/packages/deslop-js/tests/fixtures/flow-js-app/src/actions/helper.js +++ /dev/null @@ -1 +0,0 @@ -export const helper = (): void => {}; diff --git a/packages/deslop-js/tests/fixtures/flow-js-app/src/main.dev.js b/packages/deslop-js/tests/fixtures/flow-js-app/src/main.dev.js deleted file mode 100644 index 6b0af8a92c..0000000000 --- a/packages/deslop-js/tests/fixtures/flow-js-app/src/main.dev.js +++ /dev/null @@ -1,6 +0,0 @@ -// @flow -import { renderWidget } from "./Widget"; - -export const bootstrap = (): void => { - renderWidget(); -}; diff --git a/packages/deslop-js/tests/fixtures/flow-js-app/src/orphan.js b/packages/deslop-js/tests/fixtures/flow-js-app/src/orphan.js deleted file mode 100644 index 74448acbb5..0000000000 --- a/packages/deslop-js/tests/fixtures/flow-js-app/src/orphan.js +++ /dev/null @@ -1 +0,0 @@ -export const orphanValue = 1; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/no-framework/app/dashboard/page.tsx b/packages/deslop-js/tests/fixtures/framework-gate/no-framework/app/dashboard/page.tsx deleted file mode 100644 index ffe69d222a..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/no-framework/app/dashboard/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export const DashboardPage = () => "dash"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/no-framework/index.ts b/packages/deslop-js/tests/fixtures/framework-gate/no-framework/index.ts deleted file mode 100644 index d98d5d98cc..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/no-framework/index.ts +++ /dev/null @@ -1 +0,0 @@ -import { HomePage } from "./pages/index"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/no-framework/package.json b/packages/deslop-js/tests/fixtures/framework-gate/no-framework/package.json deleted file mode 100644 index e856dfb1d3..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/no-framework/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "test-no-framework", - "dependencies": { - "react": "^18.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/framework-gate/no-framework/pages/index.tsx b/packages/deslop-js/tests/fixtures/framework-gate/no-framework/pages/index.tsx deleted file mode 100644 index 2788a3150f..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/no-framework/pages/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export const HomePage = () => "home"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/no-framework/resources/js/Pages/dashboard.tsx b/packages/deslop-js/tests/fixtures/framework-gate/no-framework/resources/js/Pages/dashboard.tsx deleted file mode 100644 index a2a6993a9a..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/no-framework/resources/js/Pages/dashboard.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Dashboard = (): string => "dashboard"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/no-framework/src/routes/index.tsx b/packages/deslop-js/tests/fixtures/framework-gate/no-framework/src/routes/index.tsx deleted file mode 100644 index c4286605d2..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/no-framework/src/routes/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Route = () => "route"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/module-federation.config.ts b/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/module-federation.config.ts deleted file mode 100644 index 664a993a59..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/module-federation.config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { remoteName } from "./src/remote-entry"; - -export default { - name: remoteName, -}; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/package.json b/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/package.json deleted file mode 100644 index c70d1f3c97..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "test-with-additional-framework-pages", - "dependencies": { - "@module-federation/vite": "*", - "@redwoodjs/router": "*", - "rakkasjs": "*", - "vike": "*", - "waku": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/orphan.ts b/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/orphan.ts deleted file mode 100644 index 6d12bf73d8..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphanFrameworkFile = "unused"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/pages/blog/index.page.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/pages/blog/index.page.tsx deleted file mode 100644 index 003fbbcbe2..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/pages/blog/index.page.tsx +++ /dev/null @@ -1 +0,0 @@ -export const BlogPage = (): string => "blog"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/remote-entry.ts b/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/remote-entry.ts deleted file mode 100644 index a1c9442d4a..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/remote-entry.ts +++ /dev/null @@ -1 +0,0 @@ -export const remoteName = "remote"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/renderer/on-render-client.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/renderer/on-render-client.tsx deleted file mode 100644 index 598d590bed..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/renderer/on-render-client.tsx +++ /dev/null @@ -1 +0,0 @@ -export const onRenderClient = (): string => "render"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/routes/dashboard/index.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/routes/dashboard/index.tsx deleted file mode 100644 index 9c4655bc59..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/routes/dashboard/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export const DashboardRoute = (): string => "dashboard"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/waku.client.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/waku.client.tsx deleted file mode 100644 index acf3a48244..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/src/waku.client.tsx +++ /dev/null @@ -1 +0,0 @@ -export const WakuClient = (): string => "client"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/web/src/Routes.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/web/src/Routes.tsx deleted file mode 100644 index 0b2ce6ffa9..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/web/src/Routes.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Routes = (): string => "routes"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/web/src/layouts/main.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/web/src/layouts/main.tsx deleted file mode 100644 index 239dd29820..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/web/src/layouts/main.tsx +++ /dev/null @@ -1 +0,0 @@ -export const MainLayout = (): string => "layout"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/web/src/pages/home.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/web/src/pages/home.tsx deleted file mode 100644 index 7bfc3d1681..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-additional-framework-pages/web/src/pages/home.tsx +++ /dev/null @@ -1 +0,0 @@ -export const HomePage = (): string => "home"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/package.json b/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/package.json deleted file mode 100644 index 18a190735f..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "test-with-inertia", - "dependencies": { - "@inertiajs/react": "*", - "react": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/resources/js/Pages/Admin/index.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/resources/js/Pages/Admin/index.tsx deleted file mode 100644 index 151a39c795..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/resources/js/Pages/Admin/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { PageTitle } from "../../components/page-title"; - -export default PageTitle; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/resources/js/app.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/resources/js/app.tsx deleted file mode 100644 index cfaa4a6fac..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/resources/js/app.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import "./components/bootstrap"; - -export const mountInertiaApp = (): string => "mounted"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/resources/js/components/bootstrap.ts b/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/resources/js/components/bootstrap.ts deleted file mode 100644 index 85fe8ac9be..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/resources/js/components/bootstrap.ts +++ /dev/null @@ -1 +0,0 @@ -export const bootInertia = (): string => "booted"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/resources/js/components/page-title.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/resources/js/components/page-title.tsx deleted file mode 100644 index db7f9a7374..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/resources/js/components/page-title.tsx +++ /dev/null @@ -1 +0,0 @@ -export const PageTitle = (): string => "Users"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/resources/js/orphan.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/resources/js/orphan.tsx deleted file mode 100644 index e8d2aa89c7..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-inertia/resources/js/orphan.tsx +++ /dev/null @@ -1 +0,0 @@ -export const UnusedInertiaComponent = (): string => "unused"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-nextjs/app/dashboard/page.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-nextjs/app/dashboard/page.tsx deleted file mode 100644 index ffe69d222a..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-nextjs/app/dashboard/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export const DashboardPage = () => "dash"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-nextjs/package.json b/packages/deslop-js/tests/fixtures/framework-gate/with-nextjs/package.json deleted file mode 100644 index 6653798dee..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-nextjs/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "test-with-nextjs", - "dependencies": { - "next": "^14.0.0", - "react": "^18.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-nextjs/pages/index.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-nextjs/pages/index.tsx deleted file mode 100644 index 2788a3150f..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-nextjs/pages/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export const HomePage = () => "home"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-nextjs/unused.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-nextjs/unused.tsx deleted file mode 100644 index 89d64b2432..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-nextjs/unused.tsx +++ /dev/null @@ -1 +0,0 @@ -export const unusedComponent = () => "unused"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-react-router/package.json b/packages/deslop-js/tests/fixtures/framework-gate/with-react-router/package.json deleted file mode 100644 index 0049f26279..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-react-router/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "test-with-react-router", - "dependencies": { - "react-router": "^7.0.0" - }, - "devDependencies": { - "@react-router/dev": "^7.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-react-router/react-router.config.ts b/packages/deslop-js/tests/fixtures/framework-gate/with-react-router/react-router.config.ts deleted file mode 100644 index 8468ef2a18..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-react-router/react-router.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { Config } from "@react-router/dev/config"; -export default { - appDirectory: "src", -} satisfies Config; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-react-router/src/root.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-react-router/src/root.tsx deleted file mode 100644 index be7bf095f4..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-react-router/src/root.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Root() { - return "root"; -} diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-react-router/src/routes/home.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-react-router/src/routes/home.tsx deleted file mode 100644 index 34bbf0528e..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-react-router/src/routes/home.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Home() { - return "home"; -} diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-react-router/unused.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-react-router/unused.tsx deleted file mode 100644 index 1a863ec533..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-react-router/unused.tsx +++ /dev/null @@ -1 +0,0 @@ -export const unused = () => "unused"; diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-redwood-non-router-package/package.json b/packages/deslop-js/tests/fixtures/framework-gate/with-redwood-non-router-package/package.json deleted file mode 100644 index bfdca7aab6..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-redwood-non-router-package/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "test-with-redwood-non-router-package", - "dependencies": { - "@redwoodjs/forms": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/framework-gate/with-redwood-non-router-package/web/src/pages/home.tsx b/packages/deslop-js/tests/fixtures/framework-gate/with-redwood-non-router-package/web/src/pages/home.tsx deleted file mode 100644 index 7bfc3d1681..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-gate/with-redwood-non-router-package/web/src/pages/home.tsx +++ /dev/null @@ -1 +0,0 @@ -export const HomePage = (): string => "home"; diff --git a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/package.json b/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/package.json deleted file mode 100644 index 8cbe28bf45..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "test-framework-hoisted-router-scripts", - "private": true, - "workspaces": [ - "packages/*" - ], - "devDependencies": { - "@react-router/dev": "*", - "@remix-run/dev": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/react-router-app/app/root.tsx b/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/react-router-app/app/root.tsx deleted file mode 100644 index b0673d83e6..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/react-router-app/app/root.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Root = (): string => "root"; diff --git a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/react-router-app/app/routes/home.tsx b/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/react-router-app/app/routes/home.tsx deleted file mode 100644 index 3308a72e26..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/react-router-app/app/routes/home.tsx +++ /dev/null @@ -1 +0,0 @@ -export const HomeRoute = (): string => "home"; diff --git a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/react-router-app/orphan.tsx b/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/react-router-app/orphan.tsx deleted file mode 100644 index 89d4be9d7d..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/react-router-app/orphan.tsx +++ /dev/null @@ -1 +0,0 @@ -export const UnusedReactRouterFile = (): string => "unused"; diff --git a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/react-router-app/package.json b/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/react-router-app/package.json deleted file mode 100644 index e6c0a5cc70..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/react-router-app/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "test-hoisted-react-router-app", - "scripts": { - "dev": "react-router dev" - } -} diff --git a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/remix-app/app/root.tsx b/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/remix-app/app/root.tsx deleted file mode 100644 index b0673d83e6..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/remix-app/app/root.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Root = (): string => "root"; diff --git a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/remix-app/app/routes/home.tsx b/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/remix-app/app/routes/home.tsx deleted file mode 100644 index 3308a72e26..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/remix-app/app/routes/home.tsx +++ /dev/null @@ -1 +0,0 @@ -export const HomeRoute = (): string => "home"; diff --git a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/remix-app/orphan.tsx b/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/remix-app/orphan.tsx deleted file mode 100644 index f6fbd9809f..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/remix-app/orphan.tsx +++ /dev/null @@ -1 +0,0 @@ -export const UnusedRemixFile = (): string => "unused"; diff --git a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/remix-app/package.json b/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/remix-app/package.json deleted file mode 100644 index 6e2ae0ddfb..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-hoisted-router-scripts/packages/remix-app/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "test-hoisted-remix-app", - "scripts": { - "dev": "remix dev" - } -} diff --git a/packages/deslop-js/tests/fixtures/framework-hoisted-script-entry/package.json b/packages/deslop-js/tests/fixtures/framework-hoisted-script-entry/package.json deleted file mode 100644 index 2c778f4255..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-hoisted-script-entry/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "test-framework-hoisted-script-entry", - "private": true, - "workspaces": [ - "packages/*" - ], - "devDependencies": { - "next": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/framework-hoisted-script-entry/packages/app/orphan.tsx b/packages/deslop-js/tests/fixtures/framework-hoisted-script-entry/packages/app/orphan.tsx deleted file mode 100644 index 2d3066d084..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-hoisted-script-entry/packages/app/orphan.tsx +++ /dev/null @@ -1 +0,0 @@ -export const UnusedHoistedNextFile = (): string => "unused"; diff --git a/packages/deslop-js/tests/fixtures/framework-hoisted-script-entry/packages/app/package.json b/packages/deslop-js/tests/fixtures/framework-hoisted-script-entry/packages/app/package.json deleted file mode 100644 index ac85d9f46c..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-hoisted-script-entry/packages/app/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "test-hoisted-next-app", - "scripts": { - "dev": "next dev" - }, - "dependencies": { - "react": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/framework-hoisted-script-entry/packages/app/pages/index.tsx b/packages/deslop-js/tests/fixtures/framework-hoisted-script-entry/packages/app/pages/index.tsx deleted file mode 100644 index 1bd2661034..0000000000 --- a/packages/deslop-js/tests/fixtures/framework-hoisted-script-entry/packages/app/pages/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export const HoistedNextPage = (): string => "next"; diff --git a/packages/deslop-js/tests/fixtures/gatsby-app/gatsby-config.js b/packages/deslop-js/tests/fixtures/gatsby-app/gatsby-config.js deleted file mode 100644 index d20b0a1ee5..0000000000 --- a/packages/deslop-js/tests/fixtures/gatsby-app/gatsby-config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - siteMetadata: { title: "test" }, -}; diff --git a/packages/deslop-js/tests/fixtures/gatsby-app/package.json b/packages/deslop-js/tests/fixtures/gatsby-app/package.json deleted file mode 100644 index 619269ee7e..0000000000 --- a/packages/deslop-js/tests/fixtures/gatsby-app/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "gatsby-test", - "dependencies": { - "gatsby": "5.0.0", - "react": "18.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/gatsby-app/src/api/hello.ts b/packages/deslop-js/tests/fixtures/gatsby-app/src/api/hello.ts deleted file mode 100644 index 99ed137f19..0000000000 --- a/packages/deslop-js/tests/fixtures/gatsby-app/src/api/hello.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function handler(req: any, res: any) { - res.json({ message: "hello" }); -} diff --git a/packages/deslop-js/tests/fixtures/gatsby-app/src/components/unused.tsx b/packages/deslop-js/tests/fixtures/gatsby-app/src/components/unused.tsx deleted file mode 100644 index aaca9348f0..0000000000 --- a/packages/deslop-js/tests/fixtures/gatsby-app/src/components/unused.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import React from "react"; -export const UnusedComponent = () => <div>unused</div>; diff --git a/packages/deslop-js/tests/fixtures/gatsby-app/src/components/used.tsx b/packages/deslop-js/tests/fixtures/gatsby-app/src/components/used.tsx deleted file mode 100644 index 640d0f2dc5..0000000000 --- a/packages/deslop-js/tests/fixtures/gatsby-app/src/components/used.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import React from "react"; -export const UsedComponent = () => <div>used</div>; diff --git a/packages/deslop-js/tests/fixtures/gatsby-app/src/pages/index.tsx b/packages/deslop-js/tests/fixtures/gatsby-app/src/pages/index.tsx deleted file mode 100644 index c0a9eaf68f..0000000000 --- a/packages/deslop-js/tests/fixtures/gatsby-app/src/pages/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import React from "react"; -import { UsedComponent } from "../components/used"; - -export default () => <UsedComponent />; diff --git a/packages/deslop-js/tests/fixtures/gatsby-app/src/templates/post.tsx b/packages/deslop-js/tests/fixtures/gatsby-app/src/templates/post.tsx deleted file mode 100644 index 04ab8c3331..0000000000 --- a/packages/deslop-js/tests/fixtures/gatsby-app/src/templates/post.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import React from "react"; -export default () => <div>template</div>; diff --git a/packages/deslop-js/tests/fixtures/generated-specs/package.json b/packages/deslop-js/tests/fixtures/generated-specs/package.json deleted file mode 100644 index 3c6d02d55f..0000000000 --- a/packages/deslop-js/tests/fixtures/generated-specs/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "generated-spec-test", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "jest": "^29.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/generated-specs/src/__tests__/index.test.ts b/packages/deslop-js/tests/fixtures/generated-specs/src/__tests__/index.test.ts deleted file mode 100644 index a0439305d7..0000000000 --- a/packages/deslop-js/tests/fixtures/generated-specs/src/__tests__/index.test.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { hello } from "../index"; -test("hello", () => expect(hello()).toBe("world")); diff --git a/packages/deslop-js/tests/fixtures/generated-specs/src/generated/schema.gen.ts b/packages/deslop-js/tests/fixtures/generated-specs/src/generated/schema.gen.ts deleted file mode 100644 index 71658feb3e..0000000000 --- a/packages/deslop-js/tests/fixtures/generated-specs/src/generated/schema.gen.ts +++ /dev/null @@ -1 +0,0 @@ -export const schema = { type: "object" }; diff --git a/packages/deslop-js/tests/fixtures/generated-specs/src/generated/types.spec.gen.ts b/packages/deslop-js/tests/fixtures/generated-specs/src/generated/types.spec.gen.ts deleted file mode 100644 index 85f82d5f1e..0000000000 --- a/packages/deslop-js/tests/fixtures/generated-specs/src/generated/types.spec.gen.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface GeneratedType { - id: string; - name: string; -} diff --git a/packages/deslop-js/tests/fixtures/generated-specs/src/index.ts b/packages/deslop-js/tests/fixtures/generated-specs/src/index.ts deleted file mode 100644 index 066f32ce02..0000000000 --- a/packages/deslop-js/tests/fixtures/generated-specs/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const hello = () => "world"; diff --git a/packages/deslop-js/tests/fixtures/gh-actions-scripts/.github/actions/deploy/run.js b/packages/deslop-js/tests/fixtures/gh-actions-scripts/.github/actions/deploy/run.js deleted file mode 100644 index 29db3224d7..0000000000 --- a/packages/deslop-js/tests/fixtures/gh-actions-scripts/.github/actions/deploy/run.js +++ /dev/null @@ -1 +0,0 @@ -console.log("deploying"); diff --git a/packages/deslop-js/tests/fixtures/gh-actions-scripts/.github/actions/deploy/unused-helper.js b/packages/deslop-js/tests/fixtures/gh-actions-scripts/.github/actions/deploy/unused-helper.js deleted file mode 100644 index f2e98815cc..0000000000 --- a/packages/deslop-js/tests/fixtures/gh-actions-scripts/.github/actions/deploy/unused-helper.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = { help: true }; diff --git a/packages/deslop-js/tests/fixtures/gh-actions-scripts/.github/workflows/ci.yml b/packages/deslop-js/tests/fixtures/gh-actions-scripts/.github/workflows/ci.yml deleted file mode 100644 index 23737277d3..0000000000 --- a/packages/deslop-js/tests/fixtures/gh-actions-scripts/.github/workflows/ci.yml +++ /dev/null @@ -1,7 +0,0 @@ -name: CI -on: push -jobs: - build: - runs-on: ubuntu-latest - steps: - - run: node .github/actions/deploy/run.js diff --git a/packages/deslop-js/tests/fixtures/gh-actions-scripts/package.json b/packages/deslop-js/tests/fixtures/gh-actions-scripts/package.json deleted file mode 100644 index 8df2089eda..0000000000 --- a/packages/deslop-js/tests/fixtures/gh-actions-scripts/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "github-actions-test", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/gh-actions-scripts/src/index.ts b/packages/deslop-js/tests/fixtures/gh-actions-scripts/src/index.ts deleted file mode 100644 index c5961da749..0000000000 --- a/packages/deslop-js/tests/fixtures/gh-actions-scripts/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/github-workflow-script/.github/workflows/bundle-size.yml b/packages/deslop-js/tests/fixtures/github-workflow-script/.github/workflows/bundle-size.yml deleted file mode 100644 index bc95638719..0000000000 --- a/packages/deslop-js/tests/fixtures/github-workflow-script/.github/workflows/bundle-size.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Bundle size -on: pull_request -jobs: - measure: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: | - cp -r components/.github/workflows/bundle-size bundle-size - cd bundle-size - npm i --force - npm run build diff --git a/packages/deslop-js/tests/fixtures/github-workflow-script/.github/workflows/bundle-size/build.js b/packages/deslop-js/tests/fixtures/github-workflow-script/.github/workflows/bundle-size/build.js deleted file mode 100644 index dee2063eff..0000000000 --- a/packages/deslop-js/tests/fixtures/github-workflow-script/.github/workflows/bundle-size/build.js +++ /dev/null @@ -1 +0,0 @@ -console.log("bundle size build"); diff --git a/packages/deslop-js/tests/fixtures/github-workflow-script/.github/workflows/bundle-size/package.json b/packages/deslop-js/tests/fixtures/github-workflow-script/.github/workflows/bundle-size/package.json deleted file mode 100644 index 71aad86ae2..0000000000 --- a/packages/deslop-js/tests/fixtures/github-workflow-script/.github/workflows/bundle-size/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "bundle-size-test-project", - "private": true, - "type": "module", - "scripts": { - "build": "node build.js" - } -} diff --git a/packages/deslop-js/tests/fixtures/github-workflow-script/package.json b/packages/deslop-js/tests/fixtures/github-workflow-script/package.json deleted file mode 100644 index da33fdd34c..0000000000 --- a/packages/deslop-js/tests/fixtures/github-workflow-script/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "github-workflow-script" -} diff --git a/packages/deslop-js/tests/fixtures/github-workflow-script/src/index.ts b/packages/deslop-js/tests/fixtures/github-workflow-script/src/index.ts deleted file mode 100644 index 702645f13d..0000000000 --- a/packages/deslop-js/tests/fixtures/github-workflow-script/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("app"); diff --git a/packages/deslop-js/tests/fixtures/gitignore-app/.gitignore b/packages/deslop-js/tests/fixtures/gitignore-app/.gitignore deleted file mode 100644 index 9ab870da89..0000000000 --- a/packages/deslop-js/tests/fixtures/gitignore-app/.gitignore +++ /dev/null @@ -1 +0,0 @@ -generated/ diff --git a/packages/deslop-js/tests/fixtures/gitignore-app/generated/output.ts b/packages/deslop-js/tests/fixtures/gitignore-app/generated/output.ts deleted file mode 100644 index a0e4274d2c..0000000000 --- a/packages/deslop-js/tests/fixtures/gitignore-app/generated/output.ts +++ /dev/null @@ -1 +0,0 @@ -export const generatedValue = "gitignored orphan; must not be reported as unused"; diff --git a/packages/deslop-js/tests/fixtures/gitignore-app/generated/routes.ts b/packages/deslop-js/tests/fixtures/gitignore-app/generated/routes.ts deleted file mode 100644 index 221d1bc60a..0000000000 --- a/packages/deslop-js/tests/fixtures/gitignore-app/generated/routes.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { homeRoute } from "../src/home-route"; - -export const routeTree = [homeRoute]; - -export const unusedGeneratedExport = "never imported, but gitignored so it must not be reported"; diff --git a/packages/deslop-js/tests/fixtures/gitignore-app/package.json b/packages/deslop-js/tests/fixtures/gitignore-app/package.json deleted file mode 100644 index 832b5bd1f2..0000000000 --- a/packages/deslop-js/tests/fixtures/gitignore-app/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "gitignore-app", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/gitignore-app/src/home-route.ts b/packages/deslop-js/tests/fixtures/gitignore-app/src/home-route.ts deleted file mode 100644 index cf19f91fbc..0000000000 --- a/packages/deslop-js/tests/fixtures/gitignore-app/src/home-route.ts +++ /dev/null @@ -1 +0,0 @@ -export const homeRoute = "registered only by the gitignored generated route tree"; diff --git a/packages/deslop-js/tests/fixtures/gitignore-app/src/index.ts b/packages/deslop-js/tests/fixtures/gitignore-app/src/index.ts deleted file mode 100644 index 6790fb798c..0000000000 --- a/packages/deslop-js/tests/fixtures/gitignore-app/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { routeTree } from "../generated/routes"; - -export const main = routeTree; diff --git a/packages/deslop-js/tests/fixtures/gitignore-app/src/orphan.ts b/packages/deslop-js/tests/fixtures/gitignore-app/src/orphan.ts deleted file mode 100644 index 95dc90f9e6..0000000000 --- a/packages/deslop-js/tests/fixtures/gitignore-app/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphaned = "not imported anywhere"; diff --git a/packages/deslop-js/tests/fixtures/graphql-schema/package.json b/packages/deslop-js/tests/fixtures/graphql-schema/package.json deleted file mode 100644 index f03b388630..0000000000 --- a/packages/deslop-js/tests/fixtures/graphql-schema/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "graphql-files", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/graphql-schema/src/index.ts b/packages/deslop-js/tests/fixtures/graphql-schema/src/index.ts deleted file mode 100644 index 233cfbec2d..0000000000 --- a/packages/deslop-js/tests/fixtures/graphql-schema/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import "./schema.graphql"; -export const api = "api"; diff --git a/packages/deslop-js/tests/fixtures/graphql-schema/src/schema.graphql b/packages/deslop-js/tests/fixtures/graphql-schema/src/schema.graphql deleted file mode 100644 index 6ae991f681..0000000000 --- a/packages/deslop-js/tests/fixtures/graphql-schema/src/schema.graphql +++ /dev/null @@ -1,3 +0,0 @@ -type Query { - hello: String -} diff --git a/packages/deslop-js/tests/fixtures/graphql-schema/src/unused.graphql b/packages/deslop-js/tests/fixtures/graphql-schema/src/unused.graphql deleted file mode 100644 index af3c4141e1..0000000000 --- a/packages/deslop-js/tests/fixtures/graphql-schema/src/unused.graphql +++ /dev/null @@ -1,3 +0,0 @@ -type Mutation { - noop: Boolean -} diff --git a/packages/deslop-js/tests/fixtures/heuristic-no-dir-fallback/package.json b/packages/deslop-js/tests/fixtures/heuristic-no-dir-fallback/package.json deleted file mode 100644 index 5165872398..0000000000 --- a/packages/deslop-js/tests/fixtures/heuristic-no-dir-fallback/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "heuristic-no-dir-fallback", - "version": "1.0.0", - "bin": { - "mycli": "./dist/cli.js" - }, - "exports": { - ".": { - "default": "./dist/index.js" - } - } -} diff --git a/packages/deslop-js/tests/fixtures/heuristic-no-dir-fallback/src/cli/index.ts b/packages/deslop-js/tests/fixtures/heuristic-no-dir-fallback/src/cli/index.ts deleted file mode 100644 index 9461e5bd04..0000000000 --- a/packages/deslop-js/tests/fixtures/heuristic-no-dir-fallback/src/cli/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { greet } from "../index.js"; -console.log(greet()); diff --git a/packages/deslop-js/tests/fixtures/heuristic-no-dir-fallback/src/index.ts b/packages/deslop-js/tests/fixtures/heuristic-no-dir-fallback/src/index.ts deleted file mode 100644 index be3e33de9c..0000000000 --- a/packages/deslop-js/tests/fixtures/heuristic-no-dir-fallback/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const greet = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/heuristic-no-dir-fallback/src/orphan.ts b/packages/deslop-js/tests/fixtures/heuristic-no-dir-fallback/src/orphan.ts deleted file mode 100644 index b79aa99b63..0000000000 --- a/packages/deslop-js/tests/fixtures/heuristic-no-dir-fallback/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = true; diff --git a/packages/deslop-js/tests/fixtures/hoc-wrapped-default-export/package.json b/packages/deslop-js/tests/fixtures/hoc-wrapped-default-export/package.json deleted file mode 100644 index 290063ca62..0000000000 --- a/packages/deslop-js/tests/fixtures/hoc-wrapped-default-export/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "hoc-wrapped-default-export", - "private": true, - "type": "module" -} diff --git a/packages/deslop-js/tests/fixtures/hoc-wrapped-default-export/src/apps-badge.tsx b/packages/deslop-js/tests/fixtures/hoc-wrapped-default-export/src/apps-badge.tsx deleted file mode 100644 index ff8ce57daf..0000000000 --- a/packages/deslop-js/tests/fixtures/hoc-wrapped-default-export/src/apps-badge.tsx +++ /dev/null @@ -1,11 +0,0 @@ -const connect = (mapState: unknown, mapDispatch: unknown) => { - return (component: unknown) => component; -}; - -export class AppsBadge { - render(): null { - return null; - } -} - -export default connect(null, {})(AppsBadge); diff --git a/packages/deslop-js/tests/fixtures/hoc-wrapped-default-export/src/index.ts b/packages/deslop-js/tests/fixtures/hoc-wrapped-default-export/src/index.ts deleted file mode 100644 index e9c48416fe..0000000000 --- a/packages/deslop-js/tests/fixtures/hoc-wrapped-default-export/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import AppsBadge from "./apps-badge.js"; - -export const renderBadge = (): typeof AppsBadge => AppsBadge; diff --git a/packages/deslop-js/tests/fixtures/hoc-wrapped-default-export/src/orphan.ts b/packages/deslop-js/tests/fixtures/hoc-wrapped-default-export/src/orphan.ts deleted file mode 100644 index cba46d4f9c..0000000000 --- a/packages/deslop-js/tests/fixtures/hoc-wrapped-default-export/src/orphan.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function OrphanExport(): null { - return null; -} diff --git a/packages/deslop-js/tests/fixtures/html-entry-scope/package.json b/packages/deslop-js/tests/fixtures/html-entry-scope/package.json deleted file mode 100644 index 90a1dbb31e..0000000000 --- a/packages/deslop-js/tests/fixtures/html-entry-scope/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "html-entry-scoping", - "workspaces": [ - "packages/*" - ] -} diff --git a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/index.html b/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/index.html deleted file mode 100644 index d5eecd05d5..0000000000 --- a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/index.html +++ /dev/null @@ -1,7 +0,0 @@ -<!doctype html> -<html> - <body> - <div id="root"></div> - <script type="module" src="./src/main.tsx"></script> - </body> -</html> diff --git a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/package.json b/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/package.json deleted file mode 100644 index c3db9bba03..0000000000 --- a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "@test/app" -} diff --git a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/sample/demo.tsx b/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/sample/demo.tsx deleted file mode 100644 index 62647520da..0000000000 --- a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/sample/demo.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Demo = () => "demo"; diff --git a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/sample/index.html b/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/sample/index.html deleted file mode 100644 index 0aecac417b..0000000000 --- a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/sample/index.html +++ /dev/null @@ -1,6 +0,0 @@ -<!doctype html> -<html> - <body> - <script type="module" src="./demo.tsx"></script> - </body> -</html> diff --git a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/src/helper.ts b/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/src/helper.ts deleted file mode 100644 index 83d52b7862..0000000000 --- a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/src/helper.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/src/main.tsx b/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/src/main.tsx deleted file mode 100644 index 6468dde580..0000000000 --- a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/app/src/main.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { helper } from "./helper"; -export const App = () => helper(); diff --git a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/lib/package.json b/packages/deslop-js/tests/fixtures/html-entry-scope/packages/lib/package.json deleted file mode 100644 index a5270eb309..0000000000 --- a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/lib/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "@test/lib", - "main": "./src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/lib/src/index.ts b/packages/deslop-js/tests/fixtures/html-entry-scope/packages/lib/src/index.ts deleted file mode 100644 index 308119b80e..0000000000 --- a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/lib/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const libUtil = () => "lib"; diff --git a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/lib/src/orphan.ts b/packages/deslop-js/tests/fixtures/html-entry-scope/packages/lib/src/orphan.ts deleted file mode 100644 index 1b61e3211e..0000000000 --- a/packages/deslop-js/tests/fixtures/html-entry-scope/packages/lib/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = () => "unused"; diff --git a/packages/deslop-js/tests/fixtures/i18n-app/package.json b/packages/deslop-js/tests/fixtures/i18n-app/package.json deleted file mode 100644 index a785bedefa..0000000000 --- a/packages/deslop-js/tests/fixtures/i18n-app/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "i18n-project", - "version": "1.0.0", - "main": "src/index.ts", - "dependencies": { - "i18next": "^23.0.0", - "react": "^18.0.0", - "react-i18next": "^13.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/i18n-app/public/locales/en.json b/packages/deslop-js/tests/fixtures/i18n-app/public/locales/en.json deleted file mode 100644 index c21310b9a7..0000000000 --- a/packages/deslop-js/tests/fixtures/i18n-app/public/locales/en.json +++ /dev/null @@ -1 +0,0 @@ -{ "hello": "Hello" } diff --git a/packages/deslop-js/tests/fixtures/i18n-app/src/i18n.ts b/packages/deslop-js/tests/fixtures/i18n-app/src/i18n.ts deleted file mode 100644 index 7d584cbedd..0000000000 --- a/packages/deslop-js/tests/fixtures/i18n-app/src/i18n.ts +++ /dev/null @@ -1 +0,0 @@ -export const i18nConfig = { lng: "en" }; diff --git a/packages/deslop-js/tests/fixtures/i18n-app/src/index.ts b/packages/deslop-js/tests/fixtures/i18n-app/src/index.ts deleted file mode 100644 index c9177a8ed9..0000000000 --- a/packages/deslop-js/tests/fixtures/i18n-app/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import "./i18n"; -export const app = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/i18n-app/src/orphan.ts b/packages/deslop-js/tests/fixtures/i18n-app/src/orphan.ts deleted file mode 100644 index 2e2026e157..0000000000 --- a/packages/deslop-js/tests/fixtures/i18n-app/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "orphan"; diff --git a/packages/deslop-js/tests/fixtures/i18n-glob-skip/package.json b/packages/deslop-js/tests/fixtures/i18n-glob-skip/package.json deleted file mode 100644 index 7531bf5f83..0000000000 --- a/packages/deslop-js/tests/fixtures/i18n-glob-skip/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "i18n-extract-test", - "scripts": { - "i18n:extract": "formatjs extract 'src/**/*.{ts,tsx}' --out-file locales/en.json" - }, - "dependencies": { - "react": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/i18n-glob-skip/src/app.ts b/packages/deslop-js/tests/fixtures/i18n-glob-skip/src/app.ts deleted file mode 100644 index 6a156a2b53..0000000000 --- a/packages/deslop-js/tests/fixtures/i18n-glob-skip/src/app.ts +++ /dev/null @@ -1 +0,0 @@ -export const app = "main app"; diff --git a/packages/deslop-js/tests/fixtures/i18n-glob-skip/src/orphan.ts b/packages/deslop-js/tests/fixtures/i18n-glob-skip/src/orphan.ts deleted file mode 100644 index 0277a52e92..0000000000 --- a/packages/deslop-js/tests/fixtures/i18n-glob-skip/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "should be unused"; diff --git a/packages/deslop-js/tests/fixtures/i18n-glob-skip/tsconfig.json b/packages/deslop-js/tests/fixtures/i18n-glob-skip/tsconfig.json deleted file mode 100644 index 19d4ac2e6f..0000000000 --- a/packages/deslop-js/tests/fixtures/i18n-glob-skip/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "paths": {} - } -} diff --git a/packages/deslop-js/tests/fixtures/import-dynamic-literal/notes.ts b/packages/deslop-js/tests/fixtures/import-dynamic-literal/notes.ts deleted file mode 100644 index b69ace218d..0000000000 --- a/packages/deslop-js/tests/fixtures/import-dynamic-literal/notes.ts +++ /dev/null @@ -1 +0,0 @@ -export const notes = "loaded from parent path"; diff --git a/packages/deslop-js/tests/fixtures/import-dynamic-literal/package.json b/packages/deslop-js/tests/fixtures/import-dynamic-literal/package.json deleted file mode 100644 index 0fe92f92fa..0000000000 --- a/packages/deslop-js/tests/fixtures/import-dynamic-literal/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "dynamic-import-literals", - "main": "src/index.ts", - "dependencies": { - "@some/package": "1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/import-dynamic-literal/src/index.ts b/packages/deslop-js/tests/fixtures/import-dynamic-literal/src/index.ts deleted file mode 100644 index 5de9d37df4..0000000000 --- a/packages/deslop-js/tests/fixtures/import-dynamic-literal/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -const notes = import("../notes"); -const packageModule = import("@some/package"); -const missing = import("./missing"); - -console.log(notes, packageModule, missing); diff --git a/packages/deslop-js/tests/fixtures/import-dynamic-literal/src/orphan.ts b/packages/deslop-js/tests/fixtures/import-dynamic-literal/src/orphan.ts deleted file mode 100644 index 3e6c764780..0000000000 --- a/packages/deslop-js/tests/fixtures/import-dynamic-literal/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "unreachable"; diff --git a/packages/deslop-js/tests/fixtures/import-dynamic-template/package.json b/packages/deslop-js/tests/fixtures/import-dynamic-template/package.json deleted file mode 100644 index f4033d97d1..0000000000 --- a/packages/deslop-js/tests/fixtures/import-dynamic-template/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "dynamic-import-template-test", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/import-dynamic-template/src/index.ts b/packages/deslop-js/tests/fixtures/import-dynamic-template/src/index.ts deleted file mode 100644 index 074276c009..0000000000 --- a/packages/deslop-js/tests/fixtures/import-dynamic-template/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -const loadLocale = async (language: string) => { - const { default: locale } = await import(`./locales/${language}/core.js`); - return locale; -}; -export { loadLocale }; diff --git a/packages/deslop-js/tests/fixtures/import-dynamic-template/src/locales/de/core.js b/packages/deslop-js/tests/fixtures/import-dynamic-template/src/locales/de/core.js deleted file mode 100644 index e4d1492940..0000000000 --- a/packages/deslop-js/tests/fixtures/import-dynamic-template/src/locales/de/core.js +++ /dev/null @@ -1 +0,0 @@ -export default { greeting: "Hallo" }; diff --git a/packages/deslop-js/tests/fixtures/import-dynamic-template/src/locales/en/core.js b/packages/deslop-js/tests/fixtures/import-dynamic-template/src/locales/en/core.js deleted file mode 100644 index 4089707f4b..0000000000 --- a/packages/deslop-js/tests/fixtures/import-dynamic-template/src/locales/en/core.js +++ /dev/null @@ -1 +0,0 @@ -export default { greeting: "Hello" }; diff --git a/packages/deslop-js/tests/fixtures/import-dynamic-template/src/locales/fr/core.js b/packages/deslop-js/tests/fixtures/import-dynamic-template/src/locales/fr/core.js deleted file mode 100644 index 3977e70b62..0000000000 --- a/packages/deslop-js/tests/fixtures/import-dynamic-template/src/locales/fr/core.js +++ /dev/null @@ -1 +0,0 @@ -export default { greeting: "Bonjour" }; diff --git a/packages/deslop-js/tests/fixtures/import-dynamic-template/src/orphan.ts b/packages/deslop-js/tests/fixtures/import-dynamic-template/src/orphan.ts deleted file mode 100644 index 1b61e3211e..0000000000 --- a/packages/deslop-js/tests/fixtures/import-dynamic-template/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = () => "unused"; diff --git a/packages/deslop-js/tests/fixtures/import-dynamic/package.json b/packages/deslop-js/tests/fixtures/import-dynamic/package.json deleted file mode 100644 index 1fb5b1cd30..0000000000 --- a/packages/deslop-js/tests/fixtures/import-dynamic/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "dynamic-imports", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/import-dynamic/src/index.ts b/packages/deslop-js/tests/fixtures/import-dynamic/src/index.ts deleted file mode 100644 index 3509602c10..0000000000 --- a/packages/deslop-js/tests/fixtures/import-dynamic/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { greet } from "./utils"; -const lazy = import("./lazy"); -console.log(greet(), lazy); diff --git a/packages/deslop-js/tests/fixtures/import-dynamic/src/lazy.ts b/packages/deslop-js/tests/fixtures/import-dynamic/src/lazy.ts deleted file mode 100644 index 9b7ae860cd..0000000000 --- a/packages/deslop-js/tests/fixtures/import-dynamic/src/lazy.ts +++ /dev/null @@ -1 +0,0 @@ -export const lazyValue = "loaded lazily"; diff --git a/packages/deslop-js/tests/fixtures/import-dynamic/src/orphan.ts b/packages/deslop-js/tests/fixtures/import-dynamic/src/orphan.ts deleted file mode 100644 index 3e6c764780..0000000000 --- a/packages/deslop-js/tests/fixtures/import-dynamic/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "unreachable"; diff --git a/packages/deslop-js/tests/fixtures/import-dynamic/src/utils.ts b/packages/deslop-js/tests/fixtures/import-dynamic/src/utils.ts deleted file mode 100644 index 59cdb1855d..0000000000 --- a/packages/deslop-js/tests/fixtures/import-dynamic/src/utils.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const greet = () => "hello"; -export const unused = () => "unused"; diff --git a/packages/deslop-js/tests/fixtures/import-mixed/index.ts b/packages/deslop-js/tests/fixtures/import-mixed/index.ts deleted file mode 100644 index 4e6fa50dcc..0000000000 --- a/packages/deslop-js/tests/fixtures/import-mixed/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import Foo, { bar, baz } from "./lib"; -import * as utils from "./utils"; - -Foo(); -bar(); -utils.format("test"); diff --git a/packages/deslop-js/tests/fixtures/import-mixed/lib.ts b/packages/deslop-js/tests/fixtures/import-mixed/lib.ts deleted file mode 100644 index c65f248d7d..0000000000 --- a/packages/deslop-js/tests/fixtures/import-mixed/lib.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default function Foo() { - return "foo"; -} -export const bar = () => "bar"; -export const baz = () => "baz"; -export const unused = () => "unused"; diff --git a/packages/deslop-js/tests/fixtures/import-mixed/orphan.ts b/packages/deslop-js/tests/fixtures/import-mixed/orphan.ts deleted file mode 100644 index d003d81734..0000000000 --- a/packages/deslop-js/tests/fixtures/import-mixed/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphaned = () => "nobody imports this file"; diff --git a/packages/deslop-js/tests/fixtures/import-mixed/package.json b/packages/deslop-js/tests/fixtures/import-mixed/package.json deleted file mode 100644 index 9a67fe7e23..0000000000 --- a/packages/deslop-js/tests/fixtures/import-mixed/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "mixed-import-patterns", - "main": "index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/import-mixed/utils.ts b/packages/deslop-js/tests/fixtures/import-mixed/utils.ts deleted file mode 100644 index 56aa13f59f..0000000000 --- a/packages/deslop-js/tests/fixtures/import-mixed/utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const format = (value: string) => value.toUpperCase(); -export const parse = (value: string) => value.toLowerCase(); -export const unusedUtil = () => "never used"; diff --git a/packages/deslop-js/tests/fixtures/import-query-param/config.ts b/packages/deslop-js/tests/fixtures/import-query-param/config.ts deleted file mode 100644 index 71dfe2c5cd..0000000000 --- a/packages/deslop-js/tests/fixtures/import-query-param/config.ts +++ /dev/null @@ -1 +0,0 @@ -export const config = { debug: true }; diff --git a/packages/deslop-js/tests/fixtures/import-query-param/index.ts b/packages/deslop-js/tests/fixtures/import-query-param/index.ts deleted file mode 100644 index a7f64580ee..0000000000 --- a/packages/deslop-js/tests/fixtures/import-query-param/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import styles from "./styles.css?url"; -import raw from "./config.ts?raw"; -import worker from "./worker.ts?worker"; - -console.log(styles, raw, worker); diff --git a/packages/deslop-js/tests/fixtures/import-query-param/orphan.ts b/packages/deslop-js/tests/fixtures/import-query-param/orphan.ts deleted file mode 100644 index b79aa99b63..0000000000 --- a/packages/deslop-js/tests/fixtures/import-query-param/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = true; diff --git a/packages/deslop-js/tests/fixtures/import-query-param/package.json b/packages/deslop-js/tests/fixtures/import-query-param/package.json deleted file mode 100644 index b2f7874f04..0000000000 --- a/packages/deslop-js/tests/fixtures/import-query-param/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "query-param-imports", - "private": true -} diff --git a/packages/deslop-js/tests/fixtures/import-query-param/styles.css b/packages/deslop-js/tests/fixtures/import-query-param/styles.css deleted file mode 100644 index e44aa2553c..0000000000 --- a/packages/deslop-js/tests/fixtures/import-query-param/styles.css +++ /dev/null @@ -1,5 +0,0 @@ -@import "./theme.css"; - -body { - margin: 0; -} diff --git a/packages/deslop-js/tests/fixtures/import-query-param/theme.css b/packages/deslop-js/tests/fixtures/import-query-param/theme.css deleted file mode 100644 index 6e6d3f36e4..0000000000 --- a/packages/deslop-js/tests/fixtures/import-query-param/theme.css +++ /dev/null @@ -1,3 +0,0 @@ -:root { - --color: blue; -} diff --git a/packages/deslop-js/tests/fixtures/import-query-param/worker.ts b/packages/deslop-js/tests/fixtures/import-query-param/worker.ts deleted file mode 100644 index 2bfdd11b59..0000000000 --- a/packages/deslop-js/tests/fixtures/import-query-param/worker.ts +++ /dev/null @@ -1 +0,0 @@ -self.onmessage = () => {}; diff --git a/packages/deslop-js/tests/fixtures/import-reexport-same/package.json b/packages/deslop-js/tests/fixtures/import-reexport-same/package.json deleted file mode 100644 index a130bcc116..0000000000 --- a/packages/deslop-js/tests/fixtures/import-reexport-same/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "import-and-reexport-same-target", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/import-reexport-same/src/components/helper.ts b/packages/deslop-js/tests/fixtures/import-reexport-same/src/components/helper.ts deleted file mode 100644 index 253b170d49..0000000000 --- a/packages/deslop-js/tests/fixtures/import-reexport-same/src/components/helper.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const Helper = { - run: () => "running", -}; diff --git a/packages/deslop-js/tests/fixtures/import-reexport-same/src/components/index.ts b/packages/deslop-js/tests/fixtures/import-reexport-same/src/components/index.ts deleted file mode 100644 index bfb2feb9d5..0000000000 --- a/packages/deslop-js/tests/fixtures/import-reexport-same/src/components/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./helper"; -export * from "./widget"; -export * from "./unused-component"; diff --git a/packages/deslop-js/tests/fixtures/import-reexport-same/src/components/unused-component.ts b/packages/deslop-js/tests/fixtures/import-reexport-same/src/components/unused-component.ts deleted file mode 100644 index b4d3840643..0000000000 --- a/packages/deslop-js/tests/fixtures/import-reexport-same/src/components/unused-component.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const UnusedComponent = { - render: () => "<div>unused</div>", -}; diff --git a/packages/deslop-js/tests/fixtures/import-reexport-same/src/components/widget.ts b/packages/deslop-js/tests/fixtures/import-reexport-same/src/components/widget.ts deleted file mode 100644 index 5f5582b626..0000000000 --- a/packages/deslop-js/tests/fixtures/import-reexport-same/src/components/widget.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const Widget = { - render: () => "<div>widget</div>", -}; diff --git a/packages/deslop-js/tests/fixtures/import-reexport-same/src/index.ts b/packages/deslop-js/tests/fixtures/import-reexport-same/src/index.ts deleted file mode 100644 index a378a53231..0000000000 --- a/packages/deslop-js/tests/fixtures/import-reexport-same/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Helper } from "./components"; - -export * from "./components"; - -export const main = () => Helper.run(); diff --git a/packages/deslop-js/tests/fixtures/import-reexport-same/src/orphan.ts b/packages/deslop-js/tests/fixtures/import-reexport-same/src/orphan.ts deleted file mode 100644 index e766aa1774..0000000000 --- a/packages/deslop-js/tests/fixtures/import-reexport-same/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const Orphan = "never imported"; diff --git a/packages/deslop-js/tests/fixtures/import-side-effect/package.json b/packages/deslop-js/tests/fixtures/import-side-effect/package.json deleted file mode 100644 index fe60de3d02..0000000000 --- a/packages/deslop-js/tests/fixtures/import-side-effect/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "side-effect-imports", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/import-side-effect/src/index.ts b/packages/deslop-js/tests/fixtures/import-side-effect/src/index.ts deleted file mode 100644 index 69f144a95a..0000000000 --- a/packages/deslop-js/tests/fixtures/import-side-effect/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import "./setup"; - -console.log("app started"); diff --git a/packages/deslop-js/tests/fixtures/import-side-effect/src/orphan.ts b/packages/deslop-js/tests/fixtures/import-side-effect/src/orphan.ts deleted file mode 100644 index 09f06a6780..0000000000 --- a/packages/deslop-js/tests/fixtures/import-side-effect/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphanValue = 42; diff --git a/packages/deslop-js/tests/fixtures/import-side-effect/src/setup.ts b/packages/deslop-js/tests/fixtures/import-side-effect/src/setup.ts deleted file mode 100644 index 211c9a2c01..0000000000 --- a/packages/deslop-js/tests/fixtures/import-side-effect/src/setup.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("init"); diff --git a/packages/deslop-js/tests/fixtures/import-specifier-sanitize/package.json b/packages/deslop-js/tests/fixtures/import-specifier-sanitize/package.json deleted file mode 100644 index 10cfe91d87..0000000000 --- a/packages/deslop-js/tests/fixtures/import-specifier-sanitize/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "import-specifier-sanitize", - "version": "1.0.0", - "private": true, - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/import-specifier-sanitize/src/frag.ts b/packages/deslop-js/tests/fixtures/import-specifier-sanitize/src/frag.ts deleted file mode 100644 index df736055c2..0000000000 --- a/packages/deslop-js/tests/fixtures/import-specifier-sanitize/src/frag.ts +++ /dev/null @@ -1 +0,0 @@ -export const section = (): number => 3; diff --git a/packages/deslop-js/tests/fixtures/import-specifier-sanitize/src/index.ts b/packages/deslop-js/tests/fixtures/import-specifier-sanitize/src/index.ts deleted file mode 100644 index 80dd6aeae4..0000000000 --- a/packages/deslop-js/tests/fixtures/import-specifier-sanitize/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { work } from "worker-loader!./worker"; -import { raw } from "./query?raw"; -import { section } from "./frag#section"; - -export const run = (): number => work() + raw() + section(); diff --git a/packages/deslop-js/tests/fixtures/import-specifier-sanitize/src/orphan.ts b/packages/deslop-js/tests/fixtures/import-specifier-sanitize/src/orphan.ts deleted file mode 100644 index 4386b6b609..0000000000 --- a/packages/deslop-js/tests/fixtures/import-specifier-sanitize/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = (): number => 4; diff --git a/packages/deslop-js/tests/fixtures/import-specifier-sanitize/src/query.ts b/packages/deslop-js/tests/fixtures/import-specifier-sanitize/src/query.ts deleted file mode 100644 index 43858326d9..0000000000 --- a/packages/deslop-js/tests/fixtures/import-specifier-sanitize/src/query.ts +++ /dev/null @@ -1 +0,0 @@ -export const raw = (): number => 2; diff --git a/packages/deslop-js/tests/fixtures/import-specifier-sanitize/src/worker.ts b/packages/deslop-js/tests/fixtures/import-specifier-sanitize/src/worker.ts deleted file mode 100644 index 59770d4bff..0000000000 --- a/packages/deslop-js/tests/fixtures/import-specifier-sanitize/src/worker.ts +++ /dev/null @@ -1 +0,0 @@ -export const work = (): number => 1; diff --git a/packages/deslop-js/tests/fixtures/import-subpath/package.json b/packages/deslop-js/tests/fixtures/import-subpath/package.json deleted file mode 100644 index 07efb471e7..0000000000 --- a/packages/deslop-js/tests/fixtures/import-subpath/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "subpath-imports", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "vitest": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/import-subpath/src/api/orphan.ts b/packages/deslop-js/tests/fixtures/import-subpath/src/api/orphan.ts deleted file mode 100644 index b73b3f8b65..0000000000 --- a/packages/deslop-js/tests/fixtures/import-subpath/src/api/orphan.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const orphanFunction = () => { - return "not imported"; -}; diff --git a/packages/deslop-js/tests/fixtures/import-subpath/src/api/user.ts b/packages/deslop-js/tests/fixtures/import-subpath/src/api/user.ts deleted file mode 100644 index af1aa23746..0000000000 --- a/packages/deslop-js/tests/fixtures/import-subpath/src/api/user.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const fetchUser = () => { - return { id: 1, name: "test" }; -}; diff --git a/packages/deslop-js/tests/fixtures/import-subpath/src/index.ts b/packages/deslop-js/tests/fixtures/import-subpath/src/index.ts deleted file mode 100644 index ab3ef0d425..0000000000 --- a/packages/deslop-js/tests/fixtures/import-subpath/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { fetchUser } from "#src/api/user.js"; - -export const main = () => { - return fetchUser(); -}; diff --git a/packages/deslop-js/tests/fixtures/import-subpath/tsconfig.json b/packages/deslop-js/tests/fixtures/import-subpath/tsconfig.json deleted file mode 100644 index c8c2a3721d..0000000000 --- a/packages/deslop-js/tests/fixtures/import-subpath/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "rootDir": "src", - "outDir": "lib", - "paths": { - "#src/*": ["src/*"] - } - }, - "include": ["src"] -} diff --git a/packages/deslop-js/tests/fixtures/internal-export-usage/package.json b/packages/deslop-js/tests/fixtures/internal-export-usage/package.json deleted file mode 100644 index bcfaa18368..0000000000 --- a/packages/deslop-js/tests/fixtures/internal-export-usage/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "internal-export-usage", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/internal-export-usage/src/index.ts b/packages/deslop-js/tests/fixtures/internal-export-usage/src/index.ts deleted file mode 100644 index a14ce19a79..0000000000 --- a/packages/deslop-js/tests/fixtures/internal-export-usage/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import "./service.module"; - -export const main = (): void => {}; diff --git a/packages/deslop-js/tests/fixtures/internal-export-usage/src/module-registry.ts b/packages/deslop-js/tests/fixtures/internal-export-usage/src/module-registry.ts deleted file mode 100644 index ff0c4ac47f..0000000000 --- a/packages/deslop-js/tests/fixtures/internal-export-usage/src/module-registry.ts +++ /dev/null @@ -1,8 +0,0 @@ -interface ModuleRegistration { - module: { token: string }; - token: string; -} - -export const registerModule = (registration: ModuleRegistration): void => { - registration.module.token; -}; diff --git a/packages/deslop-js/tests/fixtures/internal-export-usage/src/orphan.ts b/packages/deslop-js/tests/fixtures/internal-export-usage/src/orphan.ts deleted file mode 100644 index 74448acbb5..0000000000 --- a/packages/deslop-js/tests/fixtures/internal-export-usage/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphanValue = 1; diff --git a/packages/deslop-js/tests/fixtures/internal-export-usage/src/service.module.ts b/packages/deslop-js/tests/fixtures/internal-export-usage/src/service.module.ts deleted file mode 100644 index 291db9fbe9..0000000000 --- a/packages/deslop-js/tests/fixtures/internal-export-usage/src/service.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { registerModule } from "./module-registry"; - -export const serviceModule = createModule(); - -registerModule({ - module: serviceModule, - token: "Service", -}); - -export function createModule() { - return { token: "Service" }; -} diff --git a/packages/deslop-js/tests/fixtures/jest-config-cts/jest.config.cts b/packages/deslop-js/tests/fixtures/jest-config-cts/jest.config.cts deleted file mode 100644 index e3e6f1ceb4..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-config-cts/jest.config.cts +++ /dev/null @@ -1,5 +0,0 @@ -const config = { - setupFilesAfterEnv: ["<rootDir>/test-setup.ts"], -}; - -export default config; diff --git a/packages/deslop-js/tests/fixtures/jest-config-cts/package.json b/packages/deslop-js/tests/fixtures/jest-config-cts/package.json deleted file mode 100644 index 41c2d4c61a..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-config-cts/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "jest-config-cts", - "private": true, - "type": "module" -} diff --git a/packages/deslop-js/tests/fixtures/jest-config-cts/src/orphan.ts b/packages/deslop-js/tests/fixtures/jest-config-cts/src/orphan.ts deleted file mode 100644 index 64aae7b6ca..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-config-cts/src/orphan.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function OrphanSetup(): null { - return null; -} diff --git a/packages/deslop-js/tests/fixtures/jest-config-cts/test-setup.ts b/packages/deslop-js/tests/fixtures/jest-config-cts/test-setup.ts deleted file mode 100644 index cb0ff5c3b5..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-config-cts/test-setup.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/deslop-js/tests/fixtures/jest-custom-testmatch-mocks/__mocks__/axios.ts b/packages/deslop-js/tests/fixtures/jest-custom-testmatch-mocks/__mocks__/axios.ts deleted file mode 100644 index 9cbdb5dc1f..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-custom-testmatch-mocks/__mocks__/axios.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default { - get: async () => ({ data: {} }), -}; diff --git a/packages/deslop-js/tests/fixtures/jest-custom-testmatch-mocks/jest.config.js b/packages/deslop-js/tests/fixtures/jest-custom-testmatch-mocks/jest.config.js deleted file mode 100644 index 1bf2c34e40..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-custom-testmatch-mocks/jest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - testMatch: ["<rootDir>/static/**/*_test.jsx"], -}; diff --git a/packages/deslop-js/tests/fixtures/jest-custom-testmatch-mocks/package.json b/packages/deslop-js/tests/fixtures/jest-custom-testmatch-mocks/package.json deleted file mode 100644 index fe03252087..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-custom-testmatch-mocks/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "jest-custom-testmatch-mocks", - "main": "src/index.ts", - "dependencies": { - "jest": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/jest-custom-testmatch-mocks/src/index.ts b/packages/deslop-js/tests/fixtures/jest-custom-testmatch-mocks/src/index.ts deleted file mode 100644 index 0d5f89b52d..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-custom-testmatch-mocks/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = "app"; diff --git a/packages/deslop-js/tests/fixtures/jest-custom-testmatch-mocks/src/orphan.ts b/packages/deslop-js/tests/fixtures/jest-custom-testmatch-mocks/src/orphan.ts deleted file mode 100644 index d55ec1e00e..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-custom-testmatch-mocks/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const trulyOrphaned = "never imported"; diff --git a/packages/deslop-js/tests/fixtures/jest-mapper/__mocks__/fileMock.js b/packages/deslop-js/tests/fixtures/jest-mapper/__mocks__/fileMock.js deleted file mode 100644 index 0a445d0600..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mapper/__mocks__/fileMock.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = "test-file-stub"; diff --git a/packages/deslop-js/tests/fixtures/jest-mapper/__mocks__/styleMock.js b/packages/deslop-js/tests/fixtures/jest-mapper/__mocks__/styleMock.js deleted file mode 100644 index f053ebf797..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mapper/__mocks__/styleMock.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/packages/deslop-js/tests/fixtures/jest-mapper/jest.config.js b/packages/deslop-js/tests/fixtures/jest-mapper/jest.config.js deleted file mode 100644 index bee62ec100..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mapper/jest.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - moduleNameMapper: { - "\\.(css|less)$": "<rootDir>/__mocks__/styleMock.js", - "\\.(jpg|png)$": "<rootDir>/__mocks__/fileMock.js", - }, -}; diff --git a/packages/deslop-js/tests/fixtures/jest-mapper/package.json b/packages/deslop-js/tests/fixtures/jest-mapper/package.json deleted file mode 100644 index c9a669be05..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mapper/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "jest-module-mapper-fixture", - "private": true, - "devDependencies": { - "jest": "^29.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/jest-mapper/src/index.ts b/packages/deslop-js/tests/fixtures/jest-mapper/src/index.ts deleted file mode 100644 index c5961da749..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mapper/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/jest-mapper/src/orphan.ts b/packages/deslop-js/tests/fixtures/jest-mapper/src/orphan.ts deleted file mode 100644 index 2e65a50551..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mapper/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "never imported"; diff --git a/packages/deslop-js/tests/fixtures/jest-match/jest.config.ts b/packages/deslop-js/tests/fixtures/jest-match/jest.config.ts deleted file mode 100644 index a672c0c4b5..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-match/jest.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default { - testMatch: ["<rootDir>/src/**/*.(test).(ts|js)?(x)"], - setupFilesAfterSetup: ["<rootDir>/src/test-setup.ts"], -}; diff --git a/packages/deslop-js/tests/fixtures/jest-match/package.json b/packages/deslop-js/tests/fixtures/jest-match/package.json deleted file mode 100644 index 42d4eded2f..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-match/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "jest-test-match-fixture", - "main": "src/index.ts", - "dependencies": { - "jest": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/jest-match/src/__tests__/app.test.ts b/packages/deslop-js/tests/fixtures/jest-match/src/__tests__/app.test.ts deleted file mode 100644 index 235fac53e8..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-match/src/__tests__/app.test.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { main } from "../index"; -test("works", () => expect(main).toBe("hello")); diff --git a/packages/deslop-js/tests/fixtures/jest-match/src/index.ts b/packages/deslop-js/tests/fixtures/jest-match/src/index.ts deleted file mode 100644 index 4124a4c154..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-match/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = "hello"; diff --git a/packages/deslop-js/tests/fixtures/jest-match/src/orphan.ts b/packages/deslop-js/tests/fixtures/jest-match/src/orphan.ts deleted file mode 100644 index bd93db0a49..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-match/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "unused"; diff --git a/packages/deslop-js/tests/fixtures/jest-match/src/utils.test.ts b/packages/deslop-js/tests/fixtures/jest-match/src/utils.test.ts deleted file mode 100644 index e716b56c3d..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-match/src/utils.test.ts +++ /dev/null @@ -1 +0,0 @@ -test("util test matching testMatch pattern", () => expect(true).toBe(true)); diff --git a/packages/deslop-js/tests/fixtures/jest-match/tests/outside.test.ts b/packages/deslop-js/tests/fixtures/jest-match/tests/outside.test.ts deleted file mode 100644 index df4799daf5..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-match/tests/outside.test.ts +++ /dev/null @@ -1 +0,0 @@ -test("this test is outside testMatch scope", () => expect(true).toBe(true)); diff --git a/packages/deslop-js/tests/fixtures/jest-mock-entry/__mocks__/some-lib.js b/packages/deslop-js/tests/fixtures/jest-mock-entry/__mocks__/some-lib.js deleted file mode 100644 index 08059bd706..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mock-entry/__mocks__/some-lib.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = { mockFn: () => "mocked" }; diff --git a/packages/deslop-js/tests/fixtures/jest-mock-entry/orphan.ts b/packages/deslop-js/tests/fixtures/jest-mock-entry/orphan.ts deleted file mode 100644 index b79aa99b63..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mock-entry/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = true; diff --git a/packages/deslop-js/tests/fixtures/jest-mock-entry/package.json b/packages/deslop-js/tests/fixtures/jest-mock-entry/package.json deleted file mode 100644 index 53df4fc1b9..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mock-entry/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "jest-mocks-entry", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "jest": "^29.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/jest-mock-entry/src/__mocks__/axios.ts b/packages/deslop-js/tests/fixtures/jest-mock-entry/src/__mocks__/axios.ts deleted file mode 100644 index df023200c8..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mock-entry/src/__mocks__/axios.ts +++ /dev/null @@ -1 +0,0 @@ -export default { get: () => "mocked" }; diff --git a/packages/deslop-js/tests/fixtures/jest-mock-entry/src/__mocks__/fs.ts b/packages/deslop-js/tests/fixtures/jest-mock-entry/src/__mocks__/fs.ts deleted file mode 100644 index 6ee685da80..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mock-entry/src/__mocks__/fs.ts +++ /dev/null @@ -1 +0,0 @@ -export const readFile = () => "mocked"; diff --git a/packages/deslop-js/tests/fixtures/jest-mock-entry/src/index.ts b/packages/deslop-js/tests/fixtures/jest-mock-entry/src/index.ts deleted file mode 100644 index ea6b750c3c..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mock-entry/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const greet = (name: string) => `Hello, ${name}`; diff --git a/packages/deslop-js/tests/fixtures/jest-mock-files/__mocks__/api-client.ts b/packages/deslop-js/tests/fixtures/jest-mock-files/__mocks__/api-client.ts deleted file mode 100644 index e6be4c0fe9..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mock-files/__mocks__/api-client.ts +++ /dev/null @@ -1 +0,0 @@ -export const fetchData = () => ({ data: "mocked" }); diff --git a/packages/deslop-js/tests/fixtures/jest-mock-files/__mocks__/fs.ts b/packages/deslop-js/tests/fixtures/jest-mock-files/__mocks__/fs.ts deleted file mode 100644 index ea676d10d8..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mock-files/__mocks__/fs.ts +++ /dev/null @@ -1 +0,0 @@ -export const readFileSync = () => "mocked"; diff --git a/packages/deslop-js/tests/fixtures/jest-mock-files/package.json b/packages/deslop-js/tests/fixtures/jest-mock-files/package.json deleted file mode 100644 index fd13345022..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mock-files/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "jest-mocks-fixture", - "main": "src/index.ts", - "dependencies": { - "jest": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/jest-mock-files/src/index.ts b/packages/deslop-js/tests/fixtures/jest-mock-files/src/index.ts deleted file mode 100644 index 4124a4c154..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mock-files/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = "hello"; diff --git a/packages/deslop-js/tests/fixtures/jest-mock-files/src/orphan.ts b/packages/deslop-js/tests/fixtures/jest-mock-files/src/orphan.ts deleted file mode 100644 index bd93db0a49..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-mock-files/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "unused"; diff --git a/packages/deslop-js/tests/fixtures/jest-module-name-mapper/jest.config.js b/packages/deslop-js/tests/fixtures/jest-module-name-mapper/jest.config.js deleted file mode 100644 index f3c6e9ddcf..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-module-name-mapper/jest.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - preset: "ts-jest", - moduleNameMapper: { - "^@app/(.*)$": "<rootDir>/src/$1", - }, -}; diff --git a/packages/deslop-js/tests/fixtures/jest-module-name-mapper/package.json b/packages/deslop-js/tests/fixtures/jest-module-name-mapper/package.json deleted file mode 100644 index 1093adf053..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-module-name-mapper/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "jest-module-name-mapper", - "version": "1.0.0", - "private": true, - "main": "src/index.ts", - "devDependencies": { - "jest": "^29.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/jest-module-name-mapper/src/index.ts b/packages/deslop-js/tests/fixtures/jest-module-name-mapper/src/index.ts deleted file mode 100644 index a6d9456384..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-module-name-mapper/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { value } from "@app/value"; - -export const total = (): number => value + 1; diff --git a/packages/deslop-js/tests/fixtures/jest-module-name-mapper/src/orphan.ts b/packages/deslop-js/tests/fixtures/jest-module-name-mapper/src/orphan.ts deleted file mode 100644 index 4b15cd3459..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-module-name-mapper/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unusedValue = 7; diff --git a/packages/deslop-js/tests/fixtures/jest-module-name-mapper/src/value.ts b/packages/deslop-js/tests/fixtures/jest-module-name-mapper/src/value.ts deleted file mode 100644 index eb8b6ff3d4..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-module-name-mapper/src/value.ts +++ /dev/null @@ -1 +0,0 @@ -export const value = 41; diff --git a/packages/deslop-js/tests/fixtures/jest-setup-config/__mocks__/styleMock.js b/packages/deslop-js/tests/fixtures/jest-setup-config/__mocks__/styleMock.js deleted file mode 100644 index f053ebf797..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-setup-config/__mocks__/styleMock.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/packages/deslop-js/tests/fixtures/jest-setup-config/jest.config.js b/packages/deslop-js/tests/fixtures/jest-setup-config/jest.config.js deleted file mode 100644 index d76b5ff72a..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-setup-config/jest.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"], - moduleNameMapper: { - "\\.(css|less)$": "<rootDir>/__mocks__/styleMock.js", - }, -}; diff --git a/packages/deslop-js/tests/fixtures/jest-setup-config/jest.setup.ts b/packages/deslop-js/tests/fixtures/jest-setup-config/jest.setup.ts deleted file mode 100644 index c241c1dfd5..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-setup-config/jest.setup.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { setupHelper } from "./src/setup-helper"; - -setupHelper(); diff --git a/packages/deslop-js/tests/fixtures/jest-setup-config/package.json b/packages/deslop-js/tests/fixtures/jest-setup-config/package.json deleted file mode 100644 index cbc40a7f60..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-setup-config/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "jest-setup-config", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "jest": "^29.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/jest-setup-config/src/index.ts b/packages/deslop-js/tests/fixtures/jest-setup-config/src/index.ts deleted file mode 100644 index cb44fe1048..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-setup-config/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = (): void => {}; diff --git a/packages/deslop-js/tests/fixtures/jest-setup-config/src/orphan.ts b/packages/deslop-js/tests/fixtures/jest-setup-config/src/orphan.ts deleted file mode 100644 index 74448acbb5..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-setup-config/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphanValue = 1; diff --git a/packages/deslop-js/tests/fixtures/jest-setup-config/src/setup-helper.ts b/packages/deslop-js/tests/fixtures/jest-setup-config/src/setup-helper.ts deleted file mode 100644 index 7a0076f92c..0000000000 --- a/packages/deslop-js/tests/fixtures/jest-setup-config/src/setup-helper.ts +++ /dev/null @@ -1 +0,0 @@ -export const setupHelper = (): void => {}; diff --git a/packages/deslop-js/tests/fixtures/jsx-block-arrow/package.json b/packages/deslop-js/tests/fixtures/jsx-block-arrow/package.json deleted file mode 100644 index 8ecf9936eb..0000000000 --- a/packages/deslop-js/tests/fixtures/jsx-block-arrow/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "jsx-block-arrow", - "version": "1.0.0", - "main": "src/index.tsx" -} diff --git a/packages/deslop-js/tests/fixtures/jsx-block-arrow/src/index.tsx b/packages/deslop-js/tests/fixtures/jsx-block-arrow/src/index.tsx deleted file mode 100644 index 3aa9b2eeaa..0000000000 --- a/packages/deslop-js/tests/fixtures/jsx-block-arrow/src/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export const HrComponent = () => { - return <hr className="my-2" />; -}; - -export const FragmentComponent = () => { - return ( - <> - <span>a</span> - <span>b</span> - </> - ); -}; - -export const shouldFlagIdentity = () => { - return 42; -}; diff --git a/packages/deslop-js/tests/fixtures/jsx-namespace-member/package.json b/packages/deslop-js/tests/fixtures/jsx-namespace-member/package.json deleted file mode 100644 index 835018e4c1..0000000000 --- a/packages/deslop-js/tests/fixtures/jsx-namespace-member/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "jsx-namespace-member", - "main": "src/index.tsx", - "dependencies": { - "react": "^18.0.0", - "styled-components": "^6.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/jsx-namespace-member/src/index.tsx b/packages/deslop-js/tests/fixtures/jsx-namespace-member/src/index.tsx deleted file mode 100644 index 1c571cf1e8..0000000000 --- a/packages/deslop-js/tests/fixtures/jsx-namespace-member/src/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import * as S from "./style"; - -export const App = () => { - const label = S.helper(); - return ( - <div title={label}> - <S.Custom /> - </div> - ); -}; diff --git a/packages/deslop-js/tests/fixtures/jsx-namespace-member/src/style.ts b/packages/deslop-js/tests/fixtures/jsx-namespace-member/src/style.ts deleted file mode 100644 index 37bd7ad0ac..0000000000 --- a/packages/deslop-js/tests/fixtures/jsx-namespace-member/src/style.ts +++ /dev/null @@ -1,5 +0,0 @@ -import styled from "styled-components"; - -export const Custom = styled.div``; -export const Plain = styled.span``; -export const helper = (): string => "x"; diff --git a/packages/deslop-js/tests/fixtures/lerna-workspace/lerna.json b/packages/deslop-js/tests/fixtures/lerna-workspace/lerna.json deleted file mode 100644 index ec55d6feda..0000000000 --- a/packages/deslop-js/tests/fixtures/lerna-workspace/lerna.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "packages": ["packages/*"] -} diff --git a/packages/deslop-js/tests/fixtures/lerna-workspace/package.json b/packages/deslop-js/tests/fixtures/lerna-workspace/package.json deleted file mode 100644 index 6bac8c874b..0000000000 --- a/packages/deslop-js/tests/fixtures/lerna-workspace/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "lerna-workspace", - "version": "1.0.0", - "private": true -} diff --git a/packages/deslop-js/tests/fixtures/lerna-workspace/packages/app/package.json b/packages/deslop-js/tests/fixtures/lerna-workspace/packages/app/package.json deleted file mode 100644 index 432a1b98d7..0000000000 --- a/packages/deslop-js/tests/fixtures/lerna-workspace/packages/app/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@fixture/app", - "version": "1.0.0", - "main": "src/index.ts", - "dependencies": { - "@fixture/ui": "workspace:*" - } -} diff --git a/packages/deslop-js/tests/fixtures/lerna-workspace/packages/app/src/index.ts b/packages/deslop-js/tests/fixtures/lerna-workspace/packages/app/src/index.ts deleted file mode 100644 index 5a27ef5f97..0000000000 --- a/packages/deslop-js/tests/fixtures/lerna-workspace/packages/app/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Button } from "@fixture/ui"; - -export const app = Button; diff --git a/packages/deslop-js/tests/fixtures/lerna-workspace/packages/ui/package.json b/packages/deslop-js/tests/fixtures/lerna-workspace/packages/ui/package.json deleted file mode 100644 index 0fef694db5..0000000000 --- a/packages/deslop-js/tests/fixtures/lerna-workspace/packages/ui/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "@fixture/ui", - "version": "1.0.0", - "main": "dist/index.js" -} diff --git a/packages/deslop-js/tests/fixtures/lerna-workspace/packages/ui/src/button.ts b/packages/deslop-js/tests/fixtures/lerna-workspace/packages/ui/src/button.ts deleted file mode 100644 index 47893f153d..0000000000 --- a/packages/deslop-js/tests/fixtures/lerna-workspace/packages/ui/src/button.ts +++ /dev/null @@ -1 +0,0 @@ -export const Button = "button"; diff --git a/packages/deslop-js/tests/fixtures/lerna-workspace/packages/ui/src/index.ts b/packages/deslop-js/tests/fixtures/lerna-workspace/packages/ui/src/index.ts deleted file mode 100644 index 398a3c5090..0000000000 --- a/packages/deslop-js/tests/fixtures/lerna-workspace/packages/ui/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Button } from "./button"; diff --git a/packages/deslop-js/tests/fixtures/lerna-workspace/packages/ui/src/orphan.ts b/packages/deslop-js/tests/fixtures/lerna-workspace/packages/ui/src/orphan.ts deleted file mode 100644 index 88f7cf20e0..0000000000 --- a/packages/deslop-js/tests/fixtures/lerna-workspace/packages/ui/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "orphan"; diff --git a/packages/deslop-js/tests/fixtures/local-use-in-exported-declaration/package.json b/packages/deslop-js/tests/fixtures/local-use-in-exported-declaration/package.json deleted file mode 100644 index 3cbd48a104..0000000000 --- a/packages/deslop-js/tests/fixtures/local-use-in-exported-declaration/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "local-use-in-exported-declaration", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/local-use-in-exported-declaration/src/guard.ts b/packages/deslop-js/tests/fixtures/local-use-in-exported-declaration/src/guard.ts deleted file mode 100644 index e96df2cc48..0000000000 --- a/packages/deslop-js/tests/fixtures/local-use-in-exported-declaration/src/guard.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const isLikelyBookTitleAuthorResult = (value: string): boolean => value.includes(" by "); - -export const splitAuthorSearchResults = (values: string[]): string[] => - values.filter((value) => isLikelyBookTitleAuthorResult(value)); - -export const neverReferencedAnywhere = (value: string): string => value.trim(); diff --git a/packages/deslop-js/tests/fixtures/local-use-in-exported-declaration/src/index.ts b/packages/deslop-js/tests/fixtures/local-use-in-exported-declaration/src/index.ts deleted file mode 100644 index 79fd569523..0000000000 --- a/packages/deslop-js/tests/fixtures/local-use-in-exported-declaration/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { splitAuthorSearchResults } from "./guard"; - -export const main = splitAuthorSearchResults(["Book by Author"]); diff --git a/packages/deslop-js/tests/fixtures/mdx-import/docs/intro.mdx b/packages/deslop-js/tests/fixtures/mdx-import/docs/intro.mdx deleted file mode 100644 index 30ff143e31..0000000000 --- a/packages/deslop-js/tests/fixtures/mdx-import/docs/intro.mdx +++ /dev/null @@ -1,5 +0,0 @@ -import { Chart } from "../src/components/Chart"; - -# Welcome - -<Chart /> diff --git a/packages/deslop-js/tests/fixtures/mdx-import/docusaurus.config.ts b/packages/deslop-js/tests/fixtures/mdx-import/docusaurus.config.ts deleted file mode 100644 index 5b1f54d17a..0000000000 --- a/packages/deslop-js/tests/fixtures/mdx-import/docusaurus.config.ts +++ /dev/null @@ -1 +0,0 @@ -export default { title: "Test" }; diff --git a/packages/deslop-js/tests/fixtures/mdx-import/orphan.ts b/packages/deslop-js/tests/fixtures/mdx-import/orphan.ts deleted file mode 100644 index bddae4ee9b..0000000000 --- a/packages/deslop-js/tests/fixtures/mdx-import/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = true; diff --git a/packages/deslop-js/tests/fixtures/mdx-import/package.json b/packages/deslop-js/tests/fixtures/mdx-import/package.json deleted file mode 100644 index b654b12c3e..0000000000 --- a/packages/deslop-js/tests/fixtures/mdx-import/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "mdx-imports-test", - "dependencies": { - "@docusaurus/core": "3.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/mdx-import/src/components/Chart.tsx b/packages/deslop-js/tests/fixtures/mdx-import/src/components/Chart.tsx deleted file mode 100644 index b87f00ea01..0000000000 --- a/packages/deslop-js/tests/fixtures/mdx-import/src/components/Chart.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Chart = () => "<div>Chart</div>"; diff --git a/packages/deslop-js/tests/fixtures/mdx-import/src/components/Unused.tsx b/packages/deslop-js/tests/fixtures/mdx-import/src/components/Unused.tsx deleted file mode 100644 index 00e34e9905..0000000000 --- a/packages/deslop-js/tests/fixtures/mdx-import/src/components/Unused.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Unused = () => "<div>Unused</div>"; diff --git a/packages/deslop-js/tests/fixtures/migration-orm/migrations/001-create-users.ts b/packages/deslop-js/tests/fixtures/migration-orm/migrations/001-create-users.ts deleted file mode 100644 index 1ca928e88a..0000000000 --- a/packages/deslop-js/tests/fixtures/migration-orm/migrations/001-create-users.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const up = () => {}; -export const down = () => {}; diff --git a/packages/deslop-js/tests/fixtures/migration-orm/package.json b/packages/deslop-js/tests/fixtures/migration-orm/package.json deleted file mode 100644 index 8c3205d0ea..0000000000 --- a/packages/deslop-js/tests/fixtures/migration-orm/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "orm-migrations", - "version": "1.0.0", - "dependencies": { - "knex": "^3.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/migration-orm/src/index.ts b/packages/deslop-js/tests/fixtures/migration-orm/src/index.ts deleted file mode 100644 index 2062d509ef..0000000000 --- a/packages/deslop-js/tests/fixtures/migration-orm/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const app = "hello"; diff --git a/packages/deslop-js/tests/fixtures/migration-orm/src/orphan.ts b/packages/deslop-js/tests/fixtures/migration-orm/src/orphan.ts deleted file mode 100644 index b79aa99b63..0000000000 --- a/packages/deslop-js/tests/fixtures/migration-orm/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = true; diff --git a/packages/deslop-js/tests/fixtures/migration-raw/migrations/001-create-users.ts b/packages/deslop-js/tests/fixtures/migration-raw/migrations/001-create-users.ts deleted file mode 100644 index 1ca928e88a..0000000000 --- a/packages/deslop-js/tests/fixtures/migration-raw/migrations/001-create-users.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const up = () => {}; -export const down = () => {}; diff --git a/packages/deslop-js/tests/fixtures/migration-raw/package.json b/packages/deslop-js/tests/fixtures/migration-raw/package.json deleted file mode 100644 index a1204ed5cf..0000000000 --- a/packages/deslop-js/tests/fixtures/migration-raw/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "migrations-no-orm", - "version": "1.0.0", - "dependencies": { - "express": "^4.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/migration-raw/src/index.ts b/packages/deslop-js/tests/fixtures/migration-raw/src/index.ts deleted file mode 100644 index 2062d509ef..0000000000 --- a/packages/deslop-js/tests/fixtures/migration-raw/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const app = "hello"; diff --git a/packages/deslop-js/tests/fixtures/misclassified-deps-typeonly/package.json b/packages/deslop-js/tests/fixtures/misclassified-deps-typeonly/package.json deleted file mode 100644 index 9d53146a58..0000000000 --- a/packages/deslop-js/tests/fixtures/misclassified-deps-typeonly/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "misclassified-deps-typeonly", - "main": "src/index.ts", - "dependencies": { - "mixed-use-lib": "^1.0.0", - "reexported-type-lib": "^1.0.0", - "reexported-value-lib": "^1.0.0", - "side-effect-lib": "^1.0.0", - "type-only-lib": "^1.0.0", - "value-used-lib": "^1.0.0" - }, - "devDependencies": { - "typescript": "^5.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/misclassified-deps-typeonly/src/index.ts b/packages/deslop-js/tests/fixtures/misclassified-deps-typeonly/src/index.ts deleted file mode 100644 index ed3a7bc5b5..0000000000 --- a/packages/deslop-js/tests/fixtures/misclassified-deps-typeonly/src/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { TypeOnlyShape } from "type-only-lib"; -import { realFunction } from "value-used-lib"; -import "side-effect-lib"; -import type { MixedShape } from "mixed-use-lib"; -import { mixedRuntime } from "mixed-use-lib"; - -export type { ReexportedType } from "reexported-type-lib"; -export { reexportedValue } from "reexported-value-lib"; - -export const consume = (shape: TypeOnlyShape, mixed: MixedShape): string => { - realFunction(); - mixedRuntime(); - return `${shape.kind}:${mixed.id}`; -}; diff --git a/packages/deslop-js/tests/fixtures/mock-patterns/package.json b/packages/deslop-js/tests/fixtures/mock-patterns/package.json deleted file mode 100644 index 2aa9450da1..0000000000 --- a/packages/deslop-js/tests/fixtures/mock-patterns/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "fixture-patterns", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "vitest": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/mock-patterns/src/__fixtures__/user-data.ts b/packages/deslop-js/tests/fixtures/mock-patterns/src/__fixtures__/user-data.ts deleted file mode 100644 index 58b4fbec77..0000000000 --- a/packages/deslop-js/tests/fixtures/mock-patterns/src/__fixtures__/user-data.ts +++ /dev/null @@ -1 +0,0 @@ -export const mockUser = { id: 1, name: "Test User" }; diff --git a/packages/deslop-js/tests/fixtures/mock-patterns/src/__mocks__/api-client.ts b/packages/deslop-js/tests/fixtures/mock-patterns/src/__mocks__/api-client.ts deleted file mode 100644 index 06bbaabe9a..0000000000 --- a/packages/deslop-js/tests/fixtures/mock-patterns/src/__mocks__/api-client.ts +++ /dev/null @@ -1 +0,0 @@ -export const fetch = () => Promise.resolve({}); diff --git a/packages/deslop-js/tests/fixtures/mock-patterns/src/fixtures/sample-data.json b/packages/deslop-js/tests/fixtures/mock-patterns/src/fixtures/sample-data.json deleted file mode 100644 index 275f9ea33a..0000000000 --- a/packages/deslop-js/tests/fixtures/mock-patterns/src/fixtures/sample-data.json +++ /dev/null @@ -1 +0,0 @@ -{ "items": [1, 2, 3] } diff --git a/packages/deslop-js/tests/fixtures/mock-patterns/src/index.ts b/packages/deslop-js/tests/fixtures/mock-patterns/src/index.ts deleted file mode 100644 index c5961da749..0000000000 --- a/packages/deslop-js/tests/fixtures/mock-patterns/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/mock-patterns/src/orphan.ts b/packages/deslop-js/tests/fixtures/mock-patterns/src/orphan.ts deleted file mode 100644 index a5c065a386..0000000000 --- a/packages/deslop-js/tests/fixtures/mock-patterns/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphanFunction = () => "not imported anywhere"; diff --git a/packages/deslop-js/tests/fixtures/module-side-effect/index.ts b/packages/deslop-js/tests/fixtures/module-side-effect/index.ts deleted file mode 100644 index a34c6323a3..0000000000 --- a/packages/deslop-js/tests/fixtures/module-side-effect/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import "./polyfill"; -import "./register"; -import { getValue } from "./lib"; - -console.log(getValue()); diff --git a/packages/deslop-js/tests/fixtures/module-side-effect/lib.ts b/packages/deslop-js/tests/fixtures/module-side-effect/lib.ts deleted file mode 100644 index b391355dc5..0000000000 --- a/packages/deslop-js/tests/fixtures/module-side-effect/lib.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const getValue = () => 42; -export const unusedHelper = () => "not used"; diff --git a/packages/deslop-js/tests/fixtures/module-side-effect/orphan.ts b/packages/deslop-js/tests/fixtures/module-side-effect/orphan.ts deleted file mode 100644 index af18f940e9..0000000000 --- a/packages/deslop-js/tests/fixtures/module-side-effect/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphanedModule = () => "not imported"; diff --git a/packages/deslop-js/tests/fixtures/module-side-effect/package.json b/packages/deslop-js/tests/fixtures/module-side-effect/package.json deleted file mode 100644 index 91fb45389b..0000000000 --- a/packages/deslop-js/tests/fixtures/module-side-effect/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "side-effect-only-module", - "main": "index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/module-side-effect/polyfill.ts b/packages/deslop-js/tests/fixtures/module-side-effect/polyfill.ts deleted file mode 100644 index 6e14b4ba61..0000000000 --- a/packages/deslop-js/tests/fixtures/module-side-effect/polyfill.ts +++ /dev/null @@ -1 +0,0 @@ -Object.assign(globalThis, { __polyfilled: true }); diff --git a/packages/deslop-js/tests/fixtures/module-side-effect/register.ts b/packages/deslop-js/tests/fixtures/module-side-effect/register.ts deleted file mode 100644 index be4006a4bf..0000000000 --- a/packages/deslop-js/tests/fixtures/module-side-effect/register.ts +++ /dev/null @@ -1 +0,0 @@ -export const registered = true; diff --git a/packages/deslop-js/tests/fixtures/monorepo-script-entry/package.json b/packages/deslop-js/tests/fixtures/monorepo-script-entry/package.json deleted file mode 100644 index 27e21b6357..0000000000 --- a/packages/deslop-js/tests/fixtures/monorepo-script-entry/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "monorepo-script-entry", - "version": "1.0.0", - "private": true, - "scripts": { - "internal-tools": "bun ./packages/sub/internal-tools/tui.ts" - } -} diff --git a/packages/deslop-js/tests/fixtures/monorepo-script-entry/packages/sub/internal-tools/renderer.ts b/packages/deslop-js/tests/fixtures/monorepo-script-entry/packages/sub/internal-tools/renderer.ts deleted file mode 100644 index 0c321806ec..0000000000 --- a/packages/deslop-js/tests/fixtures/monorepo-script-entry/packages/sub/internal-tools/renderer.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const renderApp = (): void => { - console.log("rendered"); -}; diff --git a/packages/deslop-js/tests/fixtures/monorepo-script-entry/packages/sub/internal-tools/tui.ts b/packages/deslop-js/tests/fixtures/monorepo-script-entry/packages/sub/internal-tools/tui.ts deleted file mode 100644 index 0fd87aacd5..0000000000 --- a/packages/deslop-js/tests/fixtures/monorepo-script-entry/packages/sub/internal-tools/tui.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { renderApp } from "./renderer"; - -renderApp(); diff --git a/packages/deslop-js/tests/fixtures/monorepo-script-entry/packages/sub/package.json b/packages/deslop-js/tests/fixtures/monorepo-script-entry/packages/sub/package.json deleted file mode 100644 index a5c7bf7220..0000000000 --- a/packages/deslop-js/tests/fixtures/monorepo-script-entry/packages/sub/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "sub", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/monorepo-script-entry/packages/sub/src/index.ts b/packages/deslop-js/tests/fixtures/monorepo-script-entry/packages/sub/src/index.ts deleted file mode 100644 index aea05c0694..0000000000 --- a/packages/deslop-js/tests/fixtures/monorepo-script-entry/packages/sub/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const value = "value"; diff --git a/packages/deslop-js/tests/fixtures/monorepo-script-entry/pnpm-workspace.yaml b/packages/deslop-js/tests/fixtures/monorepo-script-entry/pnpm-workspace.yaml deleted file mode 100644 index 924b55f42e..0000000000 --- a/packages/deslop-js/tests/fixtures/monorepo-script-entry/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - - packages/* diff --git a/packages/deslop-js/tests/fixtures/namespace-destructure-exports/package.json b/packages/deslop-js/tests/fixtures/namespace-destructure-exports/package.json deleted file mode 100644 index 3736600391..0000000000 --- a/packages/deslop-js/tests/fixtures/namespace-destructure-exports/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "namespace-destructure-exports", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/namespace-destructure-exports/src/index.ts b/packages/deslop-js/tests/fixtures/namespace-destructure-exports/src/index.ts deleted file mode 100644 index 6495da1a87..0000000000 --- a/packages/deslop-js/tests/fixtures/namespace-destructure-exports/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import * as testResources from "./resources"; - -const { noFocalPath } = testResources; - -export const main = noFocalPath; diff --git a/packages/deslop-js/tests/fixtures/namespace-destructure-exports/src/resources.ts b/packages/deslop-js/tests/fixtures/namespace-destructure-exports/src/resources.ts deleted file mode 100644 index cc48047114..0000000000 --- a/packages/deslop-js/tests/fixtures/namespace-destructure-exports/src/resources.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const noFocalPath = ["focal"]; - -export const orphanResource = ["orphan"]; diff --git a/packages/deslop-js/tests/fixtures/nested-dist-non-workspace/.gitignore b/packages/deslop-js/tests/fixtures/nested-dist-non-workspace/.gitignore deleted file mode 100644 index e32e6e1622..0000000000 --- a/packages/deslop-js/tests/fixtures/nested-dist-non-workspace/.gitignore +++ /dev/null @@ -1 +0,0 @@ -!apps/orphan/dist/ diff --git a/packages/deslop-js/tests/fixtures/nested-dist-non-workspace/apps/orphan/dist/index.mjs b/packages/deslop-js/tests/fixtures/nested-dist-non-workspace/apps/orphan/dist/index.mjs deleted file mode 100644 index 8d10176757..0000000000 --- a/packages/deslop-js/tests/fixtures/nested-dist-non-workspace/apps/orphan/dist/index.mjs +++ /dev/null @@ -1 +0,0 @@ -export const builtArtifact = "should not be analyzed"; diff --git a/packages/deslop-js/tests/fixtures/nested-dist-non-workspace/package.json b/packages/deslop-js/tests/fixtures/nested-dist-non-workspace/package.json deleted file mode 100644 index 7573276063..0000000000 --- a/packages/deslop-js/tests/fixtures/nested-dist-non-workspace/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "nested-dist-non-workspace", - "version": "1.0.0", - "private": true, - "workspaces": ["apps/*"], - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/nested-dist-non-workspace/src/index.ts b/packages/deslop-js/tests/fixtures/nested-dist-non-workspace/src/index.ts deleted file mode 100644 index aea05c0694..0000000000 --- a/packages/deslop-js/tests/fixtures/nested-dist-non-workspace/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const value = "value"; diff --git a/packages/deslop-js/tests/fixtures/nested-overrides/package.json b/packages/deslop-js/tests/fixtures/nested-overrides/package.json deleted file mode 100644 index 52ae80ff29..0000000000 --- a/packages/deslop-js/tests/fixtures/nested-overrides/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "nested-overrides", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "@typescript/native-preview": "^7.0.0-dev", - "unused-dep": "^1.0.0" - }, - "pnpm": { - "overrides": { - "eslint-config-custom@1.0.0": { - "typescript": "npm:@typescript/native-preview" - } - } - } -} diff --git a/packages/deslop-js/tests/fixtures/nested-overrides/src/index.ts b/packages/deslop-js/tests/fixtures/nested-overrides/src/index.ts deleted file mode 100644 index d1d32a17e8..0000000000 --- a/packages/deslop-js/tests/fixtures/nested-overrides/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const appVersion = "1.0.0"; diff --git a/packages/deslop-js/tests/fixtures/nestjs-app/package.json b/packages/deslop-js/tests/fixtures/nestjs-app/package.json deleted file mode 100644 index 40d4cb8cce..0000000000 --- a/packages/deslop-js/tests/fixtures/nestjs-app/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "nestjs-project", - "version": "1.0.0", - "dependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/nestjs-app/src/app.module.ts b/packages/deslop-js/tests/fixtures/nestjs-app/src/app.module.ts deleted file mode 100644 index 218010265c..0000000000 --- a/packages/deslop-js/tests/fixtures/nestjs-app/src/app.module.ts +++ /dev/null @@ -1 +0,0 @@ -export class AppModule {} diff --git a/packages/deslop-js/tests/fixtures/nestjs-app/src/main.ts b/packages/deslop-js/tests/fixtures/nestjs-app/src/main.ts deleted file mode 100644 index 9ac1a3e0ca..0000000000 --- a/packages/deslop-js/tests/fixtures/nestjs-app/src/main.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { NestFactory } from "@nestjs/core"; -import { AppModule } from "./app.module"; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - await app.listen(3000); -} -bootstrap(); diff --git a/packages/deslop-js/tests/fixtures/nestjs-app/src/orphan.ts b/packages/deslop-js/tests/fixtures/nestjs-app/src/orphan.ts deleted file mode 100644 index 2e2026e157..0000000000 --- a/packages/deslop-js/tests/fixtures/nestjs-app/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "orphan"; diff --git a/packages/deslop-js/tests/fixtures/nestjs-app/src/users.controller.ts b/packages/deslop-js/tests/fixtures/nestjs-app/src/users.controller.ts deleted file mode 100644 index 79ce7f39f4..0000000000 --- a/packages/deslop-js/tests/fixtures/nestjs-app/src/users.controller.ts +++ /dev/null @@ -1,5 +0,0 @@ -export class UsersController { - findAll() { - return []; - } -} diff --git a/packages/deslop-js/tests/fixtures/next-config-scope/examples/my-app/next.config.mjs b/packages/deslop-js/tests/fixtures/next-config-scope/examples/my-app/next.config.mjs deleted file mode 100644 index bcdda2671c..0000000000 --- a/packages/deslop-js/tests/fixtures/next-config-scope/examples/my-app/next.config.mjs +++ /dev/null @@ -1 +0,0 @@ -export default { reactStrictMode: true }; diff --git a/packages/deslop-js/tests/fixtures/next-config-scope/examples/my-app/package.json b/packages/deslop-js/tests/fixtures/next-config-scope/examples/my-app/package.json deleted file mode 100644 index 0390fae4ba..0000000000 --- a/packages/deslop-js/tests/fixtures/next-config-scope/examples/my-app/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "my-app", - "version": "1.0.0", - "dependencies": { - "next": "14.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/next-config-scope/package.json b/packages/deslop-js/tests/fixtures/next-config-scope/package.json deleted file mode 100644 index e70253c397..0000000000 --- a/packages/deslop-js/tests/fixtures/next-config-scope/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "next-config-global", - "version": "1.0.0", - "dependencies": { - "next": "14.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/next-config-scope/src/index.ts b/packages/deslop-js/tests/fixtures/next-config-scope/src/index.ts deleted file mode 100644 index 810730ffb6..0000000000 --- a/packages/deslop-js/tests/fixtures/next-config-scope/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = () => "main app"; diff --git a/packages/deslop-js/tests/fixtures/next-config-scope/src/orphan.ts b/packages/deslop-js/tests/fixtures/next-config-scope/src/orphan.ts deleted file mode 100644 index bd93db0a49..0000000000 --- a/packages/deslop-js/tests/fixtures/next-config-scope/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "unused"; diff --git a/packages/deslop-js/tests/fixtures/next-empty-tsconfig/package.json b/packages/deslop-js/tests/fixtures/next-empty-tsconfig/package.json deleted file mode 100644 index c7b16bcb0c..0000000000 --- a/packages/deslop-js/tests/fixtures/next-empty-tsconfig/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "nextjs-empty-tsconfig", - "private": true, - "dependencies": { - "next": "^14.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/next-empty-tsconfig/src/env.ts b/packages/deslop-js/tests/fixtures/next-empty-tsconfig/src/env.ts deleted file mode 100644 index 6ed2c38d77..0000000000 --- a/packages/deslop-js/tests/fixtures/next-empty-tsconfig/src/env.ts +++ /dev/null @@ -1 +0,0 @@ -export const env = { NODE_ENV: "development" }; diff --git a/packages/deslop-js/tests/fixtures/next-empty-tsconfig/src/index.ts b/packages/deslop-js/tests/fixtures/next-empty-tsconfig/src/index.ts deleted file mode 100644 index 257ba664e6..0000000000 --- a/packages/deslop-js/tests/fixtures/next-empty-tsconfig/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { env } from "@/env"; -export const main = () => env; diff --git a/packages/deslop-js/tests/fixtures/next-empty-tsconfig/src/orphan.ts b/packages/deslop-js/tests/fixtures/next-empty-tsconfig/src/orphan.ts deleted file mode 100644 index 2e65a50551..0000000000 --- a/packages/deslop-js/tests/fixtures/next-empty-tsconfig/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "never imported"; diff --git a/packages/deslop-js/tests/fixtures/next-empty-tsconfig/tsconfig.json b/packages/deslop-js/tests/fixtures/next-empty-tsconfig/tsconfig.json deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/deslop-js/tests/fixtures/next-middleware/instrumentation.ts b/packages/deslop-js/tests/fixtures/next-middleware/instrumentation.ts deleted file mode 100644 index 605be34cb4..0000000000 --- a/packages/deslop-js/tests/fixtures/next-middleware/instrumentation.ts +++ /dev/null @@ -1 +0,0 @@ -export const register = () => {}; diff --git a/packages/deslop-js/tests/fixtures/next-middleware/orphan.ts b/packages/deslop-js/tests/fixtures/next-middleware/orphan.ts deleted file mode 100644 index b79aa99b63..0000000000 --- a/packages/deslop-js/tests/fixtures/next-middleware/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = true; diff --git a/packages/deslop-js/tests/fixtures/next-middleware/package.json b/packages/deslop-js/tests/fixtures/next-middleware/package.json deleted file mode 100644 index cc8fb51c38..0000000000 --- a/packages/deslop-js/tests/fixtures/next-middleware/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "nextjs-middleware", - "version": "1.0.0", - "dependencies": { - "next": "^14.0.0", - "react": "^18.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/next-middleware/proxy.ts b/packages/deslop-js/tests/fixtures/next-middleware/proxy.ts deleted file mode 100644 index 905ef4ad5f..0000000000 --- a/packages/deslop-js/tests/fixtures/next-middleware/proxy.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const proxy = (request: unknown) => request; -export const config = { matcher: ["/((?!_next/static).*)"] }; diff --git a/packages/deslop-js/tests/fixtures/next-middleware/src/auth.ts b/packages/deslop-js/tests/fixtures/next-middleware/src/auth.ts deleted file mode 100644 index 6f84827135..0000000000 --- a/packages/deslop-js/tests/fixtures/next-middleware/src/auth.ts +++ /dev/null @@ -1 +0,0 @@ -export const authCheck = (request: unknown) => request; diff --git a/packages/deslop-js/tests/fixtures/next-middleware/src/middleware.ts b/packages/deslop-js/tests/fixtures/next-middleware/src/middleware.ts deleted file mode 100644 index 1cb9803f15..0000000000 --- a/packages/deslop-js/tests/fixtures/next-middleware/src/middleware.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { authCheck } from "./auth"; -export const middleware = (request: unknown) => authCheck(request); diff --git a/packages/deslop-js/tests/fixtures/next-pages-mdx/package.json b/packages/deslop-js/tests/fixtures/next-pages-mdx/package.json deleted file mode 100644 index 88f9f0b575..0000000000 --- a/packages/deslop-js/tests/fixtures/next-pages-mdx/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "nextjs-pages-mdx", - "version": "1.0.0", - "dependencies": { - "next": "^14.0.0", - "react": "^18.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/next-pages-mdx/pages/about.mdx b/packages/deslop-js/tests/fixtures/next-pages-mdx/pages/about.mdx deleted file mode 100644 index 088a37fe69..0000000000 --- a/packages/deslop-js/tests/fixtures/next-pages-mdx/pages/about.mdx +++ /dev/null @@ -1,3 +0,0 @@ -# About - -This is a standalone MDX page. diff --git a/packages/deslop-js/tests/fixtures/next-pages-mdx/pages/index.tsx b/packages/deslop-js/tests/fixtures/next-pages-mdx/pages/index.tsx deleted file mode 100644 index a59e69ac6a..0000000000 --- a/packages/deslop-js/tests/fixtures/next-pages-mdx/pages/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { Home } from "../src/Home"; -export default function Page() { - return <Home />; -} diff --git a/packages/deslop-js/tests/fixtures/next-pages-mdx/src/Home.ts b/packages/deslop-js/tests/fixtures/next-pages-mdx/src/Home.ts deleted file mode 100644 index bd986ea683..0000000000 --- a/packages/deslop-js/tests/fixtures/next-pages-mdx/src/Home.ts +++ /dev/null @@ -1 +0,0 @@ -export const Home = "home"; diff --git a/packages/deslop-js/tests/fixtures/next-pages-mdx/src/orphan.ts b/packages/deslop-js/tests/fixtures/next-pages-mdx/src/orphan.ts deleted file mode 100644 index b79aa99b63..0000000000 --- a/packages/deslop-js/tests/fixtures/next-pages-mdx/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = true; diff --git a/packages/deslop-js/tests/fixtures/ns-chain/consumer.ts b/packages/deslop-js/tests/fixtures/ns-chain/consumer.ts deleted file mode 100644 index 7721ccb9c5..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-chain/consumer.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { helpers } from "./index"; - -const result = helpers.add(1, 2); -console.log(result); diff --git a/packages/deslop-js/tests/fixtures/ns-chain/helpers.ts b/packages/deslop-js/tests/fixtures/ns-chain/helpers.ts deleted file mode 100644 index 4b2f22fa7e..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-chain/helpers.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const add = (a: number, b: number) => a + b; -export const subtract = (a: number, b: number) => a - b; -export const multiply = (a: number, b: number) => a * b; diff --git a/packages/deslop-js/tests/fixtures/ns-chain/index.ts b/packages/deslop-js/tests/fixtures/ns-chain/index.ts deleted file mode 100644 index 9d017ad7f3..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-chain/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import * as helpers from "./helpers"; -export { helpers }; diff --git a/packages/deslop-js/tests/fixtures/ns-chain/package.json b/packages/deslop-js/tests/fixtures/ns-chain/package.json deleted file mode 100644 index 4857902622..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-chain/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "namespace-chain-reexport", - "main": "index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/ns-chain/unused-module.ts b/packages/deslop-js/tests/fixtures/ns-chain/unused-module.ts deleted file mode 100644 index 01066ec42c..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-chain/unused-module.ts +++ /dev/null @@ -1 +0,0 @@ -export const neverUsed = () => "orphan"; diff --git a/packages/deslop-js/tests/fixtures/ns-exports/package.json b/packages/deslop-js/tests/fixtures/ns-exports/package.json deleted file mode 100644 index b82ed33131..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-exports/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "namespace-exports", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/ns-exports/src/helpers.ts b/packages/deslop-js/tests/fixtures/ns-exports/src/helpers.ts deleted file mode 100644 index b782d529ca..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-exports/src/helpers.ts +++ /dev/null @@ -1,11 +0,0 @@ -export namespace BusinessHelper { - export async function inviteSupplier() { - return true; - } - - export async function toggleSuspension(supplier: string) { - return supplier; - } - - export const API_URL = "https://example.com"; -} diff --git a/packages/deslop-js/tests/fixtures/ns-exports/src/index.ts b/packages/deslop-js/tests/fixtures/ns-exports/src/index.ts deleted file mode 100644 index 8fb38d21c1..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-exports/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BusinessHelper } from "./helpers"; - -async function main() { - await BusinessHelper.inviteSupplier(); - await BusinessHelper.toggleSuspension("acme"); - console.log(BusinessHelper.API_URL); -} - -main(); diff --git a/packages/deslop-js/tests/fixtures/ns-forin/package.json b/packages/deslop-js/tests/fixtures/ns-forin/package.json deleted file mode 100644 index 5d4cfe2de1..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-forin/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "namespace-forin", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/ns-forin/src/config.ts b/packages/deslop-js/tests/fixtures/ns-forin/src/config.ts deleted file mode 100644 index 87ca1e7196..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-forin/src/config.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const debug = false; -export const verbose = true; -export const timeout = 5000; diff --git a/packages/deslop-js/tests/fixtures/ns-forin/src/index.ts b/packages/deslop-js/tests/fixtures/ns-forin/src/index.ts deleted file mode 100644 index 50e44fb7de..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-forin/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import * as config from "./config"; -for (const key in config) { - console.log(key); -} diff --git a/packages/deslop-js/tests/fixtures/ns-imports/package.json b/packages/deslop-js/tests/fixtures/ns-imports/package.json deleted file mode 100644 index 2891fd533a..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-imports/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "namespace-imports", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/ns-imports/src/index.ts b/packages/deslop-js/tests/fixtures/ns-imports/src/index.ts deleted file mode 100644 index 71f39c0830..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-imports/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import * as utils from "./utils"; -console.log(utils.foo); diff --git a/packages/deslop-js/tests/fixtures/ns-imports/src/utils.ts b/packages/deslop-js/tests/fixtures/ns-imports/src/utils.ts deleted file mode 100644 index 1ebedda40d..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-imports/src/utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const foo = "foo"; -export const bar = "bar"; -export const baz = "baz"; diff --git a/packages/deslop-js/tests/fixtures/ns-partial/package.json b/packages/deslop-js/tests/fixtures/ns-partial/package.json deleted file mode 100644 index 964a5dd39d..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-partial/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "namespace-partial-access", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/ns-partial/src/index.ts b/packages/deslop-js/tests/fixtures/ns-partial/src/index.ts deleted file mode 100644 index ab5ce79766..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-partial/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import * as math from "./math"; -console.log(math.add(1, 2)); -console.log(math.multiply(3, 4)); diff --git a/packages/deslop-js/tests/fixtures/ns-partial/src/math.ts b/packages/deslop-js/tests/fixtures/ns-partial/src/math.ts deleted file mode 100644 index 2c31f3f579..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-partial/src/math.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const add = (a: number, b: number) => a + b; -export const subtract = (a: number, b: number) => a - b; -export const multiply = (a: number, b: number) => a * b; -export const divide = (a: number, b: number) => a / b; diff --git a/packages/deslop-js/tests/fixtures/ns-reexport/package.json b/packages/deslop-js/tests/fixtures/ns-reexport/package.json deleted file mode 100644 index db284c96f0..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-reexport/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "namespace-barrel-reexport", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/ns-reexport/src/index.ts b/packages/deslop-js/tests/fixtures/ns-reexport/src/index.ts deleted file mode 100644 index a4ca38d59f..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-reexport/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import * as lib from "./lib"; -console.log(lib.helperA()); diff --git a/packages/deslop-js/tests/fixtures/ns-reexport/src/lib/helpers.ts b/packages/deslop-js/tests/fixtures/ns-reexport/src/lib/helpers.ts deleted file mode 100644 index abc67aa417..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-reexport/src/lib/helpers.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const helperA = () => "a"; -export const helperB = () => "b"; -export const helperC = () => "c"; diff --git a/packages/deslop-js/tests/fixtures/ns-reexport/src/lib/index.ts b/packages/deslop-js/tests/fixtures/ns-reexport/src/lib/index.ts deleted file mode 100644 index d4e09d7b43..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-reexport/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./helpers"; diff --git a/packages/deslop-js/tests/fixtures/ns-spread/package.json b/packages/deslop-js/tests/fixtures/ns-spread/package.json deleted file mode 100644 index 398f237619..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-spread/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "namespace-spread", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/ns-spread/src/index.ts b/packages/deslop-js/tests/fixtures/ns-spread/src/index.ts deleted file mode 100644 index f2b68dfe1c..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-spread/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import * as utils from "./utils"; -const merged = { ...utils, extra: true }; -console.log(merged); diff --git a/packages/deslop-js/tests/fixtures/ns-spread/src/utils.ts b/packages/deslop-js/tests/fixtures/ns-spread/src/utils.ts deleted file mode 100644 index 1ebedda40d..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-spread/src/utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const foo = "foo"; -export const bar = "bar"; -export const baz = "baz"; diff --git a/packages/deslop-js/tests/fixtures/ns-whole/package.json b/packages/deslop-js/tests/fixtures/ns-whole/package.json deleted file mode 100644 index fe42f81089..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-whole/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "namespace-whole-object", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/ns-whole/src/index.ts b/packages/deslop-js/tests/fixtures/ns-whole/src/index.ts deleted file mode 100644 index 0da01c2b49..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-whole/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import * as utils from "./utils"; -const allValues = Object.values(utils); -console.log(allValues); diff --git a/packages/deslop-js/tests/fixtures/ns-whole/src/utils.ts b/packages/deslop-js/tests/fixtures/ns-whole/src/utils.ts deleted file mode 100644 index 1ebedda40d..0000000000 --- a/packages/deslop-js/tests/fixtures/ns-whole/src/utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const foo = "foo"; -export const bar = "bar"; -export const baz = "baz"; diff --git a/packages/deslop-js/tests/fixtures/numeric-keys-types/package.json b/packages/deslop-js/tests/fixtures/numeric-keys-types/package.json deleted file mode 100644 index ed4d1f3452..0000000000 --- a/packages/deslop-js/tests/fixtures/numeric-keys-types/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "numeric-keys-types", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/numeric-keys-types/src/index.ts b/packages/deslop-js/tests/fixtures/numeric-keys-types/src/index.ts deleted file mode 100644 index 8e0276b2aa..0000000000 --- a/packages/deslop-js/tests/fixtures/numeric-keys-types/src/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface HttpStatusMap { - 200: "OK"; - 404: "NotFound"; - 500: "InternalServerError"; -} - -export type ResponseShape = { - [200]: { ok: true }; - [404]: { error: "missing" }; -}; - -export interface IndexSignatureShape { - [key: string]: number; - (input: string): void; - readonly tag: symbol; -} - -export const sample: HttpStatusMap = { 200: "OK", 404: "NotFound", 500: "InternalServerError" }; - -console.log(sample); diff --git a/packages/deslop-js/tests/fixtures/optional-deps/orphan.ts b/packages/deslop-js/tests/fixtures/optional-deps/orphan.ts deleted file mode 100644 index b79aa99b63..0000000000 --- a/packages/deslop-js/tests/fixtures/optional-deps/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = true; diff --git a/packages/deslop-js/tests/fixtures/optional-deps/package.json b/packages/deslop-js/tests/fixtures/optional-deps/package.json deleted file mode 100644 index 656e17f6ee..0000000000 --- a/packages/deslop-js/tests/fixtures/optional-deps/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "optional-dependencies-tooling", - "version": "1.0.0", - "main": "src/index.ts", - "dependencies": { - "react": "18.0.0" - }, - "optionalDependencies": { - "sanity": "5.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/optional-deps/sanity.cli.ts b/packages/deslop-js/tests/fixtures/optional-deps/sanity.cli.ts deleted file mode 100644 index bfcbfc03d7..0000000000 --- a/packages/deslop-js/tests/fixtures/optional-deps/sanity.cli.ts +++ /dev/null @@ -1 +0,0 @@ -export default { api: { projectId: "test" } }; diff --git a/packages/deslop-js/tests/fixtures/optional-deps/sanity.config.ts b/packages/deslop-js/tests/fixtures/optional-deps/sanity.config.ts deleted file mode 100644 index 17e3fa2db5..0000000000 --- a/packages/deslop-js/tests/fixtures/optional-deps/sanity.config.ts +++ /dev/null @@ -1 +0,0 @@ -export default { projectId: "test" }; diff --git a/packages/deslop-js/tests/fixtures/optional-deps/src/index.ts b/packages/deslop-js/tests/fixtures/optional-deps/src/index.ts deleted file mode 100644 index c5961da749..0000000000 --- a/packages/deslop-js/tests/fixtures/optional-deps/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/orphan-barrel-subtree/package.json b/packages/deslop-js/tests/fixtures/orphan-barrel-subtree/package.json deleted file mode 100644 index 01b74b2d04..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-barrel-subtree/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unreachable-barrel-subtree", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/orphan-barrel-subtree/src/index.ts b/packages/deslop-js/tests/fixtures/orphan-barrel-subtree/src/index.ts deleted file mode 100644 index 057c4e0e23..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-barrel-subtree/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Entry point — does not import anything from the dead subtree. -export const main = (): void => { - console.log("app"); -}; diff --git a/packages/deslop-js/tests/fixtures/orphan-barrel-subtree/src/subtree/setup.ts b/packages/deslop-js/tests/fixtures/orphan-barrel-subtree/src/subtree/setup.ts deleted file mode 100644 index 587456dc56..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-barrel-subtree/src/subtree/setup.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Dead root that imports through a barrel. -import { helper } from "./tabs"; - -export const setup = (): string => helper(); diff --git a/packages/deslop-js/tests/fixtures/orphan-barrel-subtree/src/subtree/tabs/helpers.ts b/packages/deslop-js/tests/fixtures/orphan-barrel-subtree/src/subtree/tabs/helpers.ts deleted file mode 100644 index 68b2b31386..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-barrel-subtree/src/subtree/tabs/helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = (): string => "helper"; diff --git a/packages/deslop-js/tests/fixtures/orphan-barrel-subtree/src/subtree/tabs/index.ts b/packages/deslop-js/tests/fixtures/orphan-barrel-subtree/src/subtree/tabs/index.ts deleted file mode 100644 index 9a764c67ba..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-barrel-subtree/src/subtree/tabs/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Dead barrel in the middle of the subtree. -export { helper } from "./helpers"; diff --git a/packages/deslop-js/tests/fixtures/orphan-dynamic-subtree/package.json b/packages/deslop-js/tests/fixtures/orphan-dynamic-subtree/package.json deleted file mode 100644 index 1dba3becb2..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-dynamic-subtree/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unreachable-dynamic-subtree", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/orphan-dynamic-subtree/src/index.ts b/packages/deslop-js/tests/fixtures/orphan-dynamic-subtree/src/index.ts deleted file mode 100644 index 057c4e0e23..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-dynamic-subtree/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Entry point — does not import anything from the dead subtree. -export const main = (): void => { - console.log("app"); -}; diff --git a/packages/deslop-js/tests/fixtures/orphan-dynamic-subtree/src/subtree/lazy.ts b/packages/deslop-js/tests/fixtures/orphan-dynamic-subtree/src/subtree/lazy.ts deleted file mode 100644 index 7c84e36df0..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-dynamic-subtree/src/subtree/lazy.ts +++ /dev/null @@ -1 +0,0 @@ -export const lazyValue = (): string => "lazy"; diff --git a/packages/deslop-js/tests/fixtures/orphan-dynamic-subtree/src/subtree/setup.ts b/packages/deslop-js/tests/fixtures/orphan-dynamic-subtree/src/subtree/setup.ts deleted file mode 100644 index 1d8cbedaa0..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-dynamic-subtree/src/subtree/setup.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Dead root that dynamically imports a child module. -const lazyModule = import("./lazy"); - -console.log(lazyModule); - -export const setup = (): string => "setup"; diff --git a/packages/deslop-js/tests/fixtures/orphan-mixed-exports/package.json b/packages/deslop-js/tests/fixtures/orphan-mixed-exports/package.json deleted file mode 100644 index c77594aca6..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-mixed-exports/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unreachable-mixed-exports", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/orphan-mixed-exports/src/index.ts b/packages/deslop-js/tests/fixtures/orphan-mixed-exports/src/index.ts deleted file mode 100644 index a4944f0e12..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-mixed-exports/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Entry point — does not import anything from test-utils -export const main = (): void => { - console.log("app"); -}; diff --git a/packages/deslop-js/tests/fixtures/orphan-mixed-exports/src/test-utils/helpers.ts b/packages/deslop-js/tests/fixtures/orphan-mixed-exports/src/test-utils/helpers.ts deleted file mode 100644 index fc66f295f3..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-mixed-exports/src/test-utils/helpers.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Both exports are unreachable from the entry point. -// usedHelper is imported by setup.ts (also unreachable) — should still be flagged. -// unusedHelper is not imported by anyone — should be flagged. - -export const usedHelper = (): string => "used"; - -export const unusedHelper = (): string => "unused"; diff --git a/packages/deslop-js/tests/fixtures/orphan-mixed-exports/src/test-utils/setup.ts b/packages/deslop-js/tests/fixtures/orphan-mixed-exports/src/test-utils/setup.ts deleted file mode 100644 index 3dd4e77775..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-mixed-exports/src/test-utils/setup.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Also unreachable from the entry point. -// Imports usedHelper from helpers — but since this file is also unreachable, -// the reference should not save usedHelper from being flagged. - -import { usedHelper } from "./helpers"; - -export const setup = (): string => usedHelper(); diff --git a/packages/deslop-js/tests/fixtures/orphan-shared-child/package.json b/packages/deslop-js/tests/fixtures/orphan-shared-child/package.json deleted file mode 100644 index 14f47514d9..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-shared-child/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unreachable-shared-child", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/orphan-shared-child/src/index.ts b/packages/deslop-js/tests/fixtures/orphan-shared-child/src/index.ts deleted file mode 100644 index c900ef39fc..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-shared-child/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { sharedValue } from "./shared/utils"; - -console.log(sharedValue()); diff --git a/packages/deslop-js/tests/fixtures/orphan-shared-child/src/shared/utils.ts b/packages/deslop-js/tests/fixtures/orphan-shared-child/src/shared/utils.ts deleted file mode 100644 index ed536c225f..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-shared-child/src/shared/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const sharedValue = (): string => "shared"; diff --git a/packages/deslop-js/tests/fixtures/orphan-shared-child/src/subtree/helpers.ts b/packages/deslop-js/tests/fixtures/orphan-shared-child/src/subtree/helpers.ts deleted file mode 100644 index 68b2b31386..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-shared-child/src/subtree/helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = (): string => "helper"; diff --git a/packages/deslop-js/tests/fixtures/orphan-shared-child/src/subtree/setup.ts b/packages/deslop-js/tests/fixtures/orphan-shared-child/src/subtree/setup.ts deleted file mode 100644 index 9f802940b2..0000000000 --- a/packages/deslop-js/tests/fixtures/orphan-shared-child/src/subtree/setup.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Dead root that imports one dead child and one child that is also reachable elsewhere. -import { helper } from "./helpers"; -import { sharedValue } from "../shared/utils"; - -console.log(helper(), sharedValue()); - -export const setup = (): string => "setup"; diff --git a/packages/deslop-js/tests/fixtures/outdir-mapping/main/index.ts b/packages/deslop-js/tests/fixtures/outdir-mapping/main/index.ts deleted file mode 100644 index 6b1cffa43b..0000000000 --- a/packages/deslop-js/tests/fixtures/outdir-mapping/main/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { setup } from "./setup"; - -setup(); diff --git a/packages/deslop-js/tests/fixtures/outdir-mapping/main/orphan.ts b/packages/deslop-js/tests/fixtures/outdir-mapping/main/orphan.ts deleted file mode 100644 index af0a341bc7..0000000000 --- a/packages/deslop-js/tests/fixtures/outdir-mapping/main/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "not imported"; diff --git a/packages/deslop-js/tests/fixtures/outdir-mapping/main/setup.ts b/packages/deslop-js/tests/fixtures/outdir-mapping/main/setup.ts deleted file mode 100644 index c4458eece3..0000000000 --- a/packages/deslop-js/tests/fixtures/outdir-mapping/main/setup.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const setup = () => { - console.log("setup"); -}; diff --git a/packages/deslop-js/tests/fixtures/outdir-mapping/package.json b/packages/deslop-js/tests/fixtures/outdir-mapping/package.json deleted file mode 100644 index 377e6eb095..0000000000 --- a/packages/deslop-js/tests/fixtures/outdir-mapping/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "outdir-source-map-test", - "version": "1.0.0", - "main": "dist/index.js", - "dependencies": { - "electron": "^28.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/outdir-mapping/tsconfig.json b/packages/deslop-js/tests/fixtures/outdir-mapping/tsconfig.json deleted file mode 100644 index 346a532208..0000000000 --- a/packages/deslop-js/tests/fixtures/outdir-mapping/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "compilerOptions": { - "outDir": "dist", - "rootDir": "main", - "target": "ES2020", - "module": "commonjs" - }, - "include": ["main/**/*"] -} diff --git a/packages/deslop-js/tests/fixtures/path-alias-specificity/general/feature/thing.ts b/packages/deslop-js/tests/fixtures/path-alias-specificity/general/feature/thing.ts deleted file mode 100644 index 04e1f35334..0000000000 --- a/packages/deslop-js/tests/fixtures/path-alias-specificity/general/feature/thing.ts +++ /dev/null @@ -1 +0,0 @@ -export const thing = (): number => 2; diff --git a/packages/deslop-js/tests/fixtures/path-alias-specificity/package.json b/packages/deslop-js/tests/fixtures/path-alias-specificity/package.json deleted file mode 100644 index a03f3c6019..0000000000 --- a/packages/deslop-js/tests/fixtures/path-alias-specificity/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "path-alias-specificity", - "version": "1.0.0", - "private": true, - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/path-alias-specificity/special/thing.ts b/packages/deslop-js/tests/fixtures/path-alias-specificity/special/thing.ts deleted file mode 100644 index 015f01499c..0000000000 --- a/packages/deslop-js/tests/fixtures/path-alias-specificity/special/thing.ts +++ /dev/null @@ -1 +0,0 @@ -export const thing = (): number => 1; diff --git a/packages/deslop-js/tests/fixtures/path-alias-specificity/src/index.ts b/packages/deslop-js/tests/fixtures/path-alias-specificity/src/index.ts deleted file mode 100644 index 3248582daa..0000000000 --- a/packages/deslop-js/tests/fixtures/path-alias-specificity/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { thing } from "@x/feature/thing"; - -export const run = (): number => thing(); diff --git a/packages/deslop-js/tests/fixtures/playwright-ext/index.ts b/packages/deslop-js/tests/fixtures/playwright-ext/index.ts deleted file mode 100644 index 22d7102859..0000000000 --- a/packages/deslop-js/tests/fixtures/playwright-ext/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = () => "app"; diff --git a/packages/deslop-js/tests/fixtures/playwright-ext/my-test.pw.ts b/packages/deslop-js/tests/fixtures/playwright-ext/my-test.pw.ts deleted file mode 100644 index 55a4dc1527..0000000000 --- a/packages/deslop-js/tests/fixtures/playwright-ext/my-test.pw.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { main } from "./index"; -export const test = () => main(); diff --git a/packages/deslop-js/tests/fixtures/playwright-ext/orphan.ts b/packages/deslop-js/tests/fixtures/playwright-ext/orphan.ts deleted file mode 100644 index eace45e1a2..0000000000 --- a/packages/deslop-js/tests/fixtures/playwright-ext/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = () => "orphan"; diff --git a/packages/deslop-js/tests/fixtures/playwright-ext/package.json b/packages/deslop-js/tests/fixtures/playwright-ext/package.json deleted file mode 100644 index cc4ab4a05f..0000000000 --- a/packages/deslop-js/tests/fixtures/playwright-ext/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "playwright-pw-extension", - "private": true, - "devDependencies": { - "@playwright/test": "1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/playwright-lib/e2e/login.spec.ts b/packages/deslop-js/tests/fixtures/playwright-lib/e2e/login.spec.ts deleted file mode 100644 index 1ee9032061..0000000000 --- a/packages/deslop-js/tests/fixtures/playwright-lib/e2e/login.spec.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { test, expect } from "@playwright/test"; -test("login", () => {}); diff --git a/packages/deslop-js/tests/fixtures/playwright-lib/index.ts b/packages/deslop-js/tests/fixtures/playwright-lib/index.ts deleted file mode 100644 index db73c9fd41..0000000000 --- a/packages/deslop-js/tests/fixtures/playwright-lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const app = "main"; diff --git a/packages/deslop-js/tests/fixtures/playwright-lib/lib/helpers.ts b/packages/deslop-js/tests/fixtures/playwright-lib/lib/helpers.ts deleted file mode 100644 index aaececc016..0000000000 --- a/packages/deslop-js/tests/fixtures/playwright-lib/lib/helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export const waitForLoad = () => {}; diff --git a/packages/deslop-js/tests/fixtures/playwright-lib/orphan.ts b/packages/deslop-js/tests/fixtures/playwright-lib/orphan.ts deleted file mode 100644 index 9e3c922b2e..0000000000 --- a/packages/deslop-js/tests/fixtures/playwright-lib/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "dead code"; diff --git a/packages/deslop-js/tests/fixtures/playwright-lib/package.json b/packages/deslop-js/tests/fixtures/playwright-lib/package.json deleted file mode 100644 index dce0873e5c..0000000000 --- a/packages/deslop-js/tests/fixtures/playwright-lib/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "playwright-lib-support", - "private": true, - "devDependencies": { - "@playwright/test": "1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/playwright-lib/support/commands.ts b/packages/deslop-js/tests/fixtures/playwright-lib/support/commands.ts deleted file mode 100644 index f00eace31a..0000000000 --- a/packages/deslop-js/tests/fixtures/playwright-lib/support/commands.ts +++ /dev/null @@ -1 +0,0 @@ -export const login = () => {}; diff --git a/packages/deslop-js/tests/fixtures/playwright-lib/tests/smoke.spec.ts b/packages/deslop-js/tests/fixtures/playwright-lib/tests/smoke.spec.ts deleted file mode 100644 index 2e1b76f026..0000000000 --- a/packages/deslop-js/tests/fixtures/playwright-lib/tests/smoke.spec.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { test, expect } from "@playwright/test"; -test("smoke", () => {}); diff --git a/packages/deslop-js/tests/fixtures/pnpm-nested-overrides/package.json b/packages/deslop-js/tests/fixtures/pnpm-nested-overrides/package.json deleted file mode 100644 index 7abefa8b25..0000000000 --- a/packages/deslop-js/tests/fixtures/pnpm-nested-overrides/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "pnpm-nested-overrides", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "@typescript/native-preview": "^7.0.0-dev", - "unused-dep": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/pnpm-nested-overrides/pnpm-workspace.yaml b/packages/deslop-js/tests/fixtures/pnpm-nested-overrides/pnpm-workspace.yaml deleted file mode 100644 index 7a4c357c5a..0000000000 --- a/packages/deslop-js/tests/fixtures/pnpm-nested-overrides/pnpm-workspace.yaml +++ /dev/null @@ -1,7 +0,0 @@ -packages: - - "." - -pnpm: - overrides: - eslint-config-custom@1.0.0: - typescript: npm:@typescript/native-preview@7.0.0-dev diff --git a/packages/deslop-js/tests/fixtures/pnpm-nested-overrides/src/index.ts b/packages/deslop-js/tests/fixtures/pnpm-nested-overrides/src/index.ts deleted file mode 100644 index d1d32a17e8..0000000000 --- a/packages/deslop-js/tests/fixtures/pnpm-nested-overrides/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const appVersion = "1.0.0"; diff --git a/packages/deslop-js/tests/fixtures/pnpm-workspace-override/package.json b/packages/deslop-js/tests/fixtures/pnpm-workspace-override/package.json deleted file mode 100644 index b430137442..0000000000 --- a/packages/deslop-js/tests/fixtures/pnpm-workspace-override/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "pnpm-workspace-override", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "@voidzero-dev/vite-plus-core": "^0.1.20", - "unused-dep": "^1.0.0", - "vite-plus": "^0.1.20" - } -} diff --git a/packages/deslop-js/tests/fixtures/pnpm-workspace-override/pnpm-workspace.yaml b/packages/deslop-js/tests/fixtures/pnpm-workspace-override/pnpm-workspace.yaml deleted file mode 100644 index 7ee7ebd083..0000000000 --- a/packages/deslop-js/tests/fixtures/pnpm-workspace-override/pnpm-workspace.yaml +++ /dev/null @@ -1,5 +0,0 @@ -packages: - - "." - -overrides: - vite: npm:@voidzero-dev/vite-plus-core@^0.1.20 diff --git a/packages/deslop-js/tests/fixtures/pnpm-workspace-override/src/index.ts b/packages/deslop-js/tests/fixtures/pnpm-workspace-override/src/index.ts deleted file mode 100644 index 8ffe822bcd..0000000000 --- a/packages/deslop-js/tests/fixtures/pnpm-workspace-override/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineConfig } from "vite-plus"; - -export default defineConfig({}); diff --git a/packages/deslop-js/tests/fixtures/pnpm-workspace-override/vite.config.ts b/packages/deslop-js/tests/fixtures/pnpm-workspace-override/vite.config.ts deleted file mode 100644 index 8ffe822bcd..0000000000 --- a/packages/deslop-js/tests/fixtures/pnpm-workspace-override/vite.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineConfig } from "vite-plus"; - -export default defineConfig({}); diff --git a/packages/deslop-js/tests/fixtures/polyrepo/orphan-dir/stray.ts b/packages/deslop-js/tests/fixtures/polyrepo/orphan-dir/stray.ts deleted file mode 100644 index 162533484f..0000000000 --- a/packages/deslop-js/tests/fixtures/polyrepo/orphan-dir/stray.ts +++ /dev/null @@ -1 +0,0 @@ -export const stray = "not in any project"; diff --git a/packages/deslop-js/tests/fixtures/polyrepo/project-a/package.json b/packages/deslop-js/tests/fixtures/polyrepo/project-a/package.json deleted file mode 100644 index 6483e84e90..0000000000 --- a/packages/deslop-js/tests/fixtures/polyrepo/project-a/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "project-a", - "main": "./lib/index.js" -} diff --git a/packages/deslop-js/tests/fixtures/polyrepo/project-a/src/helper.ts b/packages/deslop-js/tests/fixtures/polyrepo/project-a/src/helper.ts deleted file mode 100644 index 9fa1cd03a4..0000000000 --- a/packages/deslop-js/tests/fixtures/polyrepo/project-a/src/helper.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = () => "help"; diff --git a/packages/deslop-js/tests/fixtures/polyrepo/project-a/src/index.ts b/packages/deslop-js/tests/fixtures/polyrepo/project-a/src/index.ts deleted file mode 100644 index b9d617ab01..0000000000 --- a/packages/deslop-js/tests/fixtures/polyrepo/project-a/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { helper } from "./helper"; -export const main = () => helper(); diff --git a/packages/deslop-js/tests/fixtures/polyrepo/project-a/src/orphan.ts b/packages/deslop-js/tests/fixtures/polyrepo/project-a/src/orphan.ts deleted file mode 100644 index bd93db0a49..0000000000 --- a/packages/deslop-js/tests/fixtures/polyrepo/project-a/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "unused"; diff --git a/packages/deslop-js/tests/fixtures/polyrepo/project-b/package.json b/packages/deslop-js/tests/fixtures/polyrepo/project-b/package.json deleted file mode 100644 index 50e4bea7cf..0000000000 --- a/packages/deslop-js/tests/fixtures/polyrepo/project-b/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "project-b", - "main": "./dist/index.js" -} diff --git a/packages/deslop-js/tests/fixtures/polyrepo/project-b/src/index.ts b/packages/deslop-js/tests/fixtures/polyrepo/project-b/src/index.ts deleted file mode 100644 index ca88026112..0000000000 --- a/packages/deslop-js/tests/fixtures/polyrepo/project-b/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const projectB = "b"; diff --git a/packages/deslop-js/tests/fixtures/polyrepo/project-b/src/unused.ts b/packages/deslop-js/tests/fixtures/polyrepo/project-b/src/unused.ts deleted file mode 100644 index 2e65a50551..0000000000 --- a/packages/deslop-js/tests/fixtures/polyrepo/project-b/src/unused.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "never imported"; diff --git a/packages/deslop-js/tests/fixtures/prettier-rc-plugins/.prettierrc b/packages/deslop-js/tests/fixtures/prettier-rc-plugins/.prettierrc deleted file mode 100644 index 113c23a17f..0000000000 --- a/packages/deslop-js/tests/fixtures/prettier-rc-plugins/.prettierrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "plugins": ["@trivago/prettier-plugin-sort-imports"], - "importOrder": ["^@core/(.*)$", "^[./]"] -} diff --git a/packages/deslop-js/tests/fixtures/prettier-rc-plugins/package.json b/packages/deslop-js/tests/fixtures/prettier-rc-plugins/package.json deleted file mode 100644 index a64ed23b72..0000000000 --- a/packages/deslop-js/tests/fixtures/prettier-rc-plugins/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "prettier-rc-plugins", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "@trivago/prettier-plugin-sort-imports": "^4.3.0", - "prettier": "^3.0.0", - "unused-dev-dep": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/prettier-rc-plugins/src/index.ts b/packages/deslop-js/tests/fixtures/prettier-rc-plugins/src/index.ts deleted file mode 100644 index ed8736247c..0000000000 --- a/packages/deslop-js/tests/fixtures/prettier-rc-plugins/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = (): string => "main"; diff --git a/packages/deslop-js/tests/fixtures/private-type-leak/package.json b/packages/deslop-js/tests/fixtures/private-type-leak/package.json deleted file mode 100644 index 5a58567d6a..0000000000 --- a/packages/deslop-js/tests/fixtures/private-type-leak/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "private-type-leak", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/private-type-leak/src/index.ts b/packages/deslop-js/tests/fixtures/private-type-leak/src/index.ts deleted file mode 100644 index a4ac79cb93..0000000000 --- a/packages/deslop-js/tests/fixtures/private-type-leak/src/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -interface InternalConfig { - apiKey: string; - region: string; -} - -export interface PublicResult { - success: boolean; -} - -export const initialize = (config: InternalConfig): PublicResult => { - return { success: Boolean(config.apiKey) }; -}; - -export const teardown = (config: InternalConfig): void => { - void config; -}; diff --git a/packages/deslop-js/tests/fixtures/re-export-cycle/package.json b/packages/deslop-js/tests/fixtures/re-export-cycle/package.json deleted file mode 100644 index a7934f7b02..0000000000 --- a/packages/deslop-js/tests/fixtures/re-export-cycle/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "re-export-cycle", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/re-export-cycle/src/barrel.ts b/packages/deslop-js/tests/fixtures/re-export-cycle/src/barrel.ts deleted file mode 100644 index 135b393b0d..0000000000 --- a/packages/deslop-js/tests/fixtures/re-export-cycle/src/barrel.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { foo } from "./leaf.js"; -export * from "./other.js"; diff --git a/packages/deslop-js/tests/fixtures/re-export-cycle/src/index.ts b/packages/deslop-js/tests/fixtures/re-export-cycle/src/index.ts deleted file mode 100644 index b6eae23157..0000000000 --- a/packages/deslop-js/tests/fixtures/re-export-cycle/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { foo } from "./barrel.js"; diff --git a/packages/deslop-js/tests/fixtures/re-export-cycle/src/leaf.ts b/packages/deslop-js/tests/fixtures/re-export-cycle/src/leaf.ts deleted file mode 100644 index f412f8cedc..0000000000 --- a/packages/deslop-js/tests/fixtures/re-export-cycle/src/leaf.ts +++ /dev/null @@ -1 +0,0 @@ -export const foo = "leaf"; diff --git a/packages/deslop-js/tests/fixtures/re-export-cycle/src/other.ts b/packages/deslop-js/tests/fixtures/re-export-cycle/src/other.ts deleted file mode 100644 index 928f49a40b..0000000000 --- a/packages/deslop-js/tests/fixtures/re-export-cycle/src/other.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./barrel.js"; diff --git a/packages/deslop-js/tests/fixtures/react-router/app/components/header.tsx b/packages/deslop-js/tests/fixtures/react-router/app/components/header.tsx deleted file mode 100644 index b2d90807f4..0000000000 --- a/packages/deslop-js/tests/fixtures/react-router/app/components/header.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Header = () => <header>Header</header>; diff --git a/packages/deslop-js/tests/fixtures/react-router/app/components/unused-widget.tsx b/packages/deslop-js/tests/fixtures/react-router/app/components/unused-widget.tsx deleted file mode 100644 index 705dbe370f..0000000000 --- a/packages/deslop-js/tests/fixtures/react-router/app/components/unused-widget.tsx +++ /dev/null @@ -1 +0,0 @@ -export const UnusedWidget = () => <div>Unused</div>; diff --git a/packages/deslop-js/tests/fixtures/react-router/app/dashboard/layout.tsx b/packages/deslop-js/tests/fixtures/react-router/app/dashboard/layout.tsx deleted file mode 100644 index 3913ebfa20..0000000000 --- a/packages/deslop-js/tests/fixtures/react-router/app/dashboard/layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function DashboardLayout({ children }: { children: React.ReactNode }) { - return <div>{children}</div>; -} diff --git a/packages/deslop-js/tests/fixtures/react-router/app/dashboard/page.tsx b/packages/deslop-js/tests/fixtures/react-router/app/dashboard/page.tsx deleted file mode 100644 index 064e898f86..0000000000 --- a/packages/deslop-js/tests/fixtures/react-router/app/dashboard/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function DashboardPage() { - return <div>Dashboard</div>; -} diff --git a/packages/deslop-js/tests/fixtures/react-router/app/root.tsx b/packages/deslop-js/tests/fixtures/react-router/app/root.tsx deleted file mode 100644 index b137a38e3a..0000000000 --- a/packages/deslop-js/tests/fixtures/react-router/app/root.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Outlet } from "react-router"; -import { Header } from "./components/header"; - -export default function Root() { - return ( - <div> - <Header /> - <Outlet /> - </div> - ); -} diff --git a/packages/deslop-js/tests/fixtures/react-router/app/routes.ts b/packages/deslop-js/tests/fixtures/react-router/app/routes.ts deleted file mode 100644 index e0f122f9e3..0000000000 --- a/packages/deslop-js/tests/fixtures/react-router/app/routes.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { route, layout, index } from "@react-router/dev/routes"; - -export default [ - route("/", "./routes/home.tsx"), - route("/about", "./routes/about.tsx"), - layout("./dashboard/layout.tsx", [index("./dashboard/page.tsx")]), -]; diff --git a/packages/deslop-js/tests/fixtures/react-router/app/routes/about.tsx b/packages/deslop-js/tests/fixtures/react-router/app/routes/about.tsx deleted file mode 100644 index 15a73fb423..0000000000 --- a/packages/deslop-js/tests/fixtures/react-router/app/routes/about.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function About() { - return <div>About Page</div>; -} diff --git a/packages/deslop-js/tests/fixtures/react-router/app/routes/home.tsx b/packages/deslop-js/tests/fixtures/react-router/app/routes/home.tsx deleted file mode 100644 index e44250f675..0000000000 --- a/packages/deslop-js/tests/fixtures/react-router/app/routes/home.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Header } from "../components/header"; - -export default function Home() { - return ( - <div> - <Header /> - Home Page - </div> - ); -} diff --git a/packages/deslop-js/tests/fixtures/react-router/package.json b/packages/deslop-js/tests/fixtures/react-router/package.json deleted file mode 100644 index 9ba113a5d6..0000000000 --- a/packages/deslop-js/tests/fixtures/react-router/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "react-router-v7-app", - "private": true, - "dependencies": { - "@react-router/dev": "^7.0.0", - "@react-router/node": "^7.0.0", - "react": "^18.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/react-router/react-router.config.ts b/packages/deslop-js/tests/fixtures/react-router/react-router.config.ts deleted file mode 100644 index 9aa0e259ec..0000000000 --- a/packages/deslop-js/tests/fixtures/react-router/react-router.config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Config } from "@react-router/dev/config"; - -export default { - appDirectory: "app", -} satisfies Config; diff --git a/packages/deslop-js/tests/fixtures/redundant-aliases-self/package.json b/packages/deslop-js/tests/fixtures/redundant-aliases-self/package.json deleted file mode 100644 index 906fcc17e8..0000000000 --- a/packages/deslop-js/tests/fixtures/redundant-aliases-self/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "redundant-aliases-self", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/redundant-aliases-self/src/barrel.ts b/packages/deslop-js/tests/fixtures/redundant-aliases-self/src/barrel.ts deleted file mode 100644 index 2a1dd860a1..0000000000 --- a/packages/deslop-js/tests/fixtures/redundant-aliases-self/src/barrel.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { reExportedThrough as reExportedThrough } from "./source.js"; -export { usedThing as renamedUsedThing } from "./source.js"; diff --git a/packages/deslop-js/tests/fixtures/redundant-aliases-self/src/index.ts b/packages/deslop-js/tests/fixtures/redundant-aliases-self/src/index.ts deleted file mode 100644 index 764d168725..0000000000 --- a/packages/deslop-js/tests/fixtures/redundant-aliases-self/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { usedThing as usedThing, alsoUsed as betterName } from "./source.js"; -import { reExportedThrough, renamedUsedThing } from "./barrel.js"; - -const reusedLocal = usedThing + 1; -export { reusedLocal as reusedLocal }; - -console.log(usedThing, betterName, reExportedThrough, renamedUsedThing, reusedLocal); diff --git a/packages/deslop-js/tests/fixtures/redundant-aliases-self/src/source.ts b/packages/deslop-js/tests/fixtures/redundant-aliases-self/src/source.ts deleted file mode 100644 index 3554ed3d37..0000000000 --- a/packages/deslop-js/tests/fixtures/redundant-aliases-self/src/source.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const usedThing = 1; -export const alsoUsed = 2; -export const reExportedThrough = 3; diff --git a/packages/deslop-js/tests/fixtures/redundant-aliases-variable/package.json b/packages/deslop-js/tests/fixtures/redundant-aliases-variable/package.json deleted file mode 100644 index 2555bacc74..0000000000 --- a/packages/deslop-js/tests/fixtures/redundant-aliases-variable/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "redundant-aliases-variable", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/redundant-aliases-variable/src/index.ts b/packages/deslop-js/tests/fixtures/redundant-aliases-variable/src/index.ts deleted file mode 100644 index b9f18144c2..0000000000 --- a/packages/deslop-js/tests/fixtures/redundant-aliases-variable/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ARRIVED_AT_VALUE, SHARED_VALUE } from "./source.js"; - -const renamedOnce = ARRIVED_AT_VALUE; - -const sharedAlias = SHARED_VALUE; - -const usedDirectlyAndAliased = sharedAlias; - -console.log(renamedOnce, usedDirectlyAndAliased, SHARED_VALUE); diff --git a/packages/deslop-js/tests/fixtures/redundant-aliases-variable/src/source.ts b/packages/deslop-js/tests/fixtures/redundant-aliases-variable/src/source.ts deleted file mode 100644 index 80c4fc6daf..0000000000 --- a/packages/deslop-js/tests/fixtures/redundant-aliases-variable/src/source.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const ARRIVED_AT_VALUE = "the original"; -export const SHARED_VALUE = "shared across multiple consumers"; diff --git a/packages/deslop-js/tests/fixtures/redundant-aliases-variable/tsconfig.json b/packages/deslop-js/tests/fixtures/redundant-aliases-variable/tsconfig.json deleted file mode 100644 index e77b932f81..0000000000 --- a/packages/deslop-js/tests/fixtures/redundant-aliases-variable/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/package.json b/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/package.json deleted file mode 100644 index 3d020f38b8..0000000000 --- a/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "redundant-reexports-semantic", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/src/barrel.ts b/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/src/barrel.ts deleted file mode 100644 index 7b05ccb281..0000000000 --- a/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/src/barrel.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { realThing as renamedThing } from "./impl.js"; -export { goodRename as goodAlias } from "./impl.js"; -export { usedOnlyByOriginalName as wronglyAliased } from "./impl.js"; diff --git a/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/src/consumer.ts b/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/src/consumer.ts deleted file mode 100644 index 07565c2d93..0000000000 --- a/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/src/consumer.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { renamedThing as realThing } from "./barrel.js"; -import { goodAlias } from "./barrel.js"; -import { usedOnlyByOriginalName } from "./impl.js"; - -export const consume = (): string => `${realThing}-${goodAlias}-${usedOnlyByOriginalName}`; diff --git a/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/src/impl.ts b/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/src/impl.ts deleted file mode 100644 index a2c8b81a25..0000000000 --- a/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/src/impl.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const realThing = "the real implementation"; - -export const goodRename = "this gets a legitimate consumer"; - -export const usedOnlyByOriginalName = "consumers import this directly"; diff --git a/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/src/index.ts b/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/src/index.ts deleted file mode 100644 index be516eeb7f..0000000000 --- a/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { consume } from "./consumer.js"; - -console.log(consume()); diff --git a/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/tsconfig.json b/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/tsconfig.json deleted file mode 100644 index e77b932f81..0000000000 --- a/packages/deslop-js/tests/fixtures/redundant-reexports-semantic/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/deslop-js/tests/fixtures/reexport-alias/package.json b/packages/deslop-js/tests/fixtures/reexport-alias/package.json deleted file mode 100644 index b0240924a0..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-alias/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "re-export-alias-chain", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/reexport-alias/src/barrel-mid.ts b/packages/deslop-js/tests/fixtures/reexport-alias/src/barrel-mid.ts deleted file mode 100644 index 5f96010037..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-alias/src/barrel-mid.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Middle barrel: re-exports with aliases from source -export { - original as aliasB, - renamed as renamedOnce, - unusedOriginal as unusedAliasB, -} from "./source"; diff --git a/packages/deslop-js/tests/fixtures/reexport-alias/src/barrel-top.ts b/packages/deslop-js/tests/fixtures/reexport-alias/src/barrel-top.ts deleted file mode 100644 index fd5de32552..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-alias/src/barrel-top.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Top barrel: re-exports with aliases from middle barrel -export { - aliasB as aliasC, - renamedOnce as doubleAlias, - unusedAliasB as unusedAliasC, -} from "./barrel-mid"; diff --git a/packages/deslop-js/tests/fixtures/reexport-alias/src/index.ts b/packages/deslop-js/tests/fixtures/reexport-alias/src/index.ts deleted file mode 100644 index 4f82bcec1e..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-alias/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { aliasC, doubleAlias } from "./barrel-top"; - -console.log(aliasC, doubleAlias); diff --git a/packages/deslop-js/tests/fixtures/reexport-alias/src/source.ts b/packages/deslop-js/tests/fixtures/reexport-alias/src/source.ts deleted file mode 100644 index ca6a858f46..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-alias/src/source.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const original = "aliased twice: original -> aliasB -> aliasC"; -export const renamed = "aliased twice: renamed -> renamedOnce -> doubleAlias"; -export const unusedOriginal = "aliased but never consumed at top"; -export const neverExported = "not re-exported by any barrel"; diff --git a/packages/deslop-js/tests/fixtures/reexport-chains/package.json b/packages/deslop-js/tests/fixtures/reexport-chains/package.json deleted file mode 100644 index d0759ca17a..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-chains/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "re-export-chains", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/reexport-chains/src/barrel-a.ts b/packages/deslop-js/tests/fixtures/reexport-chains/src/barrel-a.ts deleted file mode 100644 index 380b0497be..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-chains/src/barrel-a.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./barrel-b"; diff --git a/packages/deslop-js/tests/fixtures/reexport-chains/src/barrel-b.ts b/packages/deslop-js/tests/fixtures/reexport-chains/src/barrel-b.ts deleted file mode 100644 index efe83cdbd2..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-chains/src/barrel-b.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./barrel-c"; diff --git a/packages/deslop-js/tests/fixtures/reexport-chains/src/barrel-c.ts b/packages/deslop-js/tests/fixtures/reexport-chains/src/barrel-c.ts deleted file mode 100644 index a461c3b008..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-chains/src/barrel-c.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./source"; diff --git a/packages/deslop-js/tests/fixtures/reexport-chains/src/index.ts b/packages/deslop-js/tests/fixtures/reexport-chains/src/index.ts deleted file mode 100644 index c557403484..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-chains/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { alpha, beta } from "./barrel-a"; - -console.log(alpha, beta); diff --git a/packages/deslop-js/tests/fixtures/reexport-chains/src/source.ts b/packages/deslop-js/tests/fixtures/reexport-chains/src/source.ts deleted file mode 100644 index b2df32d685..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-chains/src/source.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const alpha = "alpha"; -export const beta = "beta"; -export const gamma = "gamma"; -export const delta = "delta"; diff --git a/packages/deslop-js/tests/fixtures/reexport-default-named/consumer.ts b/packages/deslop-js/tests/fixtures/reexport-default-named/consumer.ts deleted file mode 100644 index 86926d69b3..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-default-named/consumer.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Widget } from "./index"; - -const instance = new Widget(); -instance.render(); diff --git a/packages/deslop-js/tests/fixtures/reexport-default-named/gadget.ts b/packages/deslop-js/tests/fixtures/reexport-default-named/gadget.ts deleted file mode 100644 index 54ba7f0fda..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-default-named/gadget.ts +++ /dev/null @@ -1,7 +0,0 @@ -export default class Gadget { - activate() { - return "gadget"; - } -} - -export const gadgetHelper = () => "help"; diff --git a/packages/deslop-js/tests/fixtures/reexport-default-named/index.ts b/packages/deslop-js/tests/fixtures/reexport-default-named/index.ts deleted file mode 100644 index c333ca3c5f..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-default-named/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as Widget } from "./widget"; -export { default as Gadget } from "./gadget"; diff --git a/packages/deslop-js/tests/fixtures/reexport-default-named/package.json b/packages/deslop-js/tests/fixtures/reexport-default-named/package.json deleted file mode 100644 index fd80b41ff5..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-default-named/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "re-export-default-as-named", - "main": "index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/reexport-default-named/widget.ts b/packages/deslop-js/tests/fixtures/reexport-default-named/widget.ts deleted file mode 100644 index 7fd0a14676..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-default-named/widget.ts +++ /dev/null @@ -1,7 +0,0 @@ -export default class Widget { - render() { - return "widget"; - } -} - -export const widgetHelper = () => "help"; diff --git a/packages/deslop-js/tests/fixtures/reexport-default/package.json b/packages/deslop-js/tests/fixtures/reexport-default/package.json deleted file mode 100644 index e17e1988eb..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-default/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "barrel-default-reexport", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/reexport-default/src/components/Button.ts b/packages/deslop-js/tests/fixtures/reexport-default/src/components/Button.ts deleted file mode 100644 index 060460bf54..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-default/src/components/Button.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function Button() { - return "button"; -} diff --git a/packages/deslop-js/tests/fixtures/reexport-default/src/components/Card/Card.tsx b/packages/deslop-js/tests/fixtures/reexport-default/src/components/Card/Card.tsx deleted file mode 100644 index 05e7f23a40..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-default/src/components/Card/Card.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Card() { - return "card"; -} diff --git a/packages/deslop-js/tests/fixtures/reexport-default/src/components/Card/index.ts b/packages/deslop-js/tests/fixtures/reexport-default/src/components/Card/index.ts deleted file mode 100644 index c0424cdb70..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-default/src/components/Card/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./Card"; diff --git a/packages/deslop-js/tests/fixtures/reexport-default/src/components/index.ts b/packages/deslop-js/tests/fixtures/reexport-default/src/components/index.ts deleted file mode 100644 index 2ad106baf0..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-default/src/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as Button } from "./Button"; -export { default as Card } from "./Card"; diff --git a/packages/deslop-js/tests/fixtures/reexport-default/src/index.ts b/packages/deslop-js/tests/fixtures/reexport-default/src/index.ts deleted file mode 100644 index 0df95b4240..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-default/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Only imports Button from barrel, not Card -import { Button } from "./components"; - -console.log(Button); diff --git a/packages/deslop-js/tests/fixtures/reexport-file-variants/package.json b/packages/deslop-js/tests/fixtures/reexport-file-variants/package.json deleted file mode 100644 index 437c204c70..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-file-variants/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "barrel-file-types", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/reexport-file-variants/src/index.ts b/packages/deslop-js/tests/fixtures/reexport-file-variants/src/index.ts deleted file mode 100644 index 517cfdfe62..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-file-variants/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { greet } from "./utils/greet"; -greet(); diff --git a/packages/deslop-js/tests/fixtures/reexport-file-variants/src/named-barrel.ts b/packages/deslop-js/tests/fixtures/reexport-file-variants/src/named-barrel.ts deleted file mode 100644 index eb4596e2ca..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-file-variants/src/named-barrel.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { greet } from "./utils/greet"; -export { format } from "./utils/format"; diff --git a/packages/deslop-js/tests/fixtures/reexport-file-variants/src/orphan.ts b/packages/deslop-js/tests/fixtures/reexport-file-variants/src/orphan.ts deleted file mode 100644 index bddae4ee9b..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-file-variants/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = true; diff --git a/packages/deslop-js/tests/fixtures/reexport-file-variants/src/star-barrel.ts b/packages/deslop-js/tests/fixtures/reexport-file-variants/src/star-barrel.ts deleted file mode 100644 index 9eff49eaf7..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-file-variants/src/star-barrel.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./utils/greet"; -export * from "./utils/format"; diff --git a/packages/deslop-js/tests/fixtures/reexport-file-variants/src/utils/format.ts b/packages/deslop-js/tests/fixtures/reexport-file-variants/src/utils/format.ts deleted file mode 100644 index 2b0536f3a8..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-file-variants/src/utils/format.ts +++ /dev/null @@ -1 +0,0 @@ -export const format = (value: string) => value.trim(); diff --git a/packages/deslop-js/tests/fixtures/reexport-file-variants/src/utils/greet.ts b/packages/deslop-js/tests/fixtures/reexport-file-variants/src/utils/greet.ts deleted file mode 100644 index be3e33de9c..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-file-variants/src/utils/greet.ts +++ /dev/null @@ -1 +0,0 @@ -export const greet = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/reexport-mixed/package.json b/packages/deslop-js/tests/fixtures/reexport-mixed/package.json deleted file mode 100644 index 42a2388e13..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-mixed/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "mixed-named-star-reexports", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/reexport-mixed/src/barrel.ts b/packages/deslop-js/tests/fixtures/reexport-mixed/src/barrel.ts deleted file mode 100644 index c7c5efd384..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-mixed/src/barrel.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Mixed: named re-exports from one module, star from another -export { namedUsed, namedUnused } from "./named-source"; -export * from "./star-source"; diff --git a/packages/deslop-js/tests/fixtures/reexport-mixed/src/index.ts b/packages/deslop-js/tests/fixtures/reexport-mixed/src/index.ts deleted file mode 100644 index 176ed57413..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-mixed/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { namedUsed, starUsed } from "./barrel"; - -console.log(namedUsed, starUsed); diff --git a/packages/deslop-js/tests/fixtures/reexport-mixed/src/named-source.ts b/packages/deslop-js/tests/fixtures/reexport-mixed/src/named-source.ts deleted file mode 100644 index e96a1200b8..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-mixed/src/named-source.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const namedUsed = "consumed through barrel named re-export"; -export const namedUnused = "re-exported by barrel but never imported"; diff --git a/packages/deslop-js/tests/fixtures/reexport-mixed/src/star-source.ts b/packages/deslop-js/tests/fixtures/reexport-mixed/src/star-source.ts deleted file mode 100644 index 4b6e147c93..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-mixed/src/star-source.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const starUsed = "consumed through barrel star re-export"; -export const starUnused = "star re-exported but never imported"; diff --git a/packages/deslop-js/tests/fixtures/reexport-multi-hop/package.json b/packages/deslop-js/tests/fixtures/reexport-multi-hop/package.json deleted file mode 100644 index 70e4a8364a..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-multi-hop/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "multi-hop-barrel", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/reexport-multi-hop/src/barrel1.ts b/packages/deslop-js/tests/fixtures/reexport-multi-hop/src/barrel1.ts deleted file mode 100644 index 61ba929093..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-multi-hop/src/barrel1.ts +++ /dev/null @@ -1 +0,0 @@ -export { used, unused1 } from "./barrel2"; diff --git a/packages/deslop-js/tests/fixtures/reexport-multi-hop/src/barrel2.ts b/packages/deslop-js/tests/fixtures/reexport-multi-hop/src/barrel2.ts deleted file mode 100644 index 1e364e9177..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-multi-hop/src/barrel2.ts +++ /dev/null @@ -1 +0,0 @@ -export { used, unused1, unused2 } from "./source"; diff --git a/packages/deslop-js/tests/fixtures/reexport-multi-hop/src/index.ts b/packages/deslop-js/tests/fixtures/reexport-multi-hop/src/index.ts deleted file mode 100644 index 709466f212..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-multi-hop/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { used } from "./barrel1"; - -console.log(used); diff --git a/packages/deslop-js/tests/fixtures/reexport-multi-hop/src/source.ts b/packages/deslop-js/tests/fixtures/reexport-multi-hop/src/source.ts deleted file mode 100644 index 065fa782c5..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-multi-hop/src/source.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const used = 1; -export const unused1 = 2; -export const unused2 = 3; diff --git a/packages/deslop-js/tests/fixtures/reexport-multi-level/package.json b/packages/deslop-js/tests/fixtures/reexport-multi-level/package.json deleted file mode 100644 index caa8c7347e..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-multi-level/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "multi-level-barrel-chain", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/reexport-multi-level/src/barrel-a.ts b/packages/deslop-js/tests/fixtures/reexport-multi-level/src/barrel-a.ts deleted file mode 100644 index 72f7f3fd7f..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-multi-level/src/barrel-a.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Level 1: re-exports from barrel-b -export { alpha, beta, gamma } from "./barrel-b"; diff --git a/packages/deslop-js/tests/fixtures/reexport-multi-level/src/barrel-b.ts b/packages/deslop-js/tests/fixtures/reexport-multi-level/src/barrel-b.ts deleted file mode 100644 index b2f23f622c..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-multi-level/src/barrel-b.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Level 2: re-exports from source -export { alpha, beta, gamma, delta } from "./source"; diff --git a/packages/deslop-js/tests/fixtures/reexport-multi-level/src/index.ts b/packages/deslop-js/tests/fixtures/reexport-multi-level/src/index.ts deleted file mode 100644 index c557403484..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-multi-level/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { alpha, beta } from "./barrel-a"; - -console.log(alpha, beta); diff --git a/packages/deslop-js/tests/fixtures/reexport-multi-level/src/source.ts b/packages/deslop-js/tests/fixtures/reexport-multi-level/src/source.ts deleted file mode 100644 index 1ebe34dc4e..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-multi-level/src/source.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const alpha = "used through 3-level chain"; -export const beta = "also used through 3-level chain"; -export const gamma = "only re-exported through barrel-a but never imported"; -export const delta = "only re-exported through barrel-b, not barrel-a"; -export const epsilon = "not re-exported at all"; diff --git a/packages/deslop-js/tests/fixtures/reexport-neighbor-import/package.json b/packages/deslop-js/tests/fixtures/reexport-neighbor-import/package.json deleted file mode 100644 index b2719ddccb..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-neighbor-import/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "reexport-neighbor-import", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/reexport-neighbor-import/src/barrel.ts b/packages/deslop-js/tests/fixtures/reexport-neighbor-import/src/barrel.ts deleted file mode 100644 index 31e13cac6f..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-neighbor-import/src/barrel.ts +++ /dev/null @@ -1,3 +0,0 @@ -import "./imported-only"; - -export { used } from "./exported"; diff --git a/packages/deslop-js/tests/fixtures/reexport-neighbor-import/src/exported.ts b/packages/deslop-js/tests/fixtures/reexport-neighbor-import/src/exported.ts deleted file mode 100644 index fb8c9665cc..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-neighbor-import/src/exported.ts +++ /dev/null @@ -1 +0,0 @@ -export const used = "exported"; diff --git a/packages/deslop-js/tests/fixtures/reexport-neighbor-import/src/imported-only.ts b/packages/deslop-js/tests/fixtures/reexport-neighbor-import/src/imported-only.ts deleted file mode 100644 index ec65dff40f..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-neighbor-import/src/imported-only.ts +++ /dev/null @@ -1,4 +0,0 @@ -globalThis.__deslopSideEffect = true; - -export const used = "neighbor"; -export const alsoUnused = "neighbor"; diff --git a/packages/deslop-js/tests/fixtures/reexport-neighbor-import/src/index.ts b/packages/deslop-js/tests/fixtures/reexport-neighbor-import/src/index.ts deleted file mode 100644 index 0d9ca5361b..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-neighbor-import/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { used } from "./barrel"; - -export const result = used; diff --git a/packages/deslop-js/tests/fixtures/reexport-star-named/consumer.ts b/packages/deslop-js/tests/fixtures/reexport-star-named/consumer.ts deleted file mode 100644 index 6dc343eaf9..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-star-named/consumer.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { formatDate, special } from "./index"; - -console.log(formatDate(new Date()), special()); diff --git a/packages/deslop-js/tests/fixtures/reexport-star-named/index.ts b/packages/deslop-js/tests/fixtures/reexport-star-named/index.ts deleted file mode 100644 index c8c581fdc6..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-star-named/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./utils"; -export { special } from "./special"; diff --git a/packages/deslop-js/tests/fixtures/reexport-star-named/package.json b/packages/deslop-js/tests/fixtures/reexport-star-named/package.json deleted file mode 100644 index f700c1154f..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-star-named/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "re-export-star-with-named", - "main": "index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/reexport-star-named/special.ts b/packages/deslop-js/tests/fixtures/reexport-star-named/special.ts deleted file mode 100644 index b9e71b6aad..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-star-named/special.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const special = () => "special export"; -export const notReExported = () => "only available via direct import"; diff --git a/packages/deslop-js/tests/fixtures/reexport-star-named/utils.ts b/packages/deslop-js/tests/fixtures/reexport-star-named/utils.ts deleted file mode 100644 index c4f14e9346..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-star-named/utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const formatDate = (date: Date) => date.toISOString(); -export const formatNumber = (num: number) => num.toLocaleString(); -export const internalOnly = () => "not re-exported by name but via star"; diff --git a/packages/deslop-js/tests/fixtures/reexport-star/package.json b/packages/deslop-js/tests/fixtures/reexport-star/package.json deleted file mode 100644 index 6a135bd27e..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-star/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "barrel-exports", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/reexport-star/src/barrel.ts b/packages/deslop-js/tests/fixtures/reexport-star/src/barrel.ts deleted file mode 100644 index 990a1427f9..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-star/src/barrel.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { foo } from "./module-a"; -export { bar } from "./module-b"; -export * from "./module-c"; diff --git a/packages/deslop-js/tests/fixtures/reexport-star/src/index.ts b/packages/deslop-js/tests/fixtures/reexport-star/src/index.ts deleted file mode 100644 index db1315ff35..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-star/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { foo } from "./barrel"; - -console.log(foo); diff --git a/packages/deslop-js/tests/fixtures/reexport-star/src/module-a.ts b/packages/deslop-js/tests/fixtures/reexport-star/src/module-a.ts deleted file mode 100644 index 10907464a6..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-star/src/module-a.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const foo = "used through barrel"; -export const fooUnused = "not used"; diff --git a/packages/deslop-js/tests/fixtures/reexport-star/src/module-b.ts b/packages/deslop-js/tests/fixtures/reexport-star/src/module-b.ts deleted file mode 100644 index 08932c98bb..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-star/src/module-b.ts +++ /dev/null @@ -1 +0,0 @@ -export const bar = "not used (barrel re-exports but nobody imports)"; diff --git a/packages/deslop-js/tests/fixtures/reexport-star/src/module-c.ts b/packages/deslop-js/tests/fixtures/reexport-star/src/module-c.ts deleted file mode 100644 index bed7bd2f9b..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-star/src/module-c.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const baz = "star re-exported but maybe not used"; -export const qux = "also star re-exported"; diff --git a/packages/deslop-js/tests/fixtures/reexport-unused/package.json b/packages/deslop-js/tests/fixtures/reexport-unused/package.json deleted file mode 100644 index 64999fa994..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-unused/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "barrel-unused-reexports", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/reexport-unused/src/components/index.ts b/packages/deslop-js/tests/fixtures/reexport-unused/src/components/index.ts deleted file mode 100644 index b6e75c6282..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-unused/src/components/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Barrel file: re-exports from multiple source modules -export { UsedComponent } from "./used-source"; -export { UnusedComponent } from "./unused-source"; -export type { UsedType } from "./types-source"; -export type { UnusedType } from "./types-source"; diff --git a/packages/deslop-js/tests/fixtures/reexport-unused/src/components/types-source.ts b/packages/deslop-js/tests/fixtures/reexport-unused/src/components/types-source.ts deleted file mode 100644 index 111a6cae4d..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-unused/src/components/types-source.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface UsedType { - name: string; -} - -export interface UnusedType { - value: number; -} diff --git a/packages/deslop-js/tests/fixtures/reexport-unused/src/components/unused-source.ts b/packages/deslop-js/tests/fixtures/reexport-unused/src/components/unused-source.ts deleted file mode 100644 index de0f3a91e5..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-unused/src/components/unused-source.ts +++ /dev/null @@ -1 +0,0 @@ -export const UnusedComponent = () => "UnusedComponent"; diff --git a/packages/deslop-js/tests/fixtures/reexport-unused/src/components/used-source.ts b/packages/deslop-js/tests/fixtures/reexport-unused/src/components/used-source.ts deleted file mode 100644 index de7b56a958..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-unused/src/components/used-source.ts +++ /dev/null @@ -1 +0,0 @@ -export const UsedComponent = () => "UsedComponent"; diff --git a/packages/deslop-js/tests/fixtures/reexport-unused/src/index.ts b/packages/deslop-js/tests/fixtures/reexport-unused/src/index.ts deleted file mode 100644 index 4139ca640e..0000000000 --- a/packages/deslop-js/tests/fixtures/reexport-unused/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { UsedComponent } from "./components"; -import type { UsedType } from "./components"; - -console.log(UsedComponent); -const x: UsedType = { name: "test" }; -console.log(x); diff --git a/packages/deslop-js/tests/fixtures/remark-config-deps/.gitignore b/packages/deslop-js/tests/fixtures/remark-config-deps/.gitignore deleted file mode 100644 index ddf342489b..0000000000 --- a/packages/deslop-js/tests/fixtures/remark-config-deps/.gitignore +++ /dev/null @@ -1 +0,0 @@ -!node_modules/ diff --git a/packages/deslop-js/tests/fixtures/remark-config-deps/.remarkrc.json b/packages/deslop-js/tests/fixtures/remark-config-deps/.remarkrc.json deleted file mode 100644 index a6dcead34a..0000000000 --- a/packages/deslop-js/tests/fixtures/remark-config-deps/.remarkrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "plugins": ["remark-gfm"] -} diff --git a/packages/deslop-js/tests/fixtures/remark-config-deps/docs/guide.mdx b/packages/deslop-js/tests/fixtures/remark-config-deps/docs/guide.mdx deleted file mode 100644 index 87b368ecdf..0000000000 --- a/packages/deslop-js/tests/fixtures/remark-config-deps/docs/guide.mdx +++ /dev/null @@ -1,3 +0,0 @@ -import { Button } from "../src/button"; - -<Button /> diff --git a/packages/deslop-js/tests/fixtures/remark-config-deps/node_modules/remark-cli/package.json b/packages/deslop-js/tests/fixtures/remark-config-deps/node_modules/remark-cli/package.json deleted file mode 100644 index d945a397dd..0000000000 --- a/packages/deslop-js/tests/fixtures/remark-config-deps/node_modules/remark-cli/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "remark-cli", - "version": "12.0.0", - "bin": { - "remark": "./cli.js" - } -} diff --git a/packages/deslop-js/tests/fixtures/remark-config-deps/package.json b/packages/deslop-js/tests/fixtures/remark-config-deps/package.json deleted file mode 100644 index 52fda9b132..0000000000 --- a/packages/deslop-js/tests/fixtures/remark-config-deps/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "remark-config-deps", - "version": "1.0.0", - "scripts": { - "lint:md": "remark docs --quiet" - }, - "devDependencies": { - "remark-cli": "^12.0.0", - "remark-gfm": "^4.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/remark-config-deps/src/button.tsx b/packages/deslop-js/tests/fixtures/remark-config-deps/src/button.tsx deleted file mode 100644 index 73ea95a019..0000000000 --- a/packages/deslop-js/tests/fixtures/remark-config-deps/src/button.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Button = (): null => null; diff --git a/packages/deslop-js/tests/fixtures/remark-config-deps/src/index.ts b/packages/deslop-js/tests/fixtures/remark-config-deps/src/index.ts deleted file mode 100644 index cb44fe1048..0000000000 --- a/packages/deslop-js/tests/fixtures/remark-config-deps/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = (): void => {}; diff --git a/packages/deslop-js/tests/fixtures/remark-config-deps/src/orphan.ts b/packages/deslop-js/tests/fixtures/remark-config-deps/src/orphan.ts deleted file mode 100644 index 74448acbb5..0000000000 --- a/packages/deslop-js/tests/fixtures/remark-config-deps/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphanValue = 1; diff --git a/packages/deslop-js/tests/fixtures/remark-glob-skip/docs/guide.mdx b/packages/deslop-js/tests/fixtures/remark-glob-skip/docs/guide.mdx deleted file mode 100644 index 5a50bb5f92..0000000000 --- a/packages/deslop-js/tests/fixtures/remark-glob-skip/docs/guide.mdx +++ /dev/null @@ -1,3 +0,0 @@ -# Guide - -This is another documentation file. diff --git a/packages/deslop-js/tests/fixtures/remark-glob-skip/docs/intro.mdx b/packages/deslop-js/tests/fixtures/remark-glob-skip/docs/intro.mdx deleted file mode 100644 index 492179ed29..0000000000 --- a/packages/deslop-js/tests/fixtures/remark-glob-skip/docs/intro.mdx +++ /dev/null @@ -1,3 +0,0 @@ -# Introduction - -This is a documentation file. diff --git a/packages/deslop-js/tests/fixtures/remark-glob-skip/package.json b/packages/deslop-js/tests/fixtures/remark-glob-skip/package.json deleted file mode 100644 index 2a67344756..0000000000 --- a/packages/deslop-js/tests/fixtures/remark-glob-skip/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "remark-glob-not-entry", - "version": "1.0.0", - "scripts": { - "lint:mdx": "remark \"docs/**/*.mdx\" --silent --output", - "spell": "cspell \"src/**/*.ts\"" - }, - "devDependencies": { - "cspell": "^8.0.0", - "remark-cli": "^12.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/remark-glob-skip/src/index.ts b/packages/deslop-js/tests/fixtures/remark-glob-skip/src/index.ts deleted file mode 100644 index be3e33de9c..0000000000 --- a/packages/deslop-js/tests/fixtures/remark-glob-skip/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const greet = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/remark-glob-skip/src/orphan.ts b/packages/deslop-js/tests/fixtures/remark-glob-skip/src/orphan.ts deleted file mode 100644 index 8796bcb7a5..0000000000 --- a/packages/deslop-js/tests/fixtures/remark-glob-skip/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unusedFunction = () => "unused"; diff --git a/packages/deslop-js/tests/fixtures/rn-app/App.tsx b/packages/deslop-js/tests/fixtures/rn-app/App.tsx deleted file mode 100644 index 50c56c3c90..0000000000 --- a/packages/deslop-js/tests/fixtures/rn-app/App.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from "react"; -import { View, Text } from "react-native"; -import { UsedScreen } from "./src/screens/used"; - -export default () => ( - <View> - <Text>App</Text> - <UsedScreen /> - </View> -); diff --git a/packages/deslop-js/tests/fixtures/rn-app/index.js b/packages/deslop-js/tests/fixtures/rn-app/index.js deleted file mode 100644 index 795727cf87..0000000000 --- a/packages/deslop-js/tests/fixtures/rn-app/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { AppRegistry } from "react-native"; -import App from "./App"; -AppRegistry.registerComponent("RNTest", () => App); diff --git a/packages/deslop-js/tests/fixtures/rn-app/metro.config.js b/packages/deslop-js/tests/fixtures/rn-app/metro.config.js deleted file mode 100644 index f053ebf797..0000000000 --- a/packages/deslop-js/tests/fixtures/rn-app/metro.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/packages/deslop-js/tests/fixtures/rn-app/package.json b/packages/deslop-js/tests/fixtures/rn-app/package.json deleted file mode 100644 index 490fea7e1a..0000000000 --- a/packages/deslop-js/tests/fixtures/rn-app/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "rn-test", - "dependencies": { - "react": "18.0.0", - "react-native": "0.72.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/rn-app/src/screens/orphan.tsx b/packages/deslop-js/tests/fixtures/rn-app/src/screens/orphan.tsx deleted file mode 100644 index fa062f1e28..0000000000 --- a/packages/deslop-js/tests/fixtures/rn-app/src/screens/orphan.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; -import { View, Text } from "react-native"; -export const OrphanScreen = () => ( - <View> - <Text>Orphan</Text> - </View> -); diff --git a/packages/deslop-js/tests/fixtures/rn-app/src/screens/used.tsx b/packages/deslop-js/tests/fixtures/rn-app/src/screens/used.tsx deleted file mode 100644 index 2238426924..0000000000 --- a/packages/deslop-js/tests/fixtures/rn-app/src/screens/used.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; -import { View, Text } from "react-native"; -export const UsedScreen = () => ( - <View> - <Text>Used</Text> - </View> -); diff --git a/packages/deslop-js/tests/fixtures/rn-platform/package.json b/packages/deslop-js/tests/fixtures/rn-platform/package.json deleted file mode 100644 index a243c71507..0000000000 --- a/packages/deslop-js/tests/fixtures/rn-platform/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "react-native-app", - "version": "1.0.0", - "main": "src/index.ts", - "dependencies": { - "react": "^18.0.0", - "react-native": "^0.72.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/rn-platform/src/button.android.tsx b/packages/deslop-js/tests/fixtures/rn-platform/src/button.android.tsx deleted file mode 100644 index a1ab24fad7..0000000000 --- a/packages/deslop-js/tests/fixtures/rn-platform/src/button.android.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Button = () => <button>Android Click</button>; diff --git a/packages/deslop-js/tests/fixtures/rn-platform/src/button.ios.tsx b/packages/deslop-js/tests/fixtures/rn-platform/src/button.ios.tsx deleted file mode 100644 index e92b88a021..0000000000 --- a/packages/deslop-js/tests/fixtures/rn-platform/src/button.ios.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Button = () => <button>iOS Click</button>; diff --git a/packages/deslop-js/tests/fixtures/rn-platform/src/button.tsx b/packages/deslop-js/tests/fixtures/rn-platform/src/button.tsx deleted file mode 100644 index 71131eb3ed..0000000000 --- a/packages/deslop-js/tests/fixtures/rn-platform/src/button.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Button = () => <button>Click</button>; diff --git a/packages/deslop-js/tests/fixtures/rn-platform/src/handler.native.ts b/packages/deslop-js/tests/fixtures/rn-platform/src/handler.native.ts deleted file mode 100644 index 3354ec4539..0000000000 --- a/packages/deslop-js/tests/fixtures/rn-platform/src/handler.native.ts +++ /dev/null @@ -1 +0,0 @@ -export const handler = () => "native handler"; diff --git a/packages/deslop-js/tests/fixtures/rn-platform/src/handler.web.ts b/packages/deslop-js/tests/fixtures/rn-platform/src/handler.web.ts deleted file mode 100644 index 7a7558f281..0000000000 --- a/packages/deslop-js/tests/fixtures/rn-platform/src/handler.web.ts +++ /dev/null @@ -1 +0,0 @@ -export const handler = () => "web handler"; diff --git a/packages/deslop-js/tests/fixtures/rn-platform/src/index.ts b/packages/deslop-js/tests/fixtures/rn-platform/src/index.ts deleted file mode 100644 index bf3df30e96..0000000000 --- a/packages/deslop-js/tests/fixtures/rn-platform/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { handler } from "./handler"; -import { utils } from "./utils"; -import { Button } from "./button"; -export { handler, utils, Button }; diff --git a/packages/deslop-js/tests/fixtures/rn-platform/src/orphan.ts b/packages/deslop-js/tests/fixtures/rn-platform/src/orphan.ts deleted file mode 100644 index 9a2a4ec39c..0000000000 --- a/packages/deslop-js/tests/fixtures/rn-platform/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = () => "orphan"; diff --git a/packages/deslop-js/tests/fixtures/rn-platform/src/utils.ts b/packages/deslop-js/tests/fixtures/rn-platform/src/utils.ts deleted file mode 100644 index 28b7b0b855..0000000000 --- a/packages/deslop-js/tests/fixtures/rn-platform/src/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const utils = () => "shared utils"; diff --git a/packages/deslop-js/tests/fixtures/rspack-app/package.json b/packages/deslop-js/tests/fixtures/rspack-app/package.json deleted file mode 100644 index 9166259a52..0000000000 --- a/packages/deslop-js/tests/fixtures/rspack-app/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "rspack-project", - "dependencies": { - "@rspack/cli": "^1.0.0", - "@rspack/core": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/rspack-app/rspack.config.js b/packages/deslop-js/tests/fixtures/rspack-app/rspack.config.js deleted file mode 100644 index d628bccf52..0000000000 --- a/packages/deslop-js/tests/fixtures/rspack-app/rspack.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = { entry: "./src/index.ts" }; diff --git a/packages/deslop-js/tests/fixtures/rspack-app/rspack.dev.config.js b/packages/deslop-js/tests/fixtures/rspack-app/rspack.dev.config.js deleted file mode 100644 index b7cf15558f..0000000000 --- a/packages/deslop-js/tests/fixtures/rspack-app/rspack.dev.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = { entry: "./src/index.ts", mode: "development" }; diff --git a/packages/deslop-js/tests/fixtures/rspack-app/src/index.ts b/packages/deslop-js/tests/fixtures/rspack-app/src/index.ts deleted file mode 100644 index c5961da749..0000000000 --- a/packages/deslop-js/tests/fixtures/rspack-app/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/rspack-app/src/orphan.ts b/packages/deslop-js/tests/fixtures/rspack-app/src/orphan.ts deleted file mode 100644 index 1b61e3211e..0000000000 --- a/packages/deslop-js/tests/fixtures/rspack-app/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = () => "unused"; diff --git a/packages/deslop-js/tests/fixtures/runner-convention-files/example-ui.config.console-analytics.js b/packages/deslop-js/tests/fixtures/runner-convention-files/example-ui.config.console-analytics.js deleted file mode 100644 index 84fcc6c513..0000000000 --- a/packages/deslop-js/tests/fixtures/runner-convention-files/example-ui.config.console-analytics.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - analytics: { enabled: true }, -}; diff --git a/packages/deslop-js/tests/fixtures/runner-convention-files/package.json b/packages/deslop-js/tests/fixtures/runner-convention-files/package.json deleted file mode 100644 index d381e6160d..0000000000 --- a/packages/deslop-js/tests/fixtures/runner-convention-files/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "runner-convention-files", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/runner-convention-files/public/theme-init.js b/packages/deslop-js/tests/fixtures/runner-convention-files/public/theme-init.js deleted file mode 100644 index 0bb68043a1..0000000000 --- a/packages/deslop-js/tests/fixtures/runner-convention-files/public/theme-init.js +++ /dev/null @@ -1 +0,0 @@ -document.documentElement.dataset.theme = localStorage.getItem("theme") ?? "light"; diff --git a/packages/deslop-js/tests/fixtures/runner-convention-files/src/index.ts b/packages/deslop-js/tests/fixtures/runner-convention-files/src/index.ts deleted file mode 100644 index 0d5f89b52d..0000000000 --- a/packages/deslop-js/tests/fixtures/runner-convention-files/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = "app"; diff --git a/packages/deslop-js/tests/fixtures/runner-convention-files/src/orphan.ts b/packages/deslop-js/tests/fixtures/runner-convention-files/src/orphan.ts deleted file mode 100644 index d55ec1e00e..0000000000 --- a/packages/deslop-js/tests/fixtures/runner-convention-files/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const trulyOrphaned = "never imported"; diff --git a/packages/deslop-js/tests/fixtures/runner-convention-files/src/render-element.test-d.ts b/packages/deslop-js/tests/fixtures/runner-convention-files/src/render-element.test-d.ts deleted file mode 100644 index 9a2dab2df4..0000000000 --- a/packages/deslop-js/tests/fixtures/runner-convention-files/src/render-element.test-d.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { main } from "./index"; - -export const typeProbe: string = main; diff --git a/packages/deslop-js/tests/fixtures/script-cli-deps/package.json b/packages/deslop-js/tests/fixtures/script-cli-deps/package.json deleted file mode 100644 index 402d8b4fa3..0000000000 --- a/packages/deslop-js/tests/fixtures/script-cli-deps/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "script-cli-deps", - "version": "1.0.0", - "main": "src/index.ts", - "scripts": { - "build": "turbo run build", - "lint": "vp lint", - "dev": "tsx src/index.ts", - "version": "changeset version" - }, - "devDependencies": { - "@changesets/cli": "^2.31.0", - "tsx": "^4.21.0", - "turbo": "^2.9.7", - "unused-dep": "^1.0.0", - "vite-plus": "^0.1.20" - } -} diff --git a/packages/deslop-js/tests/fixtures/script-cli-deps/src/index.ts b/packages/deslop-js/tests/fixtures/script-cli-deps/src/index.ts deleted file mode 100644 index d1d32a17e8..0000000000 --- a/packages/deslop-js/tests/fixtures/script-cli-deps/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const appVersion = "1.0.0"; diff --git a/packages/deslop-js/tests/fixtures/script-flags/orphan.ts b/packages/deslop-js/tests/fixtures/script-flags/orphan.ts deleted file mode 100644 index b79aa99b63..0000000000 --- a/packages/deslop-js/tests/fixtures/script-flags/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = true; diff --git a/packages/deslop-js/tests/fixtures/script-flags/package.json b/packages/deslop-js/tests/fixtures/script-flags/package.json deleted file mode 100644 index 70f3bd2e9e..0000000000 --- a/packages/deslop-js/tests/fixtures/script-flags/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "script-flag-args", - "private": true, - "scripts": { - "build": "tsx --tsconfig ./tsconfig.build.json ./scripts/build.ts --watch", - "generate": "bun run ./scripts/generate.mts", - "test": "node --import tsx --test tests/run.ts" - } -} diff --git a/packages/deslop-js/tests/fixtures/script-flags/scripts/build.ts b/packages/deslop-js/tests/fixtures/script-flags/scripts/build.ts deleted file mode 100644 index 10065dc218..0000000000 --- a/packages/deslop-js/tests/fixtures/script-flags/scripts/build.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("building"); diff --git a/packages/deslop-js/tests/fixtures/script-flags/scripts/generate.mts b/packages/deslop-js/tests/fixtures/script-flags/scripts/generate.mts deleted file mode 100644 index fbed05d2e3..0000000000 --- a/packages/deslop-js/tests/fixtures/script-flags/scripts/generate.mts +++ /dev/null @@ -1 +0,0 @@ -console.log("generating"); diff --git a/packages/deslop-js/tests/fixtures/script-flags/tests/run.ts b/packages/deslop-js/tests/fixtures/script-flags/tests/run.ts deleted file mode 100644 index 5c46b0d666..0000000000 --- a/packages/deslop-js/tests/fixtures/script-flags/tests/run.ts +++ /dev/null @@ -1,2 +0,0 @@ -import assert from "node:assert"; -assert.ok(true); diff --git a/packages/deslop-js/tests/fixtures/script-flags/tsconfig.build.json b/packages/deslop-js/tests/fixtures/script-flags/tsconfig.build.json deleted file mode 100644 index 74ad6e0407..0000000000 --- a/packages/deslop-js/tests/fixtures/script-flags/tsconfig.build.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext" - } -} diff --git a/packages/deslop-js/tests/fixtures/script-glob-formatter/package.json b/packages/deslop-js/tests/fixtures/script-glob-formatter/package.json deleted file mode 100644 index 31ea7b0e37..0000000000 --- a/packages/deslop-js/tests/fixtures/script-glob-formatter/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "formatter-glob-scripts", - "scripts": { - "format": "prettier --write \"**/*.{ts,tsx,md}\"", - "lint": "eslint \"src/**/*.ts\"", - "build": "tsx scripts/build.ts" - }, - "dependencies": {}, - "devDependencies": { - "eslint": "^8.0.0", - "prettier": "^3.0.0", - "tsx": "^4.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/script-glob-formatter/scripts/build.ts b/packages/deslop-js/tests/fixtures/script-glob-formatter/scripts/build.ts deleted file mode 100644 index 7b507822c4..0000000000 --- a/packages/deslop-js/tests/fixtures/script-glob-formatter/scripts/build.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("build script"); diff --git a/packages/deslop-js/tests/fixtures/script-glob-formatter/src/helper.ts b/packages/deslop-js/tests/fixtures/script-glob-formatter/src/helper.ts deleted file mode 100644 index 83d52b7862..0000000000 --- a/packages/deslop-js/tests/fixtures/script-glob-formatter/src/helper.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/script-glob-formatter/src/index.ts b/packages/deslop-js/tests/fixtures/script-glob-formatter/src/index.ts deleted file mode 100644 index b9d617ab01..0000000000 --- a/packages/deslop-js/tests/fixtures/script-glob-formatter/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { helper } from "./helper"; -export const main = () => helper(); diff --git a/packages/deslop-js/tests/fixtures/script-glob-formatter/src/orphan.ts b/packages/deslop-js/tests/fixtures/script-glob-formatter/src/orphan.ts deleted file mode 100644 index 6488d2dd5b..0000000000 --- a/packages/deslop-js/tests/fixtures/script-glob-formatter/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "nobody imports this"; diff --git a/packages/deslop-js/tests/fixtures/script-globs/package.json b/packages/deslop-js/tests/fixtures/script-globs/package.json deleted file mode 100644 index 3509582ec4..0000000000 --- a/packages/deslop-js/tests/fixtures/script-globs/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "script-glob-entries", - "version": "1.0.0", - "main": "src/index.ts", - "scripts": { - "build:themes": "postcss styles/themes/*.css --dir dist/themes", - "build": "tsc && node src/index.ts" - } -} diff --git a/packages/deslop-js/tests/fixtures/script-globs/src/index.ts b/packages/deslop-js/tests/fixtures/script-globs/src/index.ts deleted file mode 100644 index 2062d509ef..0000000000 --- a/packages/deslop-js/tests/fixtures/script-globs/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const app = "hello"; diff --git a/packages/deslop-js/tests/fixtures/script-globs/src/orphan.ts b/packages/deslop-js/tests/fixtures/script-globs/src/orphan.ts deleted file mode 100644 index bddae4ee9b..0000000000 --- a/packages/deslop-js/tests/fixtures/script-globs/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = true; diff --git a/packages/deslop-js/tests/fixtures/script-globs/styles/themes/dark.css b/packages/deslop-js/tests/fixtures/script-globs/styles/themes/dark.css deleted file mode 100644 index 68b957a3a9..0000000000 --- a/packages/deslop-js/tests/fixtures/script-globs/styles/themes/dark.css +++ /dev/null @@ -1,3 +0,0 @@ -main { - --color-bg: #1a1a1a; -} diff --git a/packages/deslop-js/tests/fixtures/script-globs/styles/themes/light.css b/packages/deslop-js/tests/fixtures/script-globs/styles/themes/light.css deleted file mode 100644 index 6b097568b1..0000000000 --- a/packages/deslop-js/tests/fixtures/script-globs/styles/themes/light.css +++ /dev/null @@ -1,3 +0,0 @@ -main { - --color-bg: #ffffff; -} diff --git a/packages/deslop-js/tests/fixtures/script-no-extension/orphan.ts b/packages/deslop-js/tests/fixtures/script-no-extension/orphan.ts deleted file mode 100644 index bddae4ee9b..0000000000 --- a/packages/deslop-js/tests/fixtures/script-no-extension/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = true; diff --git a/packages/deslop-js/tests/fixtures/script-no-extension/package.json b/packages/deslop-js/tests/fixtures/script-no-extension/package.json deleted file mode 100644 index 7b95514190..0000000000 --- a/packages/deslop-js/tests/fixtures/script-no-extension/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "extensionless-script-entry", - "scripts": { - "build": "tsx ./scripts/build-data", - "lint": "node scripts/lint-code", - "process": "ts-node ./scripts/process-items" - } -} diff --git a/packages/deslop-js/tests/fixtures/script-no-extension/scripts/build-data.ts b/packages/deslop-js/tests/fixtures/script-no-extension/scripts/build-data.ts deleted file mode 100644 index 140b6ee809..0000000000 --- a/packages/deslop-js/tests/fixtures/script-no-extension/scripts/build-data.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("build"); diff --git a/packages/deslop-js/tests/fixtures/script-no-extension/scripts/lint-code.js b/packages/deslop-js/tests/fixtures/script-no-extension/scripts/lint-code.js deleted file mode 100644 index cad735c3bb..0000000000 --- a/packages/deslop-js/tests/fixtures/script-no-extension/scripts/lint-code.js +++ /dev/null @@ -1 +0,0 @@ -console.log("lint"); diff --git a/packages/deslop-js/tests/fixtures/script-no-extension/scripts/process-items.ts b/packages/deslop-js/tests/fixtures/script-no-extension/scripts/process-items.ts deleted file mode 100644 index 0bf02b7d7a..0000000000 --- a/packages/deslop-js/tests/fixtures/script-no-extension/scripts/process-items.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("process"); diff --git a/packages/deslop-js/tests/fixtures/script-no-extension/src/index.ts b/packages/deslop-js/tests/fixtures/script-no-extension/src/index.ts deleted file mode 100644 index c5942a576c..0000000000 --- a/packages/deslop-js/tests/fixtures/script-no-extension/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = () => {}; diff --git a/packages/deslop-js/tests/fixtures/scss-partial/package.json b/packages/deslop-js/tests/fixtures/scss-partial/package.json deleted file mode 100644 index c93874cf1b..0000000000 --- a/packages/deslop-js/tests/fixtures/scss-partial/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "scss-partials-test", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/scss-partial/src/index.ts b/packages/deslop-js/tests/fixtures/scss-partial/src/index.ts deleted file mode 100644 index 40c18b7db9..0000000000 --- a/packages/deslop-js/tests/fixtures/scss-partial/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import "./styles/main.scss"; -export const app = "hello"; diff --git a/packages/deslop-js/tests/fixtures/scss-partial/src/styles/_mixins.scss b/packages/deslop-js/tests/fixtures/scss-partial/src/styles/_mixins.scss deleted file mode 100644 index 0c108ccb77..0000000000 --- a/packages/deslop-js/tests/fixtures/scss-partial/src/styles/_mixins.scss +++ /dev/null @@ -1,5 +0,0 @@ -@mixin flex-center { - display: flex; - align-items: center; - justify-content: center; -} diff --git a/packages/deslop-js/tests/fixtures/scss-partial/src/styles/_orphan.scss b/packages/deslop-js/tests/fixtures/scss-partial/src/styles/_orphan.scss deleted file mode 100644 index 50cbd9cdc9..0000000000 --- a/packages/deslop-js/tests/fixtures/scss-partial/src/styles/_orphan.scss +++ /dev/null @@ -1 +0,0 @@ -$unused-var: red; diff --git a/packages/deslop-js/tests/fixtures/scss-partial/src/styles/_variables.scss b/packages/deslop-js/tests/fixtures/scss-partial/src/styles/_variables.scss deleted file mode 100644 index 1131373881..0000000000 --- a/packages/deslop-js/tests/fixtures/scss-partial/src/styles/_variables.scss +++ /dev/null @@ -1,2 +0,0 @@ -$primary: #333; -$secondary: #666; diff --git a/packages/deslop-js/tests/fixtures/scss-partial/src/styles/main.scss b/packages/deslop-js/tests/fixtures/scss-partial/src/styles/main.scss deleted file mode 100644 index ddd4732821..0000000000 --- a/packages/deslop-js/tests/fixtures/scss-partial/src/styles/main.scss +++ /dev/null @@ -1,6 +0,0 @@ -@import "variables"; -@use "mixins"; - -body { - color: $primary; -} diff --git a/packages/deslop-js/tests/fixtures/side-effects-glob/package.json b/packages/deslop-js/tests/fixtures/side-effects-glob/package.json deleted file mode 100644 index fb8ca63961..0000000000 --- a/packages/deslop-js/tests/fixtures/side-effects-glob/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "side-effects-glob", - "version": "1.0.0", - "sideEffects": [ - "**/widget/style.js" - ], - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/side-effects-glob/src/foo/index.ts b/packages/deslop-js/tests/fixtures/side-effects-glob/src/foo/index.ts deleted file mode 100644 index 9288b7a1b4..0000000000 --- a/packages/deslop-js/tests/fixtures/side-effects-glob/src/foo/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const createWidget = (): void => {}; diff --git a/packages/deslop-js/tests/fixtures/side-effects-glob/src/foo/widget/style.ts b/packages/deslop-js/tests/fixtures/side-effects-glob/src/foo/widget/style.ts deleted file mode 100644 index e1f47fe0df..0000000000 --- a/packages/deslop-js/tests/fixtures/side-effects-glob/src/foo/widget/style.ts +++ /dev/null @@ -1 +0,0 @@ -import "./style.less"; diff --git a/packages/deslop-js/tests/fixtures/side-effects-glob/src/index.ts b/packages/deslop-js/tests/fixtures/side-effects-glob/src/index.ts deleted file mode 100644 index 38ff887c8c..0000000000 --- a/packages/deslop-js/tests/fixtures/side-effects-glob/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createWidget } from "./foo"; - -export const main = (): void => { - createWidget(); -}; diff --git a/packages/deslop-js/tests/fixtures/side-effects-glob/src/orphan.ts b/packages/deslop-js/tests/fixtures/side-effects-glob/src/orphan.ts deleted file mode 100644 index 74448acbb5..0000000000 --- a/packages/deslop-js/tests/fixtures/side-effects-glob/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphanValue = 1; diff --git a/packages/deslop-js/tests/fixtures/simple-app/package.json b/packages/deslop-js/tests/fixtures/simple-app/package.json deleted file mode 100644 index 1dd15522be..0000000000 --- a/packages/deslop-js/tests/fixtures/simple-app/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "basic-project", - "main": "src/index.ts", - "dependencies": { - "react": "^18.0.0", - "unused-dep": "^1.0.0" - }, - "devDependencies": { - "typescript": "^5.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/simple-app/src/index.ts b/packages/deslop-js/tests/fixtures/simple-app/src/index.ts deleted file mode 100644 index 87fe541d54..0000000000 --- a/packages/deslop-js/tests/fixtures/simple-app/src/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { anotherUnused2, usedFunction } from "./utils"; -import { UsedType } from "./types"; - -const result: UsedType = usedFunction(); -console.log(result); - -export function anotherUnused3(): void { - // This function is exported but never imported - console.log("anotherUnused"); - console.log("anotherUnused"); - console.log("anotherUnused"); - console.log("anotherUnused"); - console.log("anotherUnused"); - console.log("anotherUnused"); - console.log("anotherUnused"); - console.log("anotherUnused"); -} - -anotherUnused2(); diff --git a/packages/deslop-js/tests/fixtures/simple-app/src/orphan.ts b/packages/deslop-js/tests/fixtures/simple-app/src/orphan.ts deleted file mode 100644 index 6d3bcbdaa3..0000000000 --- a/packages/deslop-js/tests/fixtures/simple-app/src/orphan.ts +++ /dev/null @@ -1,2 +0,0 @@ -// This file is not imported by anything -export const orphanedValue = "nobody uses me"; diff --git a/packages/deslop-js/tests/fixtures/simple-app/src/types.ts b/packages/deslop-js/tests/fixtures/simple-app/src/types.ts deleted file mode 100644 index 9feeac8535..0000000000 --- a/packages/deslop-js/tests/fixtures/simple-app/src/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type UsedType = { value: number }; - -export type UnusedType = { name: string }; - -export interface UnusedInterface { - id: number; -} diff --git a/packages/deslop-js/tests/fixtures/simple-app/src/utils.ts b/packages/deslop-js/tests/fixtures/simple-app/src/utils.ts deleted file mode 100644 index dff9b02438..0000000000 --- a/packages/deslop-js/tests/fixtures/simple-app/src/utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const usedFunction = () => ({ value: 42 }); - -export const unusedFunction = () => "not used anywhere"; - -export function anotherUnused(): void { - // This function is exported but never imported -} - -/** @public */ -export function publicApiFunction(): string { - // This function is not imported by any file in the project, - // but it has @public so it should NOT be reported as unused. - return "public API"; -} diff --git a/packages/deslop-js/tests/fixtures/simplifiable-expressions/package.json b/packages/deslop-js/tests/fixtures/simplifiable-expressions/package.json deleted file mode 100644 index 68f378bdad..0000000000 --- a/packages/deslop-js/tests/fixtures/simplifiable-expressions/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "simplifiable-expressions", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/simplifiable-expressions/src/index.ts b/packages/deslop-js/tests/fixtures/simplifiable-expressions/src/index.ts deleted file mode 100644 index df91255256..0000000000 --- a/packages/deslop-js/tests/fixtures/simplifiable-expressions/src/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -const config: { value: string } | undefined = undefined as { value: string } | undefined; - -export const value = config ? config : { value: "default" }; - -export const nested = config?.value ? config?.value : "fallback"; - -export const legitTernary = config ? config.value : "x"; - -export const coerced = !!config; - -export const nested_coerced = !!config?.value; - -export const notNotNotIsNot = !!!config; - -export const condBoolean = config ? true : false; - -export const condBooleanInverse = config ? false : true; - -export const legitBoolean = config ? "yes" : "no"; - -const someValue: string | null | undefined = "x"; - -export const nullCoalesced = someValue ?? null; - -export const undefinedCoalesced = someValue ?? undefined; - -export const legitCoalesced = someValue ?? "fallback"; - -export const wordy = someValue !== null && someValue !== undefined; - -export const wordyReversed = someValue !== undefined && someValue !== null; - -export const legitCheck = someValue !== null && typeof someValue === "string"; - -console.log( - value, - nested, - legitTernary, - coerced, - nested_coerced, - notNotNotIsNot, - condBoolean, - condBooleanInverse, - legitBoolean, - nullCoalesced, - undefinedCoalesced, - legitCoalesced, - wordy, - wordyReversed, - legitCheck, -); diff --git a/packages/deslop-js/tests/fixtures/simplifiable-functions/package.json b/packages/deslop-js/tests/fixtures/simplifiable-functions/package.json deleted file mode 100644 index dc5a2eb028..0000000000 --- a/packages/deslop-js/tests/fixtures/simplifiable-functions/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "simplifiable-functions", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/simplifiable-functions/src/index.ts b/packages/deslop-js/tests/fixtures/simplifiable-functions/src/index.ts deleted file mode 100644 index 22396b2147..0000000000 --- a/packages/deslop-js/tests/fixtures/simplifiable-functions/src/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -export const blockArrowSimple = (input: string) => { - return input.toUpperCase(); -}; - -export const blockArrowComplex = (input: string) => { - const upper = input.toUpperCase(); - return upper; -}; - -export const expressionArrow = (input: string) => input.toUpperCase(); - -export const fetchDataRedundant = async (): Promise<number> => { - const value = await Promise.resolve(42); - return value; -}; - -export const fetchDataDirect = async (): Promise<number> => { - return Promise.resolve(42); -}; - -export const fetchDataMultiAwait = async (): Promise<number> => { - const partial = await Promise.resolve(1); - const remaining = await Promise.resolve(partial + 1); - return remaining; -}; - -export const uselessAsync = async (input: number) => { - return input * 2; -}; - -export const uselessAsyncWithPromiseReturnType = async ( - input: number, -): Promise<number> => { - return input * 2; -}; - -export const nextConfigLike = { - async redirects() { - return [{ source: "/old", destination: "/new", permanent: true }]; - }, -}; - -export const mockResponse = { - text: async () => "mocked body", - json: async () => ({ ok: true }), -}; - -export const inlineCallbackInvoker = (callback: (input: number) => Promise<number>): unknown => - callback(42); - -inlineCallbackInvoker(async (input) => input * 2); - -export const legitAsync = async (input: number): Promise<number> => { - const doubled = await Promise.resolve(input * 2); - return doubled + 1; -}; - -console.log( - blockArrowSimple("a"), - blockArrowComplex("b"), - expressionArrow("c"), - fetchDataRedundant(), - fetchDataDirect(), - fetchDataMultiAwait(), - uselessAsync(2), - uselessAsyncWithPromiseReturnType(3), - nextConfigLike.redirects(), - legitAsync(3), -); diff --git a/packages/deslop-js/tests/fixtures/spec-dash-patterns/package.json b/packages/deslop-js/tests/fixtures/spec-dash-patterns/package.json deleted file mode 100644 index 8732b0720b..0000000000 --- a/packages/deslop-js/tests/fixtures/spec-dash-patterns/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "dash-spec-patterns", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "vitest": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/spec-dash-patterns/spec/engine_spec.ts b/packages/deslop-js/tests/fixtures/spec-dash-patterns/spec/engine_spec.ts deleted file mode 100644 index 303c8bbd65..0000000000 --- a/packages/deslop-js/tests/fixtures/spec-dash-patterns/spec/engine_spec.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { main } from "../src/index"; -const result = main(); diff --git a/packages/deslop-js/tests/fixtures/spec-dash-patterns/spec/utils-spec.ts b/packages/deslop-js/tests/fixtures/spec-dash-patterns/spec/utils-spec.ts deleted file mode 100644 index 303c8bbd65..0000000000 --- a/packages/deslop-js/tests/fixtures/spec-dash-patterns/spec/utils-spec.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { main } from "../src/index"; -const result = main(); diff --git a/packages/deslop-js/tests/fixtures/spec-dash-patterns/src/index.ts b/packages/deslop-js/tests/fixtures/spec-dash-patterns/src/index.ts deleted file mode 100644 index c5961da749..0000000000 --- a/packages/deslop-js/tests/fixtures/spec-dash-patterns/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/spec-dash-patterns/src/orphan.ts b/packages/deslop-js/tests/fixtures/spec-dash-patterns/src/orphan.ts deleted file mode 100644 index f60a83644a..0000000000 --- a/packages/deslop-js/tests/fixtures/spec-dash-patterns/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = () => "not imported"; diff --git a/packages/deslop-js/tests/fixtures/src-build-dir/package.json b/packages/deslop-js/tests/fixtures/src-build-dir/package.json deleted file mode 100644 index 477d2a34fd..0000000000 --- a/packages/deslop-js/tests/fixtures/src-build-dir/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "source-build-directory-test", - "private": true, - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/src-build-dir/src/build/helpers.ts b/packages/deslop-js/tests/fixtures/src-build-dir/src/build/helpers.ts deleted file mode 100644 index 258d6495e3..0000000000 --- a/packages/deslop-js/tests/fixtures/src-build-dir/src/build/helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = () => "build helper"; diff --git a/packages/deslop-js/tests/fixtures/src-build-dir/src/build/plugins.ts b/packages/deslop-js/tests/fixtures/src-build-dir/src/build/plugins.ts deleted file mode 100644 index e90a5c74de..0000000000 --- a/packages/deslop-js/tests/fixtures/src-build-dir/src/build/plugins.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { helper } from "./helpers"; -export const buildPlugins = () => helper(); diff --git a/packages/deslop-js/tests/fixtures/src-build-dir/src/index.ts b/packages/deslop-js/tests/fixtures/src-build-dir/src/index.ts deleted file mode 100644 index 7eecf4395f..0000000000 --- a/packages/deslop-js/tests/fixtures/src-build-dir/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { buildPlugins } from "./build/plugins"; -export const main = () => buildPlugins(); diff --git a/packages/deslop-js/tests/fixtures/src-build-dir/src/orphan.ts b/packages/deslop-js/tests/fixtures/src-build-dir/src/orphan.ts deleted file mode 100644 index e64ecbdcd2..0000000000 --- a/packages/deslop-js/tests/fixtures/src-build-dir/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "not imported by anyone"; diff --git a/packages/deslop-js/tests/fixtures/src-path-fallback/package.json b/packages/deslop-js/tests/fixtures/src-path-fallback/package.json deleted file mode 100644 index f8bd3deb37..0000000000 --- a/packages/deslop-js/tests/fixtures/src-path-fallback/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "source-path-fallback", - "version": "1.0.0", - "exports": { - ".": { - "default": "./dist/index.js" - }, - "./cli": { - "default": "./dist/cli.js" - } - } -} diff --git a/packages/deslop-js/tests/fixtures/src-path-fallback/src/cli/index.ts b/packages/deslop-js/tests/fixtures/src-path-fallback/src/cli/index.ts deleted file mode 100644 index 518d81eec4..0000000000 --- a/packages/deslop-js/tests/fixtures/src-path-fallback/src/cli/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { runCli } from "./runner"; -export const cli = () => runCli(); diff --git a/packages/deslop-js/tests/fixtures/src-path-fallback/src/cli/runner.ts b/packages/deslop-js/tests/fixtures/src-path-fallback/src/cli/runner.ts deleted file mode 100644 index 9d675b9906..0000000000 --- a/packages/deslop-js/tests/fixtures/src-path-fallback/src/cli/runner.ts +++ /dev/null @@ -1 +0,0 @@ -export const runCli = () => "cli runner"; diff --git a/packages/deslop-js/tests/fixtures/src-path-fallback/src/helper.ts b/packages/deslop-js/tests/fixtures/src-path-fallback/src/helper.ts deleted file mode 100644 index d4b4461bad..0000000000 --- a/packages/deslop-js/tests/fixtures/src-path-fallback/src/helper.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = () => "used by index"; diff --git a/packages/deslop-js/tests/fixtures/src-path-fallback/src/index.ts b/packages/deslop-js/tests/fixtures/src-path-fallback/src/index.ts deleted file mode 100644 index b9d617ab01..0000000000 --- a/packages/deslop-js/tests/fixtures/src-path-fallback/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { helper } from "./helper"; -export const main = () => helper(); diff --git a/packages/deslop-js/tests/fixtures/src-path-fallback/src/orphan.ts b/packages/deslop-js/tests/fixtures/src-path-fallback/src/orphan.ts deleted file mode 100644 index f60a83644a..0000000000 --- a/packages/deslop-js/tests/fixtures/src-path-fallback/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = () => "not imported"; diff --git a/packages/deslop-js/tests/fixtures/src-path-fallback/tsconfig.json b/packages/deslop-js/tests/fixtures/src-path-fallback/tsconfig.json deleted file mode 100644 index 1dd8e13c58..0000000000 --- a/packages/deslop-js/tests/fixtures/src-path-fallback/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - } -} diff --git a/packages/deslop-js/tests/fixtures/star-reexport-chain/package.json b/packages/deslop-js/tests/fixtures/star-reexport-chain/package.json deleted file mode 100644 index 5a04a29942..0000000000 --- a/packages/deslop-js/tests/fixtures/star-reexport-chain/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "star-re-export-chain", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/star-reexport-chain/src/barrel1.ts b/packages/deslop-js/tests/fixtures/star-reexport-chain/src/barrel1.ts deleted file mode 100644 index e5675d9792..0000000000 --- a/packages/deslop-js/tests/fixtures/star-reexport-chain/src/barrel1.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./barrel2"; diff --git a/packages/deslop-js/tests/fixtures/star-reexport-chain/src/barrel2.ts b/packages/deslop-js/tests/fixtures/star-reexport-chain/src/barrel2.ts deleted file mode 100644 index a461c3b008..0000000000 --- a/packages/deslop-js/tests/fixtures/star-reexport-chain/src/barrel2.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./source"; diff --git a/packages/deslop-js/tests/fixtures/star-reexport-chain/src/index.ts b/packages/deslop-js/tests/fixtures/star-reexport-chain/src/index.ts deleted file mode 100644 index 709466f212..0000000000 --- a/packages/deslop-js/tests/fixtures/star-reexport-chain/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { used } from "./barrel1"; - -console.log(used); diff --git a/packages/deslop-js/tests/fixtures/star-reexport-chain/src/source.ts b/packages/deslop-js/tests/fixtures/star-reexport-chain/src/source.ts deleted file mode 100644 index 0cbb078459..0000000000 --- a/packages/deslop-js/tests/fixtures/star-reexport-chain/src/source.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const used = 1; -export const unused = 2; diff --git a/packages/deslop-js/tests/fixtures/star-selective/package.json b/packages/deslop-js/tests/fixtures/star-selective/package.json deleted file mode 100644 index 0d25693043..0000000000 --- a/packages/deslop-js/tests/fixtures/star-selective/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "star-selective-usage", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/star-selective/src/barrel.ts b/packages/deslop-js/tests/fixtures/star-selective/src/barrel.ts deleted file mode 100644 index 3a20573e0d..0000000000 --- a/packages/deslop-js/tests/fixtures/star-selective/src/barrel.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Star re-export: all exports from source are re-exported -export * from "./source"; diff --git a/packages/deslop-js/tests/fixtures/star-selective/src/index.ts b/packages/deslop-js/tests/fixtures/star-selective/src/index.ts deleted file mode 100644 index 4049536436..0000000000 --- a/packages/deslop-js/tests/fixtures/star-selective/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { usedOne, usedTwo } from "./barrel"; - -console.log(usedOne, usedTwo); diff --git a/packages/deslop-js/tests/fixtures/star-selective/src/source.ts b/packages/deslop-js/tests/fixtures/star-selective/src/source.ts deleted file mode 100644 index d90b1b1fb1..0000000000 --- a/packages/deslop-js/tests/fixtures/star-selective/src/source.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const usedOne = "imported via barrel"; -export const usedTwo = "also imported via barrel"; -export const unusedThree = "star re-exported but never imported"; -export const unusedFour = "also star re-exported but never imported"; diff --git a/packages/deslop-js/tests/fixtures/storybook-app/.storybook/main.ts b/packages/deslop-js/tests/fixtures/storybook-app/.storybook/main.ts deleted file mode 100644 index c66fa9f528..0000000000 --- a/packages/deslop-js/tests/fixtures/storybook-app/.storybook/main.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default { - stories: ["../src/**/*.stories.@(ts|tsx|js|jsx)"], - framework: "@storybook/react", -}; diff --git a/packages/deslop-js/tests/fixtures/storybook-app/.storybook/preview.ts b/packages/deslop-js/tests/fixtures/storybook-app/.storybook/preview.ts deleted file mode 100644 index c6281bc783..0000000000 --- a/packages/deslop-js/tests/fixtures/storybook-app/.storybook/preview.ts +++ /dev/null @@ -1 +0,0 @@ -export const parameters = { actions: { argTypesRegex: "^on[A-Z].*" } }; diff --git a/packages/deslop-js/tests/fixtures/storybook-app/package.json b/packages/deslop-js/tests/fixtures/storybook-app/package.json deleted file mode 100644 index 2555a5a904..0000000000 --- a/packages/deslop-js/tests/fixtures/storybook-app/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "storybook-project", - "version": "1.0.0", - "devDependencies": { - "@storybook/react": "^7.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/storybook-app/src/components/Button.stories.ts b/packages/deslop-js/tests/fixtures/storybook-app/src/components/Button.stories.ts deleted file mode 100644 index bdda1a00fc..0000000000 --- a/packages/deslop-js/tests/fixtures/storybook-app/src/components/Button.stories.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Button } from "./Button"; - -export default { title: "Button", component: Button }; -export const Primary = () => Button(); diff --git a/packages/deslop-js/tests/fixtures/storybook-app/src/components/Button.ts b/packages/deslop-js/tests/fixtures/storybook-app/src/components/Button.ts deleted file mode 100644 index 5dfc1b1794..0000000000 --- a/packages/deslop-js/tests/fixtures/storybook-app/src/components/Button.ts +++ /dev/null @@ -1 +0,0 @@ -export const Button = () => "button"; diff --git a/packages/deslop-js/tests/fixtures/storybook-app/src/orphan.ts b/packages/deslop-js/tests/fixtures/storybook-app/src/orphan.ts deleted file mode 100644 index f60a83644a..0000000000 --- a/packages/deslop-js/tests/fixtures/storybook-app/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = () => "not imported"; diff --git a/packages/deslop-js/tests/fixtures/storybook-mdx-import/package.json b/packages/deslop-js/tests/fixtures/storybook-mdx-import/package.json deleted file mode 100644 index ae2f50640a..0000000000 --- a/packages/deslop-js/tests/fixtures/storybook-mdx-import/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "storybook-mdx-app", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "@storybook/react": "^7.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/storybook-mdx-import/src/components/Alert.mdx b/packages/deslop-js/tests/fixtures/storybook-mdx-import/src/components/Alert.mdx deleted file mode 100644 index b7c913f7ba..0000000000 --- a/packages/deslop-js/tests/fixtures/storybook-mdx-import/src/components/Alert.mdx +++ /dev/null @@ -1,7 +0,0 @@ -# Alert - -Documentation for the Alert component. - -```tsx -import { Alert } from "./Alert"; -``` diff --git a/packages/deslop-js/tests/fixtures/storybook-mdx-import/src/components/Alert.story.tsx b/packages/deslop-js/tests/fixtures/storybook-mdx-import/src/components/Alert.story.tsx deleted file mode 100644 index 277bbc87e5..0000000000 --- a/packages/deslop-js/tests/fixtures/storybook-mdx-import/src/components/Alert.story.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import mdx from "./Alert.mdx"; -import { Alert } from "./Alert"; - -export default { - title: "Components/Alert", - component: Alert, - parameters: { - docs: { - page: mdx, - }, - }, -}; - -export const Default = () => Alert(); diff --git a/packages/deslop-js/tests/fixtures/storybook-mdx-import/src/components/Alert.ts b/packages/deslop-js/tests/fixtures/storybook-mdx-import/src/components/Alert.ts deleted file mode 100644 index cf6d8e0f74..0000000000 --- a/packages/deslop-js/tests/fixtures/storybook-mdx-import/src/components/Alert.ts +++ /dev/null @@ -1 +0,0 @@ -export const Alert = () => "alert"; diff --git a/packages/deslop-js/tests/fixtures/storybook-mdx-import/src/components/orphan.ts b/packages/deslop-js/tests/fixtures/storybook-mdx-import/src/components/orphan.ts deleted file mode 100644 index bd93db0a49..0000000000 --- a/packages/deslop-js/tests/fixtures/storybook-mdx-import/src/components/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "unused"; diff --git a/packages/deslop-js/tests/fixtures/storybook-mdx-import/src/index.ts b/packages/deslop-js/tests/fixtures/storybook-mdx-import/src/index.ts deleted file mode 100644 index 4d4bc42da1..0000000000 --- a/packages/deslop-js/tests/fixtures/storybook-mdx-import/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Alert } from "./components/Alert"; diff --git a/packages/deslop-js/tests/fixtures/style-alias/package.json b/packages/deslop-js/tests/fixtures/style-alias/package.json deleted file mode 100644 index 677fa3128e..0000000000 --- a/packages/deslop-js/tests/fixtures/style-alias/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "css-path-alias-fixture", - "private": true -} diff --git a/packages/deslop-js/tests/fixtures/style-alias/src/index.ts b/packages/deslop-js/tests/fixtures/style-alias/src/index.ts deleted file mode 100644 index 4e3674ca4a..0000000000 --- a/packages/deslop-js/tests/fixtures/style-alias/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import "@/styles/globals.css"; -import { helper } from "@/lib/utils"; - -export const main = () => helper(); diff --git a/packages/deslop-js/tests/fixtures/style-alias/src/lib/utils.ts b/packages/deslop-js/tests/fixtures/style-alias/src/lib/utils.ts deleted file mode 100644 index 613e5ee576..0000000000 --- a/packages/deslop-js/tests/fixtures/style-alias/src/lib/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = () => "used"; diff --git a/packages/deslop-js/tests/fixtures/style-alias/src/orphan.ts b/packages/deslop-js/tests/fixtures/style-alias/src/orphan.ts deleted file mode 100644 index 7a2285da1b..0000000000 --- a/packages/deslop-js/tests/fixtures/style-alias/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = () => "never imported"; diff --git a/packages/deslop-js/tests/fixtures/style-alias/src/styles/globals.css b/packages/deslop-js/tests/fixtures/style-alias/src/styles/globals.css deleted file mode 100644 index 63fd48cc07..0000000000 --- a/packages/deslop-js/tests/fixtures/style-alias/src/styles/globals.css +++ /dev/null @@ -1,3 +0,0 @@ -:root { - --primary: #000; -} diff --git a/packages/deslop-js/tests/fixtures/style-alias/tsconfig.json b/packages/deslop-js/tests/fixtures/style-alias/tsconfig.json deleted file mode 100644 index 8f71910b1c..0000000000 --- a/packages/deslop-js/tests/fixtures/style-alias/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@/styles/*": ["src/styles/*"], - "@/lib/*": ["src/lib/*"] - } - } -} diff --git a/packages/deslop-js/tests/fixtures/style-export-map/package.json b/packages/deslop-js/tests/fixtures/style-export-map/package.json deleted file mode 100644 index 87e2af27c7..0000000000 --- a/packages/deslop-js/tests/fixtures/style-export-map/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "css-export-map", - "version": "1.0.0", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./style.css": { - "import": "./dist/style.css" - } - } -} diff --git a/packages/deslop-js/tests/fixtures/style-export-map/src/index.ts b/packages/deslop-js/tests/fixtures/style-export-map/src/index.ts deleted file mode 100644 index a69bd7b0a3..0000000000 --- a/packages/deslop-js/tests/fixtures/style-export-map/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const component = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/style-export-map/src/orphan.css b/packages/deslop-js/tests/fixtures/style-export-map/src/orphan.css deleted file mode 100644 index b45ed03933..0000000000 --- a/packages/deslop-js/tests/fixtures/style-export-map/src/orphan.css +++ /dev/null @@ -1,3 +0,0 @@ -.unused { - color: red; -} diff --git a/packages/deslop-js/tests/fixtures/style-export-map/src/style.css b/packages/deslop-js/tests/fixtures/style-export-map/src/style.css deleted file mode 100644 index abde53a3ad..0000000000 --- a/packages/deslop-js/tests/fixtures/style-export-map/src/style.css +++ /dev/null @@ -1,3 +0,0 @@ -.container { - display: flex; -} diff --git a/packages/deslop-js/tests/fixtures/style-export-map/tsconfig.json b/packages/deslop-js/tests/fixtures/style-export-map/tsconfig.json deleted file mode 100644 index 1dd8e13c58..0000000000 --- a/packages/deslop-js/tests/fixtures/style-export-map/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - } -} diff --git a/packages/deslop-js/tests/fixtures/style-imports/package.json b/packages/deslop-js/tests/fixtures/style-imports/package.json deleted file mode 100644 index e07b3289f7..0000000000 --- a/packages/deslop-js/tests/fixtures/style-imports/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "css-imports", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/style-imports/src/app.css b/packages/deslop-js/tests/fixtures/style-imports/src/app.css deleted file mode 100644 index 60ae0f68c2..0000000000 --- a/packages/deslop-js/tests/fixtures/style-imports/src/app.css +++ /dev/null @@ -1,5 +0,0 @@ -@import "../styles/base.css"; - -.app { - color: red; -} diff --git a/packages/deslop-js/tests/fixtures/style-imports/src/index.ts b/packages/deslop-js/tests/fixtures/style-imports/src/index.ts deleted file mode 100644 index 6cbbb825d8..0000000000 --- a/packages/deslop-js/tests/fixtures/style-imports/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import "./app.css"; - -export const app = "hello"; diff --git a/packages/deslop-js/tests/fixtures/style-imports/styles/base.css b/packages/deslop-js/tests/fixtures/style-imports/styles/base.css deleted file mode 100644 index b72dd63a86..0000000000 --- a/packages/deslop-js/tests/fixtures/style-imports/styles/base.css +++ /dev/null @@ -1,5 +0,0 @@ -@import "./theme/colors.css"; - -body { - margin: 0; -} diff --git a/packages/deslop-js/tests/fixtures/style-imports/styles/orphan.css b/packages/deslop-js/tests/fixtures/style-imports/styles/orphan.css deleted file mode 100644 index f1b5efa2dd..0000000000 --- a/packages/deslop-js/tests/fixtures/style-imports/styles/orphan.css +++ /dev/null @@ -1,3 +0,0 @@ -.orphan { - display: none; -} diff --git a/packages/deslop-js/tests/fixtures/style-imports/styles/theme/colors.css b/packages/deslop-js/tests/fixtures/style-imports/styles/theme/colors.css deleted file mode 100644 index 01f785319c..0000000000 --- a/packages/deslop-js/tests/fixtures/style-imports/styles/theme/colors.css +++ /dev/null @@ -1,3 +0,0 @@ -:root { - --app-color: red; -} diff --git a/packages/deslop-js/tests/fixtures/style-tracking/package.json b/packages/deslop-js/tests/fixtures/style-tracking/package.json deleted file mode 100644 index 01b739c06d..0000000000 --- a/packages/deslop-js/tests/fixtures/style-tracking/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "css-tracking", - "version": "1.0.0", - "main": "src/index.ts", - "dependencies": {} -} diff --git a/packages/deslop-js/tests/fixtures/style-tracking/src/helper.ts b/packages/deslop-js/tests/fixtures/style-tracking/src/helper.ts deleted file mode 100644 index 9fa1cd03a4..0000000000 --- a/packages/deslop-js/tests/fixtures/style-tracking/src/helper.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = () => "help"; diff --git a/packages/deslop-js/tests/fixtures/style-tracking/src/index.ts b/packages/deslop-js/tests/fixtures/style-tracking/src/index.ts deleted file mode 100644 index 7df9b97623..0000000000 --- a/packages/deslop-js/tests/fixtures/style-tracking/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import "./styles.css"; -import { helper } from "./helper"; - -export const main = helper(); diff --git a/packages/deslop-js/tests/fixtures/style-tracking/src/orphan.ts b/packages/deslop-js/tests/fixtures/style-tracking/src/orphan.ts deleted file mode 100644 index bd93db0a49..0000000000 --- a/packages/deslop-js/tests/fixtures/style-tracking/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "unused"; diff --git a/packages/deslop-js/tests/fixtures/style-tracking/src/styles.css b/packages/deslop-js/tests/fixtures/style-tracking/src/styles.css deleted file mode 100644 index a558cead5d..0000000000 --- a/packages/deslop-js/tests/fixtures/style-tracking/src/styles.css +++ /dev/null @@ -1,3 +0,0 @@ -.main { - color: red; -} diff --git a/packages/deslop-js/tests/fixtures/style-tracking/src/unused.css b/packages/deslop-js/tests/fixtures/style-tracking/src/unused.css deleted file mode 100644 index b5c0e469e6..0000000000 --- a/packages/deslop-js/tests/fixtures/style-tracking/src/unused.css +++ /dev/null @@ -1,3 +0,0 @@ -.unused { - display: none; -} diff --git a/packages/deslop-js/tests/fixtures/subproject-standalone/app/package.json b/packages/deslop-js/tests/fixtures/subproject-standalone/app/package.json deleted file mode 100644 index 9ef9de3ea0..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-standalone/app/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "my-app", - "version": "1.0.0", - "workspaces": [ - "packages/*" - ], - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/subproject-standalone/app/packages/utils/package.json b/packages/deslop-js/tests/fixtures/subproject-standalone/app/packages/utils/package.json deleted file mode 100644 index 7f75259ff0..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-standalone/app/packages/utils/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "@app/utils", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/subproject-standalone/app/packages/utils/src/index.ts b/packages/deslop-js/tests/fixtures/subproject-standalone/app/packages/utils/src/index.ts deleted file mode 100644 index bc81dd56de..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-standalone/app/packages/utils/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const add = (a: number, b: number) => a + b; diff --git a/packages/deslop-js/tests/fixtures/subproject-standalone/app/src/index.ts b/packages/deslop-js/tests/fixtures/subproject-standalone/app/src/index.ts deleted file mode 100644 index 4124a4c154..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-standalone/app/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = "hello"; diff --git a/packages/deslop-js/tests/fixtures/subproject-standalone/app/src/orphan.ts b/packages/deslop-js/tests/fixtures/subproject-standalone/app/src/orphan.ts deleted file mode 100644 index 2220b397f5..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-standalone/app/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "orphan in app"; diff --git a/packages/deslop-js/tests/fixtures/subproject-standalone/docs/package.json b/packages/deslop-js/tests/fixtures/subproject-standalone/docs/package.json deleted file mode 100644 index 04ff8e856b..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-standalone/docs/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "docs-site", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/subproject-standalone/docs/src/guide.ts b/packages/deslop-js/tests/fixtures/subproject-standalone/docs/src/guide.ts deleted file mode 100644 index f17613b419..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-standalone/docs/src/guide.ts +++ /dev/null @@ -1 +0,0 @@ -export const guide = "standalone docs file"; diff --git a/packages/deslop-js/tests/fixtures/subproject-standalone/docs/src/index.ts b/packages/deslop-js/tests/fixtures/subproject-standalone/docs/src/index.ts deleted file mode 100644 index 53fa42fd27..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-standalone/docs/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const docsEntry = "docs"; diff --git a/packages/deslop-js/tests/fixtures/subproject-standalone/docs/yarn.lock b/packages/deslop-js/tests/fixtures/subproject-standalone/docs/yarn.lock deleted file mode 100644 index 2b0b0ed5f7..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-standalone/docs/yarn.lock +++ /dev/null @@ -1 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. diff --git a/packages/deslop-js/tests/fixtures/subproject-standalone/package.json b/packages/deslop-js/tests/fixtures/subproject-standalone/package.json deleted file mode 100644 index d74e69513e..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-standalone/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "standalone-root", - "version": "1.0.0", - "workspaces": [ - "app", - "app/packages/*" - ] -} diff --git a/packages/deslop-js/tests/fixtures/subproject-workspace/app/package.json b/packages/deslop-js/tests/fixtures/subproject-workspace/app/package.json deleted file mode 100644 index b30db17ab8..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-workspace/app/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "my-app-root", - "version": "1.0.0", - "devDependencies": { - "typescript": "^5.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/core/app/page.ts b/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/core/app/page.ts deleted file mode 100644 index 5cae7a13e1..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/core/app/page.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function Page() { - return "page"; -} diff --git a/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/core/package.json b/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/core/package.json deleted file mode 100644 index d25b870c9b..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/core/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@myapp/core", - "version": "1.0.0", - "main": "./src/index.ts", - "dependencies": { - "next": "^14.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/core/src/index.ts b/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/core/src/index.ts deleted file mode 100644 index 5957b07261..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/core/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const version = "1.0.0"; diff --git a/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/core/src/unused-util.ts b/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/core/src/unused-util.ts deleted file mode 100644 index 0eaaaf48fc..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/core/src/unused-util.ts +++ /dev/null @@ -1 +0,0 @@ -export const unusedHelper = () => "not imported anywhere"; diff --git a/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/icons/package.json b/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/icons/package.json deleted file mode 100644 index 3d2b4507b9..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/icons/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "@myapp/icons", - "version": "1.0.0", - "main": "./src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/icons/src/icons/heart.ts b/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/icons/src/icons/heart.ts deleted file mode 100644 index 9560a0d34f..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/icons/src/icons/heart.ts +++ /dev/null @@ -1 +0,0 @@ -export const Heart = "heart-icon"; diff --git a/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/icons/src/icons/star.ts b/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/icons/src/icons/star.ts deleted file mode 100644 index ee7a93495c..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/icons/src/icons/star.ts +++ /dev/null @@ -1 +0,0 @@ -export const Star = "star-icon"; diff --git a/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/icons/src/index.ts b/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/icons/src/index.ts deleted file mode 100644 index 88943e0843..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-workspace/app/packages/icons/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Star } from "./icons/star"; -export { Heart } from "./icons/heart"; diff --git a/packages/deslop-js/tests/fixtures/subproject-workspace/app/pnpm-workspace.yaml b/packages/deslop-js/tests/fixtures/subproject-workspace/app/pnpm-workspace.yaml deleted file mode 100644 index dee51e928d..0000000000 --- a/packages/deslop-js/tests/fixtures/subproject-workspace/app/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - - "packages/*" diff --git a/packages/deslop-js/tests/fixtures/tailwind-v4-plugin/package.json b/packages/deslop-js/tests/fixtures/tailwind-v4-plugin/package.json deleted file mode 100644 index 8f74c85648..0000000000 --- a/packages/deslop-js/tests/fixtures/tailwind-v4-plugin/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "tailwind-v4-plugin", - "version": "1.0.0", - "main": "src/index.ts", - "dependencies": { - "tailwindcss-animate": "^1.0.7", - "unused-dep": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/tailwind-v4-plugin/src/index.ts b/packages/deslop-js/tests/fixtures/tailwind-v4-plugin/src/index.ts deleted file mode 100644 index 4016e08a8e..0000000000 --- a/packages/deslop-js/tests/fixtures/tailwind-v4-plugin/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import "./styles.css"; - -export const value = "value"; diff --git a/packages/deslop-js/tests/fixtures/tailwind-v4-plugin/src/styles.css b/packages/deslop-js/tests/fixtures/tailwind-v4-plugin/src/styles.css deleted file mode 100644 index ceef377a86..0000000000 --- a/packages/deslop-js/tests/fixtures/tailwind-v4-plugin/src/styles.css +++ /dev/null @@ -1,3 +0,0 @@ -@import "tailwindcss"; -@plugin "tailwindcss-animate"; -@source not "../lib/edit/utils.ts"; diff --git a/packages/deslop-js/tests/fixtures/tanstack-app/package.json b/packages/deslop-js/tests/fixtures/tanstack-app/package.json deleted file mode 100644 index a71934d955..0000000000 --- a/packages/deslop-js/tests/fixtures/tanstack-app/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "tanstack-start-app", - "version": "1.0.0", - "dependencies": { - "@tanstack/react-router": "^1.0.0", - "@tanstack/react-start": "^1.0.0", - "react": "^18.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/tanstack-app/src/orphan.ts b/packages/deslop-js/tests/fixtures/tanstack-app/src/orphan.ts deleted file mode 100644 index f081f63450..0000000000 --- a/packages/deslop-js/tests/fixtures/tanstack-app/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unusedUtil = () => "not imported"; diff --git a/packages/deslop-js/tests/fixtures/tanstack-app/src/routes/about.tsx b/packages/deslop-js/tests/fixtures/tanstack-app/src/routes/about.tsx deleted file mode 100644 index a3c92721b9..0000000000 --- a/packages/deslop-js/tests/fixtures/tanstack-app/src/routes/about.tsx +++ /dev/null @@ -1 +0,0 @@ -export const loader = () => ({ title: "About" }); diff --git a/packages/deslop-js/tests/fixtures/tanstack-app/src/routes/index.tsx b/packages/deslop-js/tests/fixtures/tanstack-app/src/routes/index.tsx deleted file mode 100644 index 0f973bbaf1..0000000000 --- a/packages/deslop-js/tests/fixtures/tanstack-app/src/routes/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export const loader = () => ({ title: "Home" }); diff --git a/packages/deslop-js/tests/fixtures/tanstack-app/src/server.ts b/packages/deslop-js/tests/fixtures/tanstack-app/src/server.ts deleted file mode 100644 index 6f41ef4148..0000000000 --- a/packages/deslop-js/tests/fixtures/tanstack-app/src/server.ts +++ /dev/null @@ -1,2 +0,0 @@ -import handler from "@tanstack/react-start/server-entry"; -export default handler; diff --git a/packages/deslop-js/tests/fixtures/test-custom-ext/package.json b/packages/deslop-js/tests/fixtures/test-custom-ext/package.json deleted file mode 100644 index daeba62334..0000000000 --- a/packages/deslop-js/tests/fixtures/test-custom-ext/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "custom-test-extensions", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "vitest": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/test-custom-ext/src/__e2e__/login.test.ts b/packages/deslop-js/tests/fixtures/test-custom-ext/src/__e2e__/login.test.ts deleted file mode 100644 index fe8b51183c..0000000000 --- a/packages/deslop-js/tests/fixtures/test-custom-ext/src/__e2e__/login.test.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { greet } from "../index"; -console.log(greet("e2e")); diff --git a/packages/deslop-js/tests/fixtures/test-custom-ext/src/api.servertest.ts b/packages/deslop-js/tests/fixtures/test-custom-ext/src/api.servertest.ts deleted file mode 100644 index 2f16292ca0..0000000000 --- a/packages/deslop-js/tests/fixtures/test-custom-ext/src/api.servertest.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { greet } from "./index"; -console.log(greet("server")); diff --git a/packages/deslop-js/tests/fixtures/test-custom-ext/src/index.ts b/packages/deslop-js/tests/fixtures/test-custom-ext/src/index.ts deleted file mode 100644 index 65b16151bd..0000000000 --- a/packages/deslop-js/tests/fixtures/test-custom-ext/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const greet = (name: string) => `Hello ${name}`; diff --git a/packages/deslop-js/tests/fixtures/test-custom-ext/src/orphan.ts b/packages/deslop-js/tests/fixtures/test-custom-ext/src/orphan.ts deleted file mode 100644 index adcdfe97e6..0000000000 --- a/packages/deslop-js/tests/fixtures/test-custom-ext/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "not referenced"; diff --git a/packages/deslop-js/tests/fixtures/test-custom-ext/src/utils.clienttest.ts b/packages/deslop-js/tests/fixtures/test-custom-ext/src/utils.clienttest.ts deleted file mode 100644 index 8a7a80da08..0000000000 --- a/packages/deslop-js/tests/fixtures/test-custom-ext/src/utils.clienttest.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { greet } from "./index"; -console.log(greet("test")); diff --git a/packages/deslop-js/tests/fixtures/test-mock-import/__tests__/example.test.ts b/packages/deslop-js/tests/fixtures/test-mock-import/__tests__/example.test.ts deleted file mode 100644 index 27b700ae54..0000000000 --- a/packages/deslop-js/tests/fixtures/test-mock-import/__tests__/example.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { vi, describe, it, expect } from "vitest"; -vi.mock("../src/mocked-util"); -import { helper } from "../src/index"; - -describe("test", () => { - it("works", () => { - expect(helper()).toBe("help"); - }); -}); diff --git a/packages/deslop-js/tests/fixtures/test-mock-import/package.json b/packages/deslop-js/tests/fixtures/test-mock-import/package.json deleted file mode 100644 index a164ec2e81..0000000000 --- a/packages/deslop-js/tests/fixtures/test-mock-import/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "test-mock-imports", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "vitest": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/test-mock-import/src/helper.ts b/packages/deslop-js/tests/fixtures/test-mock-import/src/helper.ts deleted file mode 100644 index 9fa1cd03a4..0000000000 --- a/packages/deslop-js/tests/fixtures/test-mock-import/src/helper.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = () => "help"; diff --git a/packages/deslop-js/tests/fixtures/test-mock-import/src/index.ts b/packages/deslop-js/tests/fixtures/test-mock-import/src/index.ts deleted file mode 100644 index d0c47ddbec..0000000000 --- a/packages/deslop-js/tests/fixtures/test-mock-import/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { helper } from "./helper"; diff --git a/packages/deslop-js/tests/fixtures/test-mock-import/src/mocked-util.ts b/packages/deslop-js/tests/fixtures/test-mock-import/src/mocked-util.ts deleted file mode 100644 index 4610e02d13..0000000000 --- a/packages/deslop-js/tests/fixtures/test-mock-import/src/mocked-util.ts +++ /dev/null @@ -1 +0,0 @@ -export const mockedUtil = () => "mocked"; diff --git a/packages/deslop-js/tests/fixtures/test-mock-import/src/orphan.ts b/packages/deslop-js/tests/fixtures/test-mock-import/src/orphan.ts deleted file mode 100644 index bddae4ee9b..0000000000 --- a/packages/deslop-js/tests/fixtures/test-mock-import/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = true; diff --git a/packages/deslop-js/tests/fixtures/test-no-runner/package.json b/packages/deslop-js/tests/fixtures/test-no-runner/package.json deleted file mode 100644 index cc1fa7be12..0000000000 --- a/packages/deslop-js/tests/fixtures/test-no-runner/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "no-test-runner", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/test-no-runner/src/helper.test.ts b/packages/deslop-js/tests/fixtures/test-no-runner/src/helper.test.ts deleted file mode 100644 index a5f9d28fd4..0000000000 --- a/packages/deslop-js/tests/fixtures/test-no-runner/src/helper.test.ts +++ /dev/null @@ -1 +0,0 @@ -export const testCode = "should be unused without test runner"; diff --git a/packages/deslop-js/tests/fixtures/test-no-runner/src/index.ts b/packages/deslop-js/tests/fixtures/test-no-runner/src/index.ts deleted file mode 100644 index 4124a4c154..0000000000 --- a/packages/deslop-js/tests/fixtures/test-no-runner/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = "hello"; diff --git a/packages/deslop-js/tests/fixtures/test-node-runner/package.json b/packages/deslop-js/tests/fixtures/test-node-runner/package.json deleted file mode 100644 index 084ff89ae5..0000000000 --- a/packages/deslop-js/tests/fixtures/test-node-runner/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "node-test-runner", - "version": "1.0.0", - "main": "src/index.ts", - "scripts": { - "test": "node --test **/*.test.ts" - }, - "dependencies": { - "express": "^4.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/test-node-runner/src/__tests__/main.test.ts b/packages/deslop-js/tests/fixtures/test-node-runner/src/__tests__/main.test.ts deleted file mode 100644 index 462000dfdb..0000000000 --- a/packages/deslop-js/tests/fixtures/test-node-runner/src/__tests__/main.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, it } from "node:test"; -import { main } from "../index"; - -describe("main", () => { - it("should return hello", () => { - main(); - }); -}); diff --git a/packages/deslop-js/tests/fixtures/test-node-runner/src/index.ts b/packages/deslop-js/tests/fixtures/test-node-runner/src/index.ts deleted file mode 100644 index c5961da749..0000000000 --- a/packages/deslop-js/tests/fixtures/test-node-runner/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = () => "hello"; diff --git a/packages/deslop-js/tests/fixtures/test-node-runner/src/orphan.ts b/packages/deslop-js/tests/fixtures/test-node-runner/src/orphan.ts deleted file mode 100644 index 2e2026e157..0000000000 --- a/packages/deslop-js/tests/fixtures/test-node-runner/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "orphan"; diff --git a/packages/deslop-js/tests/fixtures/test-runner-detect/package.json b/packages/deslop-js/tests/fixtures/test-runner-detect/package.json deleted file mode 100644 index 17bfe96cd4..0000000000 --- a/packages/deslop-js/tests/fixtures/test-runner-detect/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "test-runner-detection", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "vitest": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/test-runner-detect/src/__tests__/utils.test.ts b/packages/deslop-js/tests/fixtures/test-runner-detect/src/__tests__/utils.test.ts deleted file mode 100644 index d5399a0a0f..0000000000 --- a/packages/deslop-js/tests/fixtures/test-runner-detect/src/__tests__/utils.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { testUtil } from "../test-only-used"; - -console.log(testUtil); diff --git a/packages/deslop-js/tests/fixtures/test-runner-detect/src/helper.test.ts b/packages/deslop-js/tests/fixtures/test-runner-detect/src/helper.test.ts deleted file mode 100644 index 310bdefd4c..0000000000 --- a/packages/deslop-js/tests/fixtures/test-runner-detect/src/helper.test.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { add } from "./helper"; - -const result = add(1, 2); -console.log(result); diff --git a/packages/deslop-js/tests/fixtures/test-runner-detect/src/helper.ts b/packages/deslop-js/tests/fixtures/test-runner-detect/src/helper.ts deleted file mode 100644 index be20497b4a..0000000000 --- a/packages/deslop-js/tests/fixtures/test-runner-detect/src/helper.ts +++ /dev/null @@ -1 +0,0 @@ -export const add = (firstOperand: number, secondOperand: number) => firstOperand + secondOperand; diff --git a/packages/deslop-js/tests/fixtures/test-runner-detect/src/index.ts b/packages/deslop-js/tests/fixtures/test-runner-detect/src/index.ts deleted file mode 100644 index 4124a4c154..0000000000 --- a/packages/deslop-js/tests/fixtures/test-runner-detect/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = "hello"; diff --git a/packages/deslop-js/tests/fixtures/test-runner-detect/src/orphan.ts b/packages/deslop-js/tests/fixtures/test-runner-detect/src/orphan.ts deleted file mode 100644 index bd93db0a49..0000000000 --- a/packages/deslop-js/tests/fixtures/test-runner-detect/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "unused"; diff --git a/packages/deslop-js/tests/fixtures/test-runner-detect/src/test-only-used.ts b/packages/deslop-js/tests/fixtures/test-runner-detect/src/test-only-used.ts deleted file mode 100644 index c6e2aeb08a..0000000000 --- a/packages/deslop-js/tests/fixtures/test-runner-detect/src/test-only-used.ts +++ /dev/null @@ -1 +0,0 @@ -export const testUtil = "only used by tests"; diff --git a/packages/deslop-js/tests/fixtures/tsconfig-wildcard/package.json b/packages/deslop-js/tests/fixtures/tsconfig-wildcard/package.json deleted file mode 100644 index 7d6c3d6b30..0000000000 --- a/packages/deslop-js/tests/fixtures/tsconfig-wildcard/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "tsconfig-path-alias-wildcard", - "version": "1.0.0", - "main": "src/index.ts", - "dependencies": { - "react": "^18.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/tsconfig-wildcard/src/constants/api.ts b/packages/deslop-js/tests/fixtures/tsconfig-wildcard/src/constants/api.ts deleted file mode 100644 index d786a5d4e7..0000000000 --- a/packages/deslop-js/tests/fixtures/tsconfig-wildcard/src/constants/api.ts +++ /dev/null @@ -1 +0,0 @@ -export const API_URL = "https://api.example.com"; diff --git a/packages/deslop-js/tests/fixtures/tsconfig-wildcard/src/index.ts b/packages/deslop-js/tests/fixtures/tsconfig-wildcard/src/index.ts deleted file mode 100644 index 5d7050ace8..0000000000 --- a/packages/deslop-js/tests/fixtures/tsconfig-wildcard/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { API_URL } from "constants/api"; -export const app = () => API_URL; diff --git a/packages/deslop-js/tests/fixtures/tsconfig-wildcard/src/orphan.ts b/packages/deslop-js/tests/fixtures/tsconfig-wildcard/src/orphan.ts deleted file mode 100644 index 2e2026e157..0000000000 --- a/packages/deslop-js/tests/fixtures/tsconfig-wildcard/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "orphan"; diff --git a/packages/deslop-js/tests/fixtures/tsconfig-wildcard/tsconfig.json b/packages/deslop-js/tests/fixtures/tsconfig-wildcard/tsconfig.json deleted file mode 100644 index af10bd498a..0000000000 --- a/packages/deslop-js/tests/fixtures/tsconfig-wildcard/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "compilerOptions": { - "paths": { - "*": ["./src/*"] - } - } -} diff --git a/packages/deslop-js/tests/fixtures/tsdown-entry/package.json b/packages/deslop-js/tests/fixtures/tsdown-entry/package.json deleted file mode 100644 index 9d761e18f7..0000000000 --- a/packages/deslop-js/tests/fixtures/tsdown-entry/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "tsdown-config-entry-test", - "private": true, - "main": "dist/index.js", - "devDependencies": { - "tsdown": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/tsdown-entry/src/main.ts b/packages/deslop-js/tests/fixtures/tsdown-entry/src/main.ts deleted file mode 100644 index b54d6f798d..0000000000 --- a/packages/deslop-js/tests/fixtures/tsdown-entry/src/main.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { helper } from "./utils"; -export const main = () => helper(); diff --git a/packages/deslop-js/tests/fixtures/tsdown-entry/src/preload.ts b/packages/deslop-js/tests/fixtures/tsdown-entry/src/preload.ts deleted file mode 100644 index 093a5adaea..0000000000 --- a/packages/deslop-js/tests/fixtures/tsdown-entry/src/preload.ts +++ /dev/null @@ -1 +0,0 @@ -export const preload = () => console.log("preloading"); diff --git a/packages/deslop-js/tests/fixtures/tsdown-entry/src/unused.ts b/packages/deslop-js/tests/fixtures/tsdown-entry/src/unused.ts deleted file mode 100644 index 50326aac84..0000000000 --- a/packages/deslop-js/tests/fixtures/tsdown-entry/src/unused.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "this file is not imported anywhere"; diff --git a/packages/deslop-js/tests/fixtures/tsdown-entry/src/utils.ts b/packages/deslop-js/tests/fixtures/tsdown-entry/src/utils.ts deleted file mode 100644 index 248b2ceac9..0000000000 --- a/packages/deslop-js/tests/fixtures/tsdown-entry/src/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = () => "helper"; diff --git a/packages/deslop-js/tests/fixtures/tsdown-entry/tsdown.config.ts b/packages/deslop-js/tests/fixtures/tsdown-entry/tsdown.config.ts deleted file mode 100644 index f6ee00eaf5..0000000000 --- a/packages/deslop-js/tests/fixtures/tsdown-entry/tsdown.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from "tsdown"; - -export default defineConfig([ - { - entry: ["src/main.ts"], - format: "cjs", - outDir: "dist", - }, - { - entry: ["src/preload.ts"], - format: "cjs", - outDir: "dist", - }, -]); diff --git a/packages/deslop-js/tests/fixtures/type-cycle/package.json b/packages/deslop-js/tests/fixtures/type-cycle/package.json deleted file mode 100644 index a4f56ffdbe..0000000000 --- a/packages/deslop-js/tests/fixtures/type-cycle/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "type-only-cycle", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/type-cycle/src/index.ts b/packages/deslop-js/tests/fixtures/type-cycle/src/index.ts deleted file mode 100644 index f4f4261264..0000000000 --- a/packages/deslop-js/tests/fixtures/type-cycle/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createUser } from "./user"; -import { createPost } from "./post"; - -console.log(createUser("Alice")); -console.log(createPost("Hello")); diff --git a/packages/deslop-js/tests/fixtures/type-cycle/src/post.ts b/packages/deslop-js/tests/fixtures/type-cycle/src/post.ts deleted file mode 100644 index 5aab5f1e37..0000000000 --- a/packages/deslop-js/tests/fixtures/type-cycle/src/post.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { User } from "./user"; - -export interface Post { - title: string; - author: User; -} - -export const createPost = (title: string): Post => ({ - title, - author: { name: "unknown", posts: [] }, -}); diff --git a/packages/deslop-js/tests/fixtures/type-cycle/src/user.ts b/packages/deslop-js/tests/fixtures/type-cycle/src/user.ts deleted file mode 100644 index 5fad98209c..0000000000 --- a/packages/deslop-js/tests/fixtures/type-cycle/src/user.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { Post } from "./post"; - -export interface User { - name: string; - posts: Post[]; -} - -export const createUser = (name: string): User => ({ name, posts: [] }); diff --git a/packages/deslop-js/tests/fixtures/type-cycle/tsconfig.json b/packages/deslop-js/tests/fixtures/type-cycle/tsconfig.json deleted file mode 100644 index aee0ec940f..0000000000 --- a/packages/deslop-js/tests/fixtures/type-cycle/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "compilerOptions": { - "strict": true - } -} diff --git a/packages/deslop-js/tests/fixtures/type-deps/package.json b/packages/deslop-js/tests/fixtures/type-deps/package.json deleted file mode 100644 index 7fa8dad985..0000000000 --- a/packages/deslop-js/tests/fixtures/type-deps/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "type-only-deps", - "main": "src/index.ts", - "dependencies": { - "express": "^4.0.0", - "zod": "^3.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/type-deps/src/index.ts b/packages/deslop-js/tests/fixtures/type-deps/src/index.ts deleted file mode 100644 index d534cfa0c7..0000000000 --- a/packages/deslop-js/tests/fixtures/type-deps/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { ZodSchema } from "zod"; -import express from "express"; - -const app = express(); - -export const validate = (schema: ZodSchema) => schema; -export const server = app; diff --git a/packages/deslop-js/tests/fixtures/type-reexport-filter/consumer.ts b/packages/deslop-js/tests/fixtures/type-reexport-filter/consumer.ts deleted file mode 100644 index 1be6d1e629..0000000000 --- a/packages/deslop-js/tests/fixtures/type-reexport-filter/consumer.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createUser } from "./index"; -import type { User } from "./index"; - -const user: User = createUser("Alice"); -console.log(user); diff --git a/packages/deslop-js/tests/fixtures/type-reexport-filter/index.ts b/packages/deslop-js/tests/fixtures/type-reexport-filter/index.ts deleted file mode 100644 index 316c276e69..0000000000 --- a/packages/deslop-js/tests/fixtures/type-reexport-filter/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { createUser } from "./user"; -export type { User, UserRole } from "./types"; diff --git a/packages/deslop-js/tests/fixtures/type-reexport-filter/package.json b/packages/deslop-js/tests/fixtures/type-reexport-filter/package.json deleted file mode 100644 index 07b3ab9c1b..0000000000 --- a/packages/deslop-js/tests/fixtures/type-reexport-filter/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "type-only-reexport-filtering", - "main": "index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/type-reexport-filter/types.ts b/packages/deslop-js/tests/fixtures/type-reexport-filter/types.ts deleted file mode 100644 index c9ac207a02..0000000000 --- a/packages/deslop-js/tests/fixtures/type-reexport-filter/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface User { - id: string; - name: string; - role: UserRole; -} - -export type UserRole = "admin" | "editor" | "viewer"; - -export interface UnusedConfig { - debug: boolean; -} diff --git a/packages/deslop-js/tests/fixtures/type-reexport-filter/user.ts b/packages/deslop-js/tests/fixtures/type-reexport-filter/user.ts deleted file mode 100644 index bb044f076e..0000000000 --- a/packages/deslop-js/tests/fixtures/type-reexport-filter/user.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { User } from "./types"; - -export const createUser = (name: string): User => ({ - id: Math.random().toString(), - name, - role: "viewer", -}); - -export const deleteUser = (id: string) => { - console.log("deleted", id); -}; diff --git a/packages/deslop-js/tests/fixtures/typescript-smells/package.json b/packages/deslop-js/tests/fixtures/typescript-smells/package.json deleted file mode 100644 index 5b79d85d6e..0000000000 --- a/packages/deslop-js/tests/fixtures/typescript-smells/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "typescript-smells", - "type": "module", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/typescript-smells/src/alpha.ts b/packages/deslop-js/tests/fixtures/typescript-smells/src/alpha.ts deleted file mode 100644 index ce23559ee0..0000000000 --- a/packages/deslop-js/tests/fixtures/typescript-smells/src/alpha.ts +++ /dev/null @@ -1 +0,0 @@ -export const alpha = "alpha"; diff --git a/packages/deslop-js/tests/fixtures/typescript-smells/src/beta.ts b/packages/deslop-js/tests/fixtures/typescript-smells/src/beta.ts deleted file mode 100644 index 81e36723d8..0000000000 --- a/packages/deslop-js/tests/fixtures/typescript-smells/src/beta.ts +++ /dev/null @@ -1 +0,0 @@ -export const beta = "beta"; diff --git a/packages/deslop-js/tests/fixtures/typescript-smells/src/delta.ts b/packages/deslop-js/tests/fixtures/typescript-smells/src/delta.ts deleted file mode 100644 index 290c691a7a..0000000000 --- a/packages/deslop-js/tests/fixtures/typescript-smells/src/delta.ts +++ /dev/null @@ -1 +0,0 @@ -export const delta = "delta"; diff --git a/packages/deslop-js/tests/fixtures/typescript-smells/src/gamma.ts b/packages/deslop-js/tests/fixtures/typescript-smells/src/gamma.ts deleted file mode 100644 index 0551ec4243..0000000000 --- a/packages/deslop-js/tests/fixtures/typescript-smells/src/gamma.ts +++ /dev/null @@ -1 +0,0 @@ -export const gamma = "gamma"; diff --git a/packages/deslop-js/tests/fixtures/typescript-smells/src/index.ts b/packages/deslop-js/tests/fixtures/typescript-smells/src/index.ts deleted file mode 100644 index 6735fc4428..0000000000 --- a/packages/deslop-js/tests/fixtures/typescript-smells/src/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -interface Config { - apiKey: string; -} - -const rawConfig: unknown = { apiKey: "abc" }; - -const doubleAssertion = rawConfig as unknown as Config; - -const escapeToAny = rawConfig as any; - -const literalNonNull = "always-set"!; - -const arrayNonNull = []!; - -let mutableValue: string | undefined; -mutableValue = "ok"; -const doubleNonNull = mutableValue!!; - -const angleBracket = <Config>rawConfig; - -const lazyAlpha = await import("./alpha.js"); - -import("./beta.js").then((module) => { - console.log(module); -}); - -// @ts-ignore -const ignored: number = "string-value"; - -const ternaryIgnored = Array.isArray(rawConfig) - ? rawConfig[0] - : // @ts-ignore Support Firefox's non-standard behavior - (rawConfig as { width: number }).width; - -// @ts-expect-error -const expectErrorNoExplanation: number = "string-value"; - -// @ts-expect-error: alpha is dynamic; the cast is intentional -const expectErrorWithExplanation: number = "string-value"; - -const direct = require("./gamma.js"); -const lazy = require("./delta.js"); - -module.exports = { - doubleAssertion, - escapeToAny, - literalNonNull, - arrayNonNull, - doubleNonNull, - angleBracket, - lazyAlpha, - ignored, - expectErrorNoExplanation, - expectErrorWithExplanation, - direct, - lazy, -}; - -exports.helper = (): number => 1; diff --git a/packages/deslop-js/tests/fixtures/unused-class-members-basic/package.json b/packages/deslop-js/tests/fixtures/unused-class-members-basic/package.json deleted file mode 100644 index 290e828d00..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-class-members-basic/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unused-class-members-basic", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/unused-class-members-basic/src/calculator.ts b/packages/deslop-js/tests/fixtures/unused-class-members-basic/src/calculator.ts deleted file mode 100644 index 217edf812b..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-class-members-basic/src/calculator.ts +++ /dev/null @@ -1,20 +0,0 @@ -class InternalCalculator { - public sum(a: number, b: number): number { - return a + b; - } - - public deadMethod(): string { - return "never called"; - } - - public usedProperty = 42; - - public deadProperty = "unused field"; - - private internalHelper(): void { - console.log("private — should not flag"); - } -} - -export const calculator = new InternalCalculator(); -console.log(calculator.sum(1, 2), calculator.usedProperty); diff --git a/packages/deslop-js/tests/fixtures/unused-class-members-basic/src/index.ts b/packages/deslop-js/tests/fixtures/unused-class-members-basic/src/index.ts deleted file mode 100644 index c63a598d39..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-class-members-basic/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { calculator } from "./calculator.js"; - -console.log(calculator.sum(3, 4)); diff --git a/packages/deslop-js/tests/fixtures/unused-class-members-basic/tsconfig.json b/packages/deslop-js/tests/fixtures/unused-class-members-basic/tsconfig.json deleted file mode 100644 index e77b932f81..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-class-members-basic/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/deslop-js/tests/fixtures/unused-class-members-decorated/package.json b/packages/deslop-js/tests/fixtures/unused-class-members-decorated/package.json deleted file mode 100644 index 520db6c718..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-class-members-decorated/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unused-class-members-decorated", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/unused-class-members-decorated/src/controller.ts b/packages/deslop-js/tests/fixtures/unused-class-members-decorated/src/controller.ts deleted file mode 100644 index a11b441fc7..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-class-members-decorated/src/controller.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Get, Internal } from "./decorators.js"; - -class UserController { - @Get("/users") - public listUsers(): string { - return "all users"; - } - - @Get("/users/me") - public currentUser(): string { - return "me"; - } - - @Internal() - public deadInternal(): string { - return "Internal decorator NOT in allowlist — should still flag"; - } - - public deadPlainMethod(): string { - return "no decorator, no usage — should flag"; - } -} - -export const userController = new UserController(); diff --git a/packages/deslop-js/tests/fixtures/unused-class-members-decorated/src/decorators.ts b/packages/deslop-js/tests/fixtures/unused-class-members-decorated/src/decorators.ts deleted file mode 100644 index adca219be7..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-class-members-decorated/src/decorators.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const Get = - (route: string) => - (target: unknown, propertyKey: string, descriptor: PropertyDescriptor): void => { - void target; - void propertyKey; - void route; - void descriptor; - }; - -export const Internal = - () => - (target: unknown, propertyKey: string, descriptor: PropertyDescriptor): void => { - void target; - void propertyKey; - void descriptor; - }; diff --git a/packages/deslop-js/tests/fixtures/unused-class-members-decorated/src/index.ts b/packages/deslop-js/tests/fixtures/unused-class-members-decorated/src/index.ts deleted file mode 100644 index 24e2125323..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-class-members-decorated/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { userController } from "./controller.js"; - -console.log(userController); diff --git a/packages/deslop-js/tests/fixtures/unused-class-members-decorated/tsconfig.json b/packages/deslop-js/tests/fixtures/unused-class-members-decorated/tsconfig.json deleted file mode 100644 index 2ffcd3a483..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-class-members-decorated/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true, - "experimentalDecorators": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/deslop-js/tests/fixtures/unused-class-members-inherited/package.json b/packages/deslop-js/tests/fixtures/unused-class-members-inherited/package.json deleted file mode 100644 index cb4c7b3044..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-class-members-inherited/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unused-class-members-inherited", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/unused-class-members-inherited/src/animals.ts b/packages/deslop-js/tests/fixtures/unused-class-members-inherited/src/animals.ts deleted file mode 100644 index 98fa50eb74..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-class-members-inherited/src/animals.ts +++ /dev/null @@ -1,23 +0,0 @@ -class Animal { - public speak(): string { - return "generic sound"; - } - - public eat(): string { - return "eating"; - } - - public sleep(): string { - return "snoring"; - } -} - -class Dog extends Animal { - public speak(): string { - return "woof"; - } -} - -const buddy = new Dog(); -console.log(buddy.speak()); -console.log(buddy.eat()); diff --git a/packages/deslop-js/tests/fixtures/unused-class-members-inherited/src/index.ts b/packages/deslop-js/tests/fixtures/unused-class-members-inherited/src/index.ts deleted file mode 100644 index 80878a4287..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-class-members-inherited/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -import "./animals.js"; diff --git a/packages/deslop-js/tests/fixtures/unused-class-members-inherited/tsconfig.json b/packages/deslop-js/tests/fixtures/unused-class-members-inherited/tsconfig.json deleted file mode 100644 index e77b932f81..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-class-members-inherited/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/deslop-js/tests/fixtures/unused-enum-members-const/package.json b/packages/deslop-js/tests/fixtures/unused-enum-members-const/package.json deleted file mode 100644 index 4b73c75bcd..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-enum-members-const/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unused-enum-members-const", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/unused-enum-members-const/src/flags.ts b/packages/deslop-js/tests/fixtures/unused-enum-members-const/src/flags.ts deleted file mode 100644 index 9eba7b8503..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-enum-members-const/src/flags.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const enum Flags { - None = 0, - Read = 1, - Write = 2, - Execute = 4, -} diff --git a/packages/deslop-js/tests/fixtures/unused-enum-members-const/src/index.ts b/packages/deslop-js/tests/fixtures/unused-enum-members-const/src/index.ts deleted file mode 100644 index 65e64fb729..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-enum-members-const/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Flags } from "./flags.js"; - -export const canRead = (mode: number): boolean => (mode & Flags.Read) !== 0; - -console.log(canRead(Flags.Read | Flags.Write)); diff --git a/packages/deslop-js/tests/fixtures/unused-enum-members-const/tsconfig.json b/packages/deslop-js/tests/fixtures/unused-enum-members-const/tsconfig.json deleted file mode 100644 index cbd17f2b1f..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-enum-members-const/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true, - "preserveConstEnums": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/deslop-js/tests/fixtures/unused-enum-members-numeric/package.json b/packages/deslop-js/tests/fixtures/unused-enum-members-numeric/package.json deleted file mode 100644 index b269c3ccf9..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-enum-members-numeric/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unused-enum-members-numeric", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/unused-enum-members-numeric/src/index.ts b/packages/deslop-js/tests/fixtures/unused-enum-members-numeric/src/index.ts deleted file mode 100644 index 84e4b81473..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-enum-members-numeric/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Level } from "./level.js"; - -export const isCritical = (currentLevel: Level): boolean => currentLevel === Level.Critical; - -console.log(isCritical(Level.Critical)); diff --git a/packages/deslop-js/tests/fixtures/unused-enum-members-numeric/src/level.ts b/packages/deslop-js/tests/fixtures/unused-enum-members-numeric/src/level.ts deleted file mode 100644 index 74f99c9b92..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-enum-members-numeric/src/level.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum Level { - Low, - Medium, - High, - Critical, -} diff --git a/packages/deslop-js/tests/fixtures/unused-enum-members-numeric/tsconfig.json b/packages/deslop-js/tests/fixtures/unused-enum-members-numeric/tsconfig.json deleted file mode 100644 index e77b932f81..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-enum-members-numeric/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/deslop-js/tests/fixtures/unused-enum-members-reverse-lookup/package.json b/packages/deslop-js/tests/fixtures/unused-enum-members-reverse-lookup/package.json deleted file mode 100644 index 45239b3107..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-enum-members-reverse-lookup/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unused-enum-members-reverse-lookup", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/unused-enum-members-reverse-lookup/src/code.ts b/packages/deslop-js/tests/fixtures/unused-enum-members-reverse-lookup/src/code.ts deleted file mode 100644 index c77c7ff06d..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-enum-members-reverse-lookup/src/code.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum HttpCode { - Ok, - Created, - BadRequest, - Unauthorized, -} diff --git a/packages/deslop-js/tests/fixtures/unused-enum-members-reverse-lookup/src/index.ts b/packages/deslop-js/tests/fixtures/unused-enum-members-reverse-lookup/src/index.ts deleted file mode 100644 index 50afc44483..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-enum-members-reverse-lookup/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { HttpCode } from "./code.js"; - -export const codeToName = (numericCode: number): string => HttpCode[numericCode]; - -console.log(codeToName(0), codeToName(2)); diff --git a/packages/deslop-js/tests/fixtures/unused-enum-members-reverse-lookup/tsconfig.json b/packages/deslop-js/tests/fixtures/unused-enum-members-reverse-lookup/tsconfig.json deleted file mode 100644 index e77b932f81..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-enum-members-reverse-lookup/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/deslop-js/tests/fixtures/unused-enum-members-string/package.json b/packages/deslop-js/tests/fixtures/unused-enum-members-string/package.json deleted file mode 100644 index 2ecb0e5376..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-enum-members-string/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unused-enum-members-string", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/unused-enum-members-string/src/index.ts b/packages/deslop-js/tests/fixtures/unused-enum-members-string/src/index.ts deleted file mode 100644 index 035fd2ada5..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-enum-members-string/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Status } from "./status.js"; - -export const isLive = (currentStatus: Status): boolean => currentStatus === Status.Active; - -export const renderPending = (currentStatus: Status): string => - currentStatus === Status.Pending ? "..." : "done"; - -console.log(isLive(Status.Active), renderPending(Status.Pending)); diff --git a/packages/deslop-js/tests/fixtures/unused-enum-members-string/src/status.ts b/packages/deslop-js/tests/fixtures/unused-enum-members-string/src/status.ts deleted file mode 100644 index cb0054f8f2..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-enum-members-string/src/status.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum Status { - Active = "active", - Pending = "pending", - Archived = "archived", - Deprecated = "deprecated", -} diff --git a/packages/deslop-js/tests/fixtures/unused-enum-members-string/tsconfig.json b/packages/deslop-js/tests/fixtures/unused-enum-members-string/tsconfig.json deleted file mode 100644 index e77b932f81..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-enum-members-string/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-basic/package.json b/packages/deslop-js/tests/fixtures/unused-types-basic/package.json deleted file mode 100644 index a2cb1a7079..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-basic/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unused-types-basic", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-basic/src/consumer.ts b/packages/deslop-js/tests/fixtures/unused-types-basic/src/consumer.ts deleted file mode 100644 index 4beac71085..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-basic/src/consumer.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { UsedType, UsedAlias } from "./types.js"; - -export const buildUser = (id: string, name: string): UsedType => ({ id, name }); - -export const formatLabel = (input: UsedAlias): string => String(input); diff --git a/packages/deslop-js/tests/fixtures/unused-types-basic/src/index.ts b/packages/deslop-js/tests/fixtures/unused-types-basic/src/index.ts deleted file mode 100644 index efdba2b5f6..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-basic/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { buildUser, formatLabel } from "./consumer.js"; - -const user = buildUser("1", "Ada"); -const label = formatLabel(42); - -console.log(user, label); diff --git a/packages/deslop-js/tests/fixtures/unused-types-basic/src/types.ts b/packages/deslop-js/tests/fixtures/unused-types-basic/src/types.ts deleted file mode 100644 index 970326d706..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-basic/src/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface UsedType { - id: string; - name: string; -} - -export interface UnusedType { - legacyId: number; - legacyLabel: string; -} - -export type UsedAlias = string | number; - -export type UnusedAlias = boolean | null; diff --git a/packages/deslop-js/tests/fixtures/unused-types-basic/tsconfig.json b/packages/deslop-js/tests/fixtures/unused-types-basic/tsconfig.json deleted file mode 100644 index 7ef0fc4667..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-basic/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts", "src/**/*.tsx"] -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-decl-merge/package.json b/packages/deslop-js/tests/fixtures/unused-types-decl-merge/package.json deleted file mode 100644 index d19f2b5f40..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-decl-merge/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unused-types-decl-merge", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-decl-merge/src/index.ts b/packages/deslop-js/tests/fixtures/unused-types-decl-merge/src/index.ts deleted file mode 100644 index f3fd36c075..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-decl-merge/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { MergedConfig } from "./merged.js"; - -export const configFactory = (base: string, extension: boolean): MergedConfig => ({ - base, - extension, -}); - -console.log(configFactory("primary", true)); diff --git a/packages/deslop-js/tests/fixtures/unused-types-decl-merge/src/merged.ts b/packages/deslop-js/tests/fixtures/unused-types-decl-merge/src/merged.ts deleted file mode 100644 index 35d7281a31..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-decl-merge/src/merged.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface MergedConfig { - base: string; -} - -export interface MergedConfig { - extension: boolean; -} - -export interface SoloDead { - legacy: number; -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-decl-merge/tsconfig.json b/packages/deslop-js/tests/fixtures/unused-types-decl-merge/tsconfig.json deleted file mode 100644 index e77b932f81..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-decl-merge/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-entry-export/package.json b/packages/deslop-js/tests/fixtures/unused-types-entry-export/package.json deleted file mode 100644 index adb074f2ca..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-entry-export/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unused-types-entry-export", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-entry-export/src/index.ts b/packages/deslop-js/tests/fixtures/unused-types-entry-export/src/index.ts deleted file mode 100644 index 6ebb18eafb..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-entry-export/src/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type PublicApiShape = { - contract: "v1"; - data: string; -}; - -export type DeadEntryType = { - legacy: true; -}; - -export const callApi = (data: string): PublicApiShape => ({ contract: "v1", data }); - -console.log(callApi("hello")); diff --git a/packages/deslop-js/tests/fixtures/unused-types-entry-export/tsconfig.json b/packages/deslop-js/tests/fixtures/unused-types-entry-export/tsconfig.json deleted file mode 100644 index e77b932f81..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-entry-export/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-extends/package.json b/packages/deslop-js/tests/fixtures/unused-types-extends/package.json deleted file mode 100644 index ef3f3b56b4..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-extends/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unused-types-extends", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-extends/src/index.ts b/packages/deslop-js/tests/fixtures/unused-types-extends/src/index.ts deleted file mode 100644 index 2018c35aca..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-extends/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Child } from "./types.js"; - -export const makeChild = (childId: string, parentId: number): Child => ({ childId, parentId }); - -console.log(makeChild("x", 1)); diff --git a/packages/deslop-js/tests/fixtures/unused-types-extends/src/types.ts b/packages/deslop-js/tests/fixtures/unused-types-extends/src/types.ts deleted file mode 100644 index bb3d15744d..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-extends/src/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface Parent { - parentId: number; -} - -export interface Child extends Parent { - childId: string; -} - -export interface OrphanInterface { - legacy: boolean; -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-extends/tsconfig.json b/packages/deslop-js/tests/fixtures/unused-types-extends/tsconfig.json deleted file mode 100644 index e77b932f81..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-extends/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-generics/package.json b/packages/deslop-js/tests/fixtures/unused-types-generics/package.json deleted file mode 100644 index fcc30e0764..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-generics/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unused-types-generics", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-generics/src/index.ts b/packages/deslop-js/tests/fixtures/unused-types-generics/src/index.ts deleted file mode 100644 index 46b1c515db..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-generics/src/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Box } from "./types.js"; - -interface User { - id: string; - name: string; -} - -export const wrap = (user: User): Box<User> => ({ content: user, label: user.name }); - -console.log(wrap({ id: "1", name: "Ada" })); diff --git a/packages/deslop-js/tests/fixtures/unused-types-generics/src/types.ts b/packages/deslop-js/tests/fixtures/unused-types-generics/src/types.ts deleted file mode 100644 index 0d212bd3f2..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-generics/src/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface Identifiable { - id: string; -} - -export type Box<TItem extends Identifiable> = { - content: TItem; - label: string; -}; - -export type DeadBox = { - marker: "dead"; -}; diff --git a/packages/deslop-js/tests/fixtures/unused-types-generics/tsconfig.json b/packages/deslop-js/tests/fixtures/unused-types-generics/tsconfig.json deleted file mode 100644 index e77b932f81..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-generics/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-import-type/package.json b/packages/deslop-js/tests/fixtures/unused-types-import-type/package.json deleted file mode 100644 index b7ee37df23..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-import-type/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unused-types-import-type", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-import-type/src/index.ts b/packages/deslop-js/tests/fixtures/unused-types-import-type/src/index.ts deleted file mode 100644 index 525506bce7..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-import-type/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ReturnedShape } from "./types.js"; - -export const compute = (value: number): ReturnedShape => ({ status: "ok", value }); - -console.log(compute(7)); diff --git a/packages/deslop-js/tests/fixtures/unused-types-import-type/src/types.ts b/packages/deslop-js/tests/fixtures/unused-types-import-type/src/types.ts deleted file mode 100644 index d65759669e..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-import-type/src/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type ReturnedShape = { - status: "ok" | "fail"; - value: number; -}; - -export type NeverImported = { - legacy: boolean; -}; diff --git a/packages/deslop-js/tests/fixtures/unused-types-import-type/tsconfig.json b/packages/deslop-js/tests/fixtures/unused-types-import-type/tsconfig.json deleted file mode 100644 index e77b932f81..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-import-type/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-jsdoc/package.json b/packages/deslop-js/tests/fixtures/unused-types-jsdoc/package.json deleted file mode 100644 index 07303792d1..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-jsdoc/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "unused-types-jsdoc", - "main": "src/index.js", - "deslop": { - "entryPatterns": [ - "src/index.js" - ] - } -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-jsdoc/src/bridge.ts b/packages/deslop-js/tests/fixtures/unused-types-jsdoc/src/bridge.ts deleted file mode 100644 index 64d18d5a31..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-jsdoc/src/bridge.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { RegularImported } from "./types.js"; - -export const bridgeValue: RegularImported = "bridge"; diff --git a/packages/deslop-js/tests/fixtures/unused-types-jsdoc/src/index.js b/packages/deslop-js/tests/fixtures/unused-types-jsdoc/src/index.js deleted file mode 100644 index c6989e1907..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-jsdoc/src/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import { renderShape } from "./jsdoc-consumer.js"; -import { bridgeValue } from "./bridge.js"; - -console.log(renderShape({ marker: "jsdoc-consumed", count: 1 }), bridgeValue); diff --git a/packages/deslop-js/tests/fixtures/unused-types-jsdoc/src/jsdoc-consumer.js b/packages/deslop-js/tests/fixtures/unused-types-jsdoc/src/jsdoc-consumer.js deleted file mode 100644 index 34568420a4..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-jsdoc/src/jsdoc-consumer.js +++ /dev/null @@ -1,5 +0,0 @@ -/** - * @param {import("./types.js").JsDocConsumed} input - * @returns {string} - */ -export const renderShape = (input) => `${input.marker}=${input.count}`; diff --git a/packages/deslop-js/tests/fixtures/unused-types-jsdoc/src/types.ts b/packages/deslop-js/tests/fixtures/unused-types-jsdoc/src/types.ts deleted file mode 100644 index 92a8afd962..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-jsdoc/src/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type JsDocConsumed = { - marker: "jsdoc-consumed"; - count: number; -}; - -export type RegularImported = "bridge"; - -export type NeverReferenced = { - marker: "dead"; -}; diff --git a/packages/deslop-js/tests/fixtures/unused-types-jsdoc/tsconfig.json b/packages/deslop-js/tests/fixtures/unused-types-jsdoc/tsconfig.json deleted file mode 100644 index e996f7ec3e..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-jsdoc/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true, - "allowJs": true, - "checkJs": true - }, - "include": ["src/**/*.ts", "src/**/*.js"] -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-nested/package.json b/packages/deslop-js/tests/fixtures/unused-types-nested/package.json deleted file mode 100644 index 3537d67543..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-nested/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unused-types-nested", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-nested/src/index.ts b/packages/deslop-js/tests/fixtures/unused-types-nested/src/index.ts deleted file mode 100644 index 8deadbd225..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-nested/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Outer } from "./types.js"; - -export const items: Outer = [{ id: "a" }]; - -console.log(items); diff --git a/packages/deslop-js/tests/fixtures/unused-types-nested/src/types.ts b/packages/deslop-js/tests/fixtures/unused-types-nested/src/types.ts deleted file mode 100644 index f57d918195..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-nested/src/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface Inner { - id: string; -} - -export type Outer = Inner[]; - -export type DeadDeep = Inner | null; diff --git a/packages/deslop-js/tests/fixtures/unused-types-nested/tsconfig.json b/packages/deslop-js/tests/fixtures/unused-types-nested/tsconfig.json deleted file mode 100644 index e77b932f81..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-nested/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/package.json b/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/package.json deleted file mode 100644 index bb42f677d3..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "unused-types-reexport-chain", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/src/a.ts b/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/src/a.ts deleted file mode 100644 index bf200571f8..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/src/a.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type TripleHopUsed = { - marker: "triple-hop"; - payload: string; -}; - -export type TripleHopDead = { - marker: "dead"; -}; diff --git a/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/src/b.ts b/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/src/b.ts deleted file mode 100644 index 4865c2446e..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/src/b.ts +++ /dev/null @@ -1 +0,0 @@ -export type { TripleHopUsed, TripleHopDead } from "./a.js"; diff --git a/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/src/c.ts b/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/src/c.ts deleted file mode 100644 index 8c3f6fb5e3..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/src/c.ts +++ /dev/null @@ -1 +0,0 @@ -export type { TripleHopUsed, TripleHopDead } from "./b.js"; diff --git a/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/src/index.ts b/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/src/index.ts deleted file mode 100644 index c5fecc79e1..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { TripleHopUsed } from "./c.js"; - -export const echoUsed = (input: TripleHopUsed): string => `${input.marker}:${input.payload}`; - -console.log(echoUsed({ marker: "triple-hop", payload: "ok" })); diff --git a/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/tsconfig.json b/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/tsconfig.json deleted file mode 100644 index e77b932f81..0000000000 --- a/packages/deslop-js/tests/fixtures/unused-types-reexport-chain/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/deslop-js/tests/fixtures/vercel-config-app/package.json b/packages/deslop-js/tests/fixtures/vercel-config-app/package.json deleted file mode 100644 index 1a84434bf3..0000000000 --- a/packages/deslop-js/tests/fixtures/vercel-config-app/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "vercel-config-app", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/vercel-config-app/src/index.ts b/packages/deslop-js/tests/fixtures/vercel-config-app/src/index.ts deleted file mode 100644 index cb44fe1048..0000000000 --- a/packages/deslop-js/tests/fixtures/vercel-config-app/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const main = (): void => {}; diff --git a/packages/deslop-js/tests/fixtures/vercel-config-app/vercel.ts b/packages/deslop-js/tests/fixtures/vercel-config-app/vercel.ts deleted file mode 100644 index ef8ffb47ca..0000000000 --- a/packages/deslop-js/tests/fixtures/vercel-config-app/vercel.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const config = { regions: ["iad1"] }; - -export default config; diff --git a/packages/deslop-js/tests/fixtures/vite-app/package.json b/packages/deslop-js/tests/fixtures/vite-app/package.json deleted file mode 100644 index e788534b10..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-app/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "vite-entry-test", - "version": "1.0.0", - "dependencies": { - "vite": "^5.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/vite-app/src/main.tsx b/packages/deslop-js/tests/fixtures/vite-app/src/main.tsx deleted file mode 100644 index b39d26bca7..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-app/src/main.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { render } from "./render"; - -render(); diff --git a/packages/deslop-js/tests/fixtures/vite-app/src/orphan.ts b/packages/deslop-js/tests/fixtures/vite-app/src/orphan.ts deleted file mode 100644 index af0a341bc7..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-app/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "not imported"; diff --git a/packages/deslop-js/tests/fixtures/vite-app/src/render.ts b/packages/deslop-js/tests/fixtures/vite-app/src/render.ts deleted file mode 100644 index 2a6a3dbf75..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-app/src/render.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const render = () => { - console.log("rendered"); -}; diff --git a/packages/deslop-js/tests/fixtures/vite-app/vite.config.ts b/packages/deslop-js/tests/fixtures/vite-app/vite.config.ts deleted file mode 100644 index c8aee49b17..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-app/vite.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "vite"; - -export default defineConfig({ - build: { - rollupOptions: { - input: { - main: "./src/main.tsx", - }, - }, - }, -}); diff --git a/packages/deslop-js/tests/fixtures/vite-glob-import/package.json b/packages/deslop-js/tests/fixtures/vite-glob-import/package.json deleted file mode 100644 index 12e3eb5aae..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-glob-import/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "import-meta-glob", - "version": "1.0.0", - "main": "./src/index.ts", - "devDependencies": { - "vite": "^5.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/vite-glob-import/src/index.ts b/packages/deslop-js/tests/fixtures/vite-glob-import/src/index.ts deleted file mode 100644 index 30e40f957b..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-glob-import/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -const modules = import.meta.glob("./modules/*.ts"); -const layouts = import.meta.glob(["./layouts/*.ts", "./modules/*.ts"]); -export { modules, layouts }; diff --git a/packages/deslop-js/tests/fixtures/vite-glob-import/src/layouts/main.ts b/packages/deslop-js/tests/fixtures/vite-glob-import/src/layouts/main.ts deleted file mode 100644 index 1d2892d238..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-glob-import/src/layouts/main.ts +++ /dev/null @@ -1 +0,0 @@ -export const mainLayout = "main"; diff --git a/packages/deslop-js/tests/fixtures/vite-glob-import/src/modules/alpha.ts b/packages/deslop-js/tests/fixtures/vite-glob-import/src/modules/alpha.ts deleted file mode 100644 index ce23559ee0..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-glob-import/src/modules/alpha.ts +++ /dev/null @@ -1 +0,0 @@ -export const alpha = "alpha"; diff --git a/packages/deslop-js/tests/fixtures/vite-glob-import/src/modules/beta.ts b/packages/deslop-js/tests/fixtures/vite-glob-import/src/modules/beta.ts deleted file mode 100644 index 81e36723d8..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-glob-import/src/modules/beta.ts +++ /dev/null @@ -1 +0,0 @@ -export const beta = "beta"; diff --git a/packages/deslop-js/tests/fixtures/vite-glob-import/src/orphan.ts b/packages/deslop-js/tests/fixtures/vite-glob-import/src/orphan.ts deleted file mode 100644 index b396078dbf..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-glob-import/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "not matched by glob"; diff --git a/packages/deslop-js/tests/fixtures/vite-resolve-alias/package.json b/packages/deslop-js/tests/fixtures/vite-resolve-alias/package.json deleted file mode 100644 index fa69c82d9a..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-resolve-alias/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "vite-resolve-alias", - "version": "1.0.0", - "private": true, - "devDependencies": { - "vite": "^5.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/vite-resolve-alias/src/lib/orphan.ts b/packages/deslop-js/tests/fixtures/vite-resolve-alias/src/lib/orphan.ts deleted file mode 100644 index 8e556ce509..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-resolve-alias/src/lib/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unusedLibHelper = (): number => 99; diff --git a/packages/deslop-js/tests/fixtures/vite-resolve-alias/src/lib/util.ts b/packages/deslop-js/tests/fixtures/vite-resolve-alias/src/lib/util.ts deleted file mode 100644 index 38e4ceed2b..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-resolve-alias/src/lib/util.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = (): number => 1; diff --git a/packages/deslop-js/tests/fixtures/vite-resolve-alias/src/main.ts b/packages/deslop-js/tests/fixtures/vite-resolve-alias/src/main.ts deleted file mode 100644 index 46ef34572f..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-resolve-alias/src/main.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { helper } from "@lib/util"; -import { widget } from "@/widget"; - -export const run = (): number => helper() + widget(); diff --git a/packages/deslop-js/tests/fixtures/vite-resolve-alias/src/widget.ts b/packages/deslop-js/tests/fixtures/vite-resolve-alias/src/widget.ts deleted file mode 100644 index 0fb8a8b2dc..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-resolve-alias/src/widget.ts +++ /dev/null @@ -1 +0,0 @@ -export const widget = (): number => 2; diff --git a/packages/deslop-js/tests/fixtures/vite-resolve-alias/vite.config.ts b/packages/deslop-js/tests/fixtures/vite-resolve-alias/vite.config.ts deleted file mode 100644 index 4ab4ea719a..0000000000 --- a/packages/deslop-js/tests/fixtures/vite-resolve-alias/vite.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { fileURLToPath, URL } from "node:url"; -import path from "node:path"; -import { defineConfig } from "vite"; - -export default defineConfig({ - resolve: { - alias: { - "@": fileURLToPath(new URL("./src", import.meta.url)), - "@lib": path.resolve(__dirname, "src/lib"), - }, - }, -}); diff --git a/packages/deslop-js/tests/fixtures/vitest-automock/__tests__/server.test.ts b/packages/deslop-js/tests/fixtures/vitest-automock/__tests__/server.test.ts deleted file mode 100644 index d0097b0c7b..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-automock/__tests__/server.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { vi, describe, it, expect } from "vitest"; -import { fetchData } from "../src/server/api"; - -vi.mock("../src/server/api"); - -describe("server", () => { - it("should use auto mock", () => { - expect(fetchData()).toBe("mocked data"); - }); -}); diff --git a/packages/deslop-js/tests/fixtures/vitest-automock/__tests__/utils.test.ts b/packages/deslop-js/tests/fixtures/vitest-automock/__tests__/utils.test.ts deleted file mode 100644 index 4bd1646009..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-automock/__tests__/utils.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { vi, describe, it, expect } from "vitest"; -import { formatDate } from "../src/utils/helper"; - -vi.mock("../src/utils/helper", () => ({ - formatDate: () => "inline mocked", -})); - -describe("utils", () => { - it("should use factory mock", () => { - expect(formatDate()).toBe("inline mocked"); - }); -}); diff --git a/packages/deslop-js/tests/fixtures/vitest-automock/package.json b/packages/deslop-js/tests/fixtures/vitest-automock/package.json deleted file mode 100644 index 11aec84e5a..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-automock/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "vitest-auto-mock", - "version": "1.0.0", - "devDependencies": { - "vitest": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/vitest-automock/src/index.ts b/packages/deslop-js/tests/fixtures/vitest-automock/src/index.ts deleted file mode 100644 index 64117fa67f..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-automock/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { fetchData } from "./server/api"; -export { fetchData }; diff --git a/packages/deslop-js/tests/fixtures/vitest-automock/src/server/__mocks__/api.ts b/packages/deslop-js/tests/fixtures/vitest-automock/src/server/__mocks__/api.ts deleted file mode 100644 index ad2ff42c9f..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-automock/src/server/__mocks__/api.ts +++ /dev/null @@ -1 +0,0 @@ -export const fetchData = () => "mocked data"; diff --git a/packages/deslop-js/tests/fixtures/vitest-automock/src/server/api.ts b/packages/deslop-js/tests/fixtures/vitest-automock/src/server/api.ts deleted file mode 100644 index d5ef4d9d83..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-automock/src/server/api.ts +++ /dev/null @@ -1 +0,0 @@ -export const fetchData = () => "real data"; diff --git a/packages/deslop-js/tests/fixtures/vitest-automock/src/server/unused.ts b/packages/deslop-js/tests/fixtures/vitest-automock/src/server/unused.ts deleted file mode 100644 index 131de613ae..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-automock/src/server/unused.ts +++ /dev/null @@ -1 +0,0 @@ -export const unusedFunction = () => "never used"; diff --git a/packages/deslop-js/tests/fixtures/vitest-automock/src/utils/__mocks__/helper.ts b/packages/deslop-js/tests/fixtures/vitest-automock/src/utils/__mocks__/helper.ts deleted file mode 100644 index 1cde8be2f3..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-automock/src/utils/__mocks__/helper.ts +++ /dev/null @@ -1 +0,0 @@ -export const formatDate = () => "mocked date"; diff --git a/packages/deslop-js/tests/fixtures/vitest-automock/src/utils/helper.ts b/packages/deslop-js/tests/fixtures/vitest-automock/src/utils/helper.ts deleted file mode 100644 index 369356f622..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-automock/src/utils/helper.ts +++ /dev/null @@ -1 +0,0 @@ -export const formatDate = () => "2024-01-01"; diff --git a/packages/deslop-js/tests/fixtures/vitest-coverage/orphan.ts b/packages/deslop-js/tests/fixtures/vitest-coverage/orphan.ts deleted file mode 100644 index bddae4ee9b..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-coverage/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = true; diff --git a/packages/deslop-js/tests/fixtures/vitest-coverage/package.json b/packages/deslop-js/tests/fixtures/vitest-coverage/package.json deleted file mode 100644 index 9e6ebbebde..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-coverage/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "vitest-coverage-include", - "version": "1.0.0", - "devDependencies": { - "vitest": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/vitest-coverage/src/core.ts b/packages/deslop-js/tests/fixtures/vitest-coverage/src/core.ts deleted file mode 100644 index bc81dd56de..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-coverage/src/core.ts +++ /dev/null @@ -1 +0,0 @@ -export const add = (a: number, b: number) => a + b; diff --git a/packages/deslop-js/tests/fixtures/vitest-coverage/src/utils.ts b/packages/deslop-js/tests/fixtures/vitest-coverage/src/utils.ts deleted file mode 100644 index f2d5e31d03..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-coverage/src/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const noop = () => {}; diff --git a/packages/deslop-js/tests/fixtures/vitest-coverage/tests/core.test.ts b/packages/deslop-js/tests/fixtures/vitest-coverage/tests/core.test.ts deleted file mode 100644 index 5908f79edb..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-coverage/tests/core.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { add } from "../src/core"; - -describe("core", () => { - it("adds numbers", () => { - expect(add(1, 2)).toBe(3); - }); -}); diff --git a/packages/deslop-js/tests/fixtures/vitest-coverage/vitest.config.ts b/packages/deslop-js/tests/fixtures/vitest-coverage/vitest.config.ts deleted file mode 100644 index e98a6161db..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-coverage/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "vite"; - -export default defineConfig({ - test: { - coverage: { - include: ["src/core.ts", "src/utils.ts"], - }, - }, -}); diff --git a/packages/deslop-js/tests/fixtures/vitest-custom/package.json b/packages/deslop-js/tests/fixtures/vitest-custom/package.json deleted file mode 100644 index b393817a58..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-custom/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "vitest-custom-include", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "vitest": "^2.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/vitest-custom/spec/utils-spec.ts b/packages/deslop-js/tests/fixtures/vitest-custom/spec/utils-spec.ts deleted file mode 100644 index a7b36af220..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-custom/spec/utils-spec.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { hello } from "../src/index"; -console.log(hello); diff --git a/packages/deslop-js/tests/fixtures/vitest-custom/src/index.ts b/packages/deslop-js/tests/fixtures/vitest-custom/src/index.ts deleted file mode 100644 index 1e29c45764..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-custom/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const hello = "world"; diff --git a/packages/deslop-js/tests/fixtures/vitest-custom/src/orphan.ts b/packages/deslop-js/tests/fixtures/vitest-custom/src/orphan.ts deleted file mode 100644 index bddae4ee9b..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-custom/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = true; diff --git a/packages/deslop-js/tests/fixtures/vitest-custom/tsconfig.json b/packages/deslop-js/tests/fixtures/vitest-custom/tsconfig.json deleted file mode 100644 index 40d38031c3..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-custom/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "moduleResolution": "bundler" - } -} diff --git a/packages/deslop-js/tests/fixtures/vitest-custom/vitest.config.ts b/packages/deslop-js/tests/fixtures/vitest-custom/vitest.config.ts deleted file mode 100644 index 8846ac707f..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-custom/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["spec/**/*-spec.{ts,tsx,js,jsx}", "spec/**/*.spec.{ts,tsx,js,jsx}"], - }, -}); diff --git a/packages/deslop-js/tests/fixtures/vitest-override-target/package.json b/packages/deslop-js/tests/fixtures/vitest-override-target/package.json deleted file mode 100644 index d29fa31919..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-override-target/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "vitest-override-target", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "@voidzero-dev/vite-plus-test": "^0.1.20", - "unused-dep": "^1.0.0", - "vitest": "^4.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/vitest-override-target/pnpm-workspace.yaml b/packages/deslop-js/tests/fixtures/vitest-override-target/pnpm-workspace.yaml deleted file mode 100644 index 4db2a7c567..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-override-target/pnpm-workspace.yaml +++ /dev/null @@ -1,6 +0,0 @@ -packages: - - "." - -pnpm: - overrides: - vitest: npm:@voidzero-dev/vite-plus-test@^0.1.20 diff --git a/packages/deslop-js/tests/fixtures/vitest-override-target/src/index.ts b/packages/deslop-js/tests/fixtures/vitest-override-target/src/index.ts deleted file mode 100644 index d1d32a17e8..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-override-target/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const appVersion = "1.0.0"; diff --git a/packages/deslop-js/tests/fixtures/vitest-override-target/vitest.config.ts b/packages/deslop-js/tests/fixtures/vitest-override-target/vitest.config.ts deleted file mode 100644 index 8fb6f2dcff..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-override-target/vitest.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({}); diff --git a/packages/deslop-js/tests/fixtures/vitest-setup/package.json b/packages/deslop-js/tests/fixtures/vitest-setup/package.json deleted file mode 100644 index 70d56befcd..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-setup/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "vitest-setup-files-fixture", - "private": true, - "devDependencies": { - "vitest": "^2.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/vitest-setup/src/index.ts b/packages/deslop-js/tests/fixtures/vitest-setup/src/index.ts deleted file mode 100644 index ef490c9753..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-setup/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { helper } from "./utils"; - -export const main = () => helper(); diff --git a/packages/deslop-js/tests/fixtures/vitest-setup/src/orphan.ts b/packages/deslop-js/tests/fixtures/vitest-setup/src/orphan.ts deleted file mode 100644 index 7a2285da1b..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-setup/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = () => "never imported"; diff --git a/packages/deslop-js/tests/fixtures/vitest-setup/src/test/setup.ts b/packages/deslop-js/tests/fixtures/vitest-setup/src/test/setup.ts deleted file mode 100644 index add8c4882e..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-setup/src/test/setup.ts +++ /dev/null @@ -1,3 +0,0 @@ -import "@testing-library/jest-dom"; - -globalThis.IS_TEST = true; diff --git a/packages/deslop-js/tests/fixtures/vitest-setup/src/utils.ts b/packages/deslop-js/tests/fixtures/vitest-setup/src/utils.ts deleted file mode 100644 index 613e5ee576..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-setup/src/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = () => "used"; diff --git a/packages/deslop-js/tests/fixtures/vitest-setup/vitest.config.ts b/packages/deslop-js/tests/fixtures/vitest-setup/vitest.config.ts deleted file mode 100644 index 51e8ce4c1b..0000000000 --- a/packages/deslop-js/tests/fixtures/vitest-setup/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - environment: "happy-dom", - setupFiles: "./src/test/setup.ts", - }, -}); diff --git a/packages/deslop-js/tests/fixtures/vue-app/package.json b/packages/deslop-js/tests/fixtures/vue-app/package.json deleted file mode 100644 index 443ef0c40a..0000000000 --- a/packages/deslop-js/tests/fixtures/vue-app/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "vue-sfc-test", - "version": "1.0.0", - "main": "src/main.ts", - "dependencies": { - "vue": "^3.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/vue-app/src/App.vue b/packages/deslop-js/tests/fixtures/vue-app/src/App.vue deleted file mode 100644 index ec5d67e428..0000000000 --- a/packages/deslop-js/tests/fixtures/vue-app/src/App.vue +++ /dev/null @@ -1,9 +0,0 @@ -<template> - <div> - <HelloWorld msg="Hello" /> - </div> -</template> - -<script setup lang="ts"> -import HelloWorld from "./components/HelloWorld.vue"; -</script> diff --git a/packages/deslop-js/tests/fixtures/vue-app/src/components/HelloWorld.vue b/packages/deslop-js/tests/fixtures/vue-app/src/components/HelloWorld.vue deleted file mode 100644 index fb9347d507..0000000000 --- a/packages/deslop-js/tests/fixtures/vue-app/src/components/HelloWorld.vue +++ /dev/null @@ -1,12 +0,0 @@ -<template> - <div>{{ msg }}</div> -</template> - -<script lang="ts"> -import { defineComponent } from "vue"; -import { formatName } from "../utils"; - -export default defineComponent({ - props: { msg: String }, -}); -</script> diff --git a/packages/deslop-js/tests/fixtures/vue-app/src/components/OrphanComponent.vue b/packages/deslop-js/tests/fixtures/vue-app/src/components/OrphanComponent.vue deleted file mode 100644 index b37da6002b..0000000000 --- a/packages/deslop-js/tests/fixtures/vue-app/src/components/OrphanComponent.vue +++ /dev/null @@ -1,7 +0,0 @@ -<template> - <div>Orphan</div> -</template> - -<script lang="ts"> -export default { name: "OrphanComponent" }; -</script> diff --git a/packages/deslop-js/tests/fixtures/vue-app/src/main.ts b/packages/deslop-js/tests/fixtures/vue-app/src/main.ts deleted file mode 100644 index b670de8b8d..0000000000 --- a/packages/deslop-js/tests/fixtures/vue-app/src/main.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createApp } from "vue"; -import App from "./App.vue"; - -createApp(App).mount("#app"); diff --git a/packages/deslop-js/tests/fixtures/vue-app/src/orphan.ts b/packages/deslop-js/tests/fixtures/vue-app/src/orphan.ts deleted file mode 100644 index 9d7e0994f5..0000000000 --- a/packages/deslop-js/tests/fixtures/vue-app/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "not referenced anywhere"; diff --git a/packages/deslop-js/tests/fixtures/vue-app/src/utils.ts b/packages/deslop-js/tests/fixtures/vue-app/src/utils.ts deleted file mode 100644 index f7bc42f7ef..0000000000 --- a/packages/deslop-js/tests/fixtures/vue-app/src/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const formatName = (name: string) => name.toUpperCase(); diff --git a/packages/deslop-js/tests/fixtures/webpack-entries/package.json b/packages/deslop-js/tests/fixtures/webpack-entries/package.json deleted file mode 100644 index 35e09f4966..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-entries/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "webpack-entry-test", - "version": "1.0.0", - "devDependencies": { - "webpack": "^5.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/webpack-entries/src/components/App.js b/packages/deslop-js/tests/fixtures/webpack-entries/src/components/App.js deleted file mode 100644 index 776628da3a..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-entries/src/components/App.js +++ /dev/null @@ -1 +0,0 @@ -export const App = () => console.log("app"); diff --git a/packages/deslop-js/tests/fixtures/webpack-entries/src/components/Vendor.js b/packages/deslop-js/tests/fixtures/webpack-entries/src/components/Vendor.js deleted file mode 100644 index 704b9847d0..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-entries/src/components/Vendor.js +++ /dev/null @@ -1 +0,0 @@ -export const vendorInit = () => console.log("vendor"); diff --git a/packages/deslop-js/tests/fixtures/webpack-entries/src/index.js b/packages/deslop-js/tests/fixtures/webpack-entries/src/index.js deleted file mode 100644 index f08e0ddb3d..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-entries/src/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import { App } from "./components/App"; -App(); diff --git a/packages/deslop-js/tests/fixtures/webpack-entries/src/orphan.js b/packages/deslop-js/tests/fixtures/webpack-entries/src/orphan.js deleted file mode 100644 index 3bc8bff37c..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-entries/src/orphan.js +++ /dev/null @@ -1 +0,0 @@ -export const orphan = () => console.log("unused"); diff --git a/packages/deslop-js/tests/fixtures/webpack-entries/src/vendor.js b/packages/deslop-js/tests/fixtures/webpack-entries/src/vendor.js deleted file mode 100644 index eab3eba8b6..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-entries/src/vendor.js +++ /dev/null @@ -1,2 +0,0 @@ -import { vendorInit } from "./components/Vendor"; -vendorInit(); diff --git a/packages/deslop-js/tests/fixtures/webpack-entries/webpack.config.js b/packages/deslop-js/tests/fixtures/webpack-entries/webpack.config.js deleted file mode 100644 index 51cbcb8182..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-entries/webpack.config.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = { - entry: { - app: "./src/index.js", - vendor: "./src/vendor.js", - }, - output: { - filename: "[name].bundle.js", - }, -}; diff --git a/packages/deslop-js/tests/fixtures/webpack-path/app/index.js b/packages/deslop-js/tests/fixtures/webpack-path/app/index.js deleted file mode 100644 index 401df0c3d6..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-path/app/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import { render } from "./renderer"; -render(); diff --git a/packages/deslop-js/tests/fixtures/webpack-path/app/orphan.js b/packages/deslop-js/tests/fixtures/webpack-path/app/orphan.js deleted file mode 100644 index c9a1f32c82..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-path/app/orphan.js +++ /dev/null @@ -1 +0,0 @@ -export const unused = "this file is not imported"; diff --git a/packages/deslop-js/tests/fixtures/webpack-path/app/renderer.js b/packages/deslop-js/tests/fixtures/webpack-path/app/renderer.js deleted file mode 100644 index 53067208c1..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-path/app/renderer.js +++ /dev/null @@ -1 +0,0 @@ -export const render = () => console.log("render"); diff --git a/packages/deslop-js/tests/fixtures/webpack-path/configs/webpack.config.renderer.prod.babel.js b/packages/deslop-js/tests/fixtures/webpack-path/configs/webpack.config.renderer.prod.babel.js deleted file mode 100644 index 38825c11c8..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-path/configs/webpack.config.renderer.prod.babel.js +++ /dev/null @@ -1,5 +0,0 @@ -const path = require("path"); -module.exports = { - entry: path.join(__dirname, "..", "app/index"), - output: { path: path.resolve(__dirname, "dist") }, -}; diff --git a/packages/deslop-js/tests/fixtures/webpack-path/package.json b/packages/deslop-js/tests/fixtures/webpack-path/package.json deleted file mode 100644 index e4b1492df9..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-path/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "webpack-path-join", - "dependencies": { - "webpack": "^5.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/webpack-require-ctx/package.json b/packages/deslop-js/tests/fixtures/webpack-require-ctx/package.json deleted file mode 100644 index a2f60730c9..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-require-ctx/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "require-context-fixture", - "main": "src/index.ts", - "dependencies": { - "webpack": "*" - } -} diff --git a/packages/deslop-js/tests/fixtures/webpack-require-ctx/src/components/Button.tsx b/packages/deslop-js/tests/fixtures/webpack-require-ctx/src/components/Button.tsx deleted file mode 100644 index 47893f153d..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-require-ctx/src/components/Button.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Button = "button"; diff --git a/packages/deslop-js/tests/fixtures/webpack-require-ctx/src/components/nested/Card.tsx b/packages/deslop-js/tests/fixtures/webpack-require-ctx/src/components/nested/Card.tsx deleted file mode 100644 index 9e2f9fa6e9..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-require-ctx/src/components/nested/Card.tsx +++ /dev/null @@ -1 +0,0 @@ -export const Card = "card"; diff --git a/packages/deslop-js/tests/fixtures/webpack-require-ctx/src/index.ts b/packages/deslop-js/tests/fixtures/webpack-require-ctx/src/index.ts deleted file mode 100644 index c8080b95fc..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-require-ctx/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -const components = require.context("./components", true, /\.tsx$/); -const pages = require.context("./pages", false); -export { components, pages }; diff --git a/packages/deslop-js/tests/fixtures/webpack-require-ctx/src/orphan.ts b/packages/deslop-js/tests/fixtures/webpack-require-ctx/src/orphan.ts deleted file mode 100644 index bd93db0a49..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-require-ctx/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "unused"; diff --git a/packages/deslop-js/tests/fixtures/webpack-require-ctx/src/pages/home.ts b/packages/deslop-js/tests/fixtures/webpack-require-ctx/src/pages/home.ts deleted file mode 100644 index a0c8f96d30..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-require-ctx/src/pages/home.ts +++ /dev/null @@ -1 +0,0 @@ -export const homePage = "home"; diff --git a/packages/deslop-js/tests/fixtures/webpack-resolve/app/views/actions/orphan.ts b/packages/deslop-js/tests/fixtures/webpack-resolve/app/views/actions/orphan.ts deleted file mode 100644 index 16ab2e43e9..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-resolve/app/views/actions/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphanAction = "orphan"; diff --git a/packages/deslop-js/tests/fixtures/webpack-resolve/app/views/actions/run-action.ts b/packages/deslop-js/tests/fixtures/webpack-resolve/app/views/actions/run-action.ts deleted file mode 100644 index 9277c8fb97..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-resolve/app/views/actions/run-action.ts +++ /dev/null @@ -1 +0,0 @@ -export const runAction = (): string => "action"; diff --git a/packages/deslop-js/tests/fixtures/webpack-resolve/app/views/utils/helper.ts b/packages/deslop-js/tests/fixtures/webpack-resolve/app/views/utils/helper.ts deleted file mode 100644 index 68b2b31386..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-resolve/app/views/utils/helper.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = (): string => "helper"; diff --git a/packages/deslop-js/tests/fixtures/webpack-resolve/package.json b/packages/deslop-js/tests/fixtures/webpack-resolve/package.json deleted file mode 100644 index a17d42c769..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-resolve/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "webpack-resolve", - "version": "1.0.0", - "main": "src/index.ts", - "devDependencies": { - "webpack": "^5.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/webpack-resolve/src/App.ts b/packages/deslop-js/tests/fixtures/webpack-resolve/src/App.ts deleted file mode 100644 index 3ff512c6c3..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-resolve/src/App.ts +++ /dev/null @@ -1 +0,0 @@ -export const App = "app"; diff --git a/packages/deslop-js/tests/fixtures/webpack-resolve/src/index.ts b/packages/deslop-js/tests/fixtures/webpack-resolve/src/index.ts deleted file mode 100644 index 05421d7280..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-resolve/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { App } from "App"; -import { runAction } from "Actions/run-action"; -import { helper } from "Utils/helper"; - -export const result = `${App}:${runAction()}:${helper()}`; diff --git a/packages/deslop-js/tests/fixtures/webpack-resolve/src/orphan.ts b/packages/deslop-js/tests/fixtures/webpack-resolve/src/orphan.ts deleted file mode 100644 index 88f7cf20e0..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-resolve/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "orphan"; diff --git a/packages/deslop-js/tests/fixtures/webpack-resolve/webpack.config.js b/packages/deslop-js/tests/fixtures/webpack-resolve/webpack.config.js deleted file mode 100644 index f39f1cfbaa..0000000000 --- a/packages/deslop-js/tests/fixtures/webpack-resolve/webpack.config.js +++ /dev/null @@ -1,11 +0,0 @@ -const path = require("node:path"); - -module.exports = { - resolve: { - alias: { - Actions: path.resolve(__dirname, "app/views/actions"), - Utils: path.join(__dirname, "app", "views", "utils"), - }, - modules: [path.resolve(__dirname, "src"), "node_modules"], - }, -}; diff --git a/packages/deslop-js/tests/fixtures/wildcard-css/orphan.ts b/packages/deslop-js/tests/fixtures/wildcard-css/orphan.ts deleted file mode 100644 index 1b61e3211e..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-css/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = () => "unused"; diff --git a/packages/deslop-js/tests/fixtures/wildcard-css/package.json b/packages/deslop-js/tests/fixtures/wildcard-css/package.json deleted file mode 100644 index 3870a11c4a..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-css/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "wildcard-exports-css", - "version": "1.0.0", - "exports": { - ".": "./src/index.ts", - "./*": "./src/*" - } -} diff --git a/packages/deslop-js/tests/fixtures/wildcard-css/src/components/Button.css b/packages/deslop-js/tests/fixtures/wildcard-css/src/components/Button.css deleted file mode 100644 index 1dfac23168..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-css/src/components/Button.css +++ /dev/null @@ -1,3 +0,0 @@ -.button { - color: red; -} diff --git a/packages/deslop-js/tests/fixtures/wildcard-css/src/components/Button.ts b/packages/deslop-js/tests/fixtures/wildcard-css/src/components/Button.ts deleted file mode 100644 index 5dfc1b1794..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-css/src/components/Button.ts +++ /dev/null @@ -1 +0,0 @@ -export const Button = () => "button"; diff --git a/packages/deslop-js/tests/fixtures/wildcard-css/src/index.ts b/packages/deslop-js/tests/fixtures/wildcard-css/src/index.ts deleted file mode 100644 index 30ab361b4b..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-css/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Button } from "./components/Button"; diff --git a/packages/deslop-js/tests/fixtures/wildcard-late-consume/package.json b/packages/deslop-js/tests/fixtures/wildcard-late-consume/package.json deleted file mode 100644 index d99df7a829..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-late-consume/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "late-consumed-wildcard-reexport", - "private": true -} diff --git a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/components/color-picker/color-picker.ts b/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/components/color-picker/color-picker.ts deleted file mode 100644 index c11b5d0891..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/components/color-picker/color-picker.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const ColorPicker = { type: "color" }; -export const ColorUtils = { format: "hex" }; diff --git a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/components/color-picker/index.ts b/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/components/color-picker/index.ts deleted file mode 100644 index 6bad1d67ec..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/components/color-picker/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./color-picker"; diff --git a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/components/index.ts b/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/components/index.ts deleted file mode 100644 index 52a2f481fa..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./color-picker"; -export * from "./text-field"; diff --git a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/components/text-field.ts b/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/components/text-field.ts deleted file mode 100644 index 377ad8afef..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/components/text-field.ts +++ /dev/null @@ -1 +0,0 @@ -export const BaseTextField = { type: "text" }; diff --git a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/components/unused-widget.ts b/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/components/unused-widget.ts deleted file mode 100644 index a09acf12b3..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/components/unused-widget.ts +++ /dev/null @@ -1 +0,0 @@ -export const UnusedWidget = { type: "unused" }; diff --git a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/index.ts b/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/index.ts deleted file mode 100644 index 39899be240..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ColorPlugin } from "./plugins"; - -export const app = { plugins: [ColorPlugin] }; diff --git a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/plugins/color-plugin.ts b/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/plugins/color-plugin.ts deleted file mode 100644 index abcdf3145a..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/plugins/color-plugin.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ColorPicker } from "../components"; - -export const ColorPlugin = { component: ColorPicker }; diff --git a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/plugins/index.ts b/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/plugins/index.ts deleted file mode 100644 index 60fec8efa9..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/plugins/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./color-plugin"; -export * from "./text-plugin"; diff --git a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/plugins/text-plugin.ts b/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/plugins/text-plugin.ts deleted file mode 100644 index dd5cc3fc91..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-late-consume/src/plugins/text-plugin.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { BaseTextField } from "../components"; - -export const TextPlugin = { component: BaseTextField }; diff --git a/packages/deslop-js/tests/fixtures/wildcard-subpath/orphan.ts b/packages/deslop-js/tests/fixtures/wildcard-subpath/orphan.ts deleted file mode 100644 index bddae4ee9b..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-subpath/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = true; diff --git a/packages/deslop-js/tests/fixtures/wildcard-subpath/package.json b/packages/deslop-js/tests/fixtures/wildcard-subpath/package.json deleted file mode 100644 index 8bf8412574..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-subpath/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "wildcard-subpath-exports", - "version": "1.0.0", - "main": "./src/index.ts", - "exports": { - ".": "./src/index.ts", - "./templates/*": "./src/templates/*.tsx" - } -} diff --git a/packages/deslop-js/tests/fixtures/wildcard-subpath/src/index.ts b/packages/deslop-js/tests/fixtures/wildcard-subpath/src/index.ts deleted file mode 100644 index aca726718e..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-subpath/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const send = () => {}; diff --git a/packages/deslop-js/tests/fixtures/wildcard-subpath/src/templates/goodbye.tsx b/packages/deslop-js/tests/fixtures/wildcard-subpath/src/templates/goodbye.tsx deleted file mode 100644 index cb68651d23..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-subpath/src/templates/goodbye.tsx +++ /dev/null @@ -1 +0,0 @@ -export const GoodbyeEmail = () => {}; diff --git a/packages/deslop-js/tests/fixtures/wildcard-subpath/src/templates/welcome.tsx b/packages/deslop-js/tests/fixtures/wildcard-subpath/src/templates/welcome.tsx deleted file mode 100644 index f3be45823e..0000000000 --- a/packages/deslop-js/tests/fixtures/wildcard-subpath/src/templates/welcome.tsx +++ /dev/null @@ -1 +0,0 @@ -export const WelcomeEmail = () => {}; diff --git a/packages/deslop-js/tests/fixtures/worker-new-url/package.json b/packages/deslop-js/tests/fixtures/worker-new-url/package.json deleted file mode 100644 index 7cb5396f89..0000000000 --- a/packages/deslop-js/tests/fixtures/worker-new-url/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "new-url-worker-fixture", - "private": true -} diff --git a/packages/deslop-js/tests/fixtures/worker-new-url/src/index.ts b/packages/deslop-js/tests/fixtures/worker-new-url/src/index.ts deleted file mode 100644 index 59e9c4487e..0000000000 --- a/packages/deslop-js/tests/fixtures/worker-new-url/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -const workerUrl = new URL("./worker.js", import.meta.url); -const worker = new Worker(workerUrl); - -export const start = () => worker.postMessage("start"); diff --git a/packages/deslop-js/tests/fixtures/worker-new-url/src/orphan.ts b/packages/deslop-js/tests/fixtures/worker-new-url/src/orphan.ts deleted file mode 100644 index 7a2285da1b..0000000000 --- a/packages/deslop-js/tests/fixtures/worker-new-url/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = () => "never imported"; diff --git a/packages/deslop-js/tests/fixtures/worker-new-url/src/worker.js b/packages/deslop-js/tests/fixtures/worker-new-url/src/worker.js deleted file mode 100644 index 92464b2f52..0000000000 --- a/packages/deslop-js/tests/fixtures/worker-new-url/src/worker.js +++ /dev/null @@ -1,3 +0,0 @@ -self.onmessage = (event) => { - self.postMessage(`received: ${event.data}`); -}; diff --git a/packages/deslop-js/tests/fixtures/workspace-deep-imports/apps/web/package.json b/packages/deslop-js/tests/fixtures/workspace-deep-imports/apps/web/package.json deleted file mode 100644 index f13dc3edc3..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-deep-imports/apps/web/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@myapp/web", - "version": "1.0.0", - "main": "src/index.ts", - "dependencies": { - "@myapp/shared": "workspace:*" - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-deep-imports/apps/web/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-deep-imports/apps/web/src/index.ts deleted file mode 100644 index eb3cc44519..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-deep-imports/apps/web/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { useAssets } from "@myapp/shared/hooks/assets"; -import { Button } from "@myapp/shared/components/button"; -export { useAssets, Button }; diff --git a/packages/deslop-js/tests/fixtures/workspace-deep-imports/apps/web/tsconfig.json b/packages/deslop-js/tests/fixtures/workspace-deep-imports/apps/web/tsconfig.json deleted file mode 100644 index 505245051a..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-deep-imports/apps/web/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@myapp/shared/*": ["../../packages/shared/src/*"] - } - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-deep-imports/package.json b/packages/deslop-js/tests/fixtures/workspace-deep-imports/package.json deleted file mode 100644 index 96d5279bb0..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-deep-imports/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "deep-workspace-root", - "private": true, - "workspaces": [ - "packages/*", - "apps/*" - ] -} diff --git a/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/package.json b/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/package.json deleted file mode 100644 index 9a367b4760..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@myapp/shared", - "version": "1.0.0", - "main": "src/index.ts", - "exports": { - ".": "./src/index.ts", - "./*": "./src/*" - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/src/components/button.ts b/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/src/components/button.ts deleted file mode 100644 index 5dfc1b1794..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/src/components/button.ts +++ /dev/null @@ -1 +0,0 @@ -export const Button = () => "button"; diff --git a/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/src/components/orphan.ts b/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/src/components/orphan.ts deleted file mode 100644 index 6ea5b69102..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/src/components/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const Orphan = () => "orphan"; diff --git a/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/src/hooks/assets.ts b/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/src/hooks/assets.ts deleted file mode 100644 index 9abf3c7e17..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/src/hooks/assets.ts +++ /dev/null @@ -1 +0,0 @@ -export const useAssets = () => ({ data: [] }); diff --git a/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/src/index.ts deleted file mode 100644 index cd8f24ee1c..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { useAssets } from "./hooks/assets"; -export { Button } from "./components/button"; diff --git a/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/tsconfig.json b/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/tsconfig.json deleted file mode 100644 index 99c74b222c..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-deep-imports/packages/shared/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@myapp/shared/*": ["src/*"] - } - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-defaults/package.json b/packages/deslop-js/tests/fixtures/workspace-defaults/package.json deleted file mode 100644 index e3615f4e9a..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-defaults/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "workspace-default-fallback", - "private": true, - "workspaces": [ - "packages/*" - ] -} diff --git a/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-a/package.json b/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-a/package.json deleted file mode 100644 index 7552220c94..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-a/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "@test/lib-a", - "version": "1.0.0", - "main": "dist/index.js" -} diff --git a/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-a/src/helper.ts b/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-a/src/helper.ts deleted file mode 100644 index 9fa1cd03a4..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-a/src/helper.ts +++ /dev/null @@ -1 +0,0 @@ -export const helper = () => "help"; diff --git a/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-a/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-a/src/index.ts deleted file mode 100644 index d0c47ddbec..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-a/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { helper } from "./helper"; diff --git a/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-a/src/orphan.ts b/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-a/src/orphan.ts deleted file mode 100644 index 9a2a4ec39c..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-a/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = () => "orphan"; diff --git a/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-b/package.json b/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-b/package.json deleted file mode 100644 index 73e034ac86..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-b/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "@test/lib-b", - "version": "1.0.0" -} diff --git a/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-b/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-b/src/index.ts deleted file mode 100644 index 1a863ec533..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-defaults/packages/lib-b/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = () => "unused"; diff --git a/packages/deslop-js/tests/fixtures/workspace-dist-resolve/package.json b/packages/deslop-js/tests/fixtures/workspace-dist-resolve/package.json deleted file mode 100644 index 90bf0e2d05..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-dist-resolve/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "workspace-dist-test", - "private": true, - "workspaces": [ - "packages/*" - ] -} diff --git a/packages/deslop-js/tests/fixtures/workspace-dist-resolve/packages/app/package.json b/packages/deslop-js/tests/fixtures/workspace-dist-resolve/packages/app/package.json deleted file mode 100644 index d191e5a0f7..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-dist-resolve/packages/app/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "@test/app", - "version": "1.0.0", - "main": "src/index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/workspace-dist-resolve/packages/app/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-dist-resolve/packages/app/src/index.ts deleted file mode 100644 index f1feefb6b5..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-dist-resolve/packages/app/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { add } from "@test/utils"; -export const result = add(1, 2); diff --git a/packages/deslop-js/tests/fixtures/workspace-dist-resolve/packages/utils/package.json b/packages/deslop-js/tests/fixtures/workspace-dist-resolve/packages/utils/package.json deleted file mode 100644 index 6f1c81ebe7..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-dist-resolve/packages/utils/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@test/utils", - "version": "1.0.0", - "main": "dist/index.js", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.js", - "types": "./dist/index.d.ts" - } - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-dist-resolve/packages/utils/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-dist-resolve/packages/utils/src/index.ts deleted file mode 100644 index 1fa16a8ab4..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-dist-resolve/packages/utils/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const add = (a: number, b: number): number => a + b; -export const unused = () => "not used"; diff --git a/packages/deslop-js/tests/fixtures/workspace-dist-resolve/packages/utils/src/orphan.ts b/packages/deslop-js/tests/fixtures/workspace-dist-resolve/packages/utils/src/orphan.ts deleted file mode 100644 index 7c19af6258..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-dist-resolve/packages/utils/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphanFunction = () => "nobody uses this"; diff --git a/packages/deslop-js/tests/fixtures/workspace-dist-src/package.json b/packages/deslop-js/tests/fixtures/workspace-dist-src/package.json deleted file mode 100644 index b25494a554..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-dist-src/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "workspace-dist-to-src-root", - "version": "1.0.0", - "private": true, - "workspaces": [ - "packages/*" - ] -} diff --git a/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/app/package.json b/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/app/package.json deleted file mode 100644 index 4e32a75c95..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/app/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/app", - "version": "1.0.0", - "main": "src/index.ts", - "dependencies": { - "@test/core": "workspace:*" - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/app/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/app/src/index.ts deleted file mode 100644 index 50a54b110d..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/app/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { add } from "@test/core"; -import { debugLog } from "@test/core/visualdebug"; - -debugLog(String(add(1, 2))); diff --git a/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/core/package.json b/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/core/package.json deleted file mode 100644 index 7419455d13..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/core/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@test/core", - "version": "1.0.0", - "exports": { - ".": { - "types": "./dist/types/index.d.ts", - "default": "./dist/prod/index.js" - }, - "./visualdebug": { - "types": "./dist/types/visualdebug.d.ts", - "default": "./dist/prod/visualdebug.js" - }, - "./*": { - "types": "./dist/types/*.d.ts", - "default": "./dist/prod/*.js" - } - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/core/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/core/src/index.ts deleted file mode 100644 index da7ff17a4d..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/core/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const add = (a: number, b: number): number => a + b; diff --git a/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/core/src/orphan.ts b/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/core/src/orphan.ts deleted file mode 100644 index 6fa0c80b12..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/core/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = "not imported anywhere"; diff --git a/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/core/src/visualdebug.ts b/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/core/src/visualdebug.ts deleted file mode 100644 index bcce4492aa..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-dist-src/packages/core/src/visualdebug.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const debugLog = (message: string): void => { - console.log(message); -}; diff --git a/packages/deslop-js/tests/fixtures/workspace-explicit/package.json b/packages/deslop-js/tests/fixtures/workspace-explicit/package.json deleted file mode 100644 index 6193c52589..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-explicit/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "workspace-explicit-entries", - "version": "1.0.0", - "workspaces": [ - "packages/*" - ] -} diff --git a/packages/deslop-js/tests/fixtures/workspace-explicit/packages/ui/package.json b/packages/deslop-js/tests/fixtures/workspace-explicit/packages/ui/package.json deleted file mode 100644 index a58fe3e537..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-explicit/packages/ui/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "@test/ui", - "version": "1.0.0", - "main": "src/button.ts" -} diff --git a/packages/deslop-js/tests/fixtures/workspace-explicit/packages/ui/src/button.ts b/packages/deslop-js/tests/fixtures/workspace-explicit/packages/ui/src/button.ts deleted file mode 100644 index 5dfc1b1794..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-explicit/packages/ui/src/button.ts +++ /dev/null @@ -1 +0,0 @@ -export const Button = () => "button"; diff --git a/packages/deslop-js/tests/fixtures/workspace-explicit/packages/ui/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-explicit/packages/ui/src/index.ts deleted file mode 100644 index 198e8c8d4c..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-explicit/packages/ui/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const uiMain = () => "ui index"; diff --git a/packages/deslop-js/tests/fixtures/workspace-explicit/packages/utils/package.json b/packages/deslop-js/tests/fixtures/workspace-explicit/packages/utils/package.json deleted file mode 100644 index 0d8ab55253..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-explicit/packages/utils/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "@test/utils", - "version": "1.0.0" -} diff --git a/packages/deslop-js/tests/fixtures/workspace-explicit/packages/utils/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-explicit/packages/utils/src/index.ts deleted file mode 100644 index 3dd1b6cfc7..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-explicit/packages/utils/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const utilsMain = () => "utils index"; diff --git a/packages/deslop-js/tests/fixtures/workspace-explicit/packages/utils/src/orphan.ts b/packages/deslop-js/tests/fixtures/workspace-explicit/packages/utils/src/orphan.ts deleted file mode 100644 index f60a83644a..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-explicit/packages/utils/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = () => "not imported"; diff --git a/packages/deslop-js/tests/fixtures/workspace-local-bin/.gitignore b/packages/deslop-js/tests/fixtures/workspace-local-bin/.gitignore deleted file mode 100644 index ddf342489b..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-local-bin/.gitignore +++ /dev/null @@ -1 +0,0 @@ -!node_modules/ diff --git a/packages/deslop-js/tests/fixtures/workspace-local-bin/node_modules/bin-only-tool/package.json b/packages/deslop-js/tests/fixtures/workspace-local-bin/node_modules/bin-only-tool/package.json deleted file mode 100644 index ece388c15c..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-local-bin/node_modules/bin-only-tool/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "bin-only-tool", - "version": "1.0.0", - "bin": { - "bin-only-tool": "./dist/cli.js" - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-local-bin/node_modules/react-email/package.json b/packages/deslop-js/tests/fixtures/workspace-local-bin/node_modules/react-email/package.json deleted file mode 100644 index b9ba4b9261..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-local-bin/node_modules/react-email/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "react-email", - "version": "3.0.6", - "bin": { - "email": "./dist/cli/index.js" - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-local-bin/package.json b/packages/deslop-js/tests/fixtures/workspace-local-bin/package.json deleted file mode 100644 index fccc8a5255..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-local-bin/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "workspace-local-bin", - "version": "1.0.0", - "main": "src/index.ts", - "scripts": { - "email:preview": "email dev --dir ./src/server/emails" - }, - "devDependencies": { - "react-email": "^3.0.0", - "bin-only-tool": "^1.0.0", - "expo-unused": "^1.0.0", - "unused-dev-tool": "^1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-local-bin/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-local-bin/src/index.ts deleted file mode 100644 index aea05c0694..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-local-bin/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const value = "value"; diff --git a/packages/deslop-js/tests/fixtures/workspace-no-main/package.json b/packages/deslop-js/tests/fixtures/workspace-no-main/package.json deleted file mode 100644 index 964318ce7b..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-no-main/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "workspace-no-main-field", - "private": true, - "workspaces": [ - "packages/*" - ] -} diff --git a/packages/deslop-js/tests/fixtures/workspace-no-main/packages/app/index.ts b/packages/deslop-js/tests/fixtures/workspace-no-main/packages/app/index.ts deleted file mode 100644 index 4145c5099b..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-no-main/packages/app/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { greet } from "lib-a"; -console.log(greet()); diff --git a/packages/deslop-js/tests/fixtures/workspace-no-main/packages/app/package.json b/packages/deslop-js/tests/fixtures/workspace-no-main/packages/app/package.json deleted file mode 100644 index ac3aac7665..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-no-main/packages/app/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "app", - "version": "1.0.0", - "main": "index.ts" -} diff --git a/packages/deslop-js/tests/fixtures/workspace-no-main/packages/lib-a/helper.js b/packages/deslop-js/tests/fixtures/workspace-no-main/packages/lib-a/helper.js deleted file mode 100644 index 9fa1cd03a4..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-no-main/packages/lib-a/helper.js +++ /dev/null @@ -1 +0,0 @@ -export const helper = () => "help"; diff --git a/packages/deslop-js/tests/fixtures/workspace-no-main/packages/lib-a/index.js b/packages/deslop-js/tests/fixtures/workspace-no-main/packages/lib-a/index.js deleted file mode 100644 index 8b9b8732e4..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-no-main/packages/lib-a/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import { helper } from "./helper.js"; -export const greet = () => helper(); diff --git a/packages/deslop-js/tests/fixtures/workspace-no-main/packages/lib-a/orphan.js b/packages/deslop-js/tests/fixtures/workspace-no-main/packages/lib-a/orphan.js deleted file mode 100644 index eace45e1a2..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-no-main/packages/lib-a/orphan.js +++ /dev/null @@ -1 +0,0 @@ -export const unused = () => "orphan"; diff --git a/packages/deslop-js/tests/fixtures/workspace-no-main/packages/lib-a/package.json b/packages/deslop-js/tests/fixtures/workspace-no-main/packages/lib-a/package.json deleted file mode 100644 index 52709b9101..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-no-main/packages/lib-a/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "lib-a", - "version": "1.0.0" -} diff --git a/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/apps/web/package.json b/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/apps/web/package.json deleted file mode 100644 index d8a83edbd3..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/apps/web/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "web", - "version": "1.0.0" -} diff --git a/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/apps/web/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/apps/web/src/index.ts deleted file mode 100644 index fd5dc7d9b8..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/apps/web/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { add } from "@project/core/utils"; - -console.log(add(1, 2)); diff --git a/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/package.json b/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/package.json deleted file mode 100644 index a8d7987a8d..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "workspace-path-alias-no-tsconfig", - "private": true, - "workspaces": [ - "packages/*", - "apps/*" - ] -} diff --git a/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/packages/core/orphan.ts b/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/packages/core/orphan.ts deleted file mode 100644 index b79aa99b63..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/packages/core/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unused = true; diff --git a/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/packages/core/package.json b/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/packages/core/package.json deleted file mode 100644 index dfef390af2..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/packages/core/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "core", - "version": "1.0.0" -} diff --git a/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/packages/core/utils.ts b/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/packages/core/utils.ts deleted file mode 100644 index da7ff17a4d..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-path-alias-no-tsconfig/packages/core/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const add = (a: number, b: number): number => a + b; diff --git a/packages/deslop-js/tests/fixtures/workspace-path-alias/apps/web/package.json b/packages/deslop-js/tests/fixtures/workspace-path-alias/apps/web/package.json deleted file mode 100644 index 0d2c6c9d2a..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-path-alias/apps/web/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "@project/web", - "version": "1.0.0" -} diff --git a/packages/deslop-js/tests/fixtures/workspace-path-alias/apps/web/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-path-alias/apps/web/src/index.ts deleted file mode 100644 index d501bfa047..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-path-alias/apps/web/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { formatDate } from "@project/core/utils"; - -export const main = () => formatDate(new Date()); diff --git a/packages/deslop-js/tests/fixtures/workspace-path-alias/package.json b/packages/deslop-js/tests/fixtures/workspace-path-alias/package.json deleted file mode 100644 index e3784be5f2..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-path-alias/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "workspace-path-alias-root", - "private": true, - "workspaces": [ - "packages/*", - "apps/*" - ] -} diff --git a/packages/deslop-js/tests/fixtures/workspace-path-alias/packages/core/orphan.ts b/packages/deslop-js/tests/fixtures/workspace-path-alias/packages/core/orphan.ts deleted file mode 100644 index 8f51445306..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-path-alias/packages/core/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unusedHelper = 42; diff --git a/packages/deslop-js/tests/fixtures/workspace-path-alias/packages/core/package.json b/packages/deslop-js/tests/fixtures/workspace-path-alias/packages/core/package.json deleted file mode 100644 index dfef390af2..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-path-alias/packages/core/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "core", - "version": "1.0.0" -} diff --git a/packages/deslop-js/tests/fixtures/workspace-path-alias/packages/core/utils.ts b/packages/deslop-js/tests/fixtures/workspace-path-alias/packages/core/utils.ts deleted file mode 100644 index 818ccb4d80..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-path-alias/packages/core/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const formatDate = (date: Date): string => date.toISOString(); diff --git a/packages/deslop-js/tests/fixtures/workspace-path-alias/tsconfig.json b/packages/deslop-js/tests/fixtures/workspace-path-alias/tsconfig.json deleted file mode 100644 index 380d720deb..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-path-alias/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@project/core/*": ["packages/core/*"] - } - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-structural-alias/apps/web/package.json b/packages/deslop-js/tests/fixtures/workspace-structural-alias/apps/web/package.json deleted file mode 100644 index 0d2c6c9d2a..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-structural-alias/apps/web/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "@project/web", - "version": "1.0.0" -} diff --git a/packages/deslop-js/tests/fixtures/workspace-structural-alias/apps/web/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-structural-alias/apps/web/src/index.ts deleted file mode 100644 index d501bfa047..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-structural-alias/apps/web/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { formatDate } from "@project/core/utils"; - -export const main = () => formatDate(new Date()); diff --git a/packages/deslop-js/tests/fixtures/workspace-structural-alias/package.json b/packages/deslop-js/tests/fixtures/workspace-structural-alias/package.json deleted file mode 100644 index f99294e6fb..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-structural-alias/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "workspace-structural-alias-root", - "private": true, - "workspaces": [ - "packages/*", - "apps/*" - ] -} diff --git a/packages/deslop-js/tests/fixtures/workspace-structural-alias/packages/core/orphan.ts b/packages/deslop-js/tests/fixtures/workspace-structural-alias/packages/core/orphan.ts deleted file mode 100644 index 8f51445306..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-structural-alias/packages/core/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unusedHelper = 42; diff --git a/packages/deslop-js/tests/fixtures/workspace-structural-alias/packages/core/package.json b/packages/deslop-js/tests/fixtures/workspace-structural-alias/packages/core/package.json deleted file mode 100644 index dfef390af2..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-structural-alias/packages/core/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "core", - "version": "1.0.0" -} diff --git a/packages/deslop-js/tests/fixtures/workspace-structural-alias/packages/core/utils.ts b/packages/deslop-js/tests/fixtures/workspace-structural-alias/packages/core/utils.ts deleted file mode 100644 index 818ccb4d80..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-structural-alias/packages/core/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const formatDate = (date: Date): string => date.toISOString(); diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/apps/web/package.json b/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/apps/web/package.json deleted file mode 100644 index 227f6a7611..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/apps/web/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "@built-fixture/web", - "dependencies": { - "@built-fixture/ui": "workspace:*" - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/apps/web/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/apps/web/src/index.ts deleted file mode 100644 index 1f957d9b07..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/apps/web/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Button } from "@built-fixture/ui/button"; - -export const renderApp = (): unknown => Button(); diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/package.json b/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/package.json deleted file mode 100644 index 7786129940..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "workspace-subpath-import-built", - "private": true, - "workspaces": [ - "apps/*", - "packages/*" - ] -} diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/packages/ui/dist/button.js b/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/packages/ui/dist/button.js deleted file mode 100644 index 0dc01ae4d3..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/packages/ui/dist/button.js +++ /dev/null @@ -1 +0,0 @@ -export const Button = () => null; diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/packages/ui/package.json b/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/packages/ui/package.json deleted file mode 100644 index 34ffdb0025..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/packages/ui/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "@built-fixture/ui", - "exports": { - "./*": "./dist/*.js" - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/packages/ui/src/button.ts b/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/packages/ui/src/button.ts deleted file mode 100644 index 73ea95a019..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/packages/ui/src/button.ts +++ /dev/null @@ -1 +0,0 @@ -export const Button = (): null => null; diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/packages/ui/src/orphan.ts b/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/packages/ui/src/orphan.ts deleted file mode 100644 index a3d78adf6c..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-import-built/packages/ui/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unusedHelper = (): void => {}; diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-import/apps/web/package.json b/packages/deslop-js/tests/fixtures/workspace-subpath-import/apps/web/package.json deleted file mode 100644 index d8bf78a57a..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-import/apps/web/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "@subpath-fixture/web", - "dependencies": { - "@subpath-fixture/ui": "workspace:*" - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-import/apps/web/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-subpath-import/apps/web/src/index.ts deleted file mode 100644 index 4d259d70c3..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-import/apps/web/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Button } from "@subpath-fixture/ui/button"; - -export const renderApp = (): unknown => Button(); diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-import/package.json b/packages/deslop-js/tests/fixtures/workspace-subpath-import/package.json deleted file mode 100644 index 2005ac1a6d..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-import/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "workspace-subpath-import", - "private": true, - "workspaces": [ - "apps/*", - "packages/*" - ] -} diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-import/packages/ui/button.tsx b/packages/deslop-js/tests/fixtures/workspace-subpath-import/packages/ui/button.tsx deleted file mode 100644 index 4470ba93b1..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-import/packages/ui/button.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { noop } from "./utils"; - -export const Button = (): null => { - noop(); - return null; -}; diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-import/packages/ui/orphan.ts b/packages/deslop-js/tests/fixtures/workspace-subpath-import/packages/ui/orphan.ts deleted file mode 100644 index a3d78adf6c..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-import/packages/ui/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unusedHelper = (): void => {}; diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-import/packages/ui/package.json b/packages/deslop-js/tests/fixtures/workspace-subpath-import/packages/ui/package.json deleted file mode 100644 index d71c186a46..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-import/packages/ui/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "@subpath-fixture/ui" -} diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-import/packages/ui/utils.ts b/packages/deslop-js/tests/fixtures/workspace-subpath-import/packages/ui/utils.ts deleted file mode 100644 index a242d94b08..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-import/packages/ui/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const noop = (): void => {}; diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/apps/web/package.json b/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/apps/web/package.json deleted file mode 100644 index 69845ef90f..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/apps/web/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "@wildcard-fixture/web", - "dependencies": { - "@wildcard-fixture/ui": "workspace:*" - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/apps/web/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/apps/web/src/index.ts deleted file mode 100644 index e735a44a0f..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/apps/web/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Button } from "@wildcard-fixture/ui/button"; - -export const renderApp = (): unknown => Button(); diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/package.json b/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/package.json deleted file mode 100644 index ba0419fc1e..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "workspace-subpath-wildcard-export", - "private": true, - "workspaces": [ - "apps/*", - "packages/*" - ] -} diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/packages/ui/package.json b/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/packages/ui/package.json deleted file mode 100644 index d86a5bf1cb..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/packages/ui/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@wildcard-fixture/ui", - "exports": { - "./*": { - "import": { - "default": "./dist/components/*.js" - } - } - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/packages/ui/src/components/button.tsx b/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/packages/ui/src/components/button.tsx deleted file mode 100644 index 2e70cbc68b..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/packages/ui/src/components/button.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { noop } from "./helpers"; - -export const Button = (): null => { - noop(); - return null; -}; diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/packages/ui/src/components/helpers.ts b/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/packages/ui/src/components/helpers.ts deleted file mode 100644 index a242d94b08..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/packages/ui/src/components/helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export const noop = (): void => {}; diff --git a/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/packages/ui/src/components/orphan.ts b/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/packages/ui/src/components/orphan.ts deleted file mode 100644 index a3d78adf6c..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-subpath-wildcard-export/packages/ui/src/components/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const unusedHelper = (): void => {}; diff --git a/packages/deslop-js/tests/fixtures/workspace-wildcards/package.json b/packages/deslop-js/tests/fixtures/workspace-wildcards/package.json deleted file mode 100644 index 3bf9df37f8..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-wildcards/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "workspace-wildcard-exports", - "version": "1.0.0", - "workspaces": [ - "packages/*" - ] -} diff --git a/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/app/package.json b/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/app/package.json deleted file mode 100644 index 573efa83ec..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/app/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/app", - "version": "1.0.0", - "main": "src/index.ts", - "dependencies": { - "@test/ui": "1.0.0" - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/app/src/index.ts b/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/app/src/index.ts deleted file mode 100644 index 2b6aed3111..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/app/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { Button } from "@test/ui/components"; -console.log(Button()); diff --git a/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/ui/internal/hidden.ts b/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/ui/internal/hidden.ts deleted file mode 100644 index b3d0cd0cd0..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/ui/internal/hidden.ts +++ /dev/null @@ -1 +0,0 @@ -export const hidden = "not exported"; diff --git a/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/ui/package.json b/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/ui/package.json deleted file mode 100644 index 4d0b1cfeb8..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/ui/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@test/ui", - "version": "1.0.0", - "exports": { - "./*": "./src/*" - } -} diff --git a/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/ui/src/components/button.ts b/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/ui/src/components/button.ts deleted file mode 100644 index 5dfc1b1794..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/ui/src/components/button.ts +++ /dev/null @@ -1 +0,0 @@ -export const Button = () => "button"; diff --git a/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/ui/src/components/index.ts b/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/ui/src/components/index.ts deleted file mode 100644 index 1c9c0f4dfd..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/ui/src/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Button } from "./button"; -export const Card = () => "card"; diff --git a/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/ui/src/orphan.ts b/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/ui/src/orphan.ts deleted file mode 100644 index 0a17c8176c..0000000000 --- a/packages/deslop-js/tests/fixtures/workspace-wildcards/packages/ui/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphanedUtil = () => "never imported"; diff --git a/packages/deslop-js/tests/fixtures/zx-scripts/package.json b/packages/deslop-js/tests/fixtures/zx-scripts/package.json deleted file mode 100644 index cb81eb1b3e..0000000000 --- a/packages/deslop-js/tests/fixtures/zx-scripts/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "zx-script-runner", - "version": "1.0.0", - "scripts": { - "build-image": "zx scripts/build-image.mjs" - } -} diff --git a/packages/deslop-js/tests/fixtures/zx-scripts/scripts/build-image.mjs b/packages/deslop-js/tests/fixtures/zx-scripts/scripts/build-image.mjs deleted file mode 100644 index fb49438661..0000000000 --- a/packages/deslop-js/tests/fixtures/zx-scripts/scripts/build-image.mjs +++ /dev/null @@ -1 +0,0 @@ -console.log("building docker image"); diff --git a/packages/deslop-js/tests/fixtures/zx-scripts/src/index.ts b/packages/deslop-js/tests/fixtures/zx-scripts/src/index.ts deleted file mode 100644 index d80e0d9f29..0000000000 --- a/packages/deslop-js/tests/fixtures/zx-scripts/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const main = (): void => { - console.log("main"); -}; diff --git a/packages/deslop-js/tests/fixtures/zx-scripts/src/orphan.ts b/packages/deslop-js/tests/fixtures/zx-scripts/src/orphan.ts deleted file mode 100644 index af0a341bc7..0000000000 --- a/packages/deslop-js/tests/fixtures/zx-scripts/src/orphan.ts +++ /dev/null @@ -1 +0,0 @@ -export const orphan = "not imported"; diff --git a/packages/deslop-js/tests/helpers/analyze-in-subprocess.ts b/packages/deslop-js/tests/helpers/analyze-in-subprocess.ts deleted file mode 100644 index 42ca39d1a6..0000000000 --- a/packages/deslop-js/tests/helpers/analyze-in-subprocess.ts +++ /dev/null @@ -1,10 +0,0 @@ -// One-shot analyze runner for tests that need cross-PROCESS cache semantics -// (the resolver keeps module-level fs/content caches, so a config-file content -// edit is only observable from a fresh process — exactly how react-doctor's -// dead-code worker runs deslop). Reads a partial DeslopConfig as argv JSON and -// prints the ScanResult as JSON (DeslopError serializes via toJSON). -import { analyze, defineConfig } from "../../src/index.js"; - -const config = JSON.parse(process.argv[2]); -const result = await analyze(defineConfig(config)); -process.stdout.write(JSON.stringify(result)); diff --git a/packages/deslop-js/tests/helpers/fixtures-dir.ts b/packages/deslop-js/tests/helpers/fixtures-dir.ts deleted file mode 100644 index c79fe6e74b..0000000000 --- a/packages/deslop-js/tests/helpers/fixtures-dir.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { cpSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -// Fixtures are scanned from an isolated copy OUTSIDE the repository so -// deslop's monorepo-root walk (findMonorepoRoot) cannot escape upward into -// the enclosing workspace and fold its packages into the scan — which would -// otherwise let an ancestor `react` dependency mask "unused" assertions and -// extra sibling packages collide with structural-alias resolution. Mirrors -// the os.tmpdir() isolation react-doctor's own dead-code tests rely on. -const sourceFixturesDirectory = resolve(import.meta.dirname, "../fixtures"); -const temporaryFixturesRoot = mkdtempSync(join(tmpdir(), "deslop-fixtures-")); -cpSync(sourceFixturesDirectory, temporaryFixturesRoot, { recursive: true }); - -// `git init` makes the copy a standalone repo: `git check-ignore` (used by the -// gitignore fixtures) needs a repository, and a `.git` boundary with no -// monorepo markers above the scanned directory keeps findMonorepoRoot returning -// undefined — the same shape as a real single-repo checkout. -spawnSync("git", ["init", "-q"], { cwd: temporaryFixturesRoot }); - -export const FIXTURES_DIR = realpathSync(temporaryFixturesRoot); - -process.on("exit", () => { - rmSync(temporaryFixturesRoot, { recursive: true, force: true }); -}); diff --git a/packages/deslop-js/tests/package-json-entries.test.ts b/packages/deslop-js/tests/package-json-entries.test.ts deleted file mode 100644 index 9eeb72d503..0000000000 --- a/packages/deslop-js/tests/package-json-entries.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import { dirname, join } from "node:path"; -import { after, describe, it } from "node:test"; -import { extractPackageJsonEntries } from "../src/collect/package-json-entries.js"; - -const temporaryRoot = mkdtempSync(join(os.tmpdir(), "deslop-package-entries-")); - -after(() => { - rmSync(temporaryRoot, { recursive: true, force: true }); -}); - -describe("extractPackageJsonEntries", () => { - it("collects package metadata entry categories in declaration order", async () => { - const projectDirectory = join(temporaryRoot, "package-metadata-categories"); - const relativeEntryPaths = [ - "src/main.ts", - "src/export.tsx", - "src/wildcard.ts", - "src/cli.ts", - "src/side-effect.ts", - "src/build-entry.ts", - "src/jest-setup.ts", - ]; - for (const relativeEntryPath of relativeEntryPaths) { - const absoluteEntryPath = join(projectDirectory, relativeEntryPath); - mkdirSync(dirname(absoluteEntryPath), { recursive: true }); - writeFileSync(absoluteEntryPath, "export const entry = true;\n"); - } - - const packageJsonPath = join(projectDirectory, "package.json"); - writeFileSync( - packageJsonPath, - JSON.stringify({ - main: "src/main.ts", - exports: { - ".": "./src/export.ts", - "./wildcard": "./src/wildcard.*", - }, - bin: { cli: "src/cli", ignored: false }, - sideEffects: ["src/side-effect.js", false], - build: { files: ["src/build-entry", "src/*.ts", false] }, - jest: { setupFilesAfterEnv: ["<rootDir>/src/jest-setup"] }, - }), - ); - - const entries = await extractPackageJsonEntries(packageJsonPath); - - assert.deepEqual( - entries, - relativeEntryPaths.map((relativeEntryPath) => join(projectDirectory, relativeEntryPath)), - ); - }); - - it("does not treat sibling output-directory prefixes as descendants", async () => { - const projectDirectory = join(temporaryRoot, "out-directory-prefix"); - const expectedSourcePath = join(projectDirectory, "src", "index.ts"); - const misleadingSourcePath = join(projectDirectory, "-other", "index.ts"); - mkdirSync(join(projectDirectory, "src"), { recursive: true }); - mkdirSync(join(projectDirectory, "-other"), { recursive: true }); - writeFileSync(expectedSourcePath, "export const expected = true;\n"); - writeFileSync(misleadingSourcePath, "export const misleading = true;\n"); - writeFileSync( - join(projectDirectory, "tsconfig.json"), - JSON.stringify({ compilerOptions: { outDir: "dist", rootDir: "." } }), - ); - const packageJsonPath = join(projectDirectory, "package.json"); - writeFileSync(packageJsonPath, JSON.stringify({ main: "dist-other/index.js" })); - - const entries = await extractPackageJsonEntries(packageJsonPath); - - assert.ok(entries.includes(expectedSourcePath)); - assert.ok(!entries.includes(misleadingSourcePath)); - }); - - it("prefers the configured source root over common source-directory fallbacks", async () => { - const projectDirectory = join(temporaryRoot, "configured-source-root"); - const configuredSourcePath = join(projectDirectory, "source", "index.ts"); - const heuristicSourcePath = join(projectDirectory, "src", "index.ts"); - mkdirSync(dirname(configuredSourcePath), { recursive: true }); - mkdirSync(dirname(heuristicSourcePath), { recursive: true }); - writeFileSync(configuredSourcePath, "export const configured = true;\n"); - writeFileSync(heuristicSourcePath, "export const heuristic = true;\n"); - writeFileSync( - join(projectDirectory, "tsconfig.json"), - JSON.stringify({ compilerOptions: { outDir: "dist", rootDir: "source" } }), - ); - const packageJsonPath = join(projectDirectory, "package.json"); - writeFileSync(packageJsonPath, JSON.stringify({ main: "dist/index.js" })); - - const entries = await extractPackageJsonEntries(packageJsonPath); - - assert.ok(entries.includes(configuredSourcePath)); - assert.ok(!entries.includes(heuristicSourcePath)); - }); -}); diff --git a/packages/deslop-js/tests/path-normalization.test.ts b/packages/deslop-js/tests/path-normalization.test.ts deleted file mode 100644 index 43306d357c..0000000000 --- a/packages/deslop-js/tests/path-normalization.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { toPosixPath } from "../src/utils/to-posix-path.js"; -import { buildDependencyGraph, type ModuleLinkInput } from "../src/linker/build.js"; -import { traceReachability } from "../src/linker/reachability.js"; -import { resolveReExportChains } from "../src/linker/re-exports.js"; -import { detectDeadExports } from "../src/report/exports.js"; -import type { ParsedSource } from "../src/collect/parse.js"; -import type { ResolvedImport } from "../src/resolver/resolve.js"; -import type { DeslopConfig, ExportReference, ImportReference } from "../src/types.js"; - -const emptyParsed = (overrides: Partial<ParsedSource> = {}): ParsedSource => ({ - imports: [], - exports: [], - memberAccesses: [], - wholeObjectUses: [], - localIdentifierReferences: [], - referencedFilenames: [], - redundantTypePatterns: [], - identityWrappers: [], - typeDefinitionHashes: [], - inlineTypeLiterals: [], - simplifiableFunctions: [], - simplifiableExpressions: [], - duplicateConstantCandidates: [], - errors: [], - ...overrides, -}); - -const namedImport = (specifier: string, importedName: string): ImportReference => ({ - specifier, - importedNames: [ - { - name: importedName, - alias: undefined, - isNamespace: false, - isDefault: false, - isTypeOnly: false, - }, - ], - isTypeOnly: false, - isDynamic: false, - isSideEffect: false, - isGlob: false, - line: 1, - column: 1, -}); - -const namedExport = (name: string, overrides: Partial<ExportReference> = {}): ExportReference => ({ - name, - isDefault: false, - isTypeOnly: false, - isReExport: false, - isSynthetic: false, - reExportSource: undefined, - reExportOriginalName: undefined, - isNamespaceReExport: false, - line: 1, - column: 1, - ...overrides, -}); - -const deadExportConfig: DeslopConfig = { - rootDir: "C:/project", - entryPatterns: [], - ignorePatterns: [], - includeExtensions: [], - tsConfigPath: undefined, - reportTypes: false, - includeEntryExports: false, - reportRedundancy: true, - semantic: undefined, - duplicateBlocks: undefined, - featureFlags: undefined, - complexity: undefined, -}; - -describe("toPosixPath", () => { - it("converts windows separators to forward slashes", () => { - assert.equal(toPosixPath("C:\\project\\src\\App.tsx"), "C:/project/src/App.tsx"); - }); - - it("leaves posix paths untouched", () => { - assert.equal(toPosixPath("/project/src/App.tsx"), "/project/src/App.tsx"); - }); - - it("normalizes mixed separators", () => { - assert.equal(toPosixPath("C:/project\\src/App.tsx"), "C:/project/src/App.tsx"); - }); -}); - -describe("buildDependencyGraph cross-platform path keying", () => { - it("links imports when the resolver returns backslash paths", () => { - const entry: ModuleLinkInput = { - fileId: { index: 0, path: "C:/project/src/index.ts" }, - parsed: emptyParsed({ imports: [namedImport("./app", "App")] }), - resolvedImports: new Map<string, ResolvedImport>([ - [ - "./app", - { resolvedPath: "C:\\project\\src\\app.ts", isExternal: false, packageName: undefined }, - ], - ]), - isEntryPoint: true, - isTestEntry: false, - isGitIgnored: false, - }; - const target: ModuleLinkInput = { - fileId: { index: 1, path: "C:/project/src/app.ts" }, - parsed: emptyParsed(), - resolvedImports: new Map<string, ResolvedImport>(), - isEntryPoint: false, - isTestEntry: false, - isGitIgnored: false, - }; - - const graph = buildDependencyGraph([entry, target]); - - assert.ok( - graph.edges.some((edge) => edge.source === 0 && edge.target === 1), - "expected an import edge despite the backslash resolved path", - ); - - traceReachability(graph); - assert.equal( - graph.modules[1].isReachable, - true, - "app.ts must be reachable from the entry point and not reported as an unused file", - ); - }); - - it("propagates exports only from wildcard targets", () => { - const wildcardTargetNames = ["alpha", "beta", "gamma"]; - const namedTargetName = "delta"; - const barrel: ModuleLinkInput = { - fileId: { index: 0, path: "C:/project/src/index.ts" }, - parsed: emptyParsed({ - exports: [ - ...wildcardTargetNames.map((targetName) => - namedExport("*", { - isReExport: true, - reExportSource: `./${targetName}`, - reExportOriginalName: "*", - isNamespaceReExport: true, - }), - ), - namedExport(namedTargetName, { - isReExport: true, - reExportSource: `./${namedTargetName}`, - reExportOriginalName: namedTargetName, - }), - ], - }), - resolvedImports: new Map( - [...wildcardTargetNames, namedTargetName].map((targetName) => [ - `./${targetName}`, - { - resolvedPath: `C:/project/src/${targetName}.ts`, - isExternal: false, - packageName: undefined, - }, - ]), - ), - isEntryPoint: true, - isTestEntry: false, - isGitIgnored: false, - }; - const wildcardTargets: ModuleLinkInput[] = wildcardTargetNames.map( - (targetName, targetIndex) => ({ - fileId: { index: targetIndex + 1, path: `C:/project/src/${targetName}.ts` }, - parsed: emptyParsed({ exports: [namedExport(targetName)] }), - resolvedImports: new Map(), - isEntryPoint: false, - isTestEntry: false, - isGitIgnored: false, - }), - ); - const namedTarget: ModuleLinkInput = { - fileId: { - index: wildcardTargets.length + 1, - path: `C:/project/src/${namedTargetName}.ts`, - }, - parsed: emptyParsed({ - exports: [namedExport(namedTargetName), namedExport("namedOnly")], - }), - resolvedImports: new Map(), - isEntryPoint: false, - isTestEntry: false, - isGitIgnored: false, - }; - const graph = buildDependencyGraph([barrel, ...wildcardTargets, namedTarget]); - - resolveReExportChains(graph); - - assert.deepEqual( - graph.modules[0].exports - .filter((exportInfo) => !exportInfo.isNamespaceReExport) - .map((exportInfo) => exportInfo.name) - .sort(), - [...wildcardTargetNames, namedTargetName].sort(), - ); - }); - - it("keeps module paths normalized for re-export chain lookup", () => { - const entry: ModuleLinkInput = { - fileId: { index: 0, path: "C:\\project\\src\\index.ts" }, - parsed: emptyParsed({ - exports: [ - namedExport("foo", { - isReExport: true, - reExportSource: "./barrel", - reExportOriginalName: "foo", - }), - ], - }), - resolvedImports: new Map<string, ResolvedImport>([ - [ - "./barrel", - { - resolvedPath: "C:\\project\\src\\barrel.ts", - isExternal: false, - packageName: undefined, - }, - ], - ]), - isEntryPoint: true, - isTestEntry: false, - isGitIgnored: false, - }; - const barrel: ModuleLinkInput = { - fileId: { index: 1, path: "C:\\project\\src\\barrel.ts" }, - parsed: emptyParsed({ - exports: [ - namedExport("foo", { - isReExport: true, - reExportSource: "./foo", - reExportOriginalName: "foo", - }), - ], - }), - resolvedImports: new Map<string, ResolvedImport>([ - [ - "./foo", - { resolvedPath: "C:\\project\\src\\foo.ts", isExternal: false, packageName: undefined }, - ], - ]), - isEntryPoint: false, - isTestEntry: false, - isGitIgnored: false, - }; - const target: ModuleLinkInput = { - fileId: { index: 2, path: "C:\\project\\src\\foo.ts" }, - parsed: emptyParsed({ exports: [namedExport("foo")] }), - resolvedImports: new Map<string, ResolvedImport>(), - isEntryPoint: false, - isTestEntry: false, - isGitIgnored: false, - }; - - const graph = buildDependencyGraph([entry, barrel, target]); - - assert.equal(graph.modules[0].fileId.path, "C:/project/src/index.ts"); - assert.equal(graph.modules[1].fileId.path, "C:/project/src/barrel.ts"); - assert.equal(graph.modules[2].fileId.path, "C:/project/src/foo.ts"); - - traceReachability(graph); - const unusedExports = detectDeadExports(graph, deadExportConfig); - - assert.deepEqual( - unusedExports.map((unusedExport) => unusedExport.path), - [], - "re-export chains must resolve through normalized graph file paths", - ); - }); -}); diff --git a/packages/deslop-js/tests/semantic.test.ts b/packages/deslop-js/tests/semantic.test.ts deleted file mode 100644 index 6557d22c8f..0000000000 --- a/packages/deslop-js/tests/semantic.test.ts +++ /dev/null @@ -1,1282 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { resolve } from "node:path"; -import { analyze, defineConfig } from "../src/index.js"; -import type { ScanResult, SemanticConfig } from "../src/types.js"; -import { FIXTURES_DIR } from "./helpers/fixtures-dir.js"; - -const scanFixtureWithSemantic = async ( - fixtureName: string, - semanticOverrides: Partial<SemanticConfig> = {}, - extraConfigOverrides: Record<string, unknown> = {}, -): Promise<ScanResult> => { - return analyze( - defineConfig({ - rootDir: resolve(FIXTURES_DIR, fixtureName), - semantic: { enabled: true, ...semanticOverrides }, - ...extraConfigOverrides, - }), - ); -}; - -const unusedTypeNames = (result: ScanResult): string[] => - result.unusedTypes.map((unusedType) => unusedType.name).sort(); - -describe("semantic (Phase 0)", () => { - it("populates unusedTypes as [] by default (semantic disabled)", async () => { - const result = await analyze(defineConfig({ rootDir: resolve(FIXTURES_DIR, "simple-app") })); - assert.deepEqual(result.unusedTypes, []); - }); - - it("does not crash when semantic.enabled is true on a project without tsconfig", async () => { - const result = await analyze( - defineConfig({ - rootDir: resolve(FIXTURES_DIR, "simple-app"), - semantic: { enabled: true }, - }), - ); - assert.ok(Array.isArray(result.unusedTypes), "unusedTypes must be an array"); - assert.equal(result.unusedTypes.length, 0, "Phase 0 returns no findings yet"); - }); - - it("preserves all pre-existing ScanResult fields when semantic is enabled", async () => { - const result = await analyze( - defineConfig({ - rootDir: resolve(FIXTURES_DIR, "simple-app"), - semantic: { enabled: true }, - }), - ); - assert.ok(Array.isArray(result.unusedFiles)); - assert.ok(Array.isArray(result.unusedExports)); - assert.ok(Array.isArray(result.unusedDependencies)); - assert.ok(Array.isArray(result.circularDependencies)); - assert.equal(typeof result.totalFiles, "number"); - assert.equal(typeof result.totalExports, "number"); - assert.equal(typeof result.analysisTimeMs, "number"); - }); - - it("defaults semantic.enabled to true when no override is passed", async () => { - const config = defineConfig({ rootDir: resolve(FIXTURES_DIR, "simple-app") }); - assert.ok(config.semantic, "semantic should be populated by default"); - assert.equal(config.semantic.enabled, true); - }); - - it("fills semantic defaults when {} passed", async () => { - const config = defineConfig({ - rootDir: resolve(FIXTURES_DIR, "simple-app"), - semantic: {}, - }); - assert.ok(config.semantic, "semantic should be set"); - assert.equal(config.semantic.enabled, true); - assert.equal(config.semantic.reportUnusedTypes, true); - assert.equal(config.semantic.reportUnusedEnumMembers, true); - assert.equal(config.semantic.reportMisclassifiedDependencies, true); - assert.equal(config.semantic.reportRedundantVariableAliases, true); - assert.equal(config.semantic.reportRoundTripAliases, true); - assert.equal(config.semantic.reportUnusedClassMembers, false); - assert.ok(Array.isArray(config.semantic.decoratorAllowlist)); - assert.ok(config.semantic.decoratorAllowlist.length > 0); - }); -}); - -describe("semantic / unused-types: P0 basic", () => { - it("flags interface and type-alias with no references", async () => { - const result = await scanFixtureWithSemantic("unused-types-basic"); - const found = unusedTypeNames(result); - assert.deepEqual(found, ["UnusedAlias", "UnusedType"]); - }); - - it("does NOT flag types that have at least one referencing import", async () => { - const result = await scanFixtureWithSemantic("unused-types-basic"); - const found = unusedTypeNames(result); - assert.ok(!found.includes("UsedType")); - assert.ok(!found.includes("UsedAlias")); - }); - - it("classifies kinds correctly (interface vs type-alias)", async () => { - const result = await scanFixtureWithSemantic("unused-types-basic"); - const byName = new Map(result.unusedTypes.map((unusedType) => [unusedType.name, unusedType])); - assert.equal(byName.get("UnusedType")?.kind, "interface"); - assert.equal(byName.get("UnusedAlias")?.kind, "type-alias"); - }); - - it("populates trace with declaration site + reference counts", async () => { - const result = await scanFixtureWithSemantic("unused-types-basic"); - const target = result.unusedTypes.find((unusedType) => unusedType.name === "UnusedType"); - assert.ok(target); - assert.ok(target.trace.length > 0, "trace should be populated"); - assert.ok( - target.trace[0].includes("UnusedType"), - `first trace entry should mention the type, got: ${target.trace[0]}`, - ); - }); -}); - -describe("semantic / unused-types: nested references should NOT flag inner types", () => { - it("does NOT flag Inner referenced only inside Outer's body", async () => { - const result = await scanFixtureWithSemantic("unused-types-nested"); - const found = unusedTypeNames(result); - assert.ok(!found.includes("Inner"), `Inner is used inside Outer, got: ${found}`); - assert.ok(!found.includes("Outer"), `Outer is imported by entry, got: ${found}`); - }); - - it("still flags truly-unused types in the same module", async () => { - const result = await scanFixtureWithSemantic("unused-types-nested"); - assert.ok(unusedTypeNames(result).includes("DeadDeep")); - }); -}); - -describe("semantic / unused-types: heritage clauses", () => { - it("does NOT flag Parent when only Child is referenced (extends keeps Parent alive)", async () => { - const result = await scanFixtureWithSemantic("unused-types-extends"); - const found = unusedTypeNames(result); - assert.ok(!found.includes("Parent"), `Parent is extended by Child, got: ${found}`); - assert.ok(!found.includes("Child")); - }); - - it("flags OrphanInterface with no references", async () => { - const result = await scanFixtureWithSemantic("unused-types-extends"); - assert.ok(unusedTypeNames(result).includes("OrphanInterface")); - }); -}); - -describe("semantic / unused-types: re-export chains", () => { - it("does NOT flag types reachable through 3-hop re-export chain", async () => { - const result = await scanFixtureWithSemantic("unused-types-reexport-chain"); - const found = unusedTypeNames(result); - assert.ok(!found.includes("TripleHopUsed"), `TripleHopUsed reaches entry, got: ${found}`); - }); - - it("flags TripleHopDead which has zero non-re-export references", async () => { - const result = await scanFixtureWithSemantic("unused-types-reexport-chain"); - assert.ok(unusedTypeNames(result).includes("TripleHopDead")); - }); - - it("marks confidence as medium when only re-export references exist", async () => { - const result = await scanFixtureWithSemantic("unused-types-reexport-chain"); - const target = result.unusedTypes.find((unusedType) => unusedType.name === "TripleHopDead"); - assert.equal(target?.confidence, "medium"); - }); -}); - -describe("semantic / unused-types: declaration merging", () => { - it("does NOT flag any branch of a merged interface when the merged symbol is referenced", async () => { - const result = await scanFixtureWithSemantic("unused-types-decl-merge"); - const found = unusedTypeNames(result); - assert.ok( - !found.includes("MergedConfig"), - `MergedConfig branches must not flag, got: ${found}`, - ); - }); - - it("flags non-merged dead types alongside merged-and-used types", async () => { - const result = await scanFixtureWithSemantic("unused-types-decl-merge"); - assert.ok(unusedTypeNames(result).includes("SoloDead")); - }); -}); - -describe("semantic / unused-types: generics", () => { - it("does NOT flag a type used only as a generic constraint", async () => { - const result = await scanFixtureWithSemantic("unused-types-generics"); - const found = unusedTypeNames(result); - assert.ok(!found.includes("Identifiable"), `Identifiable is a constraint, got: ${found}`); - assert.ok(!found.includes("Box")); - }); - - it("flags DeadBox with no references at all", async () => { - const result = await scanFixtureWithSemantic("unused-types-generics"); - assert.ok(unusedTypeNames(result).includes("DeadBox")); - }); -}); - -describe("semantic / unused-types: import type", () => { - it("does NOT flag type referenced via import type", async () => { - const result = await scanFixtureWithSemantic("unused-types-import-type"); - const found = unusedTypeNames(result); - assert.ok(!found.includes("ReturnedShape")); - }); - - it("flags NeverImported truly dead type-alias", async () => { - const result = await scanFixtureWithSemantic("unused-types-import-type"); - assert.ok(unusedTypeNames(result).includes("NeverImported")); - }); -}); - -describe("semantic / unused-types: JSDoc references", () => { - it("does NOT flag a type referenced only from JSDoc @param annotations", async () => { - const result = await scanFixtureWithSemantic("unused-types-jsdoc"); - const found = unusedTypeNames(result); - assert.ok( - !found.includes("JsDocConsumed"), - `JsDocConsumed is used via JSDoc import("./types.js"), got: ${found}`, - ); - }); - - it("does NOT flag a type imported via regular TS import alongside JSDoc usage", async () => { - const result = await scanFixtureWithSemantic("unused-types-jsdoc"); - assert.ok(!unusedTypeNames(result).includes("RegularImported")); - }); - - it("flags NeverReferenced as unused inside a JSDoc-aware project", async () => { - const result = await scanFixtureWithSemantic("unused-types-jsdoc"); - assert.ok(unusedTypeNames(result).includes("NeverReferenced")); - }); -}); - -describe("semantic / unused-types: entry export gating", () => { - it("respects includeEntryExports=false: never flags top-level entry exports", async () => { - const result = await scanFixtureWithSemantic("unused-types-entry-export"); - assert.deepEqual(unusedTypeNames(result), []); - }); - - it("includeEntryExports=true flags dead types declared in the entry file", async () => { - const result = await scanFixtureWithSemantic( - "unused-types-entry-export", - {}, - { includeEntryExports: true }, - ); - const found = unusedTypeNames(result); - assert.ok(found.includes("DeadEntryType")); - assert.ok(!found.includes("PublicApiShape"), "PublicApiShape used by callApi"); - }); - - it("respects reportUnusedTypes=false: skips type detection entirely", async () => { - const result = await scanFixtureWithSemantic("unused-types-basic", { - reportUnusedTypes: false, - }); - assert.deepEqual(result.unusedTypes, []); - }); -}); - -const misclassifiedNames = (result: ScanResult): string[] => - result.misclassifiedDependencies.map((finding) => finding.name).sort(); - -describe("semantic / misclassified-dependencies", () => { - it("populates the additive misclassifiedDependencies field as [] when semantic is disabled", async () => { - const result = await analyze( - defineConfig({ - rootDir: resolve(FIXTURES_DIR, "misclassified-deps-typeonly"), - semantic: { enabled: false }, - }), - ); - assert.deepEqual(result.misclassifiedDependencies, []); - }); - - it("flags dependencies that are only consumed via `import type`", async () => { - const result = await scanFixtureWithSemantic("misclassified-deps-typeonly", { - reportUnusedTypes: false, - }); - const names = misclassifiedNames(result); - assert.ok(names.includes("type-only-lib"), `type-only-lib should be flagged, got: ${names}`); - }); - - it("flags dependencies that are only consumed via `export type ... from`", async () => { - const result = await scanFixtureWithSemantic("misclassified-deps-typeonly", { - reportUnusedTypes: false, - }); - assert.ok(misclassifiedNames(result).includes("reexported-type-lib")); - }); - - it("does NOT flag dependencies imported with value bindings", async () => { - const result = await scanFixtureWithSemantic("misclassified-deps-typeonly", { - reportUnusedTypes: false, - }); - const names = misclassifiedNames(result); - assert.ok(!names.includes("value-used-lib"), `value-used-lib used at runtime, got: ${names}`); - }); - - it("does NOT flag side-effect imports (always runtime)", async () => { - const result = await scanFixtureWithSemantic("misclassified-deps-typeonly", { - reportUnusedTypes: false, - }); - assert.ok(!misclassifiedNames(result).includes("side-effect-lib")); - }); - - it("does NOT flag mixed-use packages (any value import wins)", async () => { - const result = await scanFixtureWithSemantic("misclassified-deps-typeonly", { - reportUnusedTypes: false, - }); - assert.ok(!misclassifiedNames(result).includes("mixed-use-lib")); - }); - - it("does NOT flag value re-exports `export { x } from`", async () => { - const result = await scanFixtureWithSemantic("misclassified-deps-typeonly", { - reportUnusedTypes: false, - }); - assert.ok(!misclassifiedNames(result).includes("reexported-value-lib")); - }); - - it("includes a trace with at least one import site path", async () => { - const result = await scanFixtureWithSemantic("misclassified-deps-typeonly", { - reportUnusedTypes: false, - }); - const finding = result.misclassifiedDependencies.find( - (entry) => entry.name === "type-only-lib", - ); - assert.ok(finding); - assert.ok(finding.trace.length > 0); - assert.ok( - finding.trace[0].includes("src/index.ts"), - `expected trace to mention src/index.ts, got: ${finding.trace[0]}`, - ); - }); - - it("marks suggestedAs as devDependencies for all current findings", async () => { - const result = await scanFixtureWithSemantic("misclassified-deps-typeonly", { - reportUnusedTypes: false, - }); - for (const finding of result.misclassifiedDependencies) { - assert.equal(finding.suggestedAs, "devDependencies"); - } - }); - - it("respects reportMisclassifiedDependencies=false", async () => { - const result = await scanFixtureWithSemantic("misclassified-deps-typeonly", { - reportUnusedTypes: false, - reportMisclassifiedDependencies: false, - }); - assert.deepEqual(result.misclassifiedDependencies, []); - }); -}); - -const enumMemberLabels = (result: ScanResult): string[] => - result.unusedEnumMembers.map((finding) => `${finding.enumName}.${finding.memberName}`).sort(); - -describe("semantic / unused-enum-members: string enum", () => { - it("flags unreferenced members with high confidence", async () => { - const result = await scanFixtureWithSemantic("unused-enum-members-string", { - reportUnusedTypes: false, - reportMisclassifiedDependencies: false, - }); - const labels = enumMemberLabels(result); - assert.deepEqual(labels, ["Status.Archived", "Status.Deprecated"]); - for (const finding of result.unusedEnumMembers) { - assert.equal(finding.confidence, "high"); - } - }); - - it("does NOT flag members that are referenced via dot access", async () => { - const result = await scanFixtureWithSemantic("unused-enum-members-string", { - reportUnusedTypes: false, - reportMisclassifiedDependencies: false, - }); - const labels = enumMemberLabels(result); - assert.ok(!labels.includes("Status.Active")); - assert.ok(!labels.includes("Status.Pending")); - }); -}); - -describe("semantic / unused-enum-members: numeric enum", () => { - it("flags unreferenced numeric members with medium confidence", async () => { - const result = await scanFixtureWithSemantic("unused-enum-members-numeric", { - reportUnusedTypes: false, - reportMisclassifiedDependencies: false, - }); - const labels = enumMemberLabels(result); - assert.deepEqual(labels, ["Level.High", "Level.Low", "Level.Medium"]); - for (const finding of result.unusedEnumMembers) { - assert.equal(finding.confidence, "medium"); - } - }); -}); - -describe("semantic / unused-enum-members: reverse-lookup pattern", () => { - it("does NOT flag any member when Enum[X] computed access exists", async () => { - const result = await scanFixtureWithSemantic("unused-enum-members-reverse-lookup", { - reportUnusedTypes: false, - reportMisclassifiedDependencies: false, - }); - assert.deepEqual(result.unusedEnumMembers, []); - }); -}); - -describe("semantic / unused-enum-members: const enum", () => { - it("flags unreferenced const-enum members with low confidence (inlining caveat)", async () => { - const result = await scanFixtureWithSemantic("unused-enum-members-const", { - reportUnusedTypes: false, - reportMisclassifiedDependencies: false, - }); - const labels = enumMemberLabels(result); - assert.deepEqual(labels, ["Flags.Execute", "Flags.None"]); - for (const finding of result.unusedEnumMembers) { - assert.equal(finding.confidence, "low"); - } - }); -}); - -describe("semantic / unused-enum-members: feature flag", () => { - it("respects reportUnusedEnumMembers=false", async () => { - const result = await scanFixtureWithSemantic("unused-enum-members-string", { - reportUnusedTypes: false, - reportUnusedEnumMembers: false, - reportMisclassifiedDependencies: false, - }); - assert.deepEqual(result.unusedEnumMembers, []); - }); - - it("populates the additive unusedEnumMembers field as [] when semantic is disabled", async () => { - const result = await analyze( - defineConfig({ - rootDir: resolve(FIXTURES_DIR, "unused-enum-members-string"), - semantic: { enabled: false }, - }), - ); - assert.deepEqual(result.unusedEnumMembers, []); - }); -}); - -const scanFixtureSyntactic = async (fixtureName: string): Promise<ScanResult> => - analyze(defineConfig({ rootDir: resolve(FIXTURES_DIR, fixtureName) })); - -const redundantAliasKinds = (result: ScanResult): Array<{ kind: string; name: string }> => - result.redundantAliases - .map((finding) => ({ kind: finding.kind, name: finding.name })) - .sort((leftEntry, rightEntry) => - `${leftEntry.kind}/${leftEntry.name}`.localeCompare(`${rightEntry.kind}/${rightEntry.name}`), - ); - -describe("redundancy / self-aliases (syntactic, default-on)", () => { - it("flags import { x as x }", async () => { - const result = await scanFixtureSyntactic("redundant-aliases-self"); - const found = redundantAliasKinds(result); - assert.ok( - found.some((entry) => entry.kind === "import-self-alias" && entry.name === "usedThing"), - `expected import-self-alias for usedThing, got: ${JSON.stringify(found)}`, - ); - }); - - it("flags export { x as x }", async () => { - const result = await scanFixtureSyntactic("redundant-aliases-self"); - const found = redundantAliasKinds(result); - assert.ok( - found.some((entry) => entry.kind === "export-self-alias" && entry.name === "reusedLocal"), - ); - }); - - it("flags export { x as x } from ...", async () => { - const result = await scanFixtureSyntactic("redundant-aliases-self"); - const found = redundantAliasKinds(result); - assert.ok( - found.some( - (entry) => entry.kind === "reexport-self-alias" && entry.name === "reExportedThrough", - ), - ); - }); - - it("does NOT flag legitimate renaming aliases", async () => { - const result = await scanFixtureSyntactic("redundant-aliases-self"); - const found = redundantAliasKinds(result); - assert.ok( - !found.some((entry) => entry.name === "betterName"), - `betterName is a real rename, must not flag, got: ${JSON.stringify(found)}`, - ); - assert.ok( - !found.some((entry) => entry.name === "renamedUsedThing"), - `renamedUsedThing is a real rename, must not flag, got: ${JSON.stringify(found)}`, - ); - }); - - it("respects reportRedundancy=false", async () => { - const result = await analyze( - defineConfig({ - rootDir: resolve(FIXTURES_DIR, "redundant-aliases-self"), - reportRedundancy: false, - }), - ); - assert.deepEqual(result.redundantAliases, []); - assert.deepEqual(result.duplicateExports, []); - }); -}); - -describe("redundancy / variable aliases (semantic)", () => { - it("flags const x = y when y has no other consumer", async () => { - const result = await scanFixtureWithSemantic("redundant-aliases-variable", { - reportUnusedTypes: false, - reportUnusedEnumMembers: false, - reportMisclassifiedDependencies: false, - }); - const variableAliases = result.redundantAliases.filter( - (entry) => entry.kind === "variable-alias", - ); - const names = variableAliases.map((entry) => entry.name).sort(); - assert.ok( - names.includes("renamedOnce"), - `renamedOnce should be flagged (only consumer of ARRIVED_AT_VALUE), got: ${names}`, - ); - }); - - it("does NOT flag a variable alias when the source has other consumers", async () => { - const result = await scanFixtureWithSemantic("redundant-aliases-variable", { - reportUnusedTypes: false, - reportUnusedEnumMembers: false, - reportMisclassifiedDependencies: false, - }); - const variableAliases = result.redundantAliases.filter( - (entry) => entry.kind === "variable-alias", - ); - const names = variableAliases.map((entry) => entry.name).sort(); - assert.ok( - !names.includes("sharedAlias"), - `sharedAlias' source SHARED_VALUE is also consumed directly — must not flag, got: ${names}`, - ); - }); - - it("respects reportRedundantVariableAliases=false", async () => { - const result = await scanFixtureWithSemantic("redundant-aliases-variable", { - reportUnusedTypes: false, - reportUnusedEnumMembers: false, - reportMisclassifiedDependencies: false, - reportRedundantVariableAliases: false, - }); - const variableAliases = result.redundantAliases.filter( - (entry) => entry.kind === "variable-alias", - ); - assert.deepEqual(variableAliases, []); - }); -}); - -describe("redundancy / duplicate exports", () => { - it("flags barrels that export the same name from multiple sources", async () => { - const result = await scanFixtureSyntactic("duplicate-exports-barrel"); - const names = result.duplicateExports.map((entry) => entry.name).sort(); - assert.ok(names.includes("shared"), `shared exported twice from barrel.ts, got: ${names}`); - }); - - it("does NOT flag uniquely-named re-exports", async () => { - const result = await scanFixtureSyntactic("duplicate-exports-barrel"); - const names = result.duplicateExports.map((entry) => entry.name).sort(); - assert.ok(!names.includes("aOnly")); - assert.ok(!names.includes("bOnly")); - }); - - it("records each occurrence with line + reExportSource", async () => { - const result = await scanFixtureSyntactic("duplicate-exports-barrel"); - const sharedFinding = result.duplicateExports.find((entry) => entry.name === "shared"); - assert.ok(sharedFinding); - assert.equal(sharedFinding.occurrences.length, 2); - for (const occurrence of sharedFinding.occurrences) { - assert.ok(occurrence.isReExport); - assert.ok(occurrence.reExportSource); - } - }); -}); - -const classMemberLabels = (result: ScanResult): string[] => - result.unusedClassMembers.map((finding) => `${finding.className}.${finding.memberName}`).sort(); - -describe("semantic / unused-class-members: basic", () => { - it("flags methods and properties with no external references", async () => { - const result = await scanFixtureWithSemantic("unused-class-members-basic", { - reportUnusedTypes: false, - reportUnusedEnumMembers: false, - reportMisclassifiedDependencies: false, - reportRedundantVariableAliases: false, - reportUnusedClassMembers: true, - }); - const labels = classMemberLabels(result); - assert.ok(labels.includes("InternalCalculator.deadMethod")); - assert.ok(labels.includes("InternalCalculator.deadProperty")); - }); - - it("does NOT flag referenced members", async () => { - const result = await scanFixtureWithSemantic("unused-class-members-basic", { - reportUnusedTypes: false, - reportUnusedEnumMembers: false, - reportMisclassifiedDependencies: false, - reportRedundantVariableAliases: false, - reportUnusedClassMembers: true, - }); - const labels = classMemberLabels(result); - assert.ok(!labels.includes("InternalCalculator.sum"), `sum is used, got: ${labels}`); - assert.ok( - !labels.includes("InternalCalculator.usedProperty"), - `usedProperty is used, got: ${labels}`, - ); - }); - - it("does NOT flag private members", async () => { - const result = await scanFixtureWithSemantic("unused-class-members-basic", { - reportUnusedTypes: false, - reportUnusedEnumMembers: false, - reportMisclassifiedDependencies: false, - reportRedundantVariableAliases: false, - reportUnusedClassMembers: true, - }); - const labels = classMemberLabels(result); - assert.ok( - !labels.includes("InternalCalculator.internalHelper"), - `private members are ESLint territory, got: ${labels}`, - ); - }); -}); - -describe("semantic / unused-class-members: inheritance", () => { - it("does NOT flag a parent method when a subclass overrides it (polymorphic call possible)", async () => { - const result = await scanFixtureWithSemantic("unused-class-members-inherited", { - reportUnusedTypes: false, - reportUnusedEnumMembers: false, - reportMisclassifiedDependencies: false, - reportRedundantVariableAliases: false, - reportUnusedClassMembers: true, - }); - const labels = classMemberLabels(result); - assert.ok( - !labels.includes("Animal.speak"), - `Animal.speak is overridden by Dog, got: ${labels}`, - ); - }); - - it("flags a parent method that no subclass overrides and is never called", async () => { - const result = await scanFixtureWithSemantic("unused-class-members-inherited", { - reportUnusedTypes: false, - reportUnusedEnumMembers: false, - reportMisclassifiedDependencies: false, - reportRedundantVariableAliases: false, - reportUnusedClassMembers: true, - }); - const labels = classMemberLabels(result); - assert.ok(labels.includes("Animal.sleep")); - }); - - it("does NOT flag a parent method that is called on a subclass instance", async () => { - const result = await scanFixtureWithSemantic("unused-class-members-inherited", { - reportUnusedTypes: false, - reportUnusedEnumMembers: false, - reportMisclassifiedDependencies: false, - reportRedundantVariableAliases: false, - reportUnusedClassMembers: true, - }); - const labels = classMemberLabels(result); - assert.ok(!labels.includes("Animal.eat"), `Animal.eat called via buddy.eat(), got: ${labels}`); - }); -}); - -describe("semantic / unused-class-members: decorators", () => { - it("does NOT flag methods carrying a decorator in the allowlist (e.g. @Get)", async () => { - const result = await scanFixtureWithSemantic("unused-class-members-decorated", { - reportUnusedTypes: false, - reportUnusedEnumMembers: false, - reportMisclassifiedDependencies: false, - reportRedundantVariableAliases: false, - reportUnusedClassMembers: true, - }); - const labels = classMemberLabels(result); - assert.ok(!labels.includes("UserController.listUsers")); - assert.ok(!labels.includes("UserController.currentUser")); - }); - - it("flags methods decorated with non-allowlisted decorators", async () => { - const result = await scanFixtureWithSemantic("unused-class-members-decorated", { - reportUnusedTypes: false, - reportUnusedEnumMembers: false, - reportMisclassifiedDependencies: false, - reportRedundantVariableAliases: false, - reportUnusedClassMembers: true, - }); - const labels = classMemberLabels(result); - assert.ok(labels.includes("UserController.deadInternal")); - assert.ok(labels.includes("UserController.deadPlainMethod")); - }); - - it("respects custom decoratorAllowlist", async () => { - const result = await scanFixtureWithSemantic("unused-class-members-decorated", { - reportUnusedTypes: false, - reportUnusedEnumMembers: false, - reportMisclassifiedDependencies: false, - reportRedundantVariableAliases: false, - reportUnusedClassMembers: true, - decoratorAllowlist: ["Get", "Internal"], - }); - const labels = classMemberLabels(result); - assert.ok( - !labels.includes("UserController.deadInternal"), - `Internal now allowlisted, got: ${labels}`, - ); - assert.ok(labels.includes("UserController.deadPlainMethod")); - }); -}); - -describe("semantic / unused-class-members: feature flag default", () => { - it("is off by default (P1 stability)", async () => { - const result = await scanFixtureWithSemantic("unused-class-members-basic", { - reportUnusedTypes: false, - reportUnusedEnumMembers: false, - reportMisclassifiedDependencies: false, - reportRedundantVariableAliases: false, - }); - assert.deepEqual(result.unusedClassMembers, []); - }); -}); - -describe("redundancy / DRY patterns (syntactic)", () => { - it("flags duplicate imports from the same module", async () => { - const result = await scanFixtureSyntactic("dry-patterns-syntactic"); - const finding = result.duplicateImports.find((entry) => entry.specifier === "./helpers.js"); - assert.ok( - finding, - `expected ./helpers.js duplicate import, got: ${JSON.stringify(result.duplicateImports)}`, - ); - assert.equal(finding.occurrences.length, 3); - }); - - it("does NOT flag a module imported only once", async () => { - const result = await scanFixtureSyntactic("dry-patterns-syntactic"); - assert.ok(!result.duplicateImports.some((entry) => entry.specifier === "./other.js")); - }); - - it("flags every redundant type utility pattern", async () => { - const result = await scanFixtureSyntactic("dry-patterns-syntactic"); - const patternsByKind = new Map<string, string[]>(); - for (const finding of result.redundantTypePatterns) { - const list = patternsByKind.get(finding.kind); - if (list) list.push(finding.typeName); - else patternsByKind.set(finding.kind, [finding.typeName]); - } - assert.deepEqual(patternsByKind.get("intersection-with-empty-object"), ["IntersectWithEmpty"]); - assert.deepEqual(patternsByKind.get("self-union"), ["SelfUnion"]); - assert.deepEqual(patternsByKind.get("nested-partial"), ["NestedPartial"]); - assert.deepEqual(patternsByKind.get("nested-readonly"), ["NestedReadonly"]); - assert.deepEqual(patternsByKind.get("pick-all-keys"), ["PickAll"]); - assert.deepEqual(patternsByKind.get("omit-no-keys"), ["OmitNever"]); - assert.deepEqual(patternsByKind.get("empty-interface-extends-one"), ["EmptyExtends"]); - }); - - it("does NOT flag legitimate interface/type definitions", async () => { - const result = await scanFixtureSyntactic("dry-patterns-syntactic"); - const flaggedTypeNames = new Set( - result.redundantTypePatterns.map((finding) => finding.typeName), - ); - assert.ok(!flaggedTypeNames.has("User")); - assert.ok(!flaggedTypeNames.has("LegitChild")); - assert.ok(!flaggedTypeNames.has("LegitUnion")); - }); - - it("does NOT flag Zod-style declaration-merging (`interface X extends Schema.infer<typeof X>`)", async () => { - const result = await scanFixtureSyntactic("dry-patterns-syntactic"); - const flaggedTypeNames = new Set( - result.redundantTypePatterns.map((finding) => finding.typeName), - ); - assert.ok( - !flaggedTypeNames.has("ZodMergedSchemaShape"), - "extending `Namespace.infer<...>` is the canonical Zod/Effect schema-type merging idiom", - ); - }); - - it("does NOT flag UI primitive prop re-aliasing (`interface X extends Lib.Component.Props`)", async () => { - const result = await scanFixtureSyntactic("dry-patterns-syntactic"); - const flaggedTypeNames = new Set( - result.redundantTypePatterns.map((finding) => finding.typeName), - ); - assert.ok( - !flaggedTypeNames.has("CheckboxRootProps"), - "extending `Namespace.Props` is the canonical Radix/Ark prop re-export idiom", - ); - }); - - it("flags identity wrappers and ignores wrappers that add real work", async () => { - const result = await scanFixtureSyntactic("dry-patterns-syntactic"); - const wrapperNames = result.identityWrappers.map((finding) => finding.wrapperName).sort(); - assert.deepEqual(wrapperNames, ["callOnly", "debugLog", "triggerWith", "variadicWrap"]); - }); - - it("does NOT flag wrappers that transform arguments or reorder them", async () => { - const result = await scanFixtureSyntactic("dry-patterns-syntactic"); - const wrapperNames = new Set(result.identityWrappers.map((finding) => finding.wrapperName)); - assert.ok(!wrapperNames.has("legitWrap"), "legitWrap calls .toUpperCase() — not an identity"); - assert.ok(!wrapperNames.has("legitExtra"), "legitExtra adds an extra arg"); - assert.ok(!wrapperNames.has("legitDifferentOrder"), "legitDifferentOrder swaps args"); - }); - - it("flags structurally-identical type definitions across modules", async () => { - const result = await scanFixtureSyntactic("dry-patterns-syntactic"); - const userDuplicates = result.duplicateTypeDefinitions.filter((entry) => - entry.instances.some((instance) => instance.typeName === "User"), - ); - assert.equal(userDuplicates.length, 1); - assert.ok(userDuplicates[0].instances.length >= 2); - }); - - it("respects reportRedundancy=false for all DRY patterns", async () => { - const result = await analyze( - defineConfig({ - rootDir: resolve(FIXTURES_DIR, "dry-patterns-syntactic"), - reportRedundancy: false, - }), - ); - assert.deepEqual(result.duplicateImports, []); - assert.deepEqual(result.redundantTypePatterns, []); - assert.deepEqual(result.identityWrappers, []); - assert.deepEqual(result.duplicateTypeDefinitions, []); - }); -}); - -describe("redundancy / aliased re-export not consumed (syntactic graph)", () => { - it("flags re-exports whose new name no consumer imports", async () => { - const result = await scanFixtureSyntactic("redundant-reexports-semantic"); - const reexportFindings = result.redundantAliases.filter( - (finding) => finding.kind === "reexport-aliased-not-used", - ); - const flaggedNames = reexportFindings.map((finding) => finding.name).sort(); - assert.ok( - flaggedNames.includes("wronglyAliased"), - `wronglyAliased should flag — consumer imports usedOnlyByOriginalName directly, got: ${flaggedNames}`, - ); - }); - - it("does NOT flag aliased re-exports that are actually consumed under the new name", async () => { - const result = await scanFixtureSyntactic("redundant-reexports-semantic"); - const reexportFindings = result.redundantAliases.filter( - (finding) => finding.kind === "reexport-aliased-not-used", - ); - const flaggedNames = new Set(reexportFindings.map((finding) => finding.name)); - assert.ok(!flaggedNames.has("goodAlias"), `goodAlias is consumed under its alias`); - }); -}); - -describe("redundancy / round-trip aliases (semantic)", () => { - it("flags `import { x as y }` where y matches the underlying declaration name", async () => { - const result = await scanFixtureWithSemantic("redundant-reexports-semantic", { - reportUnusedTypes: false, - reportUnusedEnumMembers: false, - reportMisclassifiedDependencies: false, - reportRedundantVariableAliases: false, - }); - const roundTrips = result.redundantAliases.filter( - (finding) => finding.kind === "roundtrip-alias", - ); - assert.ok( - roundTrips.some( - (finding) => finding.name === "realThing" && finding.aliasedFrom === "renamedThing", - ), - `expected round-trip alias for realThing ← renamedThing, got: ${JSON.stringify(roundTrips)}`, - ); - }); - - it("respects reportRoundTripAliases=false", async () => { - const result = await scanFixtureWithSemantic("redundant-reexports-semantic", { - reportUnusedTypes: false, - reportUnusedEnumMembers: false, - reportMisclassifiedDependencies: false, - reportRedundantVariableAliases: false, - reportRoundTripAliases: false, - }); - const roundTrips = result.redundantAliases.filter( - (finding) => finding.kind === "roundtrip-alias", - ); - assert.deepEqual(roundTrips, []); - }); -}); - -describe("redundancy / duplicate inline types (inside functions, returns, locals)", () => { - it("flags identical inline shape across parameters, returns, and local type aliases", async () => { - const result = await scanFixtureSyntactic("duplicate-inline-types"); - assert.ok( - result.duplicateInlineTypes.length >= 1, - `expected at least 1 inline duplicate, got: ${result.duplicateInlineTypes.length}`, - ); - const profileGroup = result.duplicateInlineTypes.find( - (entry) => - entry.preview.includes("email") && - entry.preview.includes("id") && - entry.preview.includes("name"), - ); - assert.ok( - profileGroup, - `expected profile shape, got: ${JSON.stringify(result.duplicateInlineTypes)}`, - ); - assert.ok( - profileGroup.occurrences.length >= 5, - `at least 5 occurrences expected, got ${profileGroup.occurrences.length}`, - ); - const contexts = new Set(profileGroup.occurrences.map((occurrence) => occurrence.context)); - assert.ok(contexts.has("function-parameter")); - assert.ok(contexts.has("function-return")); - assert.ok(contexts.has("local-type-alias")); - }); - - it("does NOT flag shapes with fewer than 3 properties (noise threshold)", async () => { - const result = await scanFixtureSyntactic("duplicate-inline-types"); - const twoPropFinding = result.duplicateInlineTypes.find( - (entry) => entry.preview.includes("a") && entry.preview.includes("b"), - ); - assert.ok(!twoPropFinding, "2-prop shapes should be below threshold"); - }); - - it("does NOT flag shapes that occur only once", async () => { - const result = await scanFixtureSyntactic("duplicate-inline-types"); - const uniqueFinding = result.duplicateInlineTypes.find((entry) => - entry.preview.includes("onlyHere"), - ); - assert.ok(!uniqueFinding, "single-occurrence shape should not be flagged"); - }); - - it("records nearestName so the user can locate each duplicate", async () => { - const result = await scanFixtureSyntactic("duplicate-inline-types"); - const profileGroup = result.duplicateInlineTypes.find((entry) => - entry.preview.includes("email"), - ); - assert.ok(profileGroup); - const namesByContext = new Map<string, string | undefined>(); - for (const occurrence of profileGroup.occurrences) { - namesByContext.set(occurrence.context, occurrence.nearestName); - } - assert.ok( - namesByContext.get("function-return") === "fetchProfile" || - namesByContext.get("function-return") === "buildProfile", - `function-return nearestName should be a function name, got: ${namesByContext.get("function-return")}`, - ); - assert.equal(namesByContext.get("local-type-alias"), "ProfileLocal"); - }); - - it("respects reportRedundancy=false", async () => { - const result = await analyze( - defineConfig({ - rootDir: resolve(FIXTURES_DIR, "duplicate-inline-types"), - reportRedundancy: false, - }), - ); - assert.deepEqual(result.duplicateInlineTypes, []); - }); -}); - -const simplifiableLabels = (result: ScanResult): Array<{ kind: string; functionName?: string }> => - result.simplifiableFunctions.map((finding) => ({ - kind: finding.kind, - functionName: finding.functionName, - })); - -describe("redundancy / simplifiable functions", () => { - it("flags `(x) => { return f(x); }` as block-arrow-single-return", async () => { - const result = await scanFixtureSyntactic("simplifiable-functions"); - const labels = simplifiableLabels(result); - assert.ok( - labels.some( - (entry) => - entry.kind === "block-arrow-single-return" && entry.functionName === "blockArrowSimple", - ), - `expected block-arrow-single-return for blockArrowSimple, got: ${JSON.stringify(labels)}`, - ); - }); - - it("does NOT flag block bodies with more than one statement", async () => { - const result = await scanFixtureSyntactic("simplifiable-functions"); - assert.ok( - !result.simplifiableFunctions.some( - (finding) => - finding.kind === "block-arrow-single-return" && - finding.functionName === "blockArrowComplex", - ), - ); - }); - - it("does NOT flag arrows that are already expression-bodied", async () => { - const result = await scanFixtureSyntactic("simplifiable-functions"); - assert.ok( - !result.simplifiableFunctions.some((finding) => finding.functionName === "expressionArrow"), - ); - }); - - it("flags `const x = await Y; return x;` as redundant-await-return", async () => { - const result = await scanFixtureSyntactic("simplifiable-functions"); - const labels = simplifiableLabels(result); - assert.ok( - labels.some( - (entry) => - entry.kind === "redundant-await-return" && entry.functionName === "fetchDataRedundant", - ), - ); - }); - - it("flags useless-async only when body has no calls/await/Promise surface (low confidence)", async () => { - const result = await scanFixtureSyntactic("simplifiable-functions"); - const uselessAsync = result.simplifiableFunctions.filter( - (finding) => finding.kind === "useless-async-no-await", - ); - const names = new Set(uselessAsync.map((finding) => finding.functionName)); - assert.ok(names.has("uselessAsync"), `uselessAsync must flag, got: ${[...names]}`); - assert.ok( - !names.has("fetchDataDirect"), - `fetchDataDirect calls Promise.resolve — must NOT flag, got: ${[...names]}`, - ); - for (const finding of uselessAsync) { - assert.equal(finding.confidence, "low", "useless-async findings should be low-confidence"); - } - }); - - it("does NOT flag genuinely async functions that await", async () => { - const result = await scanFixtureSyntactic("simplifiable-functions"); - assert.ok( - !result.simplifiableFunctions.some( - (finding) => - finding.kind === "useless-async-no-await" && finding.functionName === "legitAsync", - ), - ); - }); - - it("does NOT flag useless-async when the function has an explicit Promise<T> return type (contract preserved)", async () => { - const result = await scanFixtureSyntactic("simplifiable-functions"); - const flaggedNames = new Set( - result.simplifiableFunctions - .filter((finding) => finding.kind === "useless-async-no-await") - .map((finding) => finding.functionName), - ); - assert.ok( - !flaggedNames.has("uselessAsyncWithPromiseReturnType"), - `Promise<T> annotation makes async load-bearing — must NOT flag, got: ${[...flaggedNames]}`, - ); - }); - - it("does NOT flag useless-async on object method shorthands (interface-contract methods)", async () => { - const result = await scanFixtureSyntactic("simplifiable-functions"); - const flaggedNames = new Set( - result.simplifiableFunctions - .filter((finding) => finding.kind === "useless-async-no-await") - .map((finding) => finding.functionName), - ); - assert.ok( - !flaggedNames.has("redirects"), - `object method shorthand 'async redirects() {}' must NOT flag (Next.js config pattern), got: ${[...flaggedNames]}`, - ); - }); - - it("does NOT flag useless-async on object property arrows (e.g. mock-response callbacks)", async () => { - const result = await scanFixtureSyntactic("simplifiable-functions"); - const flaggedNames = new Set( - result.simplifiableFunctions - .filter((finding) => finding.kind === "useless-async-no-await") - .map((finding) => finding.functionName), - ); - assert.ok( - !flaggedNames.has("text"), - `'{ text: async () => "x" }' must NOT flag (Response.text() callback signature), got: ${[...flaggedNames]}`, - ); - assert.ok( - !flaggedNames.has("json"), - `'{ json: async () => ({}) }' must NOT flag, got: ${[...flaggedNames]}`, - ); - }); - - it("does NOT flag useless-async on arrows passed directly as CallExpression arguments", async () => { - const result = await scanFixtureSyntactic("simplifiable-functions"); - const inlineAsyncCallbacks = result.simplifiableFunctions.filter( - (finding) => - finding.kind === "useless-async-no-await" && - finding.path.endsWith("simplifiable-functions/src/index.ts") && - finding.line > 30, - ); - assert.equal( - inlineAsyncCallbacks.length, - 0, - `inline inlineCallbackInvoker arrow must NOT flag (callback signature is contract), got: ${inlineAsyncCallbacks - .map((finding) => `${finding.kind} ${finding.path}:${finding.line}`) - .join(", ")}`, - ); - }); - - it("respects reportRedundancy=false", async () => { - const result = await analyze( - defineConfig({ - rootDir: resolve(FIXTURES_DIR, "simplifiable-functions"), - reportRedundancy: false, - }), - ); - assert.deepEqual(result.simplifiableFunctions, []); - }); -}); - -describe("redundancy / simplifiable expressions", () => { - it("flags `!!x` as double-bang-boolean with high confidence", async () => { - const result = await scanFixtureSyntactic("simplifiable-expressions"); - const doubleBangs = result.simplifiableExpressions.filter( - (finding) => finding.kind === "double-bang-boolean", - ); - assert.ok( - doubleBangs.length >= 3, - `expected at least 3 double-bang findings, got: ${doubleBangs.length}`, - ); - for (const finding of doubleBangs) { - assert.equal(finding.confidence, "high"); - } - }); - - it("flags `x ? x : y` as self-fallback-ternary", async () => { - const result = await scanFixtureSyntactic("simplifiable-expressions"); - const selfFallbacks = result.simplifiableExpressions.filter( - (finding) => finding.kind === "self-fallback-ternary", - ); - assert.ok( - selfFallbacks.some((finding) => finding.snippet.startsWith("config ? config")), - `expected config self-fallback, got: ${JSON.stringify(selfFallbacks.map((finding) => finding.snippet))}`, - ); - }); - - it("does NOT flag legitimate ternaries where consequent differs from test", async () => { - const result = await scanFixtureSyntactic("simplifiable-expressions"); - const selfFallbacks = result.simplifiableExpressions.filter( - (finding) => finding.kind === "self-fallback-ternary", - ); - for (const finding of selfFallbacks) { - assert.ok( - !finding.snippet.includes(`legitTernary`), - `legit ternary must not be flagged: ${finding.snippet}`, - ); - } - }); - - it("flags `cond ? true : false` and `cond ? false : true`", async () => { - const result = await scanFixtureSyntactic("simplifiable-expressions"); - const ternaryBools = result.simplifiableExpressions.filter( - (finding) => finding.kind === "ternary-returns-boolean", - ); - assert.equal(ternaryBools.length, 2); - const snippets = ternaryBools.map((finding) => finding.snippet); - assert.ok(snippets.includes("cond ? true : false")); - assert.ok(snippets.includes("cond ? false : true")); - }); - - it("does NOT flag boolean-returning ternaries with non-boolean consequents", async () => { - const result = await scanFixtureSyntactic("simplifiable-expressions"); - const ternaryBools = result.simplifiableExpressions.filter( - (finding) => finding.kind === "ternary-returns-boolean", - ); - for (const finding of ternaryBools) { - assert.ok( - finding.snippet !== "cond ? 'yes' : 'no'", - `non-boolean ternary must not flag: ${finding.snippet}`, - ); - } - }); - - it("flags `x ?? null` and `x ?? undefined` as nullish-no-op", async () => { - const result = await scanFixtureSyntactic("simplifiable-expressions"); - const nullish = result.simplifiableExpressions.filter( - (finding) => finding.kind === "nullish-coalescing-with-nullish", - ); - const snippets = nullish.map((finding) => finding.snippet); - assert.ok(snippets.includes("someValue ?? null")); - assert.ok(snippets.includes("someValue ?? undefined")); - }); - - it("does NOT flag `x ?? value` with a real fallback", async () => { - const result = await scanFixtureSyntactic("simplifiable-expressions"); - const nullish = result.simplifiableExpressions.filter( - (finding) => finding.kind === "nullish-coalescing-with-nullish", - ); - for (const finding of nullish) { - assert.ok(!finding.snippet.includes('"fallback"')); - } - }); - - it("flags `x !== null && x !== undefined` in either order", async () => { - const result = await scanFixtureSyntactic("simplifiable-expressions"); - const redundantChecks = result.simplifiableExpressions.filter( - (finding) => finding.kind === "redundant-null-and-undefined-check", - ); - assert.equal(redundantChecks.length, 2); - for (const finding of redundantChecks) { - assert.ok(finding.suggestion.includes("!= null")); - } - }); - - it("does NOT flag mixed null/typeof checks", async () => { - const result = await scanFixtureSyntactic("simplifiable-expressions"); - const redundantChecks = result.simplifiableExpressions.filter( - (finding) => finding.kind === "redundant-null-and-undefined-check", - ); - for (const finding of redundantChecks) { - assert.ok(!finding.snippet.includes("typeof")); - } - }); -}); - -describe("redundancy / cross-file duplicate constants", () => { - it("flags same string literal repeated across 3+ files with same name as high confidence", async () => { - const result = await scanFixtureSyntactic("duplicate-constants"); - const apiBaseFinding = result.duplicateConstants.find((finding) => - finding.occurrences.every((occurrence) => occurrence.constantName === "API_BASE_URL"), - ); - assert.ok( - apiBaseFinding, - `expected API_BASE_URL duplicate, got: ${JSON.stringify(result.duplicateConstants)}`, - ); - assert.equal(apiBaseFinding.confidence, "high"); - assert.equal(apiBaseFinding.occurrences.length, 3); - }); - - it("does NOT flag short strings below the length threshold", async () => { - const result = await scanFixtureSyntactic("duplicate-constants"); - for (const finding of result.duplicateConstants) { - assert.ok(!finding.literalPreview.includes('"x"')); - } - }); - - it("does NOT flag small numeric literals below the threshold", async () => { - const result = await scanFixtureSyntactic("duplicate-constants"); - for (const finding of result.duplicateConstants) { - assert.ok(!finding.literalPreview.includes("42")); - } - }); - - it("does NOT flag values that appear in fewer than 3 distinct files", async () => { - const result = await scanFixtureSyntactic("duplicate-constants"); - for (const finding of result.duplicateConstants) { - const uniquePaths = new Set(finding.occurrences.map((occurrence) => occurrence.path)); - assert.ok(uniquePaths.size >= 3); - } - }); - - it("marks duplicates with different names as medium confidence", async () => { - const result = await scanFixtureSyntactic("duplicate-constants"); - const pollFinding = result.duplicateConstants.find((finding) => - finding.occurrences.some((occurrence) => occurrence.constantName === "POLL_INTERVAL_MS"), - ); - if (pollFinding) { - assert.equal(pollFinding.confidence, "medium"); - } - }); - - it("respects reportRedundancy=false", async () => { - const result = await analyze( - defineConfig({ - rootDir: resolve(FIXTURES_DIR, "duplicate-constants"), - reportRedundancy: false, - }), - ); - assert.deepEqual(result.duplicateConstants, []); - }); -}); - -describe("redundancy / duplicate-constants unit-suffix awareness", () => { - it("does NOT flag same-value constants whose names use distinct unit suffixes (semantically different quantities)", async () => { - const result = await scanFixtureSyntactic("duplicate-constants-unit-mismatch"); - const value1000Finding = result.duplicateConstants.find( - (finding) => finding.literalPreview === "1000", - ); - assert.equal( - value1000Finding, - undefined, - `STEP_DELAY_MS(_MS) + MINIMUM_TOKENS(_TOKENS) + SCREEN_WIDTH(_WIDTH) all = 1000 but represent different units — must NOT flag, got: ${JSON.stringify(value1000Finding)}`, - ); - }); - - it("STILL flags same-value constants when all names share the same unit suffix (truly extractable)", async () => { - const result = await scanFixtureSyntactic("duplicate-constants-unit-mismatch"); - const value2000Finding = result.duplicateConstants.find( - (finding) => finding.literalPreview === "2000", - ); - assert.ok( - value2000Finding, - `CACHE_INTERVAL_MS + RECONNECT_DELAY_MS + POLL_INTERVAL_MS all = 2000 ms — SHOULD still flag, got: ${JSON.stringify(result.duplicateConstants)}`, - ); - assert.equal(value2000Finding.confidence, "medium"); - }); -}); - -describe("redundancy / regression: numeric / symbol / call-signature keys", () => { - it("does not crash on interfaces with numeric property keys, index signatures, or call signatures", async () => { - const result = await scanFixtureSyntactic("numeric-keys-types"); - assert.ok(Array.isArray(result.duplicateTypeDefinitions)); - assert.ok(Array.isArray(result.duplicateInlineTypes)); - assert.ok(result.totalFiles > 0); - }); -}); diff --git a/packages/deslop-js/tests/summary-cache.test.ts b/packages/deslop-js/tests/summary-cache.test.ts deleted file mode 100644 index 9324a58402..0000000000 --- a/packages/deslop-js/tests/summary-cache.test.ts +++ /dev/null @@ -1,732 +0,0 @@ -import { after, describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - realpathSync, - rmSync, - statSync, - symlinkSync, - unlinkSync, - utimesSync, - writeFileSync, -} from "node:fs"; -import os from "node:os"; -import { dirname, join, relative } from "node:path"; -import { analyze, defineConfig } from "../src/index.js"; -import type { DeslopConfig, ScanResult } from "../src/types.js"; -import { SUMMARY_CACHE_SCHEMA_VERSION } from "../src/constants.js"; -import { loadSummaryCache } from "../src/summary-cache.js"; - -// Canonicalized so deslop's fast-glob paths line up with oxc-resolver's — -// `os.tmpdir()` is a symlink into /private on macOS. -const temporaryRoot = realpathSync(mkdtempSync(join(os.tmpdir(), "deslop-summary-cache-"))); - -after(() => { - rmSync(temporaryRoot, { recursive: true, force: true }); -}); - -const FIXTURE_FILES: Record<string, string> = { - "package.json": JSON.stringify({ - name: "summary-cache-fixture", - type: "module", - dependencies: { "used-dep": "1.0.0", "doc-dep": "1.0.0", "unused-dep": "1.0.0" }, - }), - "tsconfig.json": JSON.stringify({ - compilerOptions: { target: "es2022", module: "esnext", moduleResolution: "bundler" }, - }), - "README.md": 'Usage:\n\n```ts\nimport { helper } from "doc-dep";\n```\n', - "src/index.ts": - 'import "used-dep";\n' + - 'import { usedFunction } from "./used.js";\n' + - 'import { targetValue } from "./target";\n' + - "export const entryValue = usedFunction() + targetValue;\n", - "src/used.ts": - "export const usedFunction = (): number => 1;\n" + - "export const staleExport = (): number => 2;\n", - "src/target/index.ts": "export const targetValue = 3;\n", - "src/orphan.ts": "export const orphanValue = 4;\n", -}; - -interface FixtureWorkspace { - readonly projectDirectory: string; - readonly cachePath: string; -} - -let fixtureCounter = 0; - -const buildFixture = (extraFiles: Record<string, string> = {}): FixtureWorkspace => { - fixtureCounter += 1; - const workspaceDirectory = join(temporaryRoot, `case-${fixtureCounter}`); - const projectDirectory = join(workspaceDirectory, "project"); - for (const [relativePath, contents] of Object.entries({ ...FIXTURE_FILES, ...extraFiles })) { - const fullPath = join(projectDirectory, relativePath); - mkdirSync(dirname(fullPath), { recursive: true }); - writeFileSync(fullPath, contents); - } - // The cache lives OUTSIDE the analyzed tree so its own writes don't churn - // the tree fingerprint — mirroring react-doctor's node_modules/.cache home. - return { projectDirectory, cachePath: join(workspaceDirectory, "cache", "summaries.json") }; -}; - -const scan = async ( - workspace: FixtureWorkspace, - options: { cached: boolean; overrides?: Partial<DeslopConfig> }, -): Promise<ScanResult> => - analyze( - defineConfig({ - rootDir: workspace.projectDirectory, - ...(options.cached ? { incrementalCachePath: workspace.cachePath } : {}), - ...options.overrides, - }), - ); - -// Cross-process scan for edits the in-process resolver content caches would -// hide (bundler-config content changes) — the dead-code worker's real model. -const scanInSubprocess = ( - workspace: FixtureWorkspace, - options: { cached: boolean }, -): ScanResult => { - const runnerPath = join(import.meta.dirname, "helpers", "analyze-in-subprocess.ts"); - const stdout = execFileSync( - process.execPath, - [ - "--import", - "tsx", - runnerPath, - JSON.stringify({ - rootDir: workspace.projectDirectory, - ...(options.cached ? { incrementalCachePath: workspace.cachePath } : {}), - }), - ], - { cwd: join(import.meta.dirname, ".."), encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 }, - ); - return JSON.parse(stdout); -}; - -// Everything a consumer can observe from the graph analysis (plus the -// redundancy findings, to prove full-fidelity summary round-trips), normalized -// to a stable order with project-relative paths. -const resultSignature = (result: ScanResult, projectDirectory: string): string => { - const relativePath = (filePath: string): string => relative(projectDirectory, filePath); - return JSON.stringify({ - unusedFiles: result.unusedFiles.map((entry) => relativePath(entry.path)).sort(), - unusedExports: result.unusedExports - .map( - (entry) => - `${relativePath(entry.path)}:${entry.line}:${entry.column}:${entry.name}` + - `${entry.isTypeOnly ? ":type" : ""}`, - ) - .sort(), - unusedDependencies: result.unusedDependencies - .map((entry) => `${entry.name}:${entry.isDevDependency ? "dev" : "prod"}`) - .sort(), - circularDependencies: result.circularDependencies - .map((cycle) => cycle.files.map(relativePath).join(" -> ")) - .sort(), - duplicateTypeDefinitions: result.duplicateTypeDefinitions - .map((duplicate) => - duplicate.instances - .map((instance) => `${relativePath(instance.path)}:${instance.typeName}`) - .sort() - .join(","), - ) - .sort(), - simplifiableExpressions: result.simplifiableExpressions - .map((entry) => `${entry.kind}:${entry.snippet}`) - .sort(), - errorCodes: result.analysisErrors.map((analysisError) => analysisError.code).sort(), - }); -}; - -const unusedFileNames = (result: ScanResult, projectDirectory: string): string[] => - result.unusedFiles.map((entry) => relative(projectDirectory, entry.path)).sort(); - -const unusedExportNames = (result: ScanResult): string[] => - result.unusedExports.map((entry) => entry.name).sort(); - -const unusedDependencyNames = (result: ScanResult): string[] => - result.unusedDependencies.map((entry) => entry.name).sort(); - -describe("summary cache", () => { - it("warm run over an unchanged tree matches the cold run and an uncached control", async () => { - const workspace = buildFixture(); - const cold = await scan(workspace, { cached: true }); - assert.ok(existsSync(workspace.cachePath), "cold run should write the cache file"); - const warm = await scan(workspace, { cached: true }); - const control = await scan(workspace, { cached: false }); - - const coldSignature = resultSignature(cold, workspace.projectDirectory); - assert.equal(resultSignature(warm, workspace.projectDirectory), coldSignature); - assert.equal(resultSignature(control, workspace.projectDirectory), coldSignature); - - // The fixture must exercise every consumed finding kind, or equality - // between empty results would be a vacuous pass. - assert.ok(unusedFileNames(cold, workspace.projectDirectory).includes("src/orphan.ts")); - assert.ok(unusedExportNames(cold).includes("staleExport")); - assert.deepEqual(unusedDependencyNames(cold), ["unused-dep"]); - }); - - it("skips the save when nothing changed", async () => { - const workspace = buildFixture(); - await scan(workspace, { cached: true }); - const bytesAfterCold = readFileSync(workspace.cachePath, "utf-8"); - const mtimeAfterCold = statSync(workspace.cachePath).mtimeMs; - await scan(workspace, { cached: true }); - assert.equal(readFileSync(workspace.cachePath, "utf-8"), bytesAfterCold); - assert.equal(statSync(workspace.cachePath).mtimeMs, mtimeAfterCold); - }); - - it("reflects a single edited file and matches an uncached control", async () => { - const workspace = buildFixture(); - const cold = await scan(workspace, { cached: true }); - assert.ok(!unusedExportNames(cold).includes("freshlyUnused")); - - const editedPath = join(workspace.projectDirectory, "src/used.ts"); - writeFileSync( - editedPath, - `${readFileSync(editedPath, "utf-8")}export const freshlyUnused = (): number => 5;\n`, - ); - - const warm = await scan(workspace, { cached: true }); - const control = await scan(workspace, { cached: false }); - assert.ok(unusedExportNames(warm).includes("freshlyUnused")); - assert.equal( - resultSignature(warm, workspace.projectDirectory), - resultSignature(control, workspace.projectDirectory), - ); - }); - - it("re-resolves when an added file shadows an existing import target", async () => { - const workspace = buildFixture(); - const cold = await scan(workspace, { cached: true }); - assert.ok( - !unusedFileNames(cold, workspace.projectDirectory).includes("src/target/index.ts"), - "before the shadow, ./target must resolve to target/index.ts", - ); - - // `./target` now resolves to the FILE, orphaning the directory index — - // exactly the resolution flip a per-file resolved-path cache would miss. - writeFileSync( - join(workspace.projectDirectory, "src/target.ts"), - "export const targetValue = 30;\n", - ); - - const warm = await scan(workspace, { cached: true }); - const control = await scan(workspace, { cached: false }); - assert.ok(unusedFileNames(warm, workspace.projectDirectory).includes("src/target/index.ts")); - assert.equal( - resultSignature(warm, workspace.projectDirectory), - resultSignature(control, workspace.projectDirectory), - ); - }); - - it("reflects a deleted file and compacts its entries out of the store", async () => { - const workspace = buildFixture(); - const cold = await scan(workspace, { cached: true }); - assert.ok(unusedFileNames(cold, workspace.projectDirectory).includes("src/orphan.ts")); - - const orphanPath = join(workspace.projectDirectory, "src/orphan.ts"); - unlinkSync(orphanPath); - - const warm = await scan(workspace, { cached: true }); - const control = await scan(workspace, { cached: false }); - assert.ok(!unusedFileNames(warm, workspace.projectDirectory).includes("src/orphan.ts")); - assert.equal( - resultSignature(warm, workspace.projectDirectory), - resultSignature(control, workspace.projectDirectory), - ); - - const persisted = JSON.parse(readFileSync(workspace.cachePath, "utf-8")); - const summaryPaths = Object.keys(persisted.summaries); - assert.ok(summaryPaths.length > 0); - assert.ok( - summaryPaths.every((summaryPath) => !summaryPath.endsWith("orphan.ts")), - "the deleted file's summary must be compacted away", - ); - }); - - it("reflects a manifest edit and matches an uncached control", async () => { - const workspace = buildFixture(); - const cold = await scan(workspace, { cached: true }); - assert.deepEqual(unusedDependencyNames(cold), ["unused-dep"]); - - const manifestPath = join(workspace.projectDirectory, "package.json"); - const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); - manifest.dependencies["second-unused-dep"] = "1.0.0"; - writeFileSync(manifestPath, JSON.stringify(manifest)); - - const warm = await scan(workspace, { cached: true }); - const control = await scan(workspace, { cached: false }); - assert.deepEqual(unusedDependencyNames(warm), ["second-unused-dep", "unused-dep"]); - assert.equal( - resultSignature(warm, workspace.projectDirectory), - resultSignature(control, workspace.projectDirectory), - ); - }); - - it("drops the resolution map when a bundler alias config changes content", async () => { - // The alias target is deliberately NOT "./"-relative so the config-string - // entry heuristic ignores it — the dest files are reachable only through - // MODULE RESOLUTION, isolating the resolution-map invalidation under test. - const workspace = buildFixture({ - "src/index.ts": - 'import "used-dep";\n' + - 'import { usedFunction } from "./used.js";\n' + - 'import { targetValue } from "./target";\n' + - 'import { aliased } from "$dest";\n' + - "export const entryValue = usedFunction() + targetValue + aliased;\n", - "src/dest-a.ts": "export const aliased = 10;\n", - "src/dest-b.ts": "export const aliased = 20;\n", - "vite.config.ts": 'export default { resolve: { alias: { "$dest": "src/dest-a.ts" } } };\n', - }); - // Subprocess scans: the resolver's in-process content caches would hide a - // config-file content edit from repeat `analyze()` calls in one process - // (pre-existing behavior, unrelated to the summary cache); the dead-code - // worker spawns a fresh process per scan, which this mirrors. - const cold = scanInSubprocess(workspace, { cached: true }); - assert.ok(unusedFileNames(cold, workspace.projectDirectory).includes("src/dest-b.ts")); - assert.ok(!unusedFileNames(cold, workspace.projectDirectory).includes("src/dest-a.ts")); - - // Same file NAME set — only the alias TARGET changed, which only the - // bundler-config content fingerprint can catch. - writeFileSync( - join(workspace.projectDirectory, "vite.config.ts"), - 'export default { resolve: { alias: { "$dest": "src/dest-b.ts" } } };\n', - ); - - const warm = scanInSubprocess(workspace, { cached: true }); - const control = scanInSubprocess(workspace, { cached: false }); - assert.ok(unusedFileNames(warm, workspace.projectDirectory).includes("src/dest-a.ts")); - assert.ok(!unusedFileNames(warm, workspace.projectDirectory).includes("src/dest-b.ts")); - assert.equal( - resultSignature(warm, workspace.projectDirectory), - resultSignature(control, workspace.projectDirectory), - ); - }); - - it("drops the resolution map when a tsconfig paths alias changes content", async () => { - const workspace = buildFixture({ - "src/index.ts": - 'import "used-dep";\n' + - 'import { usedFunction } from "./used.js";\n' + - 'import { targetValue } from "./target";\n' + - 'import { aliased } from "@dest";\n' + - "export const entryValue = usedFunction() + targetValue + aliased;\n", - "src/dest-a.ts": "export const aliased = 10;\n", - "src/dest-b.ts": "export const aliased = 20;\n", - "tsconfig.json": JSON.stringify({ - compilerOptions: { - target: "es2022", - module: "esnext", - moduleResolution: "bundler", - baseUrl: ".", - paths: { "@dest": ["./src/dest-a.ts"] }, - }, - }), - }); - const cold = scanInSubprocess(workspace, { cached: true }); - assert.ok(unusedFileNames(cold, workspace.projectDirectory).includes("src/dest-b.ts")); - assert.ok(!unusedFileNames(cold, workspace.projectDirectory).includes("src/dest-a.ts")); - - // tsconfig files are stat-fingerprinted manifest-like inputs: the edit - // invalidates BOTH the collected-file-list key and the resolution map. - writeFileSync( - join(workspace.projectDirectory, "tsconfig.json"), - JSON.stringify({ - compilerOptions: { - target: "es2022", - module: "esnext", - moduleResolution: "bundler", - baseUrl: ".", - paths: { "@dest": ["./src/dest-b.ts"] }, - }, - }), - ); - - const warm = scanInSubprocess(workspace, { cached: true }); - const control = scanInSubprocess(workspace, { cached: false }); - assert.ok(unusedFileNames(warm, workspace.projectDirectory).includes("src/dest-a.ts")); - assert.ok(!unusedFileNames(warm, workspace.projectDirectory).includes("src/dest-b.ts")); - assert.equal( - resultSignature(warm, workspace.projectDirectory), - resultSignature(control, workspace.projectDirectory), - ); - }); - - it("keeps entry resolution live: a config entry-string edit lands without invalidation", async () => { - // `resolveEntries` reads config CONTENT (here vite.config.ts's "./"- - // relative entry strings), which no name-based fingerprint can validate — - // so entries must never be served from the cache. - const workspace = buildFixture({ - "src/dest-a.ts": "export const standalone = 10;\n", - "src/dest-b.ts": "export const standalone = 20;\n", - "vite.config.ts": 'export default { build: { lib: { entry: "./src/dest-a.ts" } } };\n', - }); - const cold = await scan(workspace, { cached: true }); - assert.ok(unusedFileNames(cold, workspace.projectDirectory).includes("src/dest-b.ts")); - assert.ok(!unusedFileNames(cold, workspace.projectDirectory).includes("src/dest-a.ts")); - - writeFileSync( - join(workspace.projectDirectory, "vite.config.ts"), - 'export default { build: { lib: { entry: "./src/dest-b.ts" } } };\n', - ); - - const warm = await scan(workspace, { cached: true }); - const control = await scan(workspace, { cached: false }); - assert.ok(unusedFileNames(warm, workspace.projectDirectory).includes("src/dest-a.ts")); - assert.ok(!unusedFileNames(warm, workspace.projectDirectory).includes("src/dest-b.ts")); - assert.equal( - resultSignature(warm, workspace.projectDirectory), - resultSignature(control, workspace.projectDirectory), - ); - }); - - it("fails open on a corrupt cache file and rewrites it", async () => { - const workspace = buildFixture(); - mkdirSync(dirname(workspace.cachePath), { recursive: true }); - writeFileSync(workspace.cachePath, "{ this is not json"); - - const corrupted = await scan(workspace, { cached: true }); - const control = await scan(workspace, { cached: false }); - assert.equal( - resultSignature(corrupted, workspace.projectDirectory), - resultSignature(control, workspace.projectDirectory), - ); - const persisted = JSON.parse(readFileSync(workspace.cachePath, "utf-8")); - assert.equal(persisted.version, SUMMARY_CACHE_SCHEMA_VERSION); - }); - - it("discards a schema-version-mismatched store instead of trusting its entries", async () => { - const workspace = buildFixture(); - await scan(workspace, { cached: true }); - - const persisted = JSON.parse(readFileSync(workspace.cachePath, "utf-8")); - persisted.version = SUMMARY_CACHE_SCHEMA_VERSION + 999; - // Poison every stored summary: if the version gate ever stops discarding, - // the equivalence assertion below fails loudly. - for (const summaryPath of Object.keys(persisted.summaries)) { - persisted.summaries[summaryPath].p = { imports: "poisoned" }; - } - writeFileSync(workspace.cachePath, JSON.stringify(persisted)); - - const rebuilt = await scan(workspace, { cached: true }); - const control = await scan(workspace, { cached: false }); - assert.equal( - resultSignature(rebuilt, workspace.projectDirectory), - resultSignature(control, workspace.projectDirectory), - ); - const rewritten = JSON.parse(readFileSync(workspace.cachePath, "utf-8")); - assert.equal(rewritten.version, SUMMARY_CACHE_SCHEMA_VERSION); - }); - - it("treats a poisoned summary entry as a per-file miss", async () => { - const workspace = buildFixture(); - await scan(workspace, { cached: true }); - - const persisted = JSON.parse(readFileSync(workspace.cachePath, "utf-8")); - const usedSummaryPath = Object.keys(persisted.summaries).find((summaryPath) => - summaryPath.endsWith("used.ts"), - ); - assert.ok(usedSummaryPath); - persisted.summaries[usedSummaryPath].p = { imports: "poisoned" }; - writeFileSync(workspace.cachePath, JSON.stringify(persisted)); - - const warm = await scan(workspace, { cached: true }); - const control = await scan(workspace, { cached: false }); - assert.equal( - resultSignature(warm, workspace.projectDirectory), - resultSignature(control, workspace.projectDirectory), - ); - }); - - it("writes atomically, leaving no temp files behind", async () => { - const workspace = buildFixture(); - await scan(workspace, { cached: true }); - const cacheDirectoryEntries = readdirSync(dirname(workspace.cachePath)); - assert.deepEqual( - cacheDirectoryEntries.filter((entryName) => entryName.endsWith(".tmp")), - [], - ); - }); - - it("documents the accepted blind spot: an mtime+size-preserving edit is invisible", async () => { - // `blind.ts` must be REACHABLE (unused exports are only reported on - // reachable modules), so the entry imports its `keep` export. - const workspace = buildFixture({ - "src/blind.ts": "export const keep = 1;\nexport const aa = 1;\n", - "src/index.ts": - 'import "used-dep";\n' + - 'import { usedFunction } from "./used.js";\n' + - 'import { targetValue } from "./target";\n' + - 'import { keep } from "./blind.js";\n' + - "export const entryValue = usedFunction() + targetValue + keep;\n", - }); - const pinnedTime = new Date(Date.now() - 60_000); - const blindPath = join(workspace.projectDirectory, "src/blind.ts"); - utimesSync(blindPath, pinnedTime, pinnedTime); - - const cold = await scan(workspace, { cached: true }); - assert.ok(unusedExportNames(cold).includes("aa")); - - // Same byte length, same pinned mtime — the stat-based fingerprint cannot - // see this edit (shared with core's whole-result and lint caches). - writeFileSync(blindPath, "export const keep = 1;\nexport const ab = 1;\n"); - utimesSync(blindPath, pinnedTime, pinnedTime); - - const warm = await scan(workspace, { cached: true }); - assert.ok(unusedExportNames(warm).includes("aa"), "stale summary is served"); - assert.ok(!unusedExportNames(warm).includes("ab")); - - const control = await scan(workspace, { cached: false }); - assert.ok(unusedExportNames(control).includes("ab"), "an uncached run sees the edit"); - }); - - it("repairs a fresh-checkout mtime bump over identical content and persists the refreshed stats", async () => { - const workspace = buildFixture(); - const cold = await scan(workspace, { cached: true }); - - // Simulate a fresh CI checkout: every file's mtime is checkout time, - // content is byte-identical. - const bumpedTime = new Date(Date.now() + 60_000); - const bumpTreeMtimes = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const entryPath = join(directory, entry.name); - if (entry.isDirectory()) bumpTreeMtimes(entryPath); - else utimesSync(entryPath, bumpedTime, bumpedTime); - } - }; - bumpTreeMtimes(workspace.projectDirectory); - - const repaired = await scan(workspace, { cached: true }); - assert.deepEqual( - repaired.incrementalCacheStats, - { summaryHits: 4, summaryMisses: 0 }, - "every summary must repair-hit, none re-parse", - ); - assert.equal( - resultSignature(repaired, workspace.projectDirectory), - resultSignature(cold, workspace.projectDirectory), - ); - - // The repair run persists the refreshed stats, so the next run takes the - // stat fast path end to end — nothing dirties, and the save is skipped. - const cacheBytesAfterRepair = readFileSync(workspace.cachePath, "utf-8"); - const cacheMtimeAfterRepair = statSync(workspace.cachePath).mtimeMs; - const fastPath = await scan(workspace, { cached: true }); - assert.deepEqual(fastPath.incrementalCacheStats, { summaryHits: 4, summaryMisses: 0 }); - assert.equal(readFileSync(workspace.cachePath, "utf-8"), cacheBytesAfterRepair); - assert.equal(statSync(workspace.cachePath).mtimeMs, cacheMtimeAfterRepair); - }); - - it("misses an mtime-bumped file whose content changed at the same byte size", async () => { - const workspace = buildFixture(); - const cold = await scan(workspace, { cached: true }); - assert.ok(unusedExportNames(cold).includes("staleExport")); - - // Same byte length, different content, new mtime: the repair path must - // reject on the content hash and re-parse. - const editedPath = join(workspace.projectDirectory, "src/used.ts"); - writeFileSync( - editedPath, - readFileSync(editedPath, "utf-8").replace("staleExport", "staleXport2"), - ); - utimesSync(editedPath, new Date(Date.now() + 60_000), new Date(Date.now() + 60_000)); - - const warm = await scan(workspace, { cached: true }); - assert.deepEqual(warm.incrementalCacheStats, { summaryHits: 3, summaryMisses: 1 }); - assert.ok(unusedExportNames(warm).includes("staleXport2")); - assert.ok(!unusedExportNames(warm).includes("staleExport")); - const control = await scan(workspace, { cached: false }); - assert.equal( - resultSignature(warm, workspace.projectDirectory), - resultSignature(control, workspace.projectDirectory), - ); - }); - - it("round-trips redundancy findings at full fidelity, and slims them when disabled", async () => { - const duplicateTypeFiles = { - "src/shape-a.ts": - "export interface Shape { id: string; name: string; size: number }\n" + - "export const shapeA: Shape = { id: 'a', name: 'a', size: 1 };\n", - "src/shape-b.ts": - "export interface Shape { id: string; name: string; size: number }\n" + - "export const shapeB: Shape = { id: 'b', name: 'b', size: 2 };\n", - "src/index.ts": - 'import "used-dep";\n' + - 'import { usedFunction } from "./used.js";\n' + - 'import { targetValue } from "./target";\n' + - 'import { shapeA } from "./shape-a.js";\n' + - 'import { shapeB } from "./shape-b.js";\n' + - "export const entryValue = usedFunction() + targetValue + shapeA.size + shapeB.size;\n", - }; - - const fullFidelity = buildFixture(duplicateTypeFiles); - const cold = await scan(fullFidelity, { cached: true }); - assert.ok( - cold.duplicateTypeDefinitions.some((duplicate) => - duplicate.instances.some((instance) => instance.typeName === "Shape"), - ), - "the fixture must produce a redundancy finding", - ); - const warm = await scan(fullFidelity, { cached: true }); - const control = await scan(fullFidelity, { cached: false }); - assert.equal( - resultSignature(warm, fullFidelity.projectDirectory), - resultSignature(control, fullFidelity.projectDirectory), - ); - assert.ok(readFileSync(fullFidelity.cachePath, "utf-8").includes('"typeDefinitionHashes"')); - - // react-doctor's worker config: the DRY-pattern consumers are off, so the - // summaries must not pay for their fields. - const slimOverrides: Partial<DeslopConfig> = { - reportRedundancy: false, - reportCodeQuality: false, - semantic: { enabled: false } as DeslopConfig["semantic"], - }; - const slimmed = buildFixture(duplicateTypeFiles); - const slimCold = await scan(slimmed, { cached: true, overrides: slimOverrides }); - const slimWarm = await scan(slimmed, { cached: true, overrides: slimOverrides }); - const slimControl = await scan(slimmed, { cached: false, overrides: slimOverrides }); - assert.equal( - resultSignature(slimWarm, slimmed.projectDirectory), - resultSignature(slimControl, slimmed.projectDirectory), - ); - assert.equal( - resultSignature(slimCold, slimmed.projectDirectory), - resultSignature(slimControl, slimmed.projectDirectory), - ); - const slimmedCacheBytes = readFileSync(slimmed.cachePath, "utf-8"); - assert.ok(!slimmedCacheBytes.includes('"typeDefinitionHashes"')); - assert.ok(!slimmedCacheBytes.includes('"simplifiableExpressions"')); - }); - - it("reports summary hit/miss stats only when the cache is active", async () => { - const workspace = buildFixture(); - const collectedFileCount = 4; - - const control = await scan(workspace, { cached: false }); - assert.equal(control.incrementalCacheStats, undefined); - - const cold = await scan(workspace, { cached: true }); - assert.deepEqual(cold.incrementalCacheStats, { - summaryHits: 0, - summaryMisses: collectedFileCount, - }); - - const warm = await scan(workspace, { cached: true }); - assert.deepEqual(warm.incrementalCacheStats, { - summaryHits: collectedFileCount, - summaryMisses: 0, - }); - - const editedPath = join(workspace.projectDirectory, "src/used.ts"); - writeFileSync(editedPath, `${readFileSync(editedPath, "utf-8")}export const extra = 6;\n`); - const oneTouched = await scan(workspace, { cached: true }); - assert.deepEqual(oneTouched.incrementalCacheStats, { - summaryHits: collectedFileCount - 1, - summaryMisses: 1, - }); - }); - - it("answers the stale-package glob queries from the walk byte-identically to fast-glob", async () => { - const workspace = buildFixture({ - "packages/one/package.json": JSON.stringify({ name: "one" }), - "packages/one/nested/two/three/package.json": JSON.stringify({ name: "too-deep-for-5" }), - "dist/package.json": JSON.stringify({ name: "ignored-dist" }), - ".hidden/package.json": JSON.stringify({ name: "dot-excluded" }), - "docs/guide/deep/topics/more/usage.md": "# usage\n", - "docs/.dot.md": "# dot file\n", - "CHANGELOG.md": "# changes\n", - ".storybook/main.ts": "export default {};\n", - "tools/webpack.dev.config.js": "module.exports = {};\n", - "apps/site/tsconfig.build.json": "{}\n", - "linked-target/inner/package.json": JSON.stringify({ name: "via-symlink" }), - }); - symlinkSync( - join(workspace.projectDirectory, "linked-target"), - join(workspace.projectDirectory, "linked"), - ); - - const cache = loadSummaryCache( - defineConfig({ - rootDir: workspace.projectDirectory, - incrementalCachePath: workspace.cachePath, - }), - ); - assert.ok(cache, "the cache must load for this fixture"); - - const queries: Array<{ - patterns: string[]; - ignore: string[]; - deep: number; - dot?: boolean; - }> = [ - { - patterns: ["**/package.json"], - ignore: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.git/**"], - deep: 5, - }, - { - patterns: [".storybook/main.{js,ts,mjs,cjs}", "**/webpack*.config*.{js,ts,mjs,cjs}"], - ignore: ["**/node_modules/**"], - dot: true, - deep: 3, - }, - { - patterns: ["**/*.{mdx,md}"], - ignore: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/CHANGELOG.md"], - deep: 6, - }, - { - patterns: ["tsconfig.json", "tsconfig.*.json", "**/tsconfig.json", "**/tsconfig.*.json"], - ignore: ["**/node_modules/**"], - dot: false, - deep: 4, - }, - ]; - const fg = (await import("fast-glob")).default; - for (const query of queries) { - const walked = cache.matchWalkedFiles({ cwd: workspace.projectDirectory, ...query }); - assert.ok(walked, "the walk root matches, so the query must be answerable"); - const globbed = fg - .sync(query.patterns, { - cwd: workspace.projectDirectory, - absolute: true, - onlyFiles: true, - ignore: query.ignore, - deep: query.deep, - ...(query.dot === undefined ? {} : { dot: query.dot }), - }) - .sort(); - assert.deepEqual(walked, globbed, JSON.stringify(query.patterns)); - } - assert.ok( - cache - .matchWalkedFiles({ - cwd: workspace.projectDirectory, - patterns: ["**/package.json"], - ignore: [], - deep: 5, - }) - ?.some((matchedPath) => matchedPath.endsWith("linked/inner/package.json")), - "symlinked directories are followed, matching fast-glob", - ); - - // A search root other than the walk root (the monorepo-root case) is not - // answerable from this walk — callers fall back to a real glob scan. - assert.equal( - cache.matchWalkedFiles({ - cwd: dirname(workspace.projectDirectory), - patterns: ["**/package.json"], - ignore: [], - deep: 5, - }), - null, - ); - }); -}); diff --git a/packages/deslop-js/tests/type-analysis.test.ts b/packages/deslop-js/tests/type-analysis.test.ts deleted file mode 100644 index 54e55b9fc5..0000000000 --- a/packages/deslop-js/tests/type-analysis.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { resolve, dirname } from "node:path"; -import ts from "typescript"; -import { analyze, defineConfig } from "../src/index.js"; -import { FIXTURES_DIR } from "./helpers/fixtures-dir.js"; - -interface DifferentialOutcome { - deslopFlags: Set<string>; - tsExpectedUnused: Set<string>; - declaredTypeNames: Set<string>; -} - -const collectDeclaredExportedTypeNames = ( - program: ts.Program, - checker: ts.TypeChecker, -): Set<string> => { - const declaredNames = new Set<string>(); - for (const sourceFile of program.getSourceFiles()) { - if (sourceFile.isDeclarationFile) continue; - const moduleSymbol = checker.getSymbolAtLocation(sourceFile); - if (!moduleSymbol) continue; - for (const exportSymbol of checker.getExportsOfModule(moduleSymbol)) { - const resolvedSymbol = - exportSymbol.flags & ts.SymbolFlags.Alias - ? checker.getAliasedSymbol(exportSymbol) - : exportSymbol; - const isPureType = - Boolean( - resolvedSymbol.flags & - (ts.SymbolFlags.Interface | - ts.SymbolFlags.TypeAlias | - ts.SymbolFlags.Enum | - ts.SymbolFlags.RegularEnum | - ts.SymbolFlags.ConstEnum), - ) && - !( - resolvedSymbol.flags & - (ts.SymbolFlags.Variable | - ts.SymbolFlags.Function | - ts.SymbolFlags.Class | - ts.SymbolFlags.BlockScopedVariable | - ts.SymbolFlags.FunctionScopedVariable) - ); - if (isPureType) { - declaredNames.add(exportSymbol.name); - } - } - } - return declaredNames; -}; - -const countNonDeclarationReferences = ( - program: ts.Program, - checker: ts.TypeChecker, - targetSymbol: ts.Symbol, -): number => { - let referenceCount = 0; - - const visitForReferences = (node: ts.Node): void => { - if (ts.isIdentifier(node)) { - const symbol = checker.getSymbolAtLocation(node); - const resolvedSymbol = - symbol && symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; - if (resolvedSymbol === targetSymbol) { - const parent = node.parent; - const isDeclarationName = - parent && - (ts.isInterfaceDeclaration(parent) || - ts.isTypeAliasDeclaration(parent) || - ts.isEnumDeclaration(parent) || - ts.isClassDeclaration(parent) || - ts.isFunctionDeclaration(parent) || - ts.isVariableDeclaration(parent)) && - parent.name === node; - const isExportSpecifier = parent && ts.isExportSpecifier(parent); - if (!isDeclarationName && !isExportSpecifier) { - referenceCount++; - } - } - } - ts.forEachChild(node, visitForReferences); - const jsDocContainer = node as ts.Node & { jsDoc?: ts.JSDoc[] }; - if (jsDocContainer.jsDoc) { - for (const jsDocNode of jsDocContainer.jsDoc) { - visitForReferences(jsDocNode); - } - } - }; - - for (const sourceFile of program.getSourceFiles()) { - if (sourceFile.isDeclarationFile) continue; - visitForReferences(sourceFile); - } - - return referenceCount; -}; - -const runDifferential = async (fixtureName: string): Promise<DifferentialOutcome> => { - const fixtureDir = resolve(FIXTURES_DIR, fixtureName); - const result = await analyze( - defineConfig({ - rootDir: fixtureDir, - semantic: { enabled: true }, - }), - ); - - const tsconfigPath = resolve(fixtureDir, "tsconfig.json"); - const configContents = ts.readConfigFile(tsconfigPath, ts.sys.readFile); - const parsed = ts.parseJsonConfigFileContent( - configContents.config, - ts.sys, - dirname(tsconfigPath), - { noEmit: true, skipLibCheck: true }, - tsconfigPath, - ); - const program = ts.createProgram({ - rootNames: parsed.fileNames, - options: parsed.options, - }); - const checker = program.getTypeChecker(); - - const declaredTypeNames = collectDeclaredExportedTypeNames(program, checker); - const tsExpectedUnused = new Set<string>(); - for (const sourceFile of program.getSourceFiles()) { - if (sourceFile.isDeclarationFile) continue; - const moduleSymbol = checker.getSymbolAtLocation(sourceFile); - if (!moduleSymbol) continue; - for (const exportSymbol of checker.getExportsOfModule(moduleSymbol)) { - if (!declaredTypeNames.has(exportSymbol.name)) continue; - const resolvedSymbol = - exportSymbol.flags & ts.SymbolFlags.Alias - ? checker.getAliasedSymbol(exportSymbol) - : exportSymbol; - const referenceCount = countNonDeclarationReferences(program, checker, resolvedSymbol); - if (referenceCount === 0) { - tsExpectedUnused.add(exportSymbol.name); - } - } - } - - return { - deslopFlags: new Set(result.unusedTypes.map((unusedType) => unusedType.name)), - tsExpectedUnused, - declaredTypeNames, - }; -}; - -const assertDeslopSubsetOfTsExpected = ( - outcome: DifferentialOutcome, - fixtureName: string, -): void => { - for (const deslopName of outcome.deslopFlags) { - assert.ok( - outcome.tsExpectedUnused.has(deslopName), - `[${fixtureName}] deslop flagged "${deslopName}" but the differential checker did not. ` + - `Either the rule is a false positive, or document the divergence in this test name. ` + - `ts-expected: ${[...outcome.tsExpectedUnused]} | deslop: ${[...outcome.deslopFlags]}`, - ); - } -}; - -describe("type-analysis differential (Tier 2)", () => { - for (const fixtureName of [ - "unused-types-basic", - "unused-types-nested", - "unused-types-extends", - "unused-types-decl-merge", - "unused-types-generics", - "unused-types-import-type", - "unused-types-jsdoc", - ]) { - it(`${fixtureName}: deslop.unusedTypes is a subset of TS-known-unused`, async () => { - const outcome = await runDifferential(fixtureName); - assertDeslopSubsetOfTsExpected(outcome, fixtureName); - }); - } - - it("unused-types-reexport-chain: deslop's medium-confidence covers re-export ghosts (divergence documented)", async () => { - const outcome = await runDifferential("unused-types-reexport-chain"); - for (const deslopName of outcome.deslopFlags) { - if (outcome.tsExpectedUnused.has(deslopName)) continue; - assert.ok( - outcome.declaredTypeNames.has(deslopName), - `${deslopName} must be a declared type even when re-exported`, - ); - } - }); -}); diff --git a/packages/deslop-js/tsconfig.json b/packages/deslop-js/tsconfig.json deleted file mode 100644 index b7637bab32..0000000000 --- a/packages/deslop-js/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "module": "NodeNext", - "esModuleInterop": true, - "strictNullChecks": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "lib": ["esnext"], - "skipLibCheck": true, - "declaration": true, - "outDir": "dist" - }, - "include": ["src", "vite.config.ts"], - "exclude": ["**/node_modules/**", "dist", "**/*.test.ts"] -} diff --git a/packages/deslop-js/vite.config.ts b/packages/deslop-js/vite.config.ts deleted file mode 100644 index 87295ff145..0000000000 --- a/packages/deslop-js/vite.config.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { defineConfig } from "vite-plus"; - -export default defineConfig({ - pack: [ - { - entry: ["./src/index.ts", "./src/analyzed-inputs.ts"], - format: ["cjs", "esm"], - dts: true, - clean: true, - platform: "node", - sourcemap: false, - minify: process.env.NODE_ENV === "production", - }, - { - entry: ["./src/collect/parse-worker.ts", "./src/collect/entries-worker.ts"], - format: ["esm"], - dts: false, - clean: false, - platform: "node", - sourcemap: false, - minify: process.env.NODE_ENV === "production", - }, - ], - test: { - include: ["tests/**/*.test.ts"], - }, -}); diff --git a/packages/fuzz/corpus/regressions/no-high-complexity-react-function--optional-member-reads.tsx b/packages/fuzz/corpus/regressions/no-high-complexity-react-function--optional-member-reads.tsx new file mode 100644 index 0000000000..2ed3af04d4 --- /dev/null +++ b/packages/fuzz/corpus/regressions/no-high-complexity-react-function--optional-member-reads.tsx @@ -0,0 +1,26 @@ +// rule: no-high-complexity-react-function +// verdict: pass +// weakness: control-flow +// source: PR #1624 Daytona parity audit + +interface Defaults { + section?: { + first?: { label?: string }; + second?: { label?: string }; + third?: { label?: string }; + fourth?: { label?: string }; + fifth?: { label?: string }; + sixth?: { label?: string }; + }; +} + +export const LinearSettingsForm = ({ defaults }: { defaults?: Defaults }) => ( + <form> + <output>{defaults?.section?.first?.label}</output> + <output>{defaults?.section?.second?.label}</output> + <output>{defaults?.section?.third?.label}</output> + <output>{defaults?.section?.fourth?.label}</output> + <output>{defaults?.section?.fifth?.label}</output> + <output>{defaults?.section?.sixth?.label}</output> + </form> +); diff --git a/packages/fuzz/corpus/regressions/three-prefer-set-animation-loop--finite-frame-chunks.ts b/packages/fuzz/corpus/regressions/three-prefer-set-animation-loop--finite-frame-chunks.ts new file mode 100644 index 0000000000..eab21295fc --- /dev/null +++ b/packages/fuzz/corpus/regressions/three-prefer-set-animation-loop--finite-frame-chunks.ts @@ -0,0 +1,15 @@ +// rule: three-prefer-set-animation-loop +// verdict: pass +// weakness: control-flow +// source: thinky-3d tml-200-clay-park + +const runBuildChunk = () => { + while (stepIndex < steps.length && performance.now() < deadline) runStep(); + if (stepIndex < steps.length) { + requestAnimationFrame(runBuildChunk); + return; + } + finishBuild(); +}; + +requestAnimationFrame(runBuildChunk); diff --git a/packages/language-server/CHANGELOG.md b/packages/language-server/CHANGELOG.md deleted file mode 100644 index 72418a6af6..0000000000 --- a/packages/language-server/CHANGELOG.md +++ /dev/null @@ -1,304 +0,0 @@ -# @react-doctor/language-server - -## 0.9.12 - -### Patch Changes - -- Updated dependencies [[`51e198d`](https://github.com/millionco/react-doctor/commit/51e198db8bcbd61ad896098bb4985376641a0f69), [`0f3995b`](https://github.com/millionco/react-doctor/commit/0f3995b822ad9fdbd355eda05c8568f67643a31c)]: - - @react-doctor/core@0.9.12 - -## 0.9.11 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.9.11 - -## 0.9.10 - -### Patch Changes - -- Updated dependencies [[`e69faca`](https://github.com/millionco/react-doctor/commit/e69facac7e7ec455c7ad63c771c4a76f5cd0862c)]: - - @react-doctor/core@0.9.10 - -## 0.9.9 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.9.9 - -## 0.9.8 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.9.8 - -## 0.9.7 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.9.7 - -## 0.9.6 - -### Patch Changes - -- Updated dependencies [[`ac4e51f`](https://github.com/millionco/react-doctor/commit/ac4e51f6856dd0df091eed6ed4cdcb190574c048)]: - - @react-doctor/core@0.9.6 - -## 0.9.5 - -### Patch Changes - -- Updated dependencies [[`212b8b4`](https://github.com/millionco/react-doctor/commit/212b8b41131dcc486ffbfde19e84b2043a9a3470)]: - - @react-doctor/core@0.9.5 - -## 0.9.4 - -### Patch Changes - -- Updated dependencies [[`48ec9a8`](https://github.com/millionco/react-doctor/commit/48ec9a802077749f3ec7534a5cac00397d4dd4df)]: - - @react-doctor/core@0.9.4 - -## 0.9.3 - -### Patch Changes - -- Updated dependencies [[`83f3ff8`](https://github.com/millionco/react-doctor/commit/83f3ff8ac7c231603e9488e322039b021099a85b), [`f1a1b16`](https://github.com/millionco/react-doctor/commit/f1a1b16eb51ca89fb152cd2b472065e73bc51cac), [`2db2a97`](https://github.com/millionco/react-doctor/commit/2db2a972833dd2bf618af08be8d7bfb7beaa4f73), [`86add14`](https://github.com/millionco/react-doctor/commit/86add142688fa951a456d86c006f3f8c7c36c070), [`b1352a2`](https://github.com/millionco/react-doctor/commit/b1352a2be4baf42962b1624151b7382fc09dc3ca), [`86add14`](https://github.com/millionco/react-doctor/commit/86add142688fa951a456d86c006f3f8c7c36c070)]: - - @react-doctor/core@0.9.3 - -## 0.9.2 - -### Patch Changes - -- Updated dependencies [[`7ee59d3`](https://github.com/millionco/react-doctor/commit/7ee59d3125b28984fa02c0a8c2e6780bbac8e2bb), [`80e89c5`](https://github.com/millionco/react-doctor/commit/80e89c5ff563c88a2cc720afabf924062c103382), [`1aa6b12`](https://github.com/millionco/react-doctor/commit/1aa6b12fd69c01abfe17b5bb417c2d7ee3cae42e), [`8534d86`](https://github.com/millionco/react-doctor/commit/8534d864e4f94b90e964d56a4f741cc1596d63db)]: - - @react-doctor/core@0.9.2 - -## 0.9.1 - -### Patch Changes - -- Updated dependencies [[`30369b6`](https://github.com/millionco/react-doctor/commit/30369b6000d2bcb08ec34e07f1486ef1fbe482de)]: - - @react-doctor/core@0.9.1 - -## 0.9.0 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.9.0 - -## 0.8.3 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.8.3 - -## 0.8.2 - -### Patch Changes - -- Updated dependencies [[`f4aa821`](https://github.com/millionco/react-doctor/commit/f4aa8214bfac4b52c5613f25bbad29e68cbeb28d), [`8c4959b`](https://github.com/millionco/react-doctor/commit/8c4959bb7400a6d5f21cc35a8d54d0ed7faf6971), [`0b0b5ac`](https://github.com/millionco/react-doctor/commit/0b0b5ac52301cbfbb5abdffe4d0d9bf673325a94), [`af33723`](https://github.com/millionco/react-doctor/commit/af337232873fa5c96ec69fac453868f14a9be071), [`3598138`](https://github.com/millionco/react-doctor/commit/3598138c7bdd55dac55bf17bc72ccfef1e4c2efd), [`cd9ca68`](https://github.com/millionco/react-doctor/commit/cd9ca68faa25d287c02f4bbdc5007e1fbe1c6fc1), [`1839566`](https://github.com/millionco/react-doctor/commit/18395664810b9e08d024f4b679d7ab2089b05b7e)]: - - @react-doctor/core@0.8.2 - -## 0.8.1 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.8.1 - -## 0.8.0 - -### Patch Changes - -- Updated dependencies [[`2979a9b`](https://github.com/millionco/react-doctor/commit/2979a9bc1b1f18c685bafe6d92edb20b4b1a8707)]: - - @react-doctor/core@0.8.0 - -## 0.7.9 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.7.9 - -## 0.7.8 - -### Patch Changes - -- [#1257](https://github.com/millionco/react-doctor/pull/1257) [`e632f8a`](https://github.com/millionco/react-doctor/commit/e632f8a6c4d5a9ba1eddfb5a01d5dd0d109cce22) Thanks [@aidenybai](https://github.com/aidenybai)! - Give same-site diagnostics distinct deterministic occurrence IDs in JSON reports and editor actions. - -- Updated dependencies []: - - @react-doctor/core@0.7.8 - -## 0.7.7 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.7.7 - -## 0.7.6 - -### Patch Changes - -- Updated dependencies [[`037bd56`](https://github.com/millionco/react-doctor/commit/037bd569eca61132deb581511d8893c05ee87bf6)]: - - @react-doctor/core@0.7.6 - -## 0.7.5 - -### Patch Changes - -- Updated dependencies [[`99ac4ff`](https://github.com/millionco/react-doctor/commit/99ac4ff842ea8819b4cfce2548bddf0f5b47e6df)]: - - @react-doctor/core@0.7.5 - -## 0.7.4 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.7.4 - -## 0.7.3 - -### Patch Changes - -- Updated dependencies [[`08b768b`](https://github.com/millionco/react-doctor/commit/08b768bb4a7ce80927f7ad15bc3850a1f7585457)]: - - @react-doctor/core@0.7.3 - -## 0.7.2 - -### Patch Changes - -- Updated dependencies [[`9cb4149`](https://github.com/millionco/react-doctor/commit/9cb414905de7b360d728ca08d45167116a94ee90), [`d353dad`](https://github.com/millionco/react-doctor/commit/d353dadf988c52e3037dff52eec9cf8923145364), [`5d2f17f`](https://github.com/millionco/react-doctor/commit/5d2f17f71c9fb8e0d8d649da1b26de8f5cfe6c34), [`9cb4149`](https://github.com/millionco/react-doctor/commit/9cb414905de7b360d728ca08d45167116a94ee90), [`ce9dabf`](https://github.com/millionco/react-doctor/commit/ce9dabf1103f4f989bb8f9c1783a24674ba163e7)]: - - @react-doctor/core@0.7.2 - -## 0.7.1 - -### Patch Changes - -- Updated dependencies [[`c0c3fc1`](https://github.com/millionco/react-doctor/commit/c0c3fc170972876c8bbc2419b32e66b9c864df85)]: - - @react-doctor/core@0.7.1 - -## 0.7.0 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.7.0 - -## 0.6.3 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.6.3 - -## 0.6.2 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.6.2 - -## 0.6.1 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.6.1 - -## 0.6.0 - -### Patch Changes - -- Updated dependencies [[`8232e96`](https://github.com/millionco/react-doctor/commit/8232e967238ff7943c0cac0d0b2a2f9d349c89dd), [`99f2417`](https://github.com/millionco/react-doctor/commit/99f2417d8c181916919e6ae0a5ea0722770c7857), [`5f2bd72`](https://github.com/millionco/react-doctor/commit/5f2bd7254362109555194e43a019824478cb9ab5), [`441e6af`](https://github.com/millionco/react-doctor/commit/441e6afb55ee154e70e56f10a79565b9fd1f3295), [`c16e8ea`](https://github.com/millionco/react-doctor/commit/c16e8ea6f6cd455c837d02aafedb916817a4008e), [`fff9466`](https://github.com/millionco/react-doctor/commit/fff946689638bab3641474b6f8712a62777934ab), [`80e3093`](https://github.com/millionco/react-doctor/commit/80e3093815ecc40f29442ef44b4fee9accd76e8a), [`c2ce298`](https://github.com/millionco/react-doctor/commit/c2ce2989add3e43d21b7f609cad975e0284b6c42), [`ea4d9af`](https://github.com/millionco/react-doctor/commit/ea4d9afd4f2afc15c5d52217c3d001bd02b84046)]: - - @react-doctor/core@0.6.0 - -## 0.5.8 - -### Patch Changes - -- Updated dependencies [[`627f9ca`](https://github.com/millionco/react-doctor/commit/627f9ca4b363f7b7a037f2a77cba1213b7d605ae), [`350a6ed`](https://github.com/millionco/react-doctor/commit/350a6edc59dff4d7d4adcb6c6348144ded900d8c), [`627f9ca`](https://github.com/millionco/react-doctor/commit/627f9ca4b363f7b7a037f2a77cba1213b7d605ae), [`627f9ca`](https://github.com/millionco/react-doctor/commit/627f9ca4b363f7b7a037f2a77cba1213b7d605ae), [`627f9ca`](https://github.com/millionco/react-doctor/commit/627f9ca4b363f7b7a037f2a77cba1213b7d605ae), [`9f733f7`](https://github.com/millionco/react-doctor/commit/9f733f7cff1055f631d69dbc84848efa948c0d89), [`627f9ca`](https://github.com/millionco/react-doctor/commit/627f9ca4b363f7b7a037f2a77cba1213b7d605ae), [`627f9ca`](https://github.com/millionco/react-doctor/commit/627f9ca4b363f7b7a037f2a77cba1213b7d605ae), [`2cadd3f`](https://github.com/millionco/react-doctor/commit/2cadd3fe2cb5b0476b35b1581c0a4c99bcdf1306), [`4560b6d`](https://github.com/millionco/react-doctor/commit/4560b6dd39a6826f3d65476df12032cae7abfc63)]: - - @react-doctor/core@0.5.8 - -## 0.5.7 - -### Patch Changes - -- Updated dependencies [[`431e515`](https://github.com/millionco/react-doctor/commit/431e515260a209088c2305c6372249009dd95474)]: - - @react-doctor/core@0.5.7 - -## 0.5.6 - -### Patch Changes - -- Updated dependencies [[`ea3b827`](https://github.com/millionco/react-doctor/commit/ea3b8278996613114c9c671afe292193388741c0), [`cf9e05b`](https://github.com/millionco/react-doctor/commit/cf9e05be8ee1f1781878c28b8342490ec11c176f), [`5fc0e27`](https://github.com/millionco/react-doctor/commit/5fc0e270c9a15d25be96ef982755cea81065d141), [`ea3b827`](https://github.com/millionco/react-doctor/commit/ea3b8278996613114c9c671afe292193388741c0), [`bac7c82`](https://github.com/millionco/react-doctor/commit/bac7c82950e2392ac4b21448f3e9cf86b605567f)]: - - @react-doctor/core@0.5.6 - -## 0.5.5 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.5.5 - -## 0.5.4 - -### Patch Changes - -- Updated dependencies [[`eacdcf2`](https://github.com/millionco/react-doctor/commit/eacdcf2e65d6755fc000c6e05d8b76a49440adfb)]: - - @react-doctor/core@0.5.4 - -## 0.5.3 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.5.3 - -## 0.5.2 - -### Patch Changes - -- Updated dependencies [[`2f26228`](https://github.com/millionco/react-doctor/commit/2f26228e36cfe64a430a41596d7b1053d6d7d307), [`a48fb06`](https://github.com/millionco/react-doctor/commit/a48fb06ffbe7221655e18529fcc954ecae17a22f)]: - - @react-doctor/core@0.5.2 - -## 0.5.1 - -### Patch Changes - -- Updated dependencies []: - - @react-doctor/core@0.5.1 - -## 0.5.0 - -### Patch Changes - -- Updated dependencies [[`963eaf5`](https://github.com/millionco/react-doctor/commit/963eaf53db7de069baf2c7d18075443c3d934f9b), [`93d4eec`](https://github.com/millionco/react-doctor/commit/93d4eecdb8e9e339f4258e67fcfc3649e2024ede)]: - - @react-doctor/core@0.5.0 - -## 0.4.2 - -### Patch Changes - -- Updated dependencies [[`d17dc87`](https://github.com/millionco/react-doctor/commit/d17dc87865e059f21534990d0925115db439dc3e)]: - - @react-doctor/core@0.4.2 - -## 0.2.13 - -### Patch Changes - -- Updated dependencies [[`fe5f3de`](https://github.com/millionco/react-doctor/commit/fe5f3de330c5c55f6bcbed68070296eb67c2ec5b)]: - - @react-doctor/core@0.3.1 - -## 0.2.12 - -### Patch Changes - -- Updated dependencies [[`9a8ad6e`](https://github.com/millionco/react-doctor/commit/9a8ad6e40d9ed1fbe7ddb1f1c57bfd5c791a4b9e)]: - - @react-doctor/core@0.3.0 diff --git a/packages/language-server/README.md b/packages/language-server/README.md deleted file mode 100644 index e94f2ab4d5..0000000000 --- a/packages/language-server/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# @react-doctor/language-server - -The editor brain behind React Doctor. A Language Server Protocol (LSP) -server that surfaces React Doctor diagnostics directly in your editor — -VS Code, Cursor, Neovim, Zed, Helix, or any LSP client — instead of only -on the command line. - -This package is internal (not published on its own). It is bundled into -the published `react-doctor` CLI and started with the experimental command: - -```bash -react-doctor experimental-lsp --stdio -``` - -## What it does - -- **Live diagnostics** — scans the file you are editing on every change - using an in-memory overlay of the unsaved buffer, so squiggles reflect - what is on screen, not the last save. -- **Precise ranges** — maps oxlint's UTF-8 byte spans to exact editor - ranges, so the underline lands on the offending token. -- **Rich hovers** — rule id, severity, category, the rule's - recommendation, suppression hints, and a link to the docs. -- **Quick fixes** — "Disable this rule for this line" (with the correct - `//` or `{/* … */}` comment for the context), "Suppress all issues in - this file", plus explain / open-docs / report-false-positive actions. -- **Workspace aware** — discovers every React project across workspace - folders and monorepo packages, picks the owning project per file, and - invalidates caches when config / `package.json` / lockfiles change. -- **Responsive** — a priority scheduler runs open-buffer scans first, - debounces edits, bounds concurrency, and drops superseded scans so a - large monorepo never blocks the file you are in. -- **Push + pull diagnostics** — publishes diagnostics proactively and - answers `textDocument/diagnostic` pull requests for clients that use - them. -- **Status + progress** — reports work-done progress while scanning and a - rust-analyzer-style `experimental/serverStatus` notification (`health`, - `quiescent`, `message`) for a persistent editor status indicator. -- **Signal-tiered severity** — weak-signal `design` rules map to LSP - `Information` so they don't drown out real findings; `codeAction` - requests honor `context.only`, and the file-level suppress uses a - namespaced `source.suppressAll.reactDoctor` kind (never bare `source`, - which an on-save config could trigger destructively). - -## Architecture - -``` -documents (open buffers) ─┐ -workspace / watcher events ┤→ scheduler → scan-runner → @react-doctor/core - │ (overlay fs) runEditorScan - └→ project graph │ - ▼ - diagnostics manager (map → publish) - │ - hover / code actions / commands -``` - -All linting goes through `@react-doctor/core`'s `runEditorScan`, which -runs the same diagnostic pipeline as the CLI (config, ignores, inline -suppressions, severity controls) but offline: no hosted score lookup and -no git metadata, so scans are fast and side-effect free. - -## Commands - -The server registers these `workspace/executeCommand` commands (also -contributed by the companion editor extension): - -- `react-doctor.scanWorkspace` -- `react-doctor.scanFile` -- `react-doctor.fixAll` -- `react-doctor.explain` -- `react-doctor.openDocs` -- `react-doctor.suppressLine` -- `react-doctor.reportFalsePositive` -- `react-doctor.restart` diff --git a/packages/language-server/bin/react-doctor-language-server.js b/packages/language-server/bin/react-doctor-language-server.js deleted file mode 100755 index 068b2362fd..0000000000 --- a/packages/language-server/bin/react-doctor-language-server.js +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env node - -import module from "node:module"; - -if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) { - try { - module.enableCompileCache(); - } catch { - // Ignore compile-cache errors. - } -} - -const { startLanguageServer } = await import("../dist/index.js"); - -startLanguageServer(); diff --git a/packages/language-server/package.json b/packages/language-server/package.json deleted file mode 100644 index f0d523564a..0000000000 --- a/packages/language-server/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@react-doctor/language-server", - "version": "0.9.12", - "private": true, - "description": "Language server for React Doctor editor integrations.", - "license": "SEE LICENSE IN LICENSE", - "bin": { - "react-doctor-language-server": "./bin/react-doctor-language-server.js" - }, - "files": [ - "bin/**", - "dist/**/*.js", - "dist/**/*.d.ts" - ], - "type": "module", - "sideEffects": false, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./package.json": "./package.json" - }, - "scripts": { - "dev": "vp pack --watch", - "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && cross-env NODE_ENV=production vp pack", - "typecheck": "tsc --noEmit", - "test": "vp test run" - }, - "dependencies": { - "@react-doctor/core": "workspace:*", - "vscode-languageserver": "^9.0.1", - "vscode-languageserver-textdocument": "^1.0.12", - "vscode-uri": "^3.1.0" - }, - "devDependencies": { - "@types/node": "^25.6.0" - }, - "engines": { - "node": "^20.19.0 || >=22.13.0" - } -} diff --git a/packages/language-server/src/constants.ts b/packages/language-server/src/constants.ts deleted file mode 100644 index 2fd5d78b5f..0000000000 --- a/packages/language-server/src/constants.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** Display name used in client-facing messages and progress titles. */ -export const SERVER_DISPLAY_NAME = "React Doctor"; - -/** Server version reported in `serverInfo`; injected at build, `dev` from source. */ -export const SERVER_VERSION = process.env.VERSION ?? "0.0.0-dev"; - -/** `Diagnostic.source` shown next to every published diagnostic. */ -export const DIAGNOSTIC_SOURCE = "react-doctor"; - -/** - * Debounce window between an open document's last edit and the - * overlay rescan it triggers. Long enough that fast typing collapses - * into a single scan, short enough to still feel live. - */ -export const DOCUMENT_CHANGE_DEBOUNCE_MS = 300; - -/** - * Debounce before config / package / lockfile changes trigger a - * workspace-wide cache invalidation + rescan. Coalesces the burst of - * watcher events a single `pnpm install` or config edit produces. - */ -export const CONFIG_CHANGE_DEBOUNCE_MS = 500; - -/** Delay after `initialized` before the first background workspace scan. */ -export const INITIAL_WORKSPACE_SCAN_DELAY_MS = 300; - -/** - * Upper bound on parallel scans; effective concurrency is - * `clamp(cpus, MIN, MAX)`. React Doctor's rules run as oxlint JS plugins - * which are single-threaded per oxlint process, so the workspace scan - * scales nearly linearly with the number of concurrent oxlint processes - * (measured 3.4x going from 3 → 10 on a 10-core machine). The cap bounds - * memory on very large machines (each oxlint process holds ASTs for its - * batch). - */ -export const MAX_SCAN_CONCURRENCY = 16; - -/** Lower bound so background chunks still parallelize on small machines. */ -export const MIN_SCAN_CONCURRENCY = 2; - -/** Scheduler slots kept free for interactive/save scans during a workspace scan. */ -export const RESERVED_INTERACTIVE_SLOTS = 1; - -/** - * Source files per workspace-scan chunk. The workspace lint pass is split - * into chunks of this size so it streams diagnostics progressively, runs - * chunks in parallel, and is cancellable mid-scan (a config change or - * shutdown drops the remaining chunks instead of waiting out one giant - * non-cancellable oxlint run). - * - * Sized to match oxlint's internal `OXLINT_MAX_FILES_PER_BATCH` (100) so - * each chunk is exactly one oxlint spawn. Smaller chunks load-balance - * better across cores and reach first-diagnostics faster; measured best - * total + lowest time-to-first-result at 100 on a 10-core machine. - */ -export const WORKSPACE_SCAN_CHUNK_SIZE = 100; - -/** - * On-disk lint-cache schema version. Bump to invalidate every persisted - * cache after a format or diagnostic-semantics change. - */ -export const LINT_CACHE_VERSION = 3; - -/** - * Debounce before the in-memory lint cache is written to disk. A whole - * workspace scan stores thousands of entries; debouncing collapses that - * into a single write once the scan settles. - */ -export const LINT_CACHE_PERSIST_DEBOUNCE_MS = 2_000; - -/** - * Hex characters of the project-path hash used to name the lint cache file - * in the temp-dir fallback (when a project has no `node_modules`). Long - * enough to avoid collisions between projects, short enough for a tidy name. - */ -export const CACHE_FILENAME_HASH_LENGTH_CHARS = 16; - -// ── Command identifiers ──────────────────────────────────────────── -// Shared with the companion editor extension. Keep in sync with the -// extension's `package.json` `contributes.commands`. - -export const COMMAND_SCAN_WORKSPACE = "react-doctor.scanWorkspace"; -export const COMMAND_SCAN_FILE = "react-doctor.scanFile"; -export const COMMAND_FIX_ALL = "react-doctor.fixAll"; -export const COMMAND_EXPLAIN = "react-doctor.explain"; -export const COMMAND_OPEN_DOCS = "react-doctor.openDocs"; -export const COMMAND_SUPPRESS_LINE = "react-doctor.suppressLine"; -export const COMMAND_REPORT_FALSE_POSITIVE = "react-doctor.reportFalsePositive"; -export const COMMAND_RESTART = "react-doctor.restart"; - -/** Every command the server registers via `executeCommandProvider`. */ -export const ALL_COMMANDS = [ - COMMAND_SCAN_WORKSPACE, - COMMAND_SCAN_FILE, - COMMAND_FIX_ALL, - COMMAND_EXPLAIN, - COMMAND_OPEN_DOCS, - COMMAND_SUPPRESS_LINE, - COMMAND_REPORT_FALSE_POSITIVE, - COMMAND_RESTART, -] as const; - -/** Canonical GitHub repository, used for "report false positive" links. */ -export const CANONICAL_GITHUB_URL = "https://github.com/millionco/react-doctor"; - -/** - * Source file extensions the server scans on open / change / save / watch. - * Mirrors core's `SOURCE_FILE_PATTERN` — the set the workspace enumeration - * and the CLI lint — so reactive (open/change) and proactive (workspace) - * scanning cover exactly the same files. - */ -export const SCANNABLE_EXTENSIONS = [ - ".ts", - ".tsx", - ".js", - ".jsx", - ".mts", - ".mjs", - ".html", -] as const; - -export { CONFIG_FINGERPRINT_FILENAMES as CONFIG_WATCH_FILENAMES } from "@react-doctor/core"; diff --git a/packages/language-server/src/core/lint-cache.ts b/packages/language-server/src/core/lint-cache.ts deleted file mode 100644 index 6b58abd182..0000000000 --- a/packages/language-server/src/core/lint-cache.ts +++ /dev/null @@ -1,125 +0,0 @@ -import crypto from "node:crypto"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { messageFromUnknown, type Diagnostic as CoreDiagnostic } from "@react-doctor/core"; -import { - CACHE_FILENAME_HASH_LENGTH_CHARS, - LINT_CACHE_PERSIST_DEBOUNCE_MS, - LINT_CACHE_VERSION, -} from "../constants.js"; -import { SILENT_LOGGER, type Logger } from "../types.js"; - -export interface FileIdentity { - readonly contentHash: string; -} - -interface LintCacheEntry extends FileIdentity { - readonly diagnostics: CoreDiagnostic[]; -} - -interface PersistedCache { - readonly version: number; - readonly fingerprint: string; - readonly entries: Record<string, LintCacheEntry>; -} - -/** - * Per-project, content-aware lint result cache. Keyed by absolute file - * path + content hash so an unchanged file skips the oxlint subprocess - * entirely on re-scan. Namespaced by a config fingerprint so a config / - * dependency change starts fresh. Persisted to disk so a re-opened editor - * gets near-instant diagnostics for everything it hasn't edited. - */ -export interface LintCache { - /** Cached diagnostics for `fsPath` if its identity matches, else `null`. */ - readonly lookup: (fsPath: string, identity: FileIdentity) => CoreDiagnostic[] | null; - /** Record the diagnostics for a freshly-scanned file (empty = clean). */ - readonly store: (fsPath: string, identity: FileIdentity, diagnostics: CoreDiagnostic[]) => void; - /** Debounced write-back to disk. */ - readonly schedulePersist: () => void; - /** Write to disk now (cancels any pending debounce). */ - readonly flush: () => void; -} - -const resolveCacheFilePath = (projectDirectory: string): string => { - const nodeModules = path.join(projectDirectory, "node_modules"); - if (fs.existsSync(nodeModules)) { - return path.join(nodeModules, ".cache", "react-doctor", "lint-cache.json"); - } - // No node_modules (rare for a React project) → fall back to a temp dir - // keyed by a hash of the project path so projects don't collide. - const key = crypto - .createHash("sha1") - .update(projectDirectory) - .digest("hex") - .slice(0, CACHE_FILENAME_HASH_LENGTH_CHARS); - return path.join(os.tmpdir(), "react-doctor-cache", `${key}.json`); -}; - -export const createLintCache = (input: { - readonly projectDirectory: string; - readonly fingerprint: string; - readonly logger?: Logger; -}): LintCache => { - const logger = input.logger ?? SILENT_LOGGER; - const cacheFilePath = resolveCacheFilePath(input.projectDirectory); - const entries = new Map<string, LintCacheEntry>(); - let dirty = false; - let persistTimer: ReturnType<typeof setTimeout> | null = null; - - // Load a previous session's cache when the config fingerprint matches. - try { - const parsed: PersistedCache = JSON.parse(fs.readFileSync(cacheFilePath, "utf8")); - if (parsed.version === LINT_CACHE_VERSION && parsed.fingerprint === input.fingerprint) { - for (const [fsPath, entry] of Object.entries(parsed.entries)) { - entries.set(fsPath, entry); - } - } - } catch { - // Missing / unreadable / stale-fingerprint cache → start empty. - } - - const persist = (): void => { - if (persistTimer) { - clearTimeout(persistTimer); - persistTimer = null; - } - if (!dirty) return; - dirty = false; - try { - const payload: PersistedCache = { - version: LINT_CACHE_VERSION, - fingerprint: input.fingerprint, - entries: Object.fromEntries(entries), - }; - fs.mkdirSync(path.dirname(cacheFilePath), { recursive: true }); - // Atomic write: a crash mid-write can't corrupt the cache. - const tempPath = `${cacheFilePath}.${process.pid}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(payload)); - fs.renameSync(tempPath, cacheFilePath); - } catch (error) { - logger.warn(`Failed to persist lint cache: ${messageFromUnknown(error)}`); - } - }; - - return { - lookup: (fsPath, identity) => { - const entry = entries.get(fsPath); - if (entry !== undefined && entry.contentHash === identity.contentHash) { - return entry.diagnostics; - } - return null; - }, - store: (fsPath, identity, diagnostics) => { - entries.set(fsPath, { ...identity, diagnostics }); - dirty = true; - }, - schedulePersist: () => { - if (!dirty || persistTimer) return; - persistTimer = setTimeout(persist, LINT_CACHE_PERSIST_DEBOUNCE_MS); - if (typeof persistTimer.unref === "function") persistTimer.unref(); - }, - flush: persist, - }; -}; diff --git a/packages/language-server/src/core/overlay.ts b/packages/language-server/src/core/overlay.ts deleted file mode 100644 index 54b8873934..0000000000 --- a/packages/language-server/src/core/overlay.ts +++ /dev/null @@ -1,103 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { - ADOPTABLE_LINT_CONFIG_FILENAMES, - STAGED_FILES_PROJECT_CONFIG_FILENAMES, -} from "@react-doctor/core"; -import type { TextProvider } from "../types.js"; -import { toProjectRelative } from "../utils/to-project-relative.js"; - -const OVERLAY_TEMP_PREFIX = "react-doctor-lsp-"; - -// Project configs + adoptable lint configs (e.g. `.eslintrc.json`) the -// overlay must mirror so an on-type buffer scan resolves the SAME rule set -// as the on-save disk scan; otherwise findings flicker between the two. -const OVERLAY_CONFIG_FILENAMES = [ - ...new Set([...STAGED_FILES_PROJECT_CONFIG_FILENAMES, ...ADOPTABLE_LINT_CONFIG_FILENAMES]), -]; - -export interface OverlaySnapshot { - /** Temp directory mirroring the project with overlaid buffer content. */ - readonly tempDirectory: string; - /** Project-relative (forward-slash) paths written into the overlay. */ - readonly relativePaths: string[]; - /** Real (symlink-resolved) temp directory, for diagnostic path remap. */ - readonly realTempDirectory: string; - readonly cleanup: () => void; -} - -export interface MaterializeOverlayInput { - /** Absolute project root. */ - readonly projectDirectory: string; - /** Absolute target file paths to overlay (the open buffers). */ - readonly files: ReadonlyArray<string>; - /** Reads the live text of a file (open buffer first, then disk). */ - readonly readText: TextProvider; -} - -/** - * Writes the live (possibly unsaved) content of the target files into a - * throwaway temp tree that mirrors the project, alongside the well-known - * project config files oxlint needs to resolve. The scan runner points - * the linter at this tree so diagnostics reflect the editor buffer, not - * stale disk content. Returns `null` when nothing could be materialized - * (caller falls back to a disk scan). - */ -export const materializeOverlay = (input: MaterializeOverlayInput): OverlaySnapshot | null => { - const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), OVERLAY_TEMP_PREFIX)); - const relativePaths: string[] = []; - - try { - for (const filePath of input.files) { - const relative = toProjectRelative(input.projectDirectory, filePath); - if (relative === null) continue; - const content = input.readText(filePath); - if (content === null) continue; - const target = path.join(tempDirectory, relative); - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, content); - relativePaths.push(relative); - } - - if (relativePaths.length === 0) { - fs.rmSync(tempDirectory, { recursive: true, force: true }); - return null; - } - - for (const configFilename of OVERLAY_CONFIG_FILENAMES) { - const source = path.join(input.projectDirectory, configFilename); - const target = path.join(tempDirectory, configFilename); - if (fs.existsSync(source) && !fs.existsSync(target)) { - try { - fs.cpSync(source, target); - } catch { - // Best-effort: a missing/locked config just degrades resolution. - } - } - } - - let realTempDirectory = tempDirectory; - try { - realTempDirectory = fs.realpathSync(tempDirectory); - } catch { - // Keep the non-resolved path if realpath fails. - } - - return { - tempDirectory, - realTempDirectory, - relativePaths, - cleanup: () => { - try { - fs.rmSync(tempDirectory, { recursive: true, force: true }); - } catch { - // OS tempdir reapers eventually reclaim it. - } - }, - }; - } catch (error) { - fs.rmSync(tempDirectory, { recursive: true, force: true }); - throw error; - } -}; diff --git a/packages/language-server/src/core/project-graph.ts b/packages/language-server/src/core/project-graph.ts deleted file mode 100644 index 0225b2d94a..0000000000 --- a/packages/language-server/src/core/project-graph.ts +++ /dev/null @@ -1,86 +0,0 @@ -import path from "node:path"; -import { - clearConfigCache, - clearIgnorePatternsCache, - clearMinifiedFileCache, - clearPackageJsonCache, - clearPackageRoleCache, - clearProjectCache, - discoverReactSubprojects, - messageFromUnknown, -} from "@react-doctor/core"; -import { SILENT_LOGGER, type Logger, type ProjectGraph, type WorkspaceProject } from "../types.js"; - -export interface ProjectGraphOptions { - /** Absolute workspace root directories (LSP workspace folders). */ - readonly roots: ReadonlyArray<string>; - readonly logger?: Logger; -} - -/** Normalizes a path to absolute, forward-slash, no trailing slash. */ -const normalizeDirectory = (directory: string): string => { - const resolved = path.resolve(directory).replace(/\\/g, "/"); - return resolved.length > 1 && resolved.endsWith("/") ? resolved.slice(0, -1) : resolved; -}; - -const isInsideDirectory = (filePath: string, directory: string): boolean => - filePath === directory || filePath.startsWith(`${directory}/`); - -/** - * Discovers and indexes every React project across the workspace roots, - * and answers "which project owns this file?" with the deepest match. - * Discovery is lazy + cached; `invalidate` also flushes the core - * project / config / package.json caches so a config edit is honored on - * the next scan. - */ -export const createProjectGraph = (options: ProjectGraphOptions): ProjectGraph => { - const roots = options.roots.map(normalizeDirectory); - const logger = options.logger ?? SILENT_LOGGER; - let projects: WorkspaceProject[] | null = null; - - const discover = (): WorkspaceProject[] => { - const seen = new Map<string, WorkspaceProject>(); - for (const root of roots) { - try { - for (const workspacePackage of discoverReactSubprojects(root)) { - const directory = normalizeDirectory(workspacePackage.directory); - if (!seen.has(directory)) seen.set(directory, { directory }); - } - } catch (error) { - logger.warn(`Project discovery failed for ${root}: ${messageFromUnknown(error)}`); - } - } - // Deepest-first so owning-project resolution can take the first match. - return [...seen.values()].sort( - (first, second) => second.directory.length - first.directory.length, - ); - }; - - const ensure = (): WorkspaceProject[] => { - if (projects === null) projects = discover(); - return projects; - }; - - return { - listProjects: () => ensure(), - resolveOwningProject: (absoluteFilePath) => { - const normalizedFile = normalizeDirectory(absoluteFilePath); - for (const project of ensure()) { - if (isInsideDirectory(normalizedFile, project.directory)) return project.directory; - } - return null; - }, - refresh: () => { - projects = discover(); - }, - invalidate: () => { - clearProjectCache(); - clearConfigCache(); - clearPackageJsonCache(); - clearPackageRoleCache(); - clearIgnorePatternsCache(); - clearMinifiedFileCache(); - projects = null; - }, - }; -}; diff --git a/packages/language-server/src/core/scan-runner.ts b/packages/language-server/src/core/scan-runner.ts deleted file mode 100644 index 34bc5788cd..0000000000 --- a/packages/language-server/src/core/scan-runner.ts +++ /dev/null @@ -1,283 +0,0 @@ -import path from "node:path"; -import { - computeConfigFingerprint, - hashFileContents, - runEditorScan, - type Diagnostic as CoreDiagnostic, -} from "@react-doctor/core"; -import { - SILENT_LOGGER, - type CancellationToken, - type Logger, - type PerformScan, - type ScanOutcome, - type ScanRequest, - type TextProvider, -} from "../types.js"; -import { normalizeFsPath } from "../text/uri.js"; -import { toProjectRelative } from "../utils/to-project-relative.js"; -import { createLintCache, type FileIdentity, type LintCache } from "./lint-cache.js"; -import { materializeOverlay, type OverlaySnapshot } from "./overlay.js"; - -export interface ScanRunnerOptions { - /** Node binary able to load the oxlint native binding, or `null`. */ - readonly nodeBinaryPath: string | null; - /** Reads live file text (open buffer first, then disk) for overlays. */ - readonly readText: TextProvider; - /** - * Whether a file is currently open in the editor. Background disk scans - * re-check this at scan time (not just enqueue time) so a file opened - * mid-scan isn't overwritten by an already-queued chunk. - */ - readonly isOpen?: (fsPath: string) => boolean; - /** React Doctor version, part of the lint-cache config fingerprint. */ - readonly version: string; - /** Disable the persistent lint cache (kill switch). Defaults to enabled. */ - readonly enableCache?: boolean; - readonly logger?: Logger; -} - -export interface ScanRunner { - readonly performScan: PerformScan; - /** Drop in-memory caches after a config change (next scan reloads fresh). */ - readonly invalidateCaches: () => void; - /** Flush all caches to disk (on shutdown). */ - readonly dispose: () => void; -} - -/** - * Resolves a diagnostic's (possibly relative, possibly overlay-temp) - * file path back to the canonical absolute path inside the real project. - */ -const resolveDiagnosticFsPath = ( - rawFilePath: string, - scanDirectory: string, - projectDirectory: string, - overlay: OverlaySnapshot | null, -): string => { - const normalized = rawFilePath.replace(/\\/g, "/"); - const absolute = path.isAbsolute(normalized) - ? normalized - : path.posix.join(scanDirectory.replace(/\\/g, "/"), normalized); - - if (overlay !== null) { - for (const prefix of [overlay.tempDirectory, overlay.realTempDirectory]) { - const normalizedPrefix = prefix.replace(/\\/g, "/"); - if (absolute === normalizedPrefix || absolute.startsWith(`${normalizedPrefix}/`)) { - const rest = absolute.slice(normalizedPrefix.length); - return normalizeFsPath(`${projectDirectory}${rest}`); - } - } - } - - return normalizeFsPath(absolute); -}; - -const readFileIdentity = (fsPath: string): FileIdentity | null => { - const contentHash = hashFileContents(fsPath); - return contentHash === null ? null : { contentHash }; -}; - -/** - * Outcome for a per-file request served without running oxlint (every - * file was cached, or none resolved inside the project). `byFile` holds - * any cache hits; requested files absent from it are cleared downstream. - */ -const outcomeWithoutScan = ( - request: ScanRequest, - byFile: Map<string, CoreDiagnostic[]>, - requestedPaths: ReadonlyArray<string>, -): ScanOutcome => ({ - request, - ok: true, - skipped: false, - byFile, - coversProject: false, - requestedPaths, - project: null, - didLintFail: false, - lintFailureReason: null, - lintIncomplete: false, - error: null, -}); - -/** - * Creates the scan runner used by the scheduler. Each scan runs - * `runEditorScan` (offline, no score, no git) against either the live - * overlay tree (unsaved buffers) or disk, groups diagnostics by canonical - * absolute path, and reports stale-detection metadata. - * - * A persistent per-file lint cache (keyed by content, namespaced by a - * config fingerprint) short-circuits unchanged files so a re-opened editor - * or repeated workspace scan skips the oxlint subprocess for everything it - * hasn't edited. The cache applies only to disk-based, lint-only, per-file - * scans — overlay scans carry unsaved content, and whole-project / - * dead-code scans aren't per-file cacheable. - */ -export const createScanRunner = (options: ScanRunnerOptions): ScanRunner => { - const logger = options.logger ?? SILENT_LOGGER; - const cacheEnabled = options.enableCache ?? true; - const caches = new Map<string, LintCache>(); - - const getCache = (projectDirectory: string): LintCache => { - const existing = caches.get(projectDirectory); - if (existing) return existing; - const fingerprint = computeConfigFingerprint(projectDirectory, options.version); - const cache = createLintCache({ projectDirectory, fingerprint, logger }); - caches.set(projectDirectory, cache); - return cache; - }; - - const performScan = async ( - request: ScanRequest, - token: CancellationToken, - ): Promise<ScanOutcome | null> => { - if (token.isCancelled) return null; - - const projectDirectory = normalizeFsPath(request.projectDirectory); - const allRequested = request.files.map(normalizeFsPath); - const isWholeProject = allRequested.length === 0; - // Background disk chunks skip files open in the editor — re-checked here - // (not just at enqueue time) so a file opened mid-scan keeps its live - // overlay diagnostics instead of being clobbered by a queued chunk. - const requestedPaths = - request.priority === "background" && !request.useOverlay && options.isOpen - ? allRequested.filter((fsPath) => !options.isOpen?.(fsPath)) - : allRequested; - - const cache = - cacheEnabled && !isWholeProject && !request.useOverlay && !request.runDeadCode - ? getCache(projectDirectory) - : null; - - // Partition into cache hits (skip oxlint) and files that need scanning. - // Fresh results are merged into `byFile` after the scan below. - const byFile = new Map<string, CoreDiagnostic[]>(); - const identityByPath = new Map<string, FileIdentity>(); - let filesToScan = requestedPaths; - if (cache) { - const uncached: string[] = []; - for (const fsPath of requestedPaths) { - const identity = readFileIdentity(fsPath); - if (identity) { - identityByPath.set(fsPath, identity); - const hit = cache.lookup(fsPath, identity); - if (hit !== null) { - if (hit.length > 0) byFile.set(fsPath, hit); - continue; - } - } - uncached.push(fsPath); - } - filesToScan = uncached; - } - - // Whole batch served from cache → no subprocess needed. - if (cache && filesToScan.length === 0) { - return outcomeWithoutScan(request, byFile, requestedPaths); - } - - let scanDirectory = projectDirectory; - let includePaths: string[] | undefined; - let overlay: OverlaySnapshot | null = null; - - try { - if (!isWholeProject) { - if (request.useOverlay) { - overlay = materializeOverlay({ - projectDirectory, - files: filesToScan, - readText: options.readText, - }); - } - if (overlay !== null) { - scanDirectory = overlay.tempDirectory; - includePaths = overlay.relativePaths; - } else { - includePaths = filesToScan - .map((filePath) => toProjectRelative(projectDirectory, filePath)) - .filter((relative): relative is string => relative !== null); - } - - // A per-file request whose paths all resolved outside the project - // (or whose buffers were unreadable) yields an empty include list. - // Return null (no result): falling through, an empty `includePaths` - // would be treated as a whole-project scan, and emitting an outcome - // would clear those files as if they were lint-clean even though - // nothing was scanned. - if (includePaths.length === 0) { - return null; - } - } - - const result = await runEditorScan({ - directory: scanDirectory, - ...(includePaths !== undefined ? { includePaths } : {}), - runDeadCode: request.runDeadCode, - lint: true, - ...(options.nodeBinaryPath !== null ? { nodeBinaryPath: options.nodeBinaryPath } : {}), - }); - - if (token.isCancelled) return null; - - for (const diagnostic of result.diagnostics) { - const fsPath = resolveDiagnosticFsPath( - diagnostic.filePath, - scanDirectory, - projectDirectory, - overlay, - ); - const existing = byFile.get(fsPath); - if (existing) existing.push(diagnostic); - else byFile.set(fsPath, [diagnostic]); - } - - // Cache fresh results — but only when the scan fully succeeded (not a - // graceful skip or a partial failure), so a file that wasn't actually - // linted is never recorded as clean. - if ( - cache && - result.ok && - !result.skipped && - !result.didLintFail && - result.lintPartialFailures.length === 0 - ) { - for (const fsPath of filesToScan) { - const identity = identityByPath.get(fsPath); - if (!identity) continue; - cache.store(fsPath, identity, byFile.get(fsPath) ?? []); - } - cache.schedulePersist(); - } - - if (result.error !== null) { - logger.warn(`Scan error in ${projectDirectory}: ${result.error}`); - } - - return { - request, - ok: result.ok, - skipped: result.skipped, - byFile, - coversProject: isWholeProject, - requestedPaths, - project: result.project, - didLintFail: result.didLintFail, - lintFailureReason: result.lintFailureReason, - lintIncomplete: result.lintPartialFailures.length > 0, - error: result.error, - }; - } finally { - overlay?.cleanup(); - } - }; - - return { - performScan, - invalidateCaches: () => caches.clear(), - dispose: () => { - for (const cache of caches.values()) cache.flush(); - caches.clear(); - }, - }; -}; diff --git a/packages/language-server/src/diagnostics/manager.ts b/packages/language-server/src/diagnostics/manager.ts deleted file mode 100644 index 63cde3fa9a..0000000000 --- a/packages/language-server/src/diagnostics/manager.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { Diagnostic as LspDiagnostic, Position } from "vscode-languageserver"; -import { SILENT_LOGGER, type Logger, type ScanOutcome, type TextProvider } from "../types.js"; -import { isPositionInRange } from "../text/positions.js"; -import { fsPathToUri, uriToFsPath } from "../text/uri.js"; -import { toLspDiagnostic } from "./mapper.js"; - -export interface DiagnosticsManagerOptions { - /** Sends the authoritative diagnostic set for a URI to the client. */ - readonly publish: (uri: string, diagnostics: LspDiagnostic[]) => void; - /** Resolves current file text (open buffer or disk) for precise ranges. */ - readonly textProvider: TextProvider; - /** - * Whether a file is open in the editor. Background (disk) scans — - * including the whole-project `scanWorkspace` audit — must not overwrite - * or clear the live diagnostics of an open buffer; those belong to - * interactive overlay scans. - */ - readonly isOpen?: (fsPath: string) => boolean; - readonly logger?: Logger; -} - -/** - * Owns the published-diagnostic state. Maps scan outcomes to LSP - * diagnostics, publishes complete per-URI replacement sets (so the - * client never accumulates duplicates), and clears stale diagnostics - * when a project rescan no longer reports a file. Also answers - * position lookups for hovers and pull-diagnostic requests. - */ -export class DiagnosticsManager { - private readonly byUri = new Map<string, LspDiagnostic[]>(); - private readonly projectUris = new Map<string, Set<string>>(); - private readonly publish: DiagnosticsManagerOptions["publish"]; - private readonly textProvider: TextProvider; - private readonly isOpen: (fsPath: string) => boolean; - private readonly logger: Logger; - - constructor(options: DiagnosticsManagerOptions) { - this.publish = options.publish; - this.textProvider = options.textProvider; - this.isOpen = options.isOpen ?? (() => false); - this.logger = options.logger ?? SILENT_LOGGER; - } - - /** Applies a completed scan: maps, stores, publishes, and clears stale URIs. */ - applyOutcome(outcome: ScanOutcome): void { - if (!outcome.ok && !outcome.skipped) { - this.logger.warn( - `Scan of ${outcome.request.projectDirectory} failed: ${outcome.error ?? "unknown error"}`, - ); - } - - const project = outcome.request.projectDirectory; - const liveUris = new Set<string>(); - // A background disk scan (workspace chunk or whole-project audit) must - // not touch a file open in the editor — its live diagnostics come from - // interactive overlay scans of the unsaved buffer. - const protectOpen = outcome.request.priority === "background"; - const isProtectedPath = (fsPath: string): boolean => protectOpen && this.isOpen(fsPath); - - for (const [fsPath, coreDiagnostics] of outcome.byFile) { - if (isProtectedPath(fsPath)) continue; - const uri = fsPathToUri(fsPath); - const text = this.textProvider(fsPath); - const lspDiagnostics = coreDiagnostics.map((diagnostic) => - toLspDiagnostic({ diagnostic, fsPath, text }), - ); - if (lspDiagnostics.length > 0) { - this.byUri.set(uri, lspDiagnostics); - liveUris.add(uri); - } else { - this.byUri.delete(uri); - } - this.publish(uri, lspDiagnostics); - } - - // A failed, lint-degraded, partially-failed, or skipped scan didn't - // reliably assess every requested file. Record what it found, but never - // clear existing diagnostics — a transient/partial failure or a graceful - // skip must not strip squiggles a later successful scan would reproduce. - if (!outcome.ok || outcome.skipped || outcome.didLintFail || outcome.lintIncomplete) { - const tracked = this.projectUris.get(project) ?? new Set<string>(); - for (const uri of liveUris) tracked.add(uri); - this.projectUris.set(project, tracked); - return; - } - - // Files explicitly requested but absent from byFile were scanned - // clean — clear any diagnostics previously shown for them. - for (const fsPath of outcome.requestedPaths) { - if (isProtectedPath(fsPath)) continue; - if (outcome.byFile.has(fsPath)) continue; - const uri = fsPathToUri(fsPath); - if (this.byUri.has(uri)) this.byUri.delete(uri); - this.publish(uri, []); - } - - this.reconcileProjectUris(project, liveUris, outcome, protectOpen); - } - - private reconcileProjectUris( - project: string, - liveUris: Set<string>, - outcome: ScanOutcome, - protectOpen: boolean, - ): void { - if (outcome.coversProject) { - const previous = this.projectUris.get(project) ?? new Set<string>(); - const next = new Set(liveUris); - for (const uri of previous) { - if (liveUris.has(uri)) continue; - // Keep an open file's diagnostics (and tracking) — a whole-project - // disk audit must not clear what an interactive scan owns. - if (protectOpen && this.isOpen(uriToFsPath(uri))) { - next.add(uri); - continue; - } - this.byUri.delete(uri); - this.publish(uri, []); - } - this.projectUris.set(project, next); - return; - } - - const set = this.projectUris.get(project) ?? new Set<string>(); - for (const uri of liveUris) set.add(uri); - for (const fsPath of outcome.requestedPaths) { - const uri = fsPathToUri(fsPath); - if (!liveUris.has(uri)) set.delete(uri); - } - this.projectUris.set(project, set); - } - - /** Current published diagnostics for a URI (for pull-diagnostic requests). */ - get(uri: string): LspDiagnostic[] { - return this.byUri.get(uri) ?? []; - } - - /** Diagnostics whose range contains `position` (for hover / code actions). */ - findAt(uri: string, position: Position): LspDiagnostic[] { - return (this.byUri.get(uri) ?? []).filter((diagnostic) => - isPositionInRange(diagnostic.range, position), - ); - } - - /** Every URI that currently has published diagnostics. */ - trackedUris(): string[] { - return [...this.byUri.keys()]; - } - - /** - * Clears diagnostics for a project's tracked files that are no longer - * "live" (present in `liveFsPaths`). Used after a chunked workspace scan, - * which covers no project as a whole, to drop files that left the - * enumeration (deleted / gitignored / renamed) and would otherwise keep - * stale squiggles. - */ - retainProjectFiles(project: string, liveFsPaths: Iterable<string>): void { - const tracked = this.projectUris.get(project); - if (!tracked) return; - const liveUris = new Set<string>(); - for (const fsPath of liveFsPaths) liveUris.add(fsPathToUri(fsPath)); - for (const uri of [...tracked]) { - if (liveUris.has(uri)) continue; - this.byUri.delete(uri); - this.publish(uri, []); - tracked.delete(uri); - } - } - - /** Clears (and publishes empty for) every URI owned by a project. */ - clearProject(project: string): void { - const uris = this.projectUris.get(project); - if (!uris) return; - for (const uri of uris) { - this.byUri.delete(uri); - this.publish(uri, []); - } - this.projectUris.delete(project); - } - - /** Clears a single URI. */ - clearUri(uri: string): void { - if (this.byUri.delete(uri)) this.publish(uri, []); - for (const uris of this.projectUris.values()) uris.delete(uri); - } -} diff --git a/packages/language-server/src/diagnostics/mapper.ts b/packages/language-server/src/diagnostics/mapper.ts deleted file mode 100644 index 3817eeacfc..0000000000 --- a/packages/language-server/src/diagnostics/mapper.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { buildDiagnosticIdentity, getRuleMetadata } from "@react-doctor/core"; -import type { Diagnostic as CoreDiagnostic, DiagnosticRelatedLocation } from "@react-doctor/core"; -import { - DiagnosticSeverity, - DiagnosticTag, - type Diagnostic as LspDiagnostic, - type DiagnosticRelatedInformation, - type Range, -} from "vscode-languageserver"; -import { URI } from "vscode-uri"; -import { DIAGNOSTIC_SOURCE } from "../constants.js"; -import type { ReactDoctorDiagnosticData } from "../types.js"; -import { rangeFromByteSpan, rangeFromLineColumn } from "../text/positions.js"; - -export interface MapDiagnosticInput { - readonly diagnostic: CoreDiagnostic; - /** Absolute fs path of the file this diagnostic belongs to. */ - readonly fsPath: string; - /** Text of `fsPath` for precise byte-span ranges; `null` → line/col fallback. */ - readonly text: string | null; -} - -/** - * Maps engine severity to LSP severity, demoting weak-signal rule - * families (`design`) to `Information` so style nudges sit quietly under - * the real correctness/perf/security findings instead of competing with - * them in the editor gutter and Problems panel. - */ -const toLspSeverity = (diagnostic: CoreDiagnostic): DiagnosticSeverity => { - const tags = getRuleMetadata(diagnostic.plugin, diagnostic.rule)?.tags ?? []; - if (tags.includes("design")) return DiagnosticSeverity.Information; - return diagnostic.severity === "error" ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning; -}; - -/** - * Dead-code findings ("unused file", "unused export", …) read best with - * the faded `Unnecessary` treatment editors apply to that tag. - */ -const resolveTags = (diagnostic: CoreDiagnostic): DiagnosticTag[] | undefined => - diagnostic.category === "Dead Code" || diagnostic.plugin === "deslop" - ? [DiagnosticTag.Unnecessary] - : undefined; - -const resolveRange = ( - text: string | null, - offset: number | undefined, - length: number | undefined, - line: number, - column: number, -): Range => - text !== null && offset !== undefined - ? rangeFromByteSpan(text, offset, length ?? 0) - : rangeFromLineColumn(text, line, column); - -// All related locations resolve against the parent diagnostic's already- -// canonicalized `fsPath` / `text` rather than each location's own `filePath`. -// oxlint only emits secondary labels in the SAME file as the primary span, so -// the two always coincide today. Critically, only the primary diagnostic's -// path is run through the scan-runner's overlay/path resolution — a related -// location's raw `filePath` would still point at the overlay temp dir for an -// unsaved-buffer scan, so deriving the URI from it would break "jump to" links. -// Cross-file related locations would need that same path resolution plus a -// TextProvider for the other file's content; revisit if oxlint starts emitting -// them. -const toRelatedInformation = ( - related: ReadonlyArray<DiagnosticRelatedLocation>, - fsPath: string, - text: string | null, -): DiagnosticRelatedInformation[] => { - const uri = URI.file(fsPath).toString(); - return related.map((location) => ({ - location: { - uri, - range: resolveRange(text, location.offset, location.length, location.line, location.column), - }, - message: location.message || "Related location", - })); -}; - -/** - * Converts a core React Doctor diagnostic into an LSP diagnostic with a - * precise range, rule code + docs link, related locations, and a - * structured `data` payload the hover / code-action handlers consume. - */ -export const toLspDiagnostic = (input: MapDiagnosticInput): LspDiagnostic => { - const { diagnostic, fsPath, text } = input; - const ruleId = `${diagnostic.plugin}/${diagnostic.rule}`; - const range = resolveRange( - text, - diagnostic.offset, - diagnostic.length, - diagnostic.line, - diagnostic.column, - ); - - const data: ReactDoctorDiagnosticData = { - identity: buildDiagnosticIdentity({ - filePath: fsPath, - line: diagnostic.line, - column: diagnostic.column, - plugin: diagnostic.plugin, - rule: diagnostic.rule, - severity: diagnostic.severity, - message: diagnostic.message, - }), - plugin: diagnostic.plugin, - rule: diagnostic.rule, - ruleId, - category: diagnostic.category, - help: diagnostic.help, - url: diagnostic.url ?? null, - suppressionHint: diagnostic.suppressionHint ?? null, - line: diagnostic.line, - column: diagnostic.column, - fsPath, - }; - - const tags = resolveTags(diagnostic); - const relatedInformation = - diagnostic.relatedLocations && diagnostic.relatedLocations.length > 0 - ? toRelatedInformation(diagnostic.relatedLocations, fsPath, text) - : undefined; - - return { - range, - severity: toLspSeverity(diagnostic), - code: ruleId, - ...(diagnostic.url ? { codeDescription: { href: diagnostic.url } } : {}), - source: DIAGNOSTIC_SOURCE, - message: diagnostic.message, - ...(tags ? { tags } : {}), - ...(relatedInformation ? { relatedInformation } : {}), - data, - }; -}; diff --git a/packages/language-server/src/features/code-actions.ts b/packages/language-server/src/features/code-actions.ts deleted file mode 100644 index 23f6bdd694..0000000000 --- a/packages/language-server/src/features/code-actions.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { - CodeActionKind, - type CodeAction, - type Diagnostic as LspDiagnostic, - type TextEdit, -} from "vscode-languageserver"; -import { COMMAND_EXPLAIN, COMMAND_OPEN_DOCS, COMMAND_REPORT_FALSE_POSITIVE } from "../constants.js"; -import type { ReactDoctorDiagnosticData } from "../types.js"; -import { readDiagnosticData } from "../utils/read-diagnostic-data.js"; -import { severityLabel } from "../utils/severity-label.js"; -import { - buildSuppressAllTextEdits, - buildSuppressionTextEdit, - type SuppressionTarget, -} from "./suppress.js"; - -/** - * Namespaced source-action kind for the file-level suppress (the - * `source.fixAll.eslint` convention) so it lands in the Source Action menu - * rather than the bare `source` bucket. Note this kind alone is NOT enough to - * keep it out of on-save runs: `editor.codeActionsOnSave: { "source": true }` - * requests `only: ["source"]`, which prefix-matches this sub-kind. The server - * guards against that by withholding this action on `Automatic`-trigger - * requests (on-save) — see the `onCodeAction` handler. - */ -export const SUPPRESS_ALL_CODE_ACTION_KIND = "source.suppressAll.reactDoctor"; - -export interface BuildCodeActionsInput { - readonly uri: string; - readonly fsPath: string; - readonly documentText: string | null; - readonly relativeFilePath: string; - /** Our diagnostics overlapping the requested range. */ - readonly rangeDiagnostics: ReadonlyArray<LspDiagnostic>; - /** Every React Doctor diagnostic in the file (for "suppress all"). */ - readonly fileDiagnostics: ReadonlyArray<LspDiagnostic>; -} - -const suppressEdit = (input: BuildCodeActionsInput, data: ReactDoctorDiagnosticData): TextEdit => - buildSuppressionTextEdit({ - documentText: input.documentText, - fsPath: input.fsPath, - line: data.line, - ruleId: data.ruleId, - }); - -/** Collects (line, ruleId) suppression targets from our diagnostics. */ -export const collectSuppressionTargets = ( - diagnostics: ReadonlyArray<LspDiagnostic>, -): SuppressionTarget[] => { - const targets: SuppressionTarget[] = []; - for (const diagnostic of diagnostics) { - const data = readDiagnosticData(diagnostic); - if (data) targets.push({ line: data.line, ruleId: data.ruleId }); - } - return targets; -}; - -/** - * Builds the code actions offered for React Doctor diagnostics: per - * finding a "disable for this line" quick fix plus explain / docs / - * report commands, and a file-level "suppress all" source action. Rule- - * authored autofixes will slot in here as additional `QuickFix` edits. - */ -export const buildCodeActions = (input: BuildCodeActionsInput): CodeAction[] => { - const actions: CodeAction[] = []; - - for (const diagnostic of input.rangeDiagnostics) { - const data = readDiagnosticData(diagnostic); - if (!data) continue; - - actions.push({ - title: `Disable ${data.ruleId} for this line`, - kind: CodeActionKind.QuickFix, - diagnostics: [diagnostic], - edit: { changes: { [input.uri]: [suppressEdit(input, data)] } }, - }); - - actions.push({ - title: `Explain ${data.ruleId}`, - kind: CodeActionKind.QuickFix, - diagnostics: [diagnostic], - command: { - title: "Explain", - command: COMMAND_EXPLAIN, - arguments: [{ uri: input.uri, identity: data.identity }], - }, - }); - - if (data.url) { - actions.push({ - title: `Open ${data.ruleId} documentation`, - kind: CodeActionKind.QuickFix, - command: { title: "Open documentation", command: COMMAND_OPEN_DOCS, arguments: [data.url] }, - }); - } - - actions.push({ - title: `Report ${data.ruleId} as a false positive`, - kind: CodeActionKind.QuickFix, - command: { - title: "Report false positive", - command: COMMAND_REPORT_FALSE_POSITIVE, - arguments: [ - { - ruleId: data.ruleId, - severity: severityLabel(diagnostic.severity), - category: data.category, - message: diagnostic.message, - relativeFilePath: input.relativeFilePath, - line: data.line, - }, - ], - }, - }); - } - - const suppressAllEdits = buildSuppressAllTextEdits({ - documentText: input.documentText, - fsPath: input.fsPath, - targets: collectSuppressionTargets(input.fileDiagnostics), - }); - if (suppressAllEdits.length > 0) { - actions.push({ - title: "Suppress all React Doctor issues in this file", - kind: SUPPRESS_ALL_CODE_ACTION_KIND, - edit: { changes: { [input.uri]: suppressAllEdits } }, - }); - } - - return actions; -}; diff --git a/packages/language-server/src/features/hover.ts b/packages/language-server/src/features/hover.ts deleted file mode 100644 index 4169abddff..0000000000 --- a/packages/language-server/src/features/hover.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { getRuleMetadata } from "@react-doctor/core"; -import { MarkupKind, type Diagnostic as LspDiagnostic, type Hover } from "vscode-languageserver"; -import type { ReactDoctorDiagnosticData } from "../types.js"; -import { readDiagnosticData } from "../utils/read-diagnostic-data.js"; -import { severityLabel } from "../utils/severity-label.js"; - -const buildSection = (diagnostic: LspDiagnostic, data: ReactDoctorDiagnosticData): string => { - const metadata = getRuleMetadata(data.plugin, data.rule); - const tags = metadata?.tags ?? []; - const subtitleParts = [data.category, ...(tags.length > 0 ? [tags.join(", ")] : [])]; - - const lines = [ - `**${data.ruleId}** — ${severityLabel(diagnostic.severity)}`, - `_${subtitleParts.join(" · ")}_`, - ]; - - if (diagnostic.message) lines.push("", diagnostic.message); - - const recommendation = data.help || metadata?.recommendation || ""; - if (recommendation) lines.push("", `> ${recommendation.replace(/\n/g, "\n> ")}`); - - if (data.suppressionHint) lines.push("", `_${data.suppressionHint}_`); - if (data.url) lines.push("", `[Rule documentation](${data.url})`); - - return lines.join("\n"); -}; - -/** - * Builds a rich Markdown hover for every React Doctor diagnostic under - * the cursor: rule id, severity, category + tags, message, the rule's - * recommendation, any suppression hint, and a docs link. Returns `null` - * when no React Doctor diagnostic is at the position. - */ -export const buildHover = (diagnostics: ReadonlyArray<LspDiagnostic>): Hover | null => { - const sections: string[] = []; - for (const diagnostic of diagnostics) { - const data = readDiagnosticData(diagnostic); - if (data) sections.push(buildSection(diagnostic, data)); - } - if (sections.length === 0) return null; - return { - contents: { kind: MarkupKind.Markdown, value: sections.join("\n\n---\n\n") }, - }; -}; diff --git a/packages/language-server/src/features/issue-url.ts b/packages/language-server/src/features/issue-url.ts deleted file mode 100644 index 4e1efe9f75..0000000000 --- a/packages/language-server/src/features/issue-url.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { CANONICAL_GITHUB_URL } from "../constants.js"; - -export interface FalsePositiveReport { - readonly ruleId: string; - readonly severity: string; - readonly category: string; - readonly message: string; - readonly relativeFilePath: string; - readonly line: number; -} - -/** - * Builds a prefilled GitHub "new issue" URL for reporting a false - * positive, mirroring the CLI's `--explain` follow-up link so reports - * from the editor and terminal land in the same shape. - */ -export const buildFalsePositiveIssueUrl = (report: FalsePositiveReport): string => { - const body = [ - "## Diagnostic", - "", - `- Rule: ${report.ruleId}`, - `- Severity: ${report.severity}`, - `- Category: ${report.category}`, - `- Location: ${report.relativeFilePath}:${report.line}`, - "", - "## Message", - "", - "```text", - report.message, - "```", - "", - "## Why this looks wrong or needs follow-up", - "", - "Please explain why this should be changed, suppressed, or treated as a false positive.", - ].join("\n"); - - const url = new URL(`${CANONICAL_GITHUB_URL}/issues/new`); - url.searchParams.set("title", `Diagnostic follow-up: ${report.ruleId}`); - url.searchParams.set("labels", "bug"); - url.searchParams.set("body", body); - return url.toString(); -}; diff --git a/packages/language-server/src/features/suppress.ts b/packages/language-server/src/features/suppress.ts deleted file mode 100644 index e0abc319b2..0000000000 --- a/packages/language-server/src/features/suppress.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { TextEdit } from "vscode-languageserver"; - -export interface SuppressionEditInput { - /** Full document text (needed for indentation + JSX heuristics). */ - readonly documentText: string | null; - /** Absolute fs path (drives the comment style for `.tsx` / `.jsx`). */ - readonly fsPath: string; - /** 1-indexed source line of the diagnostic. */ - readonly line: number; - /** Fully-qualified rule id, e.g. `react-doctor/no-array-index-as-key`. */ - readonly ruleId: string; -} - -const JSX_EXTENSIONS = [".tsx", ".jsx"]; - -const leadingWhitespace = (lineText: string): string => /^\s*/.exec(lineText)?.[0] ?? ""; - -/** - * Heuristic: is the target line most likely JSX, where a `//` comment on - * the preceding line would be syntactically invalid (inside an element / - * expression container)? Used only to choose between the two suppression - * comment styles oxlint / react-doctor both accept. - */ -const isLikelyJsxLine = (lineText: string): boolean => { - const trimmed = lineText.trim(); - if (trimmed.startsWith("<") || trimmed.startsWith("{/*") || trimmed.startsWith("{")) return true; - // A bare JSX attribute on its own line, e.g. `onClick={...}` / `value={x}`. - return /^[A-Za-z_][\w-]*=/.test(trimmed); -}; - -/** - * Builds a `// react-doctor-disable-next-line <ruleId>` (or JSX - * `{/* … *​/}`) edit inserted immediately above the diagnostic line, - * matching its indentation. The JSX form is chosen for `.tsx` / `.jsx` - * files when the target line looks like markup so the inserted comment - * stays syntactically valid. - */ -export const buildSuppressionTextEdit = (input: SuppressionEditInput): TextEdit => { - const lines = input.documentText !== null ? input.documentText.split("\n") : []; - const targetLineIndex = Math.max(0, input.line - 1); - const targetLineText = lines[targetLineIndex] ?? ""; - const indent = leadingWhitespace(targetLineText); - - const isJsxFile = JSX_EXTENSIONS.some((extension) => input.fsPath.endsWith(extension)); - const useJsxComment = isJsxFile && isLikelyJsxLine(targetLineText); - - const comment = useJsxComment - ? `${indent}{/* react-doctor-disable-next-line ${input.ruleId} */}\n` - : `${indent}// react-doctor-disable-next-line ${input.ruleId}\n`; - - const insertPosition = { line: targetLineIndex, character: 0 }; - return { - range: { start: insertPosition, end: insertPosition }, - newText: comment, - }; -}; - -export interface SuppressionTarget { - /** 1-indexed source line. */ - readonly line: number; - readonly ruleId: string; -} - -/** - * Builds one merged suppression edit per source line for a batch of - * diagnostics, stacking multiple rules above the same line and skipping - * duplicate (line, rule) pairs. Used by the "suppress all in file" code - * action and the fix-all command so both produce identical, - * non-overlapping edits. - */ -export const buildSuppressAllTextEdits = (input: { - readonly documentText: string | null; - readonly fsPath: string; - readonly targets: ReadonlyArray<SuppressionTarget>; -}): TextEdit[] => { - const seen = new Set<string>(); - const byLine = new Map<number, TextEdit>(); - for (const target of input.targets) { - const dedupeKey = `${target.line}::${target.ruleId}`; - if (seen.has(dedupeKey)) continue; - seen.add(dedupeKey); - const edit = buildSuppressionTextEdit({ - documentText: input.documentText, - fsPath: input.fsPath, - line: target.line, - ruleId: target.ruleId, - }); - const existing = byLine.get(target.line); - if (existing) existing.newText += edit.newText; - else byLine.set(target.line, edit); - } - return [...byLine.values()]; -}; diff --git a/packages/language-server/src/index.ts b/packages/language-server/src/index.ts deleted file mode 100644 index 55dc2224df..0000000000 --- a/packages/language-server/src/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -export { createServer, startLanguageServer } from "./server.js"; -export { - ALL_COMMANDS, - COMMAND_EXPLAIN, - COMMAND_FIX_ALL, - COMMAND_OPEN_DOCS, - COMMAND_REPORT_FALSE_POSITIVE, - COMMAND_RESTART, - COMMAND_SCAN_FILE, - COMMAND_SCAN_WORKSPACE, - COMMAND_SUPPRESS_LINE, - DIAGNOSTIC_SOURCE, - SERVER_DISPLAY_NAME, -} from "./constants.js"; -export { NOOP_TELEMETRY } from "./types.js"; -export type { - ReactDoctorDiagnosticData, - SessionTelemetry, - Telemetry, - WorkspaceScanTelemetry, - WorkspaceScanTrigger, -} from "./types.js"; -export type { StartLanguageServerOptions } from "./server.js"; diff --git a/packages/language-server/src/runtime/scan-telemetry.ts b/packages/language-server/src/runtime/scan-telemetry.ts deleted file mode 100644 index f405d19c87..0000000000 --- a/packages/language-server/src/runtime/scan-telemetry.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { ScanOutcome, Telemetry, WorkspaceScanTrigger } from "../types.js"; - -interface ActiveBurst { - readonly trigger: WorkspaceScanTrigger; - readonly startedAtEpochMs: number; - readonly projectCount: number; - chunkCount: number; - filesWithDiagnostics: number; - totalDiagnostics: number; - errorCount: number; - warningCount: number; - readonly diagnosticsByCategory: Map<string, number>; - lintDegraded: boolean; - lintIncompleteChunks: number; -} - -/** - * Accumulates the background scan chunks of one workspace-scan burst into a - * single aggregate, then hands it to {@link Telemetry.recordWorkspaceScan} as - * the canonical per-scan "wide event". The server drives the lifecycle: - * `begin` when a workspace scan is kicked off, `accumulate` for each completed - * background outcome, and `finish` when the scheduler next goes idle. - * - * Interactive / save scans are never folded in (the server only calls - * `accumulate` for `background` outcomes), so the event reflects the workspace - * audit rather than per-keystroke activity. - */ -export interface ScanTelemetry { - /** Start a burst, discarding any prior partial (e.g. a rescan supersedes it). */ - readonly begin: (trigger: WorkspaceScanTrigger, projectCount: number) => void; - /** Fold one completed background scan outcome into the active burst. */ - readonly accumulate: (outcome: ScanOutcome) => void; - /** Emit the active burst's wide event (when it scanned anything) and reset. */ - readonly finish: () => void; -} - -export const createScanTelemetry = ( - telemetry: Telemetry, - now: () => number = Date.now, -): ScanTelemetry => { - let active: ActiveBurst | null = null; - - const begin = (trigger: WorkspaceScanTrigger, projectCount: number): void => { - active = { - trigger, - startedAtEpochMs: now(), - projectCount, - chunkCount: 0, - filesWithDiagnostics: 0, - totalDiagnostics: 0, - errorCount: 0, - warningCount: 0, - diagnosticsByCategory: new Map(), - lintDegraded: false, - lintIncompleteChunks: 0, - }; - }; - - const accumulate = (outcome: ScanOutcome): void => { - if (!active) return; - active.chunkCount += 1; - if (outcome.didLintFail) active.lintDegraded = true; - if (outcome.lintIncomplete) active.lintIncompleteChunks += 1; - for (const diagnostics of outcome.byFile.values()) { - if (diagnostics.length > 0) active.filesWithDiagnostics += 1; - for (const diagnostic of diagnostics) { - active.totalDiagnostics += 1; - if (diagnostic.severity === "error") active.errorCount += 1; - else active.warningCount += 1; - active.diagnosticsByCategory.set( - diagnostic.category, - (active.diagnosticsByCategory.get(diagnostic.category) ?? 0) + 1, - ); - } - } - }; - - const finish = (): void => { - const burst = active; - active = null; - // Skip bursts that never scanned anything (empty workspace, or every chunk - // cancelled before completing) — a zero-chunk event is noise, not signal. - if (!burst || burst.chunkCount === 0) return; - telemetry.recordWorkspaceScan({ - trigger: burst.trigger, - startedAtEpochMs: burst.startedAtEpochMs, - durationMs: Math.max(0, now() - burst.startedAtEpochMs), - projectCount: burst.projectCount, - chunkCount: burst.chunkCount, - filesWithDiagnostics: burst.filesWithDiagnostics, - totalDiagnostics: burst.totalDiagnostics, - errorCount: burst.errorCount, - warningCount: burst.warningCount, - diagnosticsByCategory: Object.fromEntries(burst.diagnosticsByCategory), - lintDegraded: burst.lintDegraded, - lintIncompleteChunks: burst.lintIncompleteChunks, - }); - }; - - return { begin, accumulate, finish }; -}; diff --git a/packages/language-server/src/runtime/scheduler.ts b/packages/language-server/src/runtime/scheduler.ts deleted file mode 100644 index cd492b06c9..0000000000 --- a/packages/language-server/src/runtime/scheduler.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { messageFromUnknown } from "@react-doctor/core"; -import { DOCUMENT_CHANGE_DEBOUNCE_MS, MIN_SCAN_CONCURRENCY } from "../constants.js"; -import { - SILENT_LOGGER, - type CancellationToken, - type ScanRequest, - type ScanRequestInput, - type Scheduler, - type SchedulerOptions, - type ScanPriority, -} from "../types.js"; - -const PRIORITY_RANK: Record<ScanPriority, number> = { - interactive: 0, - save: 1, - background: 2, -}; - -/** Coalescing key: one in-flight scan per project + file-scope. */ -const keyOf = (request: Pick<ScanRequestInput, "projectDirectory" | "files">): string => { - const scope = request.files.length === 0 ? "<project>" : [...request.files].sort().join("|"); - return `${request.projectDirectory}::${scope}`; -}; - -/** - * Priority queue for scans. Interactive (open-buffer) scans preempt save - * and background scans; per-key debounce collapses bursts of edits; a - * monotonic generation per key supersedes stale work so a slow oxlint - * subprocess can never clobber a fresher result. Bounded concurrency - * keeps large monorepos responsive. - */ -export const createScheduler = (options: SchedulerOptions): Scheduler => { - const debounceMs = options.debounceMs ?? DOCUMENT_CHANGE_DEBOUNCE_MS; - const concurrency = Math.max(1, options.concurrency ?? MIN_SCAN_CONCURRENCY); - const reservedInteractiveSlots = Math.max(0, options.reservedInteractiveSlots ?? 0); - // Background scans never occupy the reserved slots, so an interactive / - // save scan can always start while a big workspace scan churns. - const maxBackground = Math.max(1, concurrency - reservedInteractiveSlots); - const logger = options.logger ?? SILENT_LOGGER; - - let generation = 0; - let running = 0; - let runningBackground = 0; - let disposed = false; - const timers = new Map<string, ReturnType<typeof setTimeout>>(); - const latestGeneration = new Map<string, number>(); - const queue: ScanRequest[] = []; - - const notifyIdle = (): void => { - options.onIdleChange?.(running === 0 && queue.length === 0 && timers.size === 0); - }; - - const isEligible = (request: ScanRequest): boolean => - request.priority === "background" ? runningBackground < maxBackground : running < concurrency; - - /** Highest-priority queue entry that can run under the current slot budget. */ - const takeEligible = (): ScanRequest | undefined => { - let bestIndex = -1; - for (let index = 0; index < queue.length; index += 1) { - if (!isEligible(queue[index])) continue; - if ( - bestIndex === -1 || - PRIORITY_RANK[queue[index].priority] < PRIORITY_RANK[queue[bestIndex].priority] - ) { - bestIndex = index; - } - } - return bestIndex === -1 ? undefined : queue.splice(bestIndex, 1)[0]; - }; - - const drain = (): void => { - while (!disposed && running < concurrency) { - const request = takeEligible(); - if (!request) break; - const key = keyOf(request); - // Superseded while it waited in the queue. - if (latestGeneration.get(key) !== request.id) continue; - - running += 1; - const isBackground = request.priority === "background"; - if (isBackground) runningBackground += 1; - const token: CancellationToken = { - get isCancelled() { - return disposed || latestGeneration.get(key) !== request.id; - }, - }; - - Promise.resolve(options.performScan(request, token)) - .then((outcome) => { - if (outcome && !token.isCancelled) options.onResult(outcome); - }) - .catch((error: unknown) => { - if (options.onError) options.onError(error, request); - else logger.error(`Scan failed: ${messageFromUnknown(error)}`); - }) - .finally(() => { - running -= 1; - if (isBackground) runningBackground -= 1; - drain(); - notifyIdle(); - }); - } - notifyIdle(); - }; - - const enqueue = (input: ScanRequestInput): void => { - if (disposed) return; - const key = keyOf(input); - const id = (generation += 1); - latestGeneration.set(key, id); - const request: ScanRequest = { ...input, id }; - - const delay = input.priority === "interactive" ? debounceMs : 0; - const existing = timers.get(key); - if (existing) clearTimeout(existing); - - const timer = setTimeout(() => { - timers.delete(key); - // A newer enqueue for this key arrived during the debounce window. - if (latestGeneration.get(key) !== id) { - notifyIdle(); - return; - } - queue.push(request); - drain(); - }, delay); - if (typeof timer.unref === "function") timer.unref(); - timers.set(key, timer); - }; - - const cancelProject = (projectDirectory: string): void => { - const prefix = `${projectDirectory}::`; - for (const [key, timer] of timers) { - if (key.startsWith(prefix)) { - clearTimeout(timer); - timers.delete(key); - } - } - for (const key of latestGeneration.keys()) { - // Bump to a generation no live request carries → supersedes them. - if (key.startsWith(prefix)) latestGeneration.set(key, (generation += 1)); - } - for (let index = queue.length - 1; index >= 0; index -= 1) { - if (keyOf(queue[index]).startsWith(prefix)) queue.splice(index, 1); - } - notifyIdle(); - }; - - const dispose = (): void => { - disposed = true; - for (const timer of timers.values()) clearTimeout(timer); - timers.clear(); - queue.length = 0; - }; - - return { enqueue, cancelProject, dispose }; -}; diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts deleted file mode 100644 index 3df0b904f0..0000000000 --- a/packages/language-server/src/server.ts +++ /dev/null @@ -1,874 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { listSourceFiles, messageFromUnknown, resolveNodeForOxlint } from "@react-doctor/core"; -import { - CodeActionKind, - CodeActionTriggerKind, - DidChangeWatchedFilesNotification, - DocumentDiagnosticReportKind, - FileChangeType, - TextDocuments, - TextDocumentSyncKind, - createConnection, - type CodeAction, - type Connection, - type DocumentDiagnosticReport, - type Hover, - type InitializeParams, - type InitializeResult, - type ServerCapabilities, - type TextEdit, - type WorkDoneProgressServerReporter, -} from "vscode-languageserver/node"; -import { TextDocument } from "vscode-languageserver-textdocument"; -import { - ALL_COMMANDS, - COMMAND_EXPLAIN, - COMMAND_FIX_ALL, - COMMAND_OPEN_DOCS, - COMMAND_REPORT_FALSE_POSITIVE, - COMMAND_RESTART, - COMMAND_SCAN_FILE, - COMMAND_SCAN_WORKSPACE, - COMMAND_SUPPRESS_LINE, - CONFIG_CHANGE_DEBOUNCE_MS, - CONFIG_WATCH_FILENAMES, - DIAGNOSTIC_SOURCE, - DOCUMENT_CHANGE_DEBOUNCE_MS, - INITIAL_WORKSPACE_SCAN_DELAY_MS, - MAX_SCAN_CONCURRENCY, - MIN_SCAN_CONCURRENCY, - RESERVED_INTERACTIVE_SLOTS, - SCANNABLE_EXTENSIONS, - SERVER_DISPLAY_NAME, - SERVER_VERSION, - WORKSPACE_SCAN_CHUNK_SIZE, -} from "./constants.js"; -import { DiagnosticsManager } from "./diagnostics/manager.js"; -import { - buildCodeActions, - collectSuppressionTargets, - SUPPRESS_ALL_CODE_ACTION_KIND, -} from "./features/code-actions.js"; -import { buildHover } from "./features/hover.js"; -import { buildFalsePositiveIssueUrl, type FalsePositiveReport } from "./features/issue-url.js"; -import { buildSuppressAllTextEdits } from "./features/suppress.js"; -import { createProjectGraph } from "./core/project-graph.js"; -import { createScanRunner, type ScanRunner } from "./core/scan-runner.js"; -import { createScheduler } from "./runtime/scheduler.js"; -import { createScanTelemetry } from "./runtime/scan-telemetry.js"; -import { chunk } from "./utils/chunk.js"; -import { readDiagnosticData } from "./utils/read-diagnostic-data.js"; -import { readPositiveIntEnv } from "./utils/read-positive-int-env.js"; -import { rangesOverlap } from "./text/positions.js"; -import { canonicalizeUri, fsPathToUri, normalizeFsPath, uriToFsPath } from "./text/uri.js"; -import { NOOP_TELEMETRY } from "./types.js"; -import type { - Logger, - ProjectGraph, - ScanOutcome, - ScanPriority, - Scheduler, - Telemetry, - WorkspaceScanTrigger, -} from "./types.js"; - -const isScannablePath = (filePath: string): boolean => - SCANNABLE_EXTENSIONS.some((extension) => filePath.endsWith(extension)); - -const resolveWorkspaceRoots = (params: InitializeParams): string[] => { - if (params.workspaceFolders && params.workspaceFolders.length > 0) { - return params.workspaceFolders.map((folder) => uriToFsPath(folder.uri)); - } - if (params.rootUri) return [uriToFsPath(params.rootUri)]; - if (params.rootPath) return [path.resolve(params.rootPath).replace(/\\/g, "/")]; - return []; -}; - -export interface StartLanguageServerOptions { - /** - * Analytics sink. Defaults to {@link NOOP_TELEMETRY}; the published CLI - * injects a Sentry-backed implementation via the `experimental-lsp` entry. - */ - readonly telemetry?: Telemetry; -} - -/** - * Builds and wires the React Doctor language server onto a connection. - * Exposed separately from `startLanguageServer` so tests can drive it - * over an in-memory transport. - */ -export const createServer = ( - connection: Connection, - options: StartLanguageServerOptions = {}, -): void => { - const documents = new TextDocuments(TextDocument); - const telemetry = options.telemetry ?? NOOP_TELEMETRY; - const scanTelemetry = createScanTelemetry(telemetry); - - const logger: Logger = { - info: (message) => connection.console.info(message), - warn: (message) => connection.console.warn(message), - error: (message) => connection.console.error(message), - }; - - let projectGraph: ProjectGraph | null = null; - let workspaceRoots: string[] = []; - let scheduler: Scheduler | null = null; - let scanRunner: ScanRunner | null = null; - let manager: DiagnosticsManager | null = null; - let nodeBinaryPath: string | null = null; - let supportsPullDiagnostics = false; - let supportsWatchedFileRegistration = false; - let supportsWorkDoneProgress = false; - let supportsServerStatus = false; - let supportsWorkspaceFolderChange = false; - let lintWarningShown = false; - let scanOnType = true; - let configRescanTimer: ReturnType<typeof setTimeout> | null = null; - let workDoneProgress: WorkDoneProgressServerReporter | null = null; - let isBusy = false; - let serverHealth: "ok" | "warning" = "ok"; - - // Open documents indexed by canonical fs path → client URI. `documents` - // keys by the exact URI the client sent, which can differ from - // `fsPathToUri(fsPath)` (casing, encoding, drive-letter, symlinks), so a - // naive lookup would miss the buffer and fall back to disk — silently - // defeating the open-file protections. Maintained on open/close below. - const openDocumentUriByPath = new Map<string, string>(); - - const findOpenDocument = (fsPath: string): TextDocument | undefined => { - const uri = openDocumentUriByPath.get(normalizeFsPath(fsPath)); - return uri === undefined ? undefined : documents.get(uri); - }; - - /** Live text of a file: open buffer first, then disk. */ - const readText = (fsPath: string): string | null => { - const document = findOpenDocument(fsPath); - if (document) return document.getText(); - try { - return fs.readFileSync(fsPath, "utf8"); - } catch { - return null; - } - }; - - const isOpen = (fsPath: string): boolean => findOpenDocument(fsPath) !== undefined; - - const scheduleFileScan = ( - fsPath: string, - priority: ScanPriority, - useOverlay: boolean, - reason: string, - ): void => { - if (!projectGraph || !scheduler || !isScannablePath(fsPath)) return; - const projectDirectory = projectGraph.resolveOwningProject(fsPath); - if (!projectDirectory) return; - scheduler.enqueue({ - priority, - projectDirectory, - files: [fsPath], - runDeadCode: false, - useOverlay, - reason, - }); - }; - - /** Absolute, normalized source files of a project (git-aware, gitignore-respecting). */ - const enumerateProjectFiles = (projectDirectory: string): string[] => { - try { - return listSourceFiles(projectDirectory).map((relative) => - normalizeFsPath(path.join(projectDirectory, relative)), - ); - } catch { - return []; - } - }; - - const workspaceChunkSize = readPositiveIntEnv( - "REACT_DOCTOR_LSP_CHUNK_SIZE", - WORKSPACE_SCAN_CHUNK_SIZE, - ); - - /** - * Lint the whole workspace as many small, independent chunks instead of - * one giant non-cancellable scan: diagnostics stream in per chunk, - * chunks run in parallel (bounded), and a config change / shutdown - * drops the remaining chunks. Dead-code is NOT run here (it's a - * whole-graph pass — see `scanWorkspaceFull`). - */ - const scanWorkspaceLint = (trigger: WorkspaceScanTrigger): void => { - if (!projectGraph || !scheduler) return; - const activeScheduler = scheduler; - const projectList = projectGraph.listProjects(); - let chunkCount = 0; - const enqueueChunk = (projectDirectory: string, files: string[]): void => { - chunkCount += 1; - activeScheduler.enqueue({ - priority: "background", - projectDirectory, - files, - runDeadCode: false, - useOverlay: false, - reason: "workspace lint chunk", - }); - }; - const openPaths = documents.all().map((document) => normalizeFsPath(uriToFsPath(document.uri))); - for (const project of projectList) { - const enumerated = enumerateProjectFiles(project.directory); - // A chunked scan never covers a project as a whole, so a file that - // left the enumeration (deleted / gitignored / renamed) is in no - // chunk and its diagnostics would linger. Reconcile against the live - // set — enumeration plus open buffers (owned by interactive scans). - manager?.retainProjectFiles(project.directory, [...enumerated, ...openPaths]); - // Open files are owned by interactive (buffer-aware) scans; a disk - // chunk would race and overwrite their unsaved-buffer diagnostics. - const files = enumerated.filter((fsPath) => !isOpen(fsPath)); - if (files.length === 0) { - // Nothing enumerable → one whole-project fallback scan. If every - // file is merely open, it's already covered interactively — skip. - if (enumerated.length === 0) enqueueChunk(project.directory, []); - continue; - } - for (const batch of chunk(files, workspaceChunkSize)) enqueueChunk(project.directory, batch); - } - // Open a telemetry burst only when work was actually enqueued; the - // scheduler's next idle transition closes it (see `onIdleChange`). - if (chunkCount > 0) scanTelemetry.begin(trigger, projectList.length); - logger.info( - `Workspace lint scan: ${projectList.length} project(s), ${chunkCount} chunk(s) of up to ${workspaceChunkSize} files.`, - ); - }; - - /** - * Full audit (lint + dead-code) per project, on-demand via the - * `scanWorkspace` command. Dead-code is a whole-graph reachability - * analysis, so it runs as a single per-project scan with progress. - */ - const scanWorkspaceFull = (): void => { - if (!projectGraph || !scheduler) return; - const projectList = projectGraph.listProjects(); - for (const project of projectList) { - scheduler.enqueue({ - priority: "background", - projectDirectory: project.directory, - files: [], - runDeadCode: true, - useOverlay: false, - reason: "full workspace audit", - }); - } - if (projectList.length > 0) scanTelemetry.begin("manual", projectList.length); - }; - - const cancelAllProjectScans = (): void => { - if (!projectGraph || !scheduler) return; - for (const project of projectGraph.listProjects()) { - scheduler.cancelProject(project.directory); - } - }; - - /** - * Full reset used by config changes and the restart command: cancel - * in-flight scans, drop caches + project state, then re-scan from - * scratch. Open buffers are re-scanned interactively (the workspace - * scan skips them), so unsaved edits aren't left with stale diagnostics. - */ - const rescanWorkspaceFromScratch = (trigger: WorkspaceScanTrigger): void => { - cancelAllProjectScans(); - scanRunner?.invalidateCaches(); - projectGraph?.invalidate(); - projectGraph?.refresh(); - for (const document of documents.all()) { - scheduleFileScan(uriToFsPath(document.uri), "interactive", true, trigger); - } - scanWorkspaceLint(trigger); - }; - - /** - * rust-analyzer-style persistent status (`experimental/serverStatus`): - * `quiescent: false` while scans are running, `health: "warning"` when - * lint is degraded. Companion editor clients render this in a status - * bar; clients that don't opt in simply never receive it. - */ - const publishServerStatus = (): void => { - if (!supportsServerStatus) return; - void connection.sendNotification("experimental/serverStatus", { - health: serverHealth, - quiescent: !isBusy, - ...(serverHealth === "warning" - ? { message: "Lint is degraded — diagnostics may be incomplete." } - : {}), - }); - }; - - /** - * Drives the "scanning" indicator: a native LSP work-done progress - * (spinner in capable clients) plus the `quiescent` flag in the status - * notification. Guards the async progress-create against a busy→idle - * flip happening mid-round-trip so a progress is never orphaned. - */ - const setBusy = async (busy: boolean): Promise<void> => { - if (busy === isBusy) return; - isBusy = busy; - publishServerStatus(); - if (!supportsWorkDoneProgress) return; - if (busy) { - const reporter = await connection.window.createWorkDoneProgress(); - if (!isBusy) { - reporter.done(); - return; - } - workDoneProgress = reporter; - reporter.begin(SERVER_DISPLAY_NAME, undefined, "Scanning…", false); - } else if (workDoneProgress) { - workDoneProgress.done(); - workDoneProgress = null; - } - }; - - const maybeWarnLintUnavailable = (outcome: ScanOutcome): void => { - if (!outcome.didLintFail) { - // Lint recovered → clear degraded status so it doesn't stay stuck on - // "warning" after a later scan succeeds. - if (serverHealth === "warning") { - serverHealth = "ok"; - lintWarningShown = false; - publishServerStatus(); - } - return; - } - if (serverHealth !== "warning") { - serverHealth = "warning"; - publishServerStatus(); - } - if (lintWarningShown) return; - lintWarningShown = true; - const reason = outcome.lintFailureReason ?? "oxlint could not run"; - connection.window.showWarningMessage( - `${SERVER_DISPLAY_NAME}: lint is degraded — ${reason}. Diagnostics may be incomplete.`, - ); - }; - - const applyWorkspaceEdit = async (uri: string, edits: TextEdit[]): Promise<void> => { - if (edits.length === 0) return; - await connection.workspace.applyEdit({ changes: { [uri]: edits } }); - }; - - const openExternal = async (target: string): Promise<void> => { - try { - await connection.window.showDocument({ uri: target, external: true }); - } catch { - connection.window.showInformationMessage(target); - } - }; - - // ── Lifecycle ──────────────────────────────────────────────────── - - connection.onInitialize((params: InitializeParams): InitializeResult => { - workspaceRoots = resolveWorkspaceRoots(params).map(normalizeFsPath); - projectGraph = createProjectGraph({ roots: workspaceRoots, logger }); - - try { - const resolution = resolveNodeForOxlint(); - nodeBinaryPath = resolution?.binaryPath ?? null; - } catch { - nodeBinaryPath = null; - } - - manager = new DiagnosticsManager({ - publish: (uri, diagnostics) => connection.sendDiagnostics({ uri, diagnostics }), - textProvider: readText, - isOpen, - logger, - }); - - // Total concurrency ≈ CPU count; with one reserved interactive slot - // the background workspace scan uses ~cpus-1 cores (oxlint JS plugins - // are single-threaded per process, so this scales nearly linearly). - const concurrency = readPositiveIntEnv( - "REACT_DOCTOR_LSP_SCAN_CONCURRENCY", - Math.max(MIN_SCAN_CONCURRENCY, Math.min(os.cpus().length, MAX_SCAN_CONCURRENCY)), - ); - scanRunner = createScanRunner({ - nodeBinaryPath, - readText, - isOpen, - version: SERVER_VERSION, - enableCache: !["1", "true"].includes(process.env.REACT_DOCTOR_LSP_NO_CACHE ?? ""), - logger, - }); - scheduler = createScheduler({ - performScan: scanRunner.performScan, - onResult: (outcome) => { - manager?.applyOutcome(outcome); - maybeWarnLintUnavailable(outcome); - // Only the background workspace audit feeds the wide event; per-file - // interactive / save scans are excluded so it tracks the audit, not - // keystrokes. - if (outcome.request.priority === "background") scanTelemetry.accumulate(outcome); - }, - onError: (error, request) => - logger.error(`Scan of ${request.projectDirectory} threw: ${messageFromUnknown(error)}`), - onIdleChange: (idle) => { - void setBusy(!idle); - // The scheduler draining is the reliable "burst settled" signal - // (completed + cancelled chunks alike); emit the wide event here. - if (idle) scanTelemetry.finish(); - }, - debounceMs: DOCUMENT_CHANGE_DEBOUNCE_MS, - concurrency, - reservedInteractiveSlots: RESERVED_INTERACTIVE_SLOTS, - logger, - }); - - supportsPullDiagnostics = Boolean(params.capabilities.textDocument?.diagnostic); - supportsWatchedFileRegistration = Boolean( - params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration, - ); - supportsWorkDoneProgress = Boolean(params.capabilities.window?.workDoneProgress); - supportsServerStatus = readBooleanInitOption( - params.capabilities.experimental, - "serverStatusNotification", - false, - ); - // `onDidChangeWorkspaceFolders` throws if the client didn't advertise - // workspace-folder support — guard the registration on this. - supportsWorkspaceFolderChange = Boolean(params.capabilities.workspace?.workspaceFolders); - scanOnType = readBooleanInitOption(params.initializationOptions, "scanOnType", true); - - const capabilities: ServerCapabilities = { - textDocumentSync: { - openClose: true, - change: TextDocumentSyncKind.Incremental, - save: { includeText: false }, - }, - hoverProvider: true, - codeActionProvider: { - codeActionKinds: [CodeActionKind.QuickFix, CodeActionKind.Source], - }, - executeCommandProvider: { commands: [...ALL_COMMANDS] }, - // Only advertise workspace-folders support when the client supports - // it — otherwise vscode-languageserver auto-registers folder-change - // notifications on `initialized` and throws, aborting the initial - // workspace scan. (We read folders from the initialize params, so - // this capability is only needed for live multi-root updates.) - ...(supportsWorkspaceFolderChange - ? { - workspace: { - workspaceFolders: { supported: true, changeNotifications: true }, - }, - } - : {}), - ...(supportsPullDiagnostics - ? { - diagnosticProvider: { - identifier: DIAGNOSTIC_SOURCE, - interFileDependencies: true, - workspaceDiagnostics: false, - }, - } - : {}), - }; - - return { capabilities, serverInfo: { name: SERVER_DISPLAY_NAME, version: SERVER_VERSION } }; - }); - - connection.onInitialized(() => { - if (supportsWatchedFileRegistration) { - void connection.client.register(DidChangeWatchedFilesNotification.type, { - watchers: [ - { globPattern: `**/{${CONFIG_WATCH_FILENAMES.join(",")}}` }, - { - globPattern: `**/*.{${SCANNABLE_EXTENSIONS.map((extension) => extension.slice(1)).join(",")}}`, - }, - ], - }); - } - - if (nodeBinaryPath === null) { - logger.warn( - `${SERVER_DISPLAY_NAME}: no Node binary compatible with the oxlint native binding was found; lint will be skipped until you switch to a supported Node version.`, - ); - } - - if (supportsWorkspaceFolderChange) { - connection.workspace.onDidChangeWorkspaceFolders((event) => { - const removedRoots = event.removed.map((folder) => - normalizeFsPath(uriToFsPath(folder.uri)), - ); - const addedRoots = event.added.map((folder) => normalizeFsPath(uriToFsPath(folder.uri))); - // Clear diagnostics owned by folders leaving the workspace before - // rebuilding the graph (afterwards their projects are gone). - const isUnderRemovedRoot = (directory: string): boolean => - removedRoots.some((root) => directory === root || directory.startsWith(`${root}/`)); - for (const project of projectGraph?.listProjects() ?? []) { - if (isUnderRemovedRoot(project.directory)) { - scheduler?.cancelProject(project.directory); - manager?.clearProject(project.directory); - } - } - // Drop core + lint caches (same as config-change / restart) so - // discovery, config, ignore, and package metadata don't go stale - // across folder updates. - scanRunner?.invalidateCaches(); - projectGraph?.invalidate(); - // Discovery froze its roots at `initialize`, so rebuild the graph - // against the updated set instead of just refreshing within it. - workspaceRoots = [ - ...workspaceRoots.filter((root) => !removedRoots.includes(root)), - ...addedRoots, - ]; - projectGraph = createProjectGraph({ roots: workspaceRoots, logger }); - // Re-scan open buffers interactively (the workspace scan skips open - // files), so documents already open in a newly-added folder get - // diagnostics without waiting for an edit. - for (const document of documents.all()) { - scheduleFileScan( - uriToFsPath(document.uri), - "interactive", - true, - "workspace folders changed", - ); - } - scanWorkspaceLint("workspace-folders-change"); - }); - } - - telemetry.recordSessionStart({ - serverVersion: SERVER_VERSION, - nodeMajor: nodeMajorVersion(), - projectCount: projectGraph?.listProjects().length ?? 0, - workspaceFolderCount: workspaceRoots.length, - scanOnType, - lintAvailable: nodeBinaryPath !== null, - }); - - publishServerStatus(); - setTimeout(() => scanWorkspaceLint("initial"), INITIAL_WORKSPACE_SCAN_DELAY_MS); - }); - - // ── Document sync ──────────────────────────────────────────────── - - documents.onDidOpen((event) => { - openDocumentUriByPath.set(normalizeFsPath(uriToFsPath(event.document.uri)), event.document.uri); - scheduleFileScan(uriToFsPath(event.document.uri), "interactive", true, "open"); - }); - - documents.onDidClose((event) => { - const fsPath = uriToFsPath(event.document.uri); - openDocumentUriByPath.delete(normalizeFsPath(fsPath)); - // Overlay scans may have published buffer-based diagnostics; once the - // (possibly unsaved) buffer is gone, re-scan from disk so diagnostics - // reflect the on-disk file. It's no longer open, so this reads disk. - scheduleFileScan(fsPath, "background", false, "close"); - }); - - documents.onDidChangeContent((event) => { - // `onDidOpen` already covers the first scan; skip per-keystroke - // overlay scans when the client opted out via `scanOnType: false`. - if (!scanOnType) return; - scheduleFileScan(uriToFsPath(event.document.uri), "interactive", true, "change"); - }); - - documents.onDidSave((event) => { - // Save re-lints only the saved file from disk. A whole-project - // re-lint on every save would be pathological on large repos - // (~100s on an 8k-file repo); dead-code refresh is on-demand via the - // `scanWorkspace` command. - scheduleFileScan(uriToFsPath(event.document.uri), "save", false, "save"); - }); - - // ── Watched files ──────────────────────────────────────────────── - - connection.onDidChangeWatchedFiles((params) => { - let configChanged = false; - const filesToRescan: string[] = []; - - for (const change of params.changes) { - const fsPath = uriToFsPath(change.uri); - const baseName = path.basename(fsPath); - if (CONFIG_WATCH_FILENAMES.some((watched) => watched === baseName)) { - configChanged = true; - continue; - } - if (change.type === FileChangeType.Deleted) { - manager?.clearUri(fsPathToUri(fsPath)); - continue; - } - if (isScannablePath(fsPath) && !isOpen(fsPath)) filesToRescan.push(fsPath); - } - - for (const fsPath of filesToRescan) scheduleFileScan(fsPath, "background", false, "watched"); - - if (configChanged) { - if (configRescanTimer) clearTimeout(configRescanTimer); - configRescanTimer = setTimeout(() => { - configRescanTimer = null; - // Config changed → in-flight scans + cached results are stale; the - // cache reloads under a fresh fingerprint on the next scan. - rescanWorkspaceFromScratch("config-change"); - }, CONFIG_CHANGE_DEBOUNCE_MS); - if (typeof configRescanTimer.unref === "function") configRescanTimer.unref(); - } - }); - - // ── Hover ──────────────────────────────────────────────────────── - - connection.onHover((params): Hover | null => { - if (!manager) return null; - const uri = canonicalizeUri(params.textDocument.uri); - return buildHover(manager.findAt(uri, params.position)); - }); - - // ── Code actions ───────────────────────────────────────────────── - - connection.onCodeAction((params): CodeAction[] => { - if (!manager || !projectGraph) return []; - const uri = canonicalizeUri(params.textDocument.uri); - const fsPath = uriToFsPath(params.textDocument.uri); - const fileDiagnostics = manager.get(uri); - const rangeDiagnostics = fileDiagnostics.filter((diagnostic) => - rangesOverlap(diagnostic.range, params.range), - ); - const project = projectGraph.resolveOwningProject(fsPath); - const relativeFilePath = project - ? path.relative(project, fsPath).replace(/\\/g, "/") - : path.basename(fsPath); - - const actions = buildCodeActions({ - uri, - fsPath, - documentText: readText(fsPath), - relativeFilePath, - rangeDiagnostics, - fileDiagnostics, - }); - - // Guard the destructive file-level "suppress all" source action. Editors - // running code actions on save send `triggerKind: Automatic` with - // `only: ["source"]`, which prefix-matches `source.suppressAll.reactDoctor` - // below and would mass-insert disable comments on every save. Offer it only - // on an explicit (Invoked) request — e.g. the Source Action menu — unless - // the client deliberately opted in to the exact kind. - const only = params.context.only; - const isAutomaticTrigger = params.context.triggerKind === CodeActionTriggerKind.Automatic; - const optedIntoSuppressAll = (only ?? []).includes(SUPPRESS_ALL_CODE_ACTION_KIND); - const offeredActions = - isAutomaticTrigger && !optedIntoSuppressAll - ? actions.filter((action) => action.kind !== SUPPRESS_ALL_CODE_ACTION_KIND) - : actions; - - // Honor `context.only`: a lightbulb request asks for `quickfix`, the - // Source Action menu / on-save asks for `source*`. Returning the - // wrong kinds clutters menus and risks on-save side effects. - if (!only || only.length === 0) return offeredActions; - return offeredActions.filter( - (action) => - action.kind !== undefined && - only.some((kind) => action.kind === kind || action.kind?.startsWith(`${kind}.`)), - ); - }); - - // ── Pull diagnostics ───────────────────────────────────────────── - - connection.languages.diagnostics.on((params): DocumentDiagnosticReport => { - const uri = canonicalizeUri(params.textDocument.uri); - return { - kind: DocumentDiagnosticReportKind.Full, - items: manager ? manager.get(uri) : [], - }; - }); - - // ── Commands ───────────────────────────────────────────────────── - - connection.onExecuteCommand(async (params) => { - const [firstArgument] = params.arguments ?? []; - switch (params.command) { - case COMMAND_SCAN_WORKSPACE: { - // Manual re-audit: drop in-flight scans, cached per-file lint - // results, and project metadata so the audit runs fresh against - // current config / ignore / package data rather than reusing stale - // cache entries on the old fingerprint. - cancelAllProjectScans(); - scanRunner?.invalidateCaches(); - projectGraph?.invalidate(); - projectGraph?.refresh(); - scanWorkspaceFull(); - // The audit runs at background priority and so skips open buffers; - // re-scan them interactively so an open tab isn't left stale (the - // cancel above also dropped any pending open-buffer scans). - for (const document of documents.all()) { - scheduleFileScan(uriToFsPath(document.uri), "interactive", true, "scan workspace"); - } - return; - } - case COMMAND_SCAN_FILE: { - const uri = typeof firstArgument === "string" ? firstArgument : extractUri(firstArgument); - if (uri) scheduleFileScan(uriToFsPath(uri), "interactive", true, "command"); - return; - } - case COMMAND_FIX_ALL: { - const uri = typeof firstArgument === "string" ? firstArgument : extractUri(firstArgument); - if (uri) await suppressAllInFile(canonicalizeUri(uri), uriToFsPath(uri)); - return; - } - case COMMAND_SUPPRESS_LINE: { - await suppressSingle(firstArgument); - return; - } - case COMMAND_EXPLAIN: { - explain(firstArgument); - return; - } - case COMMAND_OPEN_DOCS: { - if (typeof firstArgument === "string") await openExternal(firstArgument); - return; - } - case COMMAND_REPORT_FALSE_POSITIVE: { - const report = asFalsePositiveReport(firstArgument); - if (report) await openExternal(buildFalsePositiveIssueUrl(report)); - return; - } - case COMMAND_RESTART: { - lintWarningShown = false; - rescanWorkspaceFromScratch("restart"); - connection.window.showInformationMessage(`${SERVER_DISPLAY_NAME}: re-scanning workspace.`); - return; - } - default: - return; - } - }); - - const findByIdentity = (uri: string, identity: string) => - manager?.get(uri).find((diagnostic) => readDiagnosticData(diagnostic)?.identity === identity) ?? - null; - - const suppressSingle = async (argument: unknown): Promise<void> => { - const uri = extractUri(argument); - const identity = extractString(argument, "identity"); - if (!uri || !identity) return; - const canonical = canonicalizeUri(uri); - const diagnostic = findByIdentity(canonical, identity); - const data = diagnostic ? readDiagnosticData(diagnostic) : null; - if (!data) return; - const edits = buildSuppressAllTextEdits({ - documentText: readText(uriToFsPath(uri)), - fsPath: uriToFsPath(uri), - targets: [{ line: data.line, ruleId: data.ruleId }], - }); - await applyWorkspaceEdit(canonical, edits); - }; - - const suppressAllInFile = async (uri: string, fsPath: string): Promise<void> => { - if (!manager) return; - const edits = buildSuppressAllTextEdits({ - documentText: readText(fsPath), - fsPath, - targets: collectSuppressionTargets(manager.get(uri)), - }); - await applyWorkspaceEdit(uri, edits); - }; - - const explain = (argument: unknown): void => { - const uri = extractUri(argument); - const identity = extractString(argument, "identity"); - if (!uri || !identity) return; - const diagnostic = findByIdentity(canonicalizeUri(uri), identity); - const data = diagnostic ? readDiagnosticData(diagnostic) : null; - if (!data || !diagnostic) return; - const recommendation = data.help ? `\n\n${data.help}` : ""; - connection.window.showInformationMessage( - `${data.ruleId} (${data.category}): ${diagnostic.message}${recommendation}`, - ); - }; - - // Tear down cleanly: stop the debounced config-rescan and any in-flight / - // queued scans first, then flush the lint cache to disk so the next - // session reuses it (the debounced write may not have fired). Stopping - // work before the flush prevents a config change moments before shutdown - // from enqueuing a rescan or re-dirtying the cache after teardown begins. - connection.onShutdown(() => { - if (configRescanTimer) clearTimeout(configRescanTimer); - scheduler?.dispose(); - scanRunner?.dispose(); - // Best-effort: get queued analytics off the machine before the editor - // tears the process down. Swallow failures — telemetry never blocks exit. - void telemetry.flush?.().catch(() => {}); - }); - - documents.listen(connection); - connection.listen(); -}; - -// ── Argument coercion helpers ────────────────────────────────────── - -const extractUri = (argument: unknown): string | null => extractString(argument, "uri"); - -const extractString = (argument: unknown, key: string): string | null => { - if (argument === null || typeof argument !== "object") return null; - const value = Reflect.get(argument, key); - return typeof value === "string" ? value : null; -}; - -const asFalsePositiveReport = (argument: unknown): FalsePositiveReport | null => { - if (argument === null || typeof argument !== "object") return null; - const ruleId = extractString(argument, "ruleId"); - if (ruleId === null) return null; - const line = Reflect.get(argument, "line"); - return { - ruleId, - severity: extractString(argument, "severity") ?? "warning", - category: extractString(argument, "category") ?? "", - message: extractString(argument, "message") ?? "", - relativeFilePath: extractString(argument, "relativeFilePath") ?? "", - line: typeof line === "number" ? line : 1, - }; -}; - -const readBooleanInitOption = (options: unknown, key: string, fallback: boolean): boolean => { - if (options === null || typeof options !== "object") return fallback; - const value = Reflect.get(options, key); - return typeof value === "boolean" ? value : fallback; -}; - -const nodeMajorVersion = (): number => - Number.parseInt(process.versions.node.split(".", 1)[0] ?? "", 10) || 0; - -/** - * stdout is the LSP message channel — any stray write corrupts the - * protocol stream and silently breaks the client. Route accidental - * `console.log` / `info` / `debug` (from this server or any transitive - * dependency) to stderr; structured logs still go through the LSP - * `window/logMessage` channel via `connection.console`. - */ -const protectStdoutChannel = (): void => { - const toStderr = (...args: unknown[]): void => { - process.stderr.write(`${args.map((arg) => String(arg)).join(" ")}\n`); - }; - console.log = toStderr; - console.info = toStderr; - console.debug = toStderr; -}; - -/** Keep the daemon alive through stray errors — log instead of crashing the editor session. */ -const installProcessGuards = (connection: Connection): void => { - const describe = (value: unknown): string => - value instanceof Error ? (value.stack ?? value.message) : String(value); - process.on("uncaughtException", (error) => { - connection.console.error(`Uncaught exception: ${describe(error)}`); - }); - process.on("unhandledRejection", (reason) => { - connection.console.error(`Unhandled rejection: ${describe(reason)}`); - }); -}; - -/** Entry point: starts the server over stdio. */ -export const startLanguageServer = (options: StartLanguageServerOptions = {}): void => { - protectStdoutChannel(); - const connection = createConnection(process.stdin, process.stdout); - installProcessGuards(connection); - createServer(connection, options); -}; diff --git a/packages/language-server/src/text/positions.ts b/packages/language-server/src/text/positions.ts deleted file mode 100644 index 368a6a84a5..0000000000 --- a/packages/language-server/src/text/positions.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { Position, Range } from "vscode-languageserver"; - -/** - * oxlint reports spans as UTF-8 **byte** offsets, but LSP positions are - * UTF-16 code-unit based. These helpers convert between the two using - * the actual document text so squiggles land exactly on the offending - * token even when a line contains multi-byte characters. - */ - -const utf8ByteLength = (codePoint: number): number => { - if (codePoint <= 0x7f) return 1; - if (codePoint <= 0x7ff) return 2; - if (codePoint <= 0xffff) return 3; - return 4; -}; - -const utf16UnitLength = (codePoint: number): number => (codePoint > 0xffff ? 2 : 1); - -/** - * Converts a UTF-8 byte offset into a 0-indexed LSP `Position`. Walks - * the text by code point, accumulating byte and line/character counts. - * A target past the end clamps to the final position. - */ -export const byteOffsetToPosition = (text: string, byteOffset: number): Position => { - if (byteOffset <= 0) return { line: 0, character: 0 }; - - let byteCount = 0; - let line = 0; - let character = 0; - - for (const char of text) { - if (byteCount >= byteOffset) return { line, character }; - if (char === "\n") { - line += 1; - character = 0; - byteCount += 1; - continue; - } - const codePoint = char.codePointAt(0) ?? 0; - byteCount += utf8ByteLength(codePoint); - character += utf16UnitLength(codePoint); - } - - return { line, character }; -}; - -/** Builds an LSP `Range` from a UTF-8 byte span against the document text. */ -export const rangeFromByteSpan = (text: string, offset: number, length: number): Range => ({ - start: byteOffsetToPosition(text, offset), - end: byteOffsetToPosition(text, offset + Math.max(0, length)), -}); - -/** - * Fallback range from oxlint's 1-indexed `line` / `column` when no byte - * span is available (environment / dead-code diagnostics). When the - * document text is known, the range extends to the end of the token's - * line so the squiggle is visible; otherwise it spans a single column. - */ -export const rangeFromLineColumn = (text: string | null, line: number, column: number): Range => { - const startLine = Math.max(0, (line || 1) - 1); - const startCharacter = Math.max(0, (column || 1) - 1); - const start: Position = { line: startLine, character: startCharacter }; - - if (text !== null) { - const lines = text.split("\n"); - const lineText = lines[startLine] ?? ""; - const endCharacter = Math.max(startCharacter + 1, lineText.replace(/\r$/, "").length); - return { start, end: { line: startLine, character: endCharacter } }; - } - - return { start, end: { line: startLine, character: startCharacter + 1 } }; -}; - -/** - * Whether `position` falls within `range`. The end is exclusive, matching - * LSP range semantics (so the cursor one unit past the underline is not a - * match) — except a zero-width range still matches at its single point so - * a collapsed diagnostic span stays hoverable. - */ -export const isPositionInRange = (range: Range, position: Position): boolean => { - const afterStart = - position.line > range.start.line || - (position.line === range.start.line && position.character >= range.start.character); - const beforeEnd = - position.line < range.end.line || - (position.line === range.end.line && position.character < range.end.character); - const atZeroWidthRange = - range.start.line === range.end.line && - range.start.character === range.end.character && - position.line === range.start.line && - position.character === range.start.character; - return (afterStart && beforeEnd) || atZeroWidthRange; -}; - -const isBefore = (first: Position, second: Position): boolean => - first.line < second.line || (first.line === second.line && first.character < second.character); - -/** Whether two ranges overlap (touching endpoints count as overlap). */ -export const rangesOverlap = (first: Range, second: Range): boolean => - !isBefore(first.end, second.start) && !isBefore(second.end, first.start); diff --git a/packages/language-server/src/text/uri.ts b/packages/language-server/src/text/uri.ts deleted file mode 100644 index 403492a148..0000000000 --- a/packages/language-server/src/text/uri.ts +++ /dev/null @@ -1,20 +0,0 @@ -import path from "node:path"; -import { URI } from "vscode-uri"; - -/** Absolute, forward-slash path used as the canonical key everywhere. */ -export const normalizeFsPath = (filePath: string): string => - path.resolve(filePath).replace(/\\/g, "/"); - -/** Canonical `file://` URI for an absolute path. */ -export const fsPathToUri = (filePath: string): string => - URI.file(normalizeFsPath(filePath)).toString(); - -/** Filesystem path (normalized) for a document URI. */ -export const uriToFsPath = (uri: string): string => normalizeFsPath(URI.parse(uri).fsPath); - -/** - * Round-trips a client-supplied URI through the path layer so it matches - * the keys the server stores (`URI.file(fsPath).toString()`), absorbing - * casing / encoding differences between clients. - */ -export const canonicalizeUri = (uri: string): string => fsPathToUri(uriToFsPath(uri)); diff --git a/packages/language-server/src/types.ts b/packages/language-server/src/types.ts deleted file mode 100644 index 14b3da7abe..0000000000 --- a/packages/language-server/src/types.ts +++ /dev/null @@ -1,228 +0,0 @@ -import type { Diagnostic as CoreDiagnostic, ProjectInfo } from "@react-doctor/core"; - -/** - * Minimal logging seam so modules don't depend on the LSP connection - * directly. The server wires this to `window/logMessage`; tests pass a - * silent or recording logger. - */ -export interface Logger { - readonly info: (message: string) => void; - readonly warn: (message: string) => void; - readonly error: (message: string) => void; -} - -export const SILENT_LOGGER: Logger = { - info: () => {}, - warn: () => {}, - error: () => {}, -}; - -/** What kicked off a workspace-scan burst (the wide-event's `trigger`). */ -export type WorkspaceScanTrigger = - | "initial" - | "config-change" - | "workspace-folders-change" - | "manual" - | "restart"; - -/** One-shot session analytics, emitted once after the server initializes. */ -export interface SessionTelemetry { - readonly serverVersion: string; - readonly nodeMajor: number; - readonly projectCount: number; - readonly workspaceFolderCount: number; - readonly scanOnType: boolean; - /** Whether a Node binary able to load the oxlint native binding was found. */ - readonly lintAvailable: boolean; -} - -/** - * Aggregate outcome of one workspace-scan burst — the unit the editor - * telemetry treats as a "scan" (analogous to one CLI run). Per-keystroke - * interactive scans are deliberately excluded; only the background workspace - * audit (initial, config/folder change, manual, restart) is reported. - */ -export interface WorkspaceScanTelemetry { - readonly trigger: WorkspaceScanTrigger; - /** Epoch ms when the burst began (the wide-event span's start time). */ - readonly startedAtEpochMs: number; - readonly durationMs: number; - readonly projectCount: number; - /** Completed background scan chunks aggregated into this burst. */ - readonly chunkCount: number; - readonly filesWithDiagnostics: number; - readonly totalDiagnostics: number; - readonly errorCount: number; - readonly warningCount: number; - /** Diagnostic counts keyed by rule category (e.g. "Performance"). */ - readonly diagnosticsByCategory: Readonly<Record<string, number>>; - /** `true` when any chunk reported lint as degraded/unavailable. */ - readonly lintDegraded: boolean; - /** Chunks that linted only partially (some files failed within the batch). */ - readonly lintIncompleteChunks: number; -} - -/** - * Telemetry seam so the server reports analytics without depending on a - * concrete backend (mirrors the {@link Logger} seam). The published CLI - * injects a Sentry-backed implementation (wide-event spans + counters); - * tests and direct `startLanguageServer()` callers get {@link NOOP_TELEMETRY}. - */ -export interface Telemetry { - /** Once per session, after the project graph is first available. */ - readonly recordSessionStart: (session: SessionTelemetry) => void; - /** Once per completed workspace-scan burst (the canonical wide event). */ - readonly recordWorkspaceScan: (scan: WorkspaceScanTelemetry) => void; - /** Best-effort flush of queued telemetry before the server exits. */ - readonly flush?: () => Promise<void>; -} - -export const NOOP_TELEMETRY: Telemetry = { - recordSessionStart: () => {}, - recordWorkspaceScan: () => {}, -}; - -/** - * A React project discovered in the workspace. `directory` is an - * absolute, normalized (forward-slash) path to the project root. - */ -export interface WorkspaceProject { - readonly directory: string; -} - -/** - * Resolves which project owns a file and enumerates workspace projects. - * Backed by `@react-doctor/core`'s discovery helpers, with caching and - * explicit invalidation when watched config files change. - */ -export interface ProjectGraph { - /** All React projects discovered across the workspace roots. */ - readonly listProjects: () => ReadonlyArray<WorkspaceProject>; - /** - * Deepest project directory that owns `absoluteFilePath`, or `null` - * when the file is outside every known React project. - */ - readonly resolveOwningProject: (absoluteFilePath: string) => string | null; - /** Re-discovers projects from the workspace roots (after config changes). */ - readonly refresh: () => void; - /** Drops cached project/config state for incremental correctness. */ - readonly invalidate: () => void; -} - -/** Priority class for a queued scan; drives ordering and debounce. */ -export type ScanPriority = "interactive" | "save" | "background"; - -/** A scan to perform, before the scheduler assigns it a generation id. */ -export interface ScanRequestInput { - readonly priority: ScanPriority; - /** Absolute, normalized project root the scan targets. */ - readonly projectDirectory: string; - /** - * Absolute file paths to lint. Empty → whole-project scan (covers the - * project, enabling stale-diagnostic cleanup for that project). - */ - readonly files: ReadonlyArray<string>; - /** Whether to run dead-code analysis (whole-project background scans). */ - readonly runDeadCode: boolean; - /** Use in-memory buffer overlays for the target files (unsaved edits). */ - readonly useOverlay: boolean; - /** Short human-readable cause, for log lines. */ - readonly reason: string; -} - -/** A scan request with its monotonic generation id assigned. */ -export interface ScanRequest extends ScanRequestInput { - readonly id: number; -} - -/** - * Cancellation signal handed to `performScan`. Reflects whether a newer - * generation has superseded this scan's queue key; the scheduler also - * drops superseded results so a slow oxlint subprocess can't clobber a - * fresher one. - */ -export interface CancellationToken { - readonly isCancelled: boolean; -} - -/** Diagnostics produced by one scan, grouped by absolute file path. */ -export interface ScanOutcome { - readonly request: ScanRequest; - readonly ok: boolean; - readonly skipped: boolean; - /** Absolute fs path → core diagnostics for that file. */ - readonly byFile: ReadonlyMap<string, ReadonlyArray<CoreDiagnostic>>; - /** - * `true` when this scan covered the whole project, so the publisher - * may clear previously-published files in the project that are absent - * from `byFile`. - */ - readonly coversProject: boolean; - /** Absolute files explicitly requested (cleared when absent from `byFile`). */ - readonly requestedPaths: ReadonlyArray<string>; - readonly project: ProjectInfo | null; - readonly didLintFail: boolean; - readonly lintFailureReason: string | null; - /** - * `true` when lint ran but some files failed within the batch (partial - * failure). Like `didLintFail`, it marks the result unreliable so the - * publisher won't clear diagnostics for files that weren't linted. - */ - readonly lintIncomplete?: boolean; - readonly error: string | null; -} - -/** Performs a single scan. Implemented by the scan runner. */ -export type PerformScan = ( - request: ScanRequest, - token: CancellationToken, -) => Promise<ScanOutcome | null>; - -export interface SchedulerOptions { - readonly performScan: PerformScan; - readonly onResult: (outcome: ScanOutcome) => void; - readonly onError?: (error: unknown, request: ScanRequest) => void; - readonly onIdleChange?: (isIdle: boolean) => void; - readonly debounceMs?: number; - readonly concurrency?: number; - /** - * Slots kept free from `background` scans so an `interactive` / `save` - * scan can always start immediately, even while a large workspace scan - * is in flight. Background scans run with at most - * `concurrency - reservedInteractiveSlots` parallelism. Defaults to 0. - */ - readonly reservedInteractiveSlots?: number; - readonly logger?: Logger; -} - -export interface Scheduler { - /** Queue a scan, coalescing with any pending scan for the same target. */ - readonly enqueue: (request: ScanRequestInput) => void; - /** Cancel all pending + in-flight scans for a project (e.g. on close). */ - readonly cancelProject: (projectDirectory: string) => void; - readonly dispose: () => void; -} - -/** - * Structured payload attached to every published LSP diagnostic's - * `data` field. Lets hover / code-action / command handlers operate on a - * diagnostic the client echoes back without re-deriving anything. - */ -export interface ReactDoctorDiagnosticData { - readonly identity: string; - readonly plugin: string; - readonly rule: string; - readonly ruleId: string; - readonly category: string; - readonly help: string; - readonly url: string | null; - readonly suppressionHint: string | null; - /** 1-indexed source line of the primary span (from the engine). */ - readonly line: number; - /** 1-indexed source column of the primary span. */ - readonly column: number; - readonly fsPath: string; -} - -/** Reads the current text of a file (open buffer or disk), or `null`. */ -export type TextProvider = (absoluteFilePath: string) => string | null; diff --git a/packages/language-server/src/utils/chunk.ts b/packages/language-server/src/utils/chunk.ts deleted file mode 100644 index ae3ed774e6..0000000000 --- a/packages/language-server/src/utils/chunk.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Splits `items` into consecutive chunks of at most `size` (size >= 1). */ -export const chunk = <Item>(items: ReadonlyArray<Item>, size: number): Item[][] => { - const safeSize = Math.max(1, Math.floor(size)); - const chunks: Item[][] = []; - for (let index = 0; index < items.length; index += safeSize) { - chunks.push(items.slice(index, index + safeSize)); - } - return chunks; -}; diff --git a/packages/language-server/src/utils/read-diagnostic-data.ts b/packages/language-server/src/utils/read-diagnostic-data.ts deleted file mode 100644 index c65c425708..0000000000 --- a/packages/language-server/src/utils/read-diagnostic-data.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { DIAGNOSTIC_SOURCE } from "../constants.js"; -import type { ReactDoctorDiagnosticData } from "../types.js"; - -/** - * Reads the structured payload this server attaches to every diagnostic's - * `data` field and a client echoes back on hover / code-action / command - * requests. Returns `null` for diagnostics this server didn't emit or whose - * round-tripped payload no longer matches the server-owned contract. - */ -export const readDiagnosticData = (diagnostic: { - source?: string; - data?: unknown; -}): ReactDoctorDiagnosticData | null => { - if (diagnostic.source !== DIAGNOSTIC_SOURCE) return null; - const { data } = diagnostic; - if (data === null || typeof data !== "object") return null; - const identity = Reflect.get(data, "identity"); - const plugin = Reflect.get(data, "plugin"); - const rule = Reflect.get(data, "rule"); - const ruleId = Reflect.get(data, "ruleId"); - const category = Reflect.get(data, "category"); - const help = Reflect.get(data, "help"); - const url = Reflect.get(data, "url"); - const suppressionHint = Reflect.get(data, "suppressionHint"); - const line = Reflect.get(data, "line"); - const column = Reflect.get(data, "column"); - const fsPath = Reflect.get(data, "fsPath"); - if ( - typeof identity !== "string" || - typeof plugin !== "string" || - typeof rule !== "string" || - typeof ruleId !== "string" || - typeof category !== "string" || - typeof help !== "string" || - (url !== null && typeof url !== "string") || - (suppressionHint !== null && typeof suppressionHint !== "string") || - typeof line !== "number" || - typeof column !== "number" || - typeof fsPath !== "string" - ) { - return null; - } - return { - identity, - plugin, - rule, - ruleId, - category, - help, - url, - suppressionHint, - line, - column, - fsPath, - }; -}; diff --git a/packages/language-server/src/utils/read-positive-int-env.ts b/packages/language-server/src/utils/read-positive-int-env.ts deleted file mode 100644 index a81a39f6ff..0000000000 --- a/packages/language-server/src/utils/read-positive-int-env.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Reads a positive-integer override from an environment variable. - * Returns `fallback` when the variable is unset or not a positive integer. - */ -export const readPositiveIntEnv = (name: string, fallback: number): number => { - const raw = process.env[name]; - if (raw === undefined) return fallback; - const parsed = Number.parseInt(raw, 10); - return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; -}; diff --git a/packages/language-server/src/utils/severity-label.ts b/packages/language-server/src/utils/severity-label.ts deleted file mode 100644 index f3ed3e3f7d..0000000000 --- a/packages/language-server/src/utils/severity-label.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { DiagnosticSeverity, type Diagnostic as LspDiagnostic } from "vscode-languageserver"; - -/** - * Human-readable label for an LSP diagnostic's severity. Maps all four LSP - * severities (not just error/warning) so demoted findings — e.g. design - * rules shown as `Information` — aren't mislabeled in hovers and reports. - */ -export const severityLabel = (severity: LspDiagnostic["severity"]): string => { - switch (severity) { - case DiagnosticSeverity.Error: - return "error"; - case DiagnosticSeverity.Information: - return "info"; - case DiagnosticSeverity.Hint: - return "hint"; - default: - return "warning"; - } -}; diff --git a/packages/language-server/src/utils/to-project-relative.ts b/packages/language-server/src/utils/to-project-relative.ts deleted file mode 100644 index bd12ab44c1..0000000000 --- a/packages/language-server/src/utils/to-project-relative.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as path from "node:path"; - -// Normalizes an absolute file path to a forward-slash path relative to -// the project root, or null when it escapes the project (so callers can -// skip paths outside the scanned tree). -export const toProjectRelative = (projectDirectory: string, filePath: string): string | null => { - const relative = path.relative(projectDirectory, filePath).replace(/\\/g, "/"); - if (relative.length === 0 || relative.startsWith("../") || path.isAbsolute(relative)) return null; - return relative; -}; diff --git a/packages/language-server/tests/fixtures/simple-app/package.json b/packages/language-server/tests/fixtures/simple-app/package.json deleted file mode 100644 index 146e237618..0000000000 --- a/packages/language-server/tests/fixtures/simple-app/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "simple-app", - "version": "1.0.0", - "private": true, - "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } -} diff --git a/packages/language-server/tests/fixtures/simple-app/src/App.tsx b/packages/language-server/tests/fixtures/simple-app/src/App.tsx deleted file mode 100644 index bc9e906e1f..0000000000 --- a/packages/language-server/tests/fixtures/simple-app/src/App.tsx +++ /dev/null @@ -1,15 +0,0 @@ -interface AppProps { - items: string[]; -} - -export const App = ({ items }: AppProps) => { - return ( - <ul> - {items.map((item, index) => ( - <li key={index} style={{ color: "red" }}> - {item} - </li> - ))} - </ul> - ); -}; diff --git a/packages/language-server/tests/fixtures/simple-app/tsconfig.json b/packages/language-server/tests/fixtures/simple-app/tsconfig.json deleted file mode 100644 index 435233680c..0000000000 --- a/packages/language-server/tests/fixtures/simple-app/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "bundler", - "jsx": "react-jsx", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src"] -} diff --git a/packages/language-server/tests/integration/server.test.ts b/packages/language-server/tests/integration/server.test.ts deleted file mode 100644 index 7a3bb81f14..0000000000 --- a/packages/language-server/tests/integration/server.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; -import { LspTestClient, pathToUri, waitForNotification } from "../lsp-client.js"; - -const PACKAGE_ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url)))); -const FIXTURE_DIR = path.join(PACKAGE_ROOT, "tests", "fixtures", "simple-app"); -const APP_FILE = path.join(FIXTURE_DIR, "src", "App.tsx"); - -interface PublishDiagnosticsParams { - uri: string; - diagnostics: Array<{ - range: { start: { line: number; character: number }; end: { line: number; character: number } }; - code?: string; - source?: string; - message: string; - data?: unknown; - }>; -} - -const isAppDiagnostics = (params: unknown): params is PublishDiagnosticsParams => { - if (params === null || typeof params !== "object") return false; - const candidate = params as PublishDiagnosticsParams; - return ( - typeof candidate.uri === "string" && - candidate.uri.endsWith("App.tsx") && - Array.isArray(candidate.diagnostics) && - candidate.diagnostics.length > 0 - ); -}; - -describe("react-doctor language server (stdio)", () => { - let client: LspTestClient; - let appDiagnostics: PublishDiagnosticsParams; - let serverInfo: { name?: string; version?: string } | undefined; - const statusEvents: Array<{ health: string; quiescent: boolean }> = []; - - beforeAll(async () => { - client = new LspTestClient(); - const initialize = (await client.request("initialize", { - processId: process.pid, - rootUri: pathToUri(FIXTURE_DIR), - capabilities: { - textDocument: { publishDiagnostics: {}, hover: {}, codeAction: {} }, - workspace: {}, - experimental: { serverStatusNotification: true }, - }, - workspaceFolders: [{ uri: pathToUri(FIXTURE_DIR), name: "simple-app" }], - })) as { capabilities?: unknown; serverInfo?: { name?: string; version?: string } }; - serverInfo = initialize.serverInfo; - expect(initialize).toMatchObject({ capabilities: { hoverProvider: true } }); - - client.onNotification((method, params) => { - if (method === "experimental/serverStatus") { - statusEvents.push(params as { health: string; quiescent: boolean }); - } - }); - client.notify("initialized", {}); - - const publishPromise = waitForNotification( - client, - "textDocument/publishDiagnostics", - isAppDiagnostics, - ); - - client.notify("textDocument/didOpen", { - textDocument: { - uri: pathToUri(APP_FILE), - languageId: "typescriptreact", - version: 1, - text: fs.readFileSync(APP_FILE, "utf8"), - }, - }); - - appDiagnostics = (await publishPromise) as PublishDiagnosticsParams; - }); - - afterAll(async () => { - await client.stop(); - }); - - it("publishes a precise array-index-key diagnostic for the opened file", () => { - const indexKey = appDiagnostics.diagnostics.find((diagnostic) => - (diagnostic.code ?? "").includes("no-array-index"), - ); - expect(indexKey).toBeDefined(); - expect(indexKey?.source).toBe("react-doctor"); - // `key={index}` lives on source line 9 (0-indexed line 8). - expect(indexKey?.range.start.line).toBe(8); - // Precise byte-span range, not a whole-line fallback. - expect(indexKey?.range.end.character).toBeGreaterThan(indexKey?.range.start.character ?? 0); - }); - - it("offers a suppression quick fix and a file-level source action", async () => { - const target = appDiagnostics.diagnostics.find((diagnostic) => - (diagnostic.code ?? "").includes("no-array-index"), - ); - expect(target).toBeDefined(); - - const actions = (await client.request("textDocument/codeAction", { - textDocument: { uri: pathToUri(APP_FILE) }, - range: target?.range, - context: { diagnostics: [target] }, - })) as Array<{ title: string; kind?: string; edit?: unknown; command?: unknown }>; - - const titles = actions.map((action) => action.title); - expect(titles.some((title) => /Disable .*for this line/.test(title))).toBe(true); - expect(titles.some((title) => title.includes("Suppress all React Doctor issues"))).toBe(true); - expect(titles.some((title) => /Explain/.test(title))).toBe(true); - }); - - it("reports serverInfo with a name and version", () => { - expect(serverInfo?.name).toBe("React Doctor"); - expect(typeof serverInfo?.version).toBe("string"); - expect((serverInfo?.version ?? "").length).toBeGreaterThan(0); - }); - - it("emits experimental/serverStatus (scanning then ready)", () => { - // The open-file scan flips quiescent false while running; the initial - // and post-scan statuses are quiescent true. - expect(statusEvents.some((status) => status.quiescent === false)).toBe(true); - expect(statusEvents.some((status) => status.quiescent === true)).toBe(true); - expect(statusEvents.every((status) => ["ok", "warning", "error"].includes(status.health))).toBe( - true, - ); - }); - - it("honors context.only when returning code actions", async () => { - const target = appDiagnostics.diagnostics.find((diagnostic) => - (diagnostic.code ?? "").includes("no-array-index"), - ); - // `triggerKind` 2 === CodeActionTriggerKind.Automatic (what editors send - // for code-actions-on-save); omitted/1 is a manual (Invoked) request. - const requestActions = (only: string[], triggerKind?: number) => - client.request("textDocument/codeAction", { - textDocument: { uri: pathToUri(APP_FILE) }, - range: target?.range, - context: { diagnostics: [target], only, ...(triggerKind ? { triggerKind } : {}) }, - }) as Promise<Array<{ title: string; kind?: string }>>; - - const quickFixOnly = await requestActions(["quickfix"]); - expect(quickFixOnly.length).toBeGreaterThan(0); - expect(quickFixOnly.every((action) => !(action.kind ?? "").startsWith("source"))).toBe(true); - - // Manual Source Action menu request (Invoked): suppress-all is offered. - const sourceOnly = await requestActions(["source"]); - expect(sourceOnly.some((action) => action.kind === "source.suppressAll.reactDoctor")).toBe( - true, - ); - expect(sourceOnly.every((action) => (action.kind ?? "").startsWith("source"))).toBe(true); - - // On-save (Automatic) request for `source`: the destructive suppress-all - // must be withheld so editors can't auto-insert disable comments on save. - const sourceOnSave = await requestActions(["source"], 2); - expect(sourceOnSave.some((action) => action.kind === "source.suppressAll.reactDoctor")).toBe( - false, - ); - - // An explicit opt-in to the exact kind still gets it, even on save. - const explicitOnSave = await requestActions(["source.suppressAll.reactDoctor"], 2); - expect(explicitOnSave.some((action) => action.kind === "source.suppressAll.reactDoctor")).toBe( - true, - ); - }); - - it("returns a markdown hover describing the rule", async () => { - const target = appDiagnostics.diagnostics.find((diagnostic) => - (diagnostic.code ?? "").includes("no-array-index"), - ); - const hover = (await client.request("textDocument/hover", { - textDocument: { uri: pathToUri(APP_FILE) }, - position: target?.range.start, - })) as { contents?: { kind?: string; value?: string } } | null; - - expect(hover?.contents?.kind).toBe("markdown"); - expect(hover?.contents?.value ?? "").toContain("react-doctor/"); - }); -}); - -describe("react-doctor language server (background workspace scan)", () => { - let scanClient: LspTestClient; - - afterAll(async () => { - await scanClient.stop(); - }); - - // Regression: a client that does NOT advertise workspace-folder support - // must still receive workspace diagnostics. The folder-change capability - // used to make `onInitialized` throw, silently killing the background - // scan for minimal LSP clients (and any client that never opens a file). - it("publishes diagnostics from the chunked scan without didOpen", async () => { - scanClient = new LspTestClient(); - await scanClient.request("initialize", { - processId: process.pid, - rootUri: pathToUri(FIXTURE_DIR), - // Intentionally omit `workspace.workspaceFolders` and any - // workspaceFolders param — the previously-broken scenario. - capabilities: { textDocument: { publishDiagnostics: {} } }, - }); - const publishPromise = waitForNotification( - scanClient, - "textDocument/publishDiagnostics", - isAppDiagnostics, - ); - scanClient.notify("initialized", {}); - - const params = (await publishPromise) as PublishDiagnosticsParams; - expect(params.diagnostics.length).toBeGreaterThan(0); - }); -}); diff --git a/packages/language-server/tests/lsp-client.ts b/packages/language-server/tests/lsp-client.ts deleted file mode 100644 index 10b238d9f7..0000000000 --- a/packages/language-server/tests/lsp-client.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { once } from "node:events"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -const PACKAGE_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); -const SERVER_BIN = path.join(PACKAGE_ROOT, "bin", "react-doctor-language-server.js"); - -interface PendingRequest { - resolve: (value: unknown) => void; - reject: (error: unknown) => void; -} - -export type NotificationHandler = (method: string, params: unknown) => void; - -/** - * Minimal hand-rolled LSP/JSON-RPC client over a spawned server's - * stdio. Dependency-free (Content-Length framing) so the integration - * test exercises the real published transport without pulling a client - * library into the package. - */ -export class LspTestClient { - private readonly child: ChildProcessWithoutNullStreams; - private buffer = Buffer.alloc(0); - private nextId = 1; - private readonly pending = new Map<number, PendingRequest>(); - private readonly notificationHandlers = new Set<NotificationHandler>(); - - constructor() { - this.child = spawn(process.execPath, [SERVER_BIN, "--stdio"], { - cwd: PACKAGE_ROOT, - stdio: ["pipe", "pipe", "pipe"], - // Disable the persistent lint cache so tests never depend on cache - // state left by a prior run (the cache fingerprint can't see source - // changes between dev runs, only config/version). - env: { ...process.env, REACT_DOCTOR_LSP_NO_CACHE: "1" }, - }); - this.child.stdout.on("data", (chunk: Buffer) => this.onData(chunk)); - // Surface server logs on failure without failing the pipe. - this.child.stderr.on("data", () => {}); - } - - onNotification(handler: NotificationHandler): void { - this.notificationHandlers.add(handler); - } - - private onData(chunk: Buffer): void { - this.buffer = Buffer.concat([this.buffer, chunk]); - for (;;) { - const headerEnd = this.buffer.indexOf("\r\n\r\n"); - if (headerEnd === -1) return; - const header = this.buffer.subarray(0, headerEnd).toString("utf8"); - const match = /Content-Length: (\d+)/i.exec(header); - if (!match) { - this.buffer = this.buffer.subarray(headerEnd + 4); - continue; - } - const contentLength = Number(match[1]); - const bodyStart = headerEnd + 4; - if (this.buffer.length < bodyStart + contentLength) return; - const body = this.buffer.subarray(bodyStart, bodyStart + contentLength).toString("utf8"); - this.buffer = this.buffer.subarray(bodyStart + contentLength); - this.dispatch(JSON.parse(body)); - } - } - - private dispatch(message: { - id?: number; - method?: string; - params?: unknown; - result?: unknown; - error?: unknown; - }): void { - if ( - typeof message.id === "number" && - (message.result !== undefined || message.error !== undefined) - ) { - const pending = this.pending.get(message.id); - if (!pending) return; - this.pending.delete(message.id); - if (message.error) pending.reject(message.error); - else pending.resolve(message.result); - return; - } - if (message.method) { - for (const handler of this.notificationHandlers) handler(message.method, message.params); - } - } - - private send(message: Record<string, unknown>): void { - const body = JSON.stringify({ jsonrpc: "2.0", ...message }); - const payload = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`; - this.child.stdin.write(payload); - } - - request<T = unknown>(method: string, params: unknown): Promise<T> { - const id = this.nextId++; - return new Promise<T>((resolve, reject) => { - this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject }); - this.send({ id, method, params }); - }); - } - - notify(method: string, params: unknown): void { - this.send({ method, params }); - } - - async stop(): Promise<void> { - try { - this.child.stdin.end(); - } catch { - // ignore - } - this.child.kill(); - if (this.child.exitCode === null) await once(this.child, "exit").catch(() => {}); - } -} - -export const pathToUri = (filePath: string): string => pathToFileURL(filePath).href; - -/** Resolves when `predicate` sees a matching notification, or rejects on timeout. */ -export const waitForNotification = ( - client: LspTestClient, - method: string, - predicate: (params: unknown) => boolean, - timeoutMs = 20_000, -): Promise<unknown> => - new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${method}`)), timeoutMs); - client.onNotification((incomingMethod, params) => { - if (incomingMethod === method && predicate(params)) { - clearTimeout(timer); - resolve(params); - } - }); - }); diff --git a/packages/language-server/tests/unit/lint-cache.test.ts b/packages/language-server/tests/unit/lint-cache.test.ts deleted file mode 100644 index 0d36240c20..0000000000 --- a/packages/language-server/tests/unit/lint-cache.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; -import type { Diagnostic as CoreDiagnostic } from "@react-doctor/core"; -import { computeConfigFingerprint } from "@react-doctor/core"; -import { createLintCache } from "../../src/core/lint-cache.js"; - -let projectDir: string; - -beforeEach(() => { - projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-lint-cache-test-")); - // node_modules present → cache lands in node_modules/.cache (isolated). - fs.mkdirSync(path.join(projectDir, "node_modules"), { recursive: true }); -}); - -afterEach(() => { - fs.rmSync(projectDir, { recursive: true, force: true }); -}); - -const diagnostic = (rule: string): CoreDiagnostic => ({ - filePath: "src/App.tsx", - plugin: "react-doctor", - rule, - severity: "warning", - message: `msg ${rule}`, - help: "help", - line: 1, - column: 1, - category: "Correctness", -}); - -const fileIdentity = (contentHash: string) => ({ contentHash }); - -describe("createLintCache", () => { - it("returns a hit only when path and content hash match", () => { - const cache = createLintCache({ projectDirectory: projectDir, fingerprint: "fp1" }); - const diagnostics = [diagnostic("no-array-index-key")]; - cache.store("/p/a.tsx", fileIdentity("hash-a"), diagnostics); - - expect(cache.lookup("/p/a.tsx", fileIdentity("hash-a"))).toEqual(diagnostics); - expect(cache.lookup("/p/a.tsx", fileIdentity("hash-b"))).toBeNull(); - expect(cache.lookup("/p/unknown.tsx", fileIdentity("hash-a"))).toBeNull(); - }); - - it("distinguishes a cached-clean file ([]) from a miss (null)", () => { - const cache = createLintCache({ projectDirectory: projectDir, fingerprint: "fp1" }); - const identity = fileIdentity("clean"); - cache.store("/p/clean.tsx", identity, []); - expect(cache.lookup("/p/clean.tsx", identity)).toEqual([]); - expect(cache.lookup("/p/never.tsx", identity)).toBeNull(); - }); - - it("persists to disk and reloads under the same fingerprint", () => { - const first = createLintCache({ projectDirectory: projectDir, fingerprint: "fp1" }); - first.store("/p/a.tsx", fileIdentity("hash-a"), [diagnostic("rule-a")]); - first.store("/p/clean.tsx", fileIdentity("clean"), []); - first.flush(); - - const reloaded = createLintCache({ projectDirectory: projectDir, fingerprint: "fp1" }); - expect(reloaded.lookup("/p/a.tsx", fileIdentity("hash-a"))).toEqual([diagnostic("rule-a")]); - expect(reloaded.lookup("/p/clean.tsx", fileIdentity("clean"))).toEqual([]); - }); - - it("discards a persisted cache when the fingerprint changes", () => { - const first = createLintCache({ projectDirectory: projectDir, fingerprint: "fp1" }); - first.store("/p/a.tsx", fileIdentity("hash-a"), [diagnostic("rule-a")]); - first.flush(); - - const reloaded = createLintCache({ projectDirectory: projectDir, fingerprint: "fp2" }); - expect(reloaded.lookup("/p/a.tsx", fileIdentity("hash-a"))).toBeNull(); - }); -}); - -describe("computeConfigFingerprint", () => { - it("is stable for unchanged inputs and changes when a config file changes", () => { - // Canonical `doctor.config.*` config — not the legacy - // `react-doctor.config.json`, which core no longer reads. - const configPath = path.join(projectDir, "doctor.config.json"); - fs.writeFileSync(configPath, JSON.stringify({ rules: {} })); - - const a = computeConfigFingerprint(projectDir, "1.0.0"); - const b = computeConfigFingerprint(projectDir, "1.0.0"); - expect(a).toBe(b); - - // Different version → different fingerprint. - expect(computeConfigFingerprint(projectDir, "1.0.1")).not.toBe(a); - - // Changed config content (size differs) → different fingerprint. - fs.writeFileSync(configPath, JSON.stringify({ rules: { "react-doctor/x": "error" } })); - expect(computeConfigFingerprint(projectDir, "1.0.0")).not.toBe(a); - }); -}); diff --git a/packages/language-server/tests/unit/lsp-polish.test.ts b/packages/language-server/tests/unit/lsp-polish.test.ts deleted file mode 100644 index 0bd19511da..0000000000 --- a/packages/language-server/tests/unit/lsp-polish.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { DiagnosticSeverity } from "vscode-languageserver"; -import type { Diagnostic as CoreDiagnostic } from "@react-doctor/core"; -import { toLspDiagnostic } from "../../src/diagnostics/mapper.js"; -import { - SUPPRESS_ALL_CODE_ACTION_KIND, - buildCodeActions, -} from "../../src/features/code-actions.js"; -import type { Diagnostic as LspDiagnostic } from "vscode-languageserver"; - -const baseDiagnostic = (overrides: Partial<CoreDiagnostic>): CoreDiagnostic => ({ - filePath: "src/App.tsx", - plugin: "react-doctor", - rule: "no-array-index-key", - severity: "warning", - message: "msg", - help: "help", - line: 1, - column: 1, - category: "Performance", - ...overrides, -}); - -describe("severity demotion", () => { - it("keeps a normal warning at Warning severity", () => { - const mapped = toLspDiagnostic({ - diagnostic: baseDiagnostic({ rule: "no-array-index-key", severity: "warning" }), - fsPath: "/repo/src/App.tsx", - text: null, - }); - expect(mapped.severity).toBe(DiagnosticSeverity.Warning); - }); - - it("keeps an error at Error severity", () => { - const mapped = toLspDiagnostic({ - diagnostic: baseDiagnostic({ rule: "no-array-index-key", severity: "error" }), - fsPath: "/repo/src/App.tsx", - text: null, - }); - expect(mapped.severity).toBe(DiagnosticSeverity.Error); - }); - - it("demotes a design-tagged rule to Information", () => { - const mapped = toLspDiagnostic({ - diagnostic: baseDiagnostic({ rule: "design-no-em-dash-in-jsx-text", severity: "warning" }), - fsPath: "/repo/src/App.tsx", - text: null, - }); - expect(mapped.severity).toBe(DiagnosticSeverity.Information); - }); -}); - -describe("file-level suppress action kind", () => { - it("uses a namespaced source kind, not the bare `source` kind", () => { - const lspDiagnostic: LspDiagnostic = toLspDiagnostic({ - diagnostic: baseDiagnostic({ line: 9, column: 13 }), - fsPath: "/repo/src/App.tsx", - text: "a\nb\nc\nd\ne\nf\ng\nh\n <li key={i} />\n", - }); - const actions = buildCodeActions({ - uri: "file:///repo/src/App.tsx", - fsPath: "/repo/src/App.tsx", - documentText: "a\nb\nc\nd\ne\nf\ng\nh\n <li key={i} />\n", - relativeFilePath: "src/App.tsx", - rangeDiagnostics: [lspDiagnostic], - fileDiagnostics: [lspDiagnostic], - }); - const suppressAll = actions.find((action) => - action.title.includes("Suppress all React Doctor issues"), - ); - expect(suppressAll?.kind).toBe(SUPPRESS_ALL_CODE_ACTION_KIND); - expect(SUPPRESS_ALL_CODE_ACTION_KIND).toBe("source.suppressAll.reactDoctor"); - expect(suppressAll?.kind).not.toBe("source"); - }); -}); - -describe("same-site diagnostic actions", () => { - it("targets each occurrence with a distinct explain command", () => { - const cleanup = toLspDiagnostic({ - diagnostic: baseDiagnostic({ - rule: "exhaustive-deps", - message: - "Your cleanup may read the wrong node since the ref `sidebarRef.current` can change before it runs.", - line: 122, - column: 15, - }), - fsPath: "/repo/src/App.tsx", - text: null, - }); - const loop = toLspDiagnostic({ - diagnostic: baseDiagnostic({ - rule: "exhaustive-deps", - message: - "`useEffect` calls `setMobile` with no dependency array, so it can loop forever & freeze the component.", - line: 122, - column: 15, - }), - fsPath: "/repo/src/App.tsx", - text: null, - }); - const actions = buildCodeActions({ - uri: "file:///repo/src/App.tsx", - fsPath: "/repo/src/App.tsx", - documentText: null, - relativeFilePath: "src/App.tsx", - rangeDiagnostics: [cleanup, loop], - fileDiagnostics: [cleanup, loop], - }); - const explainActions = actions.filter( - (action) => action.title === "Explain react-doctor/exhaustive-deps", - ); - - expect(explainActions).toHaveLength(2); - expect(explainActions[0].command?.arguments).not.toEqual(explainActions[1].command?.arguments); - }); -}); diff --git a/packages/language-server/tests/unit/manager.test.ts b/packages/language-server/tests/unit/manager.test.ts deleted file mode 100644 index 269195e7f8..0000000000 --- a/packages/language-server/tests/unit/manager.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import type { Diagnostic as CoreDiagnostic } from "@react-doctor/core"; -import { DiagnosticsManager } from "../../src/diagnostics/manager.js"; -import { fsPathToUri as toUri } from "../../src/text/uri.js"; -import type { ScanOutcome, ScanRequest } from "../../src/types.js"; - -const FS_PATH = "/proj/src/App.tsx"; - -const request: ScanRequest = { - id: 1, - priority: "save", - projectDirectory: "/proj", - files: [FS_PATH], - runDeadCode: false, - useOverlay: false, - reason: "test", -}; - -const diagnostic = (): CoreDiagnostic => ({ - filePath: "src/App.tsx", - plugin: "react-doctor", - rule: "no-array-index-key", - severity: "warning", - message: "msg", - help: "help", - line: 1, - column: 1, - category: "Correctness", -}); - -const outcome = (overrides: Partial<ScanOutcome>): ScanOutcome => ({ - request, - ok: true, - skipped: false, - byFile: new Map(), - coversProject: false, - requestedPaths: [FS_PATH], - project: null, - didLintFail: false, - lintFailureReason: null, - error: null, - ...overrides, -}); - -const createManager = () => { - const cleared: string[] = []; - const manager = new DiagnosticsManager({ - publish: (uri, diagnostics) => { - if (diagnostics.length === 0) cleared.push(uri); - }, - textProvider: () => "const App = () => null\n", - }); - return { manager, cleared }; -}; - -describe("DiagnosticsManager.applyOutcome", () => { - it("clears a previously-flagged file when a successful scan finds it clean", () => { - const { manager } = createManager(); - manager.applyOutcome(outcome({ byFile: new Map([[FS_PATH, [diagnostic()]]]) })); - const [uri] = manager.trackedUris(); - expect(manager.get(uri).length).toBe(1); - - // Clean successful scan → diagnostics cleared. - manager.applyOutcome(outcome({ byFile: new Map() })); - expect(manager.get(uri).length).toBe(0); - }); - - it("preserves diagnostics when the scan failed (does not strip on transient errors)", () => { - const { manager, cleared } = createManager(); - manager.applyOutcome(outcome({ byFile: new Map([[FS_PATH, [diagnostic()]]]) })); - const [uri] = manager.trackedUris(); - cleared.length = 0; - - manager.applyOutcome(outcome({ ok: false, error: "oxlint crashed" })); - expect(manager.get(uri).length).toBe(1); - expect(cleared).not.toContain(uri); - }); - - it("preserves diagnostics when lint degraded (didLintFail)", () => { - const { manager, cleared } = createManager(); - manager.applyOutcome(outcome({ byFile: new Map([[FS_PATH, [diagnostic()]]]) })); - const [uri] = manager.trackedUris(); - cleared.length = 0; - - manager.applyOutcome(outcome({ didLintFail: true, lintFailureReason: "partial" })); - expect(manager.get(uri).length).toBe(1); - expect(cleared).not.toContain(uri); - }); - - it("preserves diagnostics on a partial lint failure (lintIncomplete)", () => { - const { manager, cleared } = createManager(); - manager.applyOutcome(outcome({ byFile: new Map([[FS_PATH, [diagnostic()]]]) })); - const [uri] = manager.trackedUris(); - cleared.length = 0; - - // ok + !didLintFail but some files failed within the batch. - manager.applyOutcome(outcome({ lintIncomplete: true })); - expect(manager.get(uri).length).toBe(1); - expect(cleared).not.toContain(uri); - }); - - it("preserves diagnostics on a graceful skip (not an analyzable project)", () => { - const { manager, cleared } = createManager(); - manager.applyOutcome(outcome({ byFile: new Map([[FS_PATH, [diagnostic()]]]) })); - const [uri] = manager.trackedUris(); - cleared.length = 0; - - manager.applyOutcome(outcome({ skipped: true })); - expect(manager.get(uri).length).toBe(1); - expect(cleared).not.toContain(uri); - }); -}); - -describe("DiagnosticsManager open-buffer protection", () => { - it("a background disk scan does not overwrite an open file's diagnostics", () => { - const openUri = toUri(FS_PATH); - const published: Array<{ uri: string; count: number }> = []; - const manager = new DiagnosticsManager({ - publish: (uri, diagnostics) => published.push({ uri, count: diagnostics.length }), - textProvider: () => "const App = () => null\n", - // Compare by URI so the predicate is stable across the fsPath <-> URI - // round-trip on every platform (App.tsx is "open"). - isOpen: (fsPath) => toUri(fsPath) === openUri, - }); - - // Interactive (overlay) scan publishes the open buffer's diagnostics. - manager.applyOutcome( - outcome({ - request: { ...request, priority: "interactive" }, - byFile: new Map([[FS_PATH, [diagnostic()]]]), - }), - ); - expect(manager.get(openUri).length).toBe(1); - published.length = 0; - - // A background whole-project audit reports the file clean from disk — - // it must NOT clear the open buffer's diagnostics. - manager.applyOutcome( - outcome({ - request: { ...request, priority: "background" }, - byFile: new Map(), - coversProject: true, - requestedPaths: [], - }), - ); - expect(manager.get(openUri).length).toBe(1); - expect(published.some((entry) => entry.uri === openUri && entry.count === 0)).toBe(false); - }); -}); - -describe("DiagnosticsManager.retainProjectFiles", () => { - it("clears tracked files that left the live set but keeps live ones", () => { - const { manager, cleared } = createManager(); - const other = "/proj/src/Other.tsx"; - manager.applyOutcome( - outcome({ - byFile: new Map([ - [FS_PATH, [diagnostic()]], - [other, [diagnostic()]], - ]), - requestedPaths: [FS_PATH, other], - }), - ); - cleared.length = 0; - - // App.tsx left the enumeration (e.g. gitignored); Other.tsx stays live. - manager.retainProjectFiles("/proj", [other]); - - expect(manager.get(toUri(FS_PATH)).length).toBe(0); // dropped → cleared - expect(manager.get(toUri(other)).length).toBe(1); // live → kept - expect(cleared).toContain(toUri(FS_PATH)); - expect(cleared).not.toContain(toUri(other)); - }); -}); diff --git a/packages/language-server/tests/unit/mapper.test.ts b/packages/language-server/tests/unit/mapper.test.ts deleted file mode 100644 index 9477499349..0000000000 --- a/packages/language-server/tests/unit/mapper.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { buildDiagnosticIdentity } from "@react-doctor/core"; -import type { Diagnostic as CoreDiagnostic } from "@react-doctor/core"; -import { DiagnosticSeverity, DiagnosticTag } from "vscode-languageserver"; -import { toLspDiagnostic } from "../../src/diagnostics/mapper.js"; - -const FS_PATH = "/repo/src/App.tsx"; - -const makeDiagnostic = (overrides: Partial<CoreDiagnostic> = {}): CoreDiagnostic => ({ - filePath: FS_PATH, - plugin: "react-doctor", - rule: "no-array-index-key", - severity: "warning", - message: "Avoid using the array index as a key", - help: "Use a stable, unique identifier", - line: 9, - column: 13, - category: "Correctness", - ...overrides, -}); - -describe("toLspDiagnostic", () => { - it("derives a precise range from a byte span and maps error severity", () => { - const text = "const greeting = 1;"; - const offset = "const ".length; - const diagnostic = makeDiagnostic({ severity: "error", offset, length: "greeting".length }); - const result = toLspDiagnostic({ diagnostic, fsPath: FS_PATH, text }); - - expect(result.severity).toBe(DiagnosticSeverity.Error); - expect(result.code).toBe("react-doctor/no-array-index-key"); - expect(result.source).toBe("react-doctor"); - expect(result.range.start).toEqual({ line: 0, character: offset }); - expect(result.range.end.character).toBeGreaterThan(result.range.start.character); - expect(result.tags).toBeUndefined(); - expect(result.codeDescription).toBeUndefined(); - }); - - it("falls back to 1-indexed line/column when no byte offset is present", () => { - const diagnostic = makeDiagnostic({ line: 9, column: 13 }); - const result = toLspDiagnostic({ diagnostic, fsPath: FS_PATH, text: null }); - - expect(result.severity).toBe(DiagnosticSeverity.Warning); - expect(result.range.start).toEqual({ line: 8, character: 12 }); - expect(result.range.end).toEqual({ line: 8, character: 13 }); - }); - - it("tags dead-code diagnostics as Unnecessary and sets codeDescription from url", () => { - const diagnostic = makeDiagnostic({ - category: "Dead Code", - rule: "no-unused-file", - url: "https://www.react.doctor/rules/no-unused-file", - }); - const result = toLspDiagnostic({ diagnostic, fsPath: FS_PATH, text: null }); - - expect(result.tags).toEqual([DiagnosticTag.Unnecessary]); - expect(result.codeDescription).toEqual({ - href: "https://www.react.doctor/rules/no-unused-file", - }); - }); - - it("maps related locations into relatedInformation", () => { - const diagnostic = makeDiagnostic({ - relatedLocations: [{ filePath: FS_PATH, line: 4, column: 5, message: "prop declared here" }], - }); - const result = toLspDiagnostic({ diagnostic, fsPath: FS_PATH, text: null }); - - expect(result.relatedInformation).toHaveLength(1); - expect(result.relatedInformation?.[0].message).toBe("prop declared here"); - expect(result.relatedInformation?.[0].location.uri).toContain("App.tsx"); - expect(result.relatedInformation?.[0].location.range.start).toEqual({ line: 3, character: 4 }); - }); - - it("attaches a structured data payload for the hover / code-action handlers", () => { - const diagnostic = makeDiagnostic({ - url: "https://www.react.doctor/rules/no-array-index-key", - suppressionHint: "// react-doctor-disable-next-line no-array-index-key", - }); - const result = toLspDiagnostic({ diagnostic, fsPath: FS_PATH, text: null }); - - expect(result.data).toMatchObject({ - identity: buildDiagnosticIdentity({ - filePath: FS_PATH, - line: 9, - column: 13, - plugin: "react-doctor", - rule: "no-array-index-key", - severity: "warning", - message: "Avoid using the array index as a key", - }), - plugin: "react-doctor", - rule: "no-array-index-key", - ruleId: "react-doctor/no-array-index-key", - category: "Correctness", - help: "Use a stable, unique identifier", - url: "https://www.react.doctor/rules/no-array-index-key", - suppressionHint: "// react-doctor-disable-next-line no-array-index-key", - line: 9, - column: 13, - fsPath: FS_PATH, - }); - }); - - it("distinguishes same-site findings from one rule by occurrence", () => { - const cleanup = toLspDiagnostic({ - diagnostic: makeDiagnostic({ - rule: "exhaustive-deps", - message: - "Your cleanup may read the wrong node since the ref `sidebarRef.current` can change before it runs.", - line: 122, - column: 15, - }), - fsPath: FS_PATH, - text: null, - }); - const loop = toLspDiagnostic({ - diagnostic: makeDiagnostic({ - rule: "exhaustive-deps", - message: - "`useEffect` calls `setMobile` with no dependency array, so it can loop forever & freeze the component.", - line: 122, - column: 15, - }), - fsPath: FS_PATH, - text: null, - }); - - expect(cleanup.data).not.toEqual(loop.data); - }); - - it("nulls out optional data fields when absent", () => { - const result = toLspDiagnostic({ diagnostic: makeDiagnostic(), fsPath: FS_PATH, text: null }); - expect(result.data).toMatchObject({ url: null, suppressionHint: null }); - }); -}); diff --git a/packages/language-server/tests/unit/positions.test.ts b/packages/language-server/tests/unit/positions.test.ts deleted file mode 100644 index e464d33ff8..0000000000 --- a/packages/language-server/tests/unit/positions.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { - byteOffsetToPosition, - isPositionInRange, - rangeFromByteSpan, - rangeFromLineColumn, - rangesOverlap, -} from "../../src/text/positions.js"; - -describe("byteOffsetToPosition", () => { - it("maps offset 0 to the document start", () => { - expect(byteOffsetToPosition("const value = 1;", 0)).toEqual({ line: 0, character: 0 }); - }); - - it("lands on the next line at character 0 for an offset just past a newline", () => { - const text = "first\nsecond"; - const secondLineByteOffset = Buffer.byteLength("first\n", "utf8"); - expect(byteOffsetToPosition(text, secondLineByteOffset)).toEqual({ line: 1, character: 0 }); - }); - - it("treats a multi-byte character as one UTF-16 unit (byte offset != character)", () => { - const text = 'const x = "café";'; - const closingQuoteCharIndex = text.lastIndexOf('"'); - const closingQuoteByteOffset = Buffer.byteLength(text.slice(0, closingQuoteCharIndex), "utf8"); - const position = byteOffsetToPosition(text, closingQuoteByteOffset); - - expect(position).toEqual({ line: 0, character: closingQuoteCharIndex }); - expect(closingQuoteByteOffset).toBeGreaterThan(position.character); - }); -}); - -describe("rangeFromByteSpan", () => { - it("produces a same-line range whose end character is past the start", () => { - const text = "const value = 1;"; - const valueByteOffset = Buffer.byteLength("const ", "utf8"); - const range = rangeFromByteSpan(text, valueByteOffset, "value".length); - - expect(range.start).toEqual({ line: 0, character: 6 }); - expect(range.end.line).toBe(range.start.line); - expect(range.end.character).toBeGreaterThan(range.start.character); - }); -}); - -describe("rangeFromLineColumn", () => { - it("converts 1-indexed line/column to a single-character 0-indexed range without text", () => { - expect(rangeFromLineColumn(null, 9, 13)).toEqual({ - start: { line: 8, character: 12 }, - end: { line: 8, character: 13 }, - }); - }); - - it("extends the end to the end of the target line when text is provided", () => { - const text = "alpha\nconst beta = 2;\ngamma"; - const range = rangeFromLineColumn(text, 2, 7); - - expect(range.start).toEqual({ line: 1, character: 6 }); - expect(range.end.character).toBe("const beta = 2;".length); - }); -}); - -describe("isPositionInRange", () => { - const range = { start: { line: 1, character: 2 }, end: { line: 1, character: 10 } }; - - it("returns true for a position inside the range", () => { - expect(isPositionInRange(range, { line: 1, character: 5 })).toBe(true); - }); - - it("returns false for a position outside the range", () => { - expect(isPositionInRange(range, { line: 1, character: 11 })).toBe(false); - expect(isPositionInRange(range, { line: 0, character: 5 })).toBe(false); - }); - - it("treats the start as inclusive and the end as exclusive (LSP semantics)", () => { - expect(isPositionInRange(range, { line: 1, character: 2 })).toBe(true); // start - expect(isPositionInRange(range, { line: 1, character: 10 })).toBe(false); // end (exclusive) - }); - - it("still matches a zero-width range at its single point", () => { - const empty = { start: { line: 3, character: 4 }, end: { line: 3, character: 4 } }; - expect(isPositionInRange(empty, { line: 3, character: 4 })).toBe(true); - expect(isPositionInRange(empty, { line: 3, character: 5 })).toBe(false); - }); -}); - -describe("rangesOverlap", () => { - it("treats touching endpoints as overlapping", () => { - const first = { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }; - const second = { start: { line: 0, character: 5 }, end: { line: 0, character: 10 } }; - expect(rangesOverlap(first, second)).toBe(true); - }); - - it("returns false for disjoint ranges", () => { - const first = { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }; - const second = { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } }; - expect(rangesOverlap(first, second)).toBe(false); - }); -}); diff --git a/packages/language-server/tests/unit/project-graph-invalidate.test.ts b/packages/language-server/tests/unit/project-graph-invalidate.test.ts deleted file mode 100644 index 3f29eb5a8c..0000000000 --- a/packages/language-server/tests/unit/project-graph-invalidate.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; -import { - clearMinifiedFileCache, - clearPackageRoleCache, - classifyPackageRole, - isLargeMinifiedFile, - MINIFIED_MAX_LINE_LENGTH_CHARS, - MINIFIED_MIN_SIZE_BYTES, -} from "@react-doctor/core"; -import { createProjectGraph } from "../../src/core/project-graph.js"; - -const minifiedBundleContents = (): string => - `var bundle=${JSON.stringify("a".repeat(MINIFIED_MIN_SIZE_BYTES + MINIFIED_MAX_LINE_LENGTH_CHARS))};`; - -describe("createProjectGraph invalidate", () => { - let workspaceRoot: string; - - beforeEach(() => { - clearMinifiedFileCache(); - clearPackageRoleCache(); - workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rd-project-graph-test-")); - }); - - afterEach(() => { - clearMinifiedFileCache(); - clearPackageRoleCache(); - fs.rmSync(workspaceRoot, { recursive: true, force: true }); - }); - - it("clears the minified-file memo so a changed bundle is re-sniffed on the next scan", () => { - const graph = createProjectGraph({ roots: [workspaceRoot] }); - const bundlePath = path.join(workspaceRoot, "bundle.js"); - fs.writeFileSync(bundlePath, minifiedBundleContents()); - expect(isLargeMinifiedFile(bundlePath)).toBe(true); - - // The editor caches listSourceFiles' minified classification at module - // scope; without invalidate() clearing it, a shrunk bundle stays excluded - // from scans for the life of the language-server process. - fs.writeFileSync(bundlePath, "export const x = 1;\n"); - expect(isLargeMinifiedFile(bundlePath)).toBe(true); - - graph.invalidate(); - expect(isLargeMinifiedFile(bundlePath)).toBe(false); - }); - - it("clears the package-role memo so a changed manifest is reclassified", () => { - const graph = createProjectGraph({ roots: [workspaceRoot] }); - const sourcePath = path.join(workspaceRoot, "src", "index.ts"); - const packageJsonPath = path.join(workspaceRoot, "package.json"); - fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); - fs.writeFileSync(packageJsonPath, JSON.stringify({ name: "example", exports: "./index.js" })); - expect(classifyPackageRole(sourcePath)).toBe("library"); - - fs.writeFileSync(packageJsonPath, JSON.stringify({ name: "example", private: true })); - expect(classifyPackageRole(sourcePath)).toBe("library"); - - graph.invalidate(); - expect(classifyPackageRole(sourcePath)).toBe("unknown"); - }); -}); diff --git a/packages/language-server/tests/unit/read-diagnostic-data.test.ts b/packages/language-server/tests/unit/read-diagnostic-data.test.ts deleted file mode 100644 index e896db2883..0000000000 --- a/packages/language-server/tests/unit/read-diagnostic-data.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { DIAGNOSTIC_SOURCE } from "../../src/constants.js"; -import { readDiagnosticData } from "../../src/utils/read-diagnostic-data.js"; - -const diagnosticData = { - identity: "src/app.tsx:1:1:react-doctor/example", - plugin: "react-doctor", - rule: "example", - ruleId: "react-doctor/example", - category: "Maintainability", - help: "Apply the recommendation.", - url: null, - suppressionHint: null, - line: 1, - column: 1, - fsPath: "/workspace/src/app.tsx", -}; - -describe("readDiagnosticData", () => { - it("returns a complete server-owned payload", () => { - expect(readDiagnosticData({ source: DIAGNOSTIC_SOURCE, data: diagnosticData })).toEqual( - diagnosticData, - ); - }); - - it("rejects incomplete round-tripped payloads", () => { - expect( - readDiagnosticData({ - source: DIAGNOSTIC_SOURCE, - data: { ruleId: diagnosticData.ruleId }, - }), - ).toBeNull(); - }); - - it("rejects payloads from another diagnostic source", () => { - expect(readDiagnosticData({ source: "typescript", data: diagnosticData })).toBeNull(); - }); -}); diff --git a/packages/language-server/tests/unit/scan-runner.test.ts b/packages/language-server/tests/unit/scan-runner.test.ts deleted file mode 100644 index 2d4183151c..0000000000 --- a/packages/language-server/tests/unit/scan-runner.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vite-plus/test"; -import { createScanRunner } from "../../src/core/scan-runner.js"; -import type { ScanOutcome, ScanRequest } from "../../src/types.js"; - -const here = path.dirname(fileURLToPath(import.meta.url)); -const FIXTURE_DIR = path.join(here, "..", "fixtures", "simple-app"); - -describe("scan-runner", () => { - it.each([ - { - firstSource: 'export const App = () => <img src="x" />;\n', - secondSource: 'export const App = () => <img alt="x" />;\n', - firstHasAltText: true, - secondHasAltText: false, - }, - { - firstSource: 'export const App = () => <img alt="x" />;\n', - secondSource: 'export const App = () => <img src="x" />;\n', - firstHasAltText: false, - secondHasAltText: true, - }, - ])( - "does not replay equal-stat diagnostics after content changes %#", - async ({ firstSource, secondSource, firstHasAltText, secondHasAltText }) => { - expect(firstSource.length).toBe(secondSource.length); - const projectDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rd-scan-runner-cache-")); - const sourceDirectory = path.join(projectDirectory, "src"); - const sourcePath = path.join(sourceDirectory, "App.tsx"); - fs.mkdirSync(path.join(projectDirectory, "node_modules"), { recursive: true }); - fs.mkdirSync(sourceDirectory, { recursive: true }); - fs.writeFileSync( - path.join(projectDirectory, "package.json"), - JSON.stringify({ dependencies: { react: "^19.0.0" } }), - ); - fs.writeFileSync(sourcePath, firstSource); - const originalStat = fs.statSync(sourcePath); - const request: ScanRequest = { - id: 1, - priority: "save", - projectDirectory, - files: [sourcePath], - runDeadCode: false, - useOverlay: false, - reason: "test", - }; - const hasAltTextDiagnostic = (outcome: ScanOutcome | null) => - outcome !== null && - Array.from(outcome.byFile.values()).some((diagnostics) => - diagnostics.some((diagnostic) => diagnostic.rule.endsWith("alt-text")), - ); - const firstRunner = createScanRunner({ - nodeBinaryPath: null, - readText: () => null, - version: "test", - }); - let secondRunner: ReturnType<typeof createScanRunner> | null = null; - - try { - const firstOutcome = await firstRunner.performScan(request, { isCancelled: false }); - expect(hasAltTextDiagnostic(firstOutcome)).toBe(firstHasAltText); - firstRunner.dispose(); - - fs.writeFileSync(sourcePath, secondSource); - fs.utimesSync(sourcePath, originalStat.atime, originalStat.mtime); - secondRunner = createScanRunner({ - nodeBinaryPath: null, - readText: () => null, - version: "test", - }); - const secondOutcome = await secondRunner.performScan( - { ...request, id: 2 }, - { isCancelled: false }, - ); - expect(hasAltTextDiagnostic(secondOutcome)).toBe(secondHasAltText); - } finally { - firstRunner.dispose(); - secondRunner?.dispose(); - fs.rmSync(projectDirectory, { recursive: true, force: true }); - } - }, - ); - - // Regression: a per-file request whose paths all resolve outside the - // project must NOT fall through to a whole-project lint (an empty - // include list is otherwise treated as "scan everything"). - it("does not whole-project scan when requested files are outside the project", async () => { - const runner = createScanRunner({ - nodeBinaryPath: null, - readText: () => null, - version: "test", - enableCache: false, - }); - - const request: ScanRequest = { - id: 1, - priority: "save", - projectDirectory: FIXTURE_DIR, - files: [path.join(here, "..", "..", "outside-the-project.tsx")], - runDeadCode: false, - useOverlay: false, - reason: "test", - }; - - const outcome = await runner.performScan(request, { isCancelled: false }); - - // Null = no result: no whole-project scan, and (crucially) no outcome - // that would clear the unscanned file as if it were lint-clean. - expect(outcome).toBeNull(); - }); -}); diff --git a/packages/language-server/tests/unit/scan-telemetry.test.ts b/packages/language-server/tests/unit/scan-telemetry.test.ts deleted file mode 100644 index abda05d820..0000000000 --- a/packages/language-server/tests/unit/scan-telemetry.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import type { Diagnostic as CoreDiagnostic } from "@react-doctor/core"; -import { createScanTelemetry } from "../../src/runtime/scan-telemetry.js"; -import type { ScanOutcome, WorkspaceScanTelemetry } from "../../src/types.js"; - -const diagnostic = (severity: "error" | "warning", category: string): CoreDiagnostic => ({ - filePath: "src/App.tsx", - plugin: "react-doctor", - rule: "some-rule", - severity, - message: "msg", - help: "help", - line: 1, - column: 1, - category, -}); - -const outcome = ( - byFile: Record<string, CoreDiagnostic[]>, - overrides: Partial<ScanOutcome> = {}, -): ScanOutcome => ({ - request: { - id: 1, - priority: "background", - projectDirectory: "/repo", - files: [], - runDeadCode: false, - useOverlay: false, - reason: "test", - }, - ok: true, - skipped: false, - byFile: new Map(Object.entries(byFile)), - coversProject: true, - requestedPaths: [], - project: null, - didLintFail: false, - lintFailureReason: null, - error: null, - ...overrides, -}); - -// A controllable clock so duration assertions are deterministic. -const clock = (...times: number[]): (() => number) => { - let index = 0; - return () => times[Math.min(index++, times.length - 1)] ?? 0; -}; - -describe("createScanTelemetry", () => { - it("aggregates background outcomes into one wide event on finish", () => { - const events: WorkspaceScanTelemetry[] = []; - // `now()` is read once at begin (start) and once at finish (duration). - const scanTelemetry = createScanTelemetry( - { recordSessionStart: () => {}, recordWorkspaceScan: (scan) => events.push(scan) }, - clock(1000, 3500), - ); - - scanTelemetry.begin("initial", 2); - scanTelemetry.accumulate( - outcome({ - "/repo/a.tsx": [diagnostic("error", "Performance"), diagnostic("warning", "Performance")], - }), - ); - scanTelemetry.accumulate(outcome({ "/repo/b.tsx": [diagnostic("warning", "Design")] })); - scanTelemetry.finish(); - - expect(events).toHaveLength(1); - const event = events[0]!; - expect(event.trigger).toBe("initial"); - expect(event.projectCount).toBe(2); - expect(event.chunkCount).toBe(2); - expect(event.filesWithDiagnostics).toBe(2); - expect(event.totalDiagnostics).toBe(3); - expect(event.errorCount).toBe(1); - expect(event.warningCount).toBe(2); - expect(event.diagnosticsByCategory).toEqual({ Performance: 2, Design: 1 }); - expect(event.durationMs).toBe(2500); - expect(event.lintDegraded).toBe(false); - }); - - it("records lint degradation and partial-lint chunks", () => { - const events: WorkspaceScanTelemetry[] = []; - const scanTelemetry = createScanTelemetry({ - recordSessionStart: () => {}, - recordWorkspaceScan: (scan) => events.push(scan), - }); - - scanTelemetry.begin("config-change", 1); - scanTelemetry.accumulate(outcome({}, { didLintFail: true })); - scanTelemetry.accumulate(outcome({}, { lintIncomplete: true })); - scanTelemetry.finish(); - - expect(events[0]?.lintDegraded).toBe(true); - expect(events[0]?.lintIncompleteChunks).toBe(1); - expect(events[0]?.chunkCount).toBe(2); - }); - - it("skips a burst that scanned nothing", () => { - const events: WorkspaceScanTelemetry[] = []; - const scanTelemetry = createScanTelemetry({ - recordSessionStart: () => {}, - recordWorkspaceScan: (scan) => events.push(scan), - }); - - scanTelemetry.begin("manual", 1); - scanTelemetry.finish(); - - expect(events).toHaveLength(0); - }); - - it("ignores accumulate / finish with no active burst", () => { - const events: WorkspaceScanTelemetry[] = []; - const scanTelemetry = createScanTelemetry({ - recordSessionStart: () => {}, - recordWorkspaceScan: (scan) => events.push(scan), - }); - - scanTelemetry.accumulate(outcome({ "/repo/a.tsx": [diagnostic("error", "Bugs")] })); - scanTelemetry.finish(); - - expect(events).toHaveLength(0); - }); - - it("discards a superseded partial burst when a new one begins", () => { - const events: WorkspaceScanTelemetry[] = []; - const scanTelemetry = createScanTelemetry({ - recordSessionStart: () => {}, - recordWorkspaceScan: (scan) => events.push(scan), - }); - - scanTelemetry.begin("initial", 1); - scanTelemetry.accumulate(outcome({ "/repo/a.tsx": [diagnostic("error", "Bugs")] })); - // A config change restarts the scan before the first burst settled. - scanTelemetry.begin("config-change", 1); - scanTelemetry.accumulate(outcome({ "/repo/b.tsx": [diagnostic("warning", "Design")] })); - scanTelemetry.finish(); - - expect(events).toHaveLength(1); - expect(events[0]?.trigger).toBe("config-change"); - expect(events[0]?.totalDiagnostics).toBe(1); - expect(events[0]?.diagnosticsByCategory).toEqual({ Design: 1 }); - }); -}); diff --git a/packages/language-server/tests/unit/scheduler-reserve.test.ts b/packages/language-server/tests/unit/scheduler-reserve.test.ts deleted file mode 100644 index 211ed112d3..0000000000 --- a/packages/language-server/tests/unit/scheduler-reserve.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { createScheduler } from "../../src/runtime/scheduler.js"; -import { chunk } from "../../src/utils/chunk.js"; -import type { ScanOutcome, ScanRequest, ScanRequestInput } from "../../src/types.js"; - -const delay = (durationMs: number): Promise<void> => - new Promise((resolve) => setTimeout(resolve, durationMs)); - -describe("chunk", () => { - it("splits into consecutive batches of the given size", () => { - expect(chunk([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 4)).toEqual([ - [1, 2, 3, 4], - [5, 6, 7, 8], - [9, 10], - ]); - }); - - it("returns [] for empty input and treats size < 1 as 1", () => { - expect(chunk([], 5)).toEqual([]); - expect(chunk([1, 2], 0)).toEqual([[1], [2]]); - }); -}); - -interface Gate { - readonly promise: Promise<ScanOutcome>; - readonly resolve: () => void; -} - -const makeOutcome = (request: ScanRequest): ScanOutcome => ({ - request, - ok: true, - skipped: false, - byFile: new Map(), - coversProject: request.files.length === 0, - requestedPaths: request.files, - project: null, - didLintFail: false, - lintFailureReason: null, - error: null, -}); - -describe("scheduler reservedInteractiveSlots", () => { - it("caps background parallelism but lets interactive use a reserved slot", async () => { - const gates: Gate[] = []; - const started: ScanRequest[] = []; - - const scheduler = createScheduler({ - performScan: (request) => { - started.push(request); - let resolveOutcome!: () => void; - const promise = new Promise<ScanOutcome>((resolve) => { - resolveOutcome = () => resolve(makeOutcome(request)); - }); - gates.push({ promise, resolve: resolveOutcome }); - return promise; - }, - onResult: () => {}, - debounceMs: 5, - concurrency: 2, - reservedInteractiveSlots: 1, - }); - - const background = (file: string): ScanRequestInput => ({ - priority: "background", - projectDirectory: "/p", - files: [file], - runDeadCode: false, - useOverlay: false, - reason: "bg", - }); - - scheduler.enqueue(background("a")); - scheduler.enqueue(background("b")); - await delay(25); - // maxBackground = concurrency(2) - reserved(1) = 1, so only one runs. - expect(started.length).toBe(1); - expect(started[0].priority).toBe("background"); - - scheduler.enqueue({ - priority: "interactive", - projectDirectory: "/p", - files: ["c"], - runDeadCode: false, - useOverlay: true, - reason: "edit", - }); - await delay(25); - // Interactive runs immediately in the reserved slot, alongside the - // one background scan still in flight. - expect(started.length).toBe(2); - expect(started[1].priority).toBe("interactive"); - - gates[0].resolve(); - await delay(25); - // First background finished → second background now runs. - expect(started.length).toBe(3); - expect(started[2].priority).toBe("background"); - - for (const gate of gates) gate.resolve(); - scheduler.dispose(); - }); -}); diff --git a/packages/language-server/tests/unit/scheduler.test.ts b/packages/language-server/tests/unit/scheduler.test.ts deleted file mode 100644 index d91340c941..0000000000 --- a/packages/language-server/tests/unit/scheduler.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { createScheduler } from "../../src/runtime/scheduler.js"; -import type { ScanOutcome, ScanRequest, ScanRequestInput } from "../../src/types.js"; - -const delay = (milliseconds: number): Promise<void> => - new Promise((resolve) => setTimeout(resolve, milliseconds)); - -/** Polls until `predicate` holds (or times out) — avoids fixed-delay flakes on slow CI. */ -const waitFor = async (predicate: () => boolean, timeoutMs = 2000): Promise<void> => { - const start = Date.now(); - while (!predicate() && Date.now() - start < timeoutMs) { - await delay(5); - } -}; - -const makeOutcome = (request: ScanRequest): ScanOutcome => ({ - request, - ok: true, - skipped: false, - byFile: new Map(), - coversProject: request.files.length === 0, - requestedPaths: request.files, - project: null, - didLintFail: false, - lintFailureReason: null, - error: null, -}); - -const interactiveInput = (overrides: Partial<ScanRequestInput> = {}): ScanRequestInput => ({ - priority: "interactive", - projectDirectory: "/repo", - files: ["/repo/src/App.tsx"], - runDeadCode: false, - useOverlay: true, - reason: "edit", - ...overrides, -}); - -describe("createScheduler", () => { - it("debounces and coalesces rapid interactive enqueues into a single scan", async () => { - let scanCount = 0; - const results: ScanOutcome[] = []; - const scheduler = createScheduler({ - performScan: async (request) => { - scanCount += 1; - return makeOutcome(request); - }, - onResult: (outcome) => results.push(outcome), - debounceMs: 15, - }); - - scheduler.enqueue(interactiveInput()); - scheduler.enqueue(interactiveInput()); - scheduler.enqueue(interactiveInput()); - - await delay(70); - - expect(scanCount).toBe(1); - expect(results).toHaveLength(1); - scheduler.dispose(); - }); - - it("drops a superseded in-flight scan and only reports the newest", async () => { - const scannedReasons: string[] = []; - const reportedReasons: string[] = []; - const releaseScanByReason = new Map<string, () => void>(); - const scheduler = createScheduler({ - performScan: async (request) => { - scannedReasons.push(request.reason); - await new Promise<void>((resolve) => releaseScanByReason.set(request.reason, resolve)); - return makeOutcome(request); - }, - onResult: (outcome) => reportedReasons.push(outcome.request.reason), - debounceMs: 10, - concurrency: 2, - }); - - scheduler.enqueue(interactiveInput({ reason: "scan-a" })); - await waitFor(() => scannedReasons.includes("scan-a")); - - scheduler.enqueue(interactiveInput({ reason: "scan-b" })); - await waitFor(() => scannedReasons.includes("scan-b")); - - // The superseded scan finishes first; its report must still be dropped. - releaseScanByReason.get("scan-a")?.(); - releaseScanByReason.get("scan-b")?.(); - await waitFor(() => reportedReasons.includes("scan-b")); - - expect(scannedReasons).toEqual(["scan-a", "scan-b"]); - expect(reportedReasons).toEqual(["scan-b"]); - scheduler.dispose(); - }); - - it("cancelProject prevents a pending scan from running", async () => { - let scanCount = 0; - let resultCount = 0; - const scheduler = createScheduler({ - performScan: async (request) => { - scanCount += 1; - return makeOutcome(request); - }, - onResult: () => { - resultCount += 1; - }, - debounceMs: 30, - }); - - scheduler.enqueue(interactiveInput()); - scheduler.cancelProject("/repo"); - - await delay(70); - - expect(scanCount).toBe(0); - expect(resultCount).toBe(0); - scheduler.dispose(); - }); - - it("dequeues interactive scans ahead of save and background scans", async () => { - const runOrder: string[] = []; - const scheduler = createScheduler({ - performScan: async (request) => { - runOrder.push(request.reason); - await delay(25); - return makeOutcome(request); - }, - onResult: () => {}, - debounceMs: 5, - concurrency: 1, - }); - - scheduler.enqueue( - interactiveInput({ priority: "save", files: ["/repo/blocker.ts"], reason: "blocker" }), - ); - scheduler.enqueue( - interactiveInput({ priority: "background", files: ["/repo/bg.ts"], reason: "bg" }), - ); - scheduler.enqueue( - interactiveInput({ priority: "save", files: ["/repo/save.ts"], reason: "save2" }), - ); - scheduler.enqueue( - interactiveInput({ priority: "interactive", files: ["/repo/int.ts"], reason: "int" }), - ); - - // Wait until all four scans have started (deterministic) rather than a - // fixed delay, which raced the trailing background scan on slow CI. - await waitFor(() => runOrder.length === 4); - - expect(runOrder).toEqual(["blocker", "int", "save2", "bg"]); - scheduler.dispose(); - }); -}); diff --git a/packages/language-server/tests/unit/severity-label.test.ts b/packages/language-server/tests/unit/severity-label.test.ts deleted file mode 100644 index 0078e4f94f..0000000000 --- a/packages/language-server/tests/unit/severity-label.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { DiagnosticSeverity } from "vscode-languageserver"; -import { severityLabel } from "../../src/utils/severity-label.js"; - -describe("severityLabel", () => { - it("maps all four LSP severities (not just error/warning)", () => { - expect(severityLabel(DiagnosticSeverity.Error)).toBe("error"); - expect(severityLabel(DiagnosticSeverity.Warning)).toBe("warning"); - expect(severityLabel(DiagnosticSeverity.Information)).toBe("info"); - expect(severityLabel(DiagnosticSeverity.Hint)).toBe("hint"); - }); - - it("falls back to warning for an undefined severity", () => { - expect(severityLabel(undefined)).toBe("warning"); - }); -}); diff --git a/packages/language-server/tests/unit/suppress.test.ts b/packages/language-server/tests/unit/suppress.test.ts deleted file mode 100644 index f9e37b79dd..0000000000 --- a/packages/language-server/tests/unit/suppress.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { - buildSuppressAllTextEdits, - buildSuppressionTextEdit, -} from "../../src/features/suppress.js"; - -describe("buildSuppressionTextEdit", () => { - it("inserts a line comment with matching indentation for a plain .ts statement", () => { - const documentText = "const a = 1;\n const b = 2;\n"; - const edit = buildSuppressionTextEdit({ - documentText, - fsPath: "/repo/src/file.ts", - line: 2, - ruleId: "react-doctor/no-derived-state", - }); - - expect(edit.range).toEqual({ - start: { line: 1, character: 0 }, - end: { line: 1, character: 0 }, - }); - expect(edit.newText).toBe( - " // react-doctor-disable-next-line react-doctor/no-derived-state\n", - ); - }); - - it("inserts a JSX-style comment for a .tsx file when the target line looks like JSX", () => { - const jsxLine = " <li key={index}>{item}</li>"; - const documentText = `return (\n${jsxLine}\n);\n`; - const edit = buildSuppressionTextEdit({ - documentText, - fsPath: "/repo/src/list.tsx", - line: 2, - ruleId: "react-doctor/no-array-index-key", - }); - - expect(edit.range.start).toEqual({ line: 1, character: 0 }); - expect(edit.newText).toBe( - " {/* react-doctor-disable-next-line react-doctor/no-array-index-key */}\n", - ); - }); -}); - -describe("buildSuppressAllTextEdits", () => { - it("merges multiple rules on the same line into a single edit", () => { - const edits = buildSuppressAllTextEdits({ - documentText: "const a = 1;\nconst b = 2;\n", - fsPath: "/repo/src/file.ts", - targets: [ - { line: 1, ruleId: "react-doctor/rule-one" }, - { line: 1, ruleId: "react-doctor/rule-two" }, - ], - }); - - expect(edits).toHaveLength(1); - expect(edits[0].newText).toContain("react-doctor/rule-one"); - expect(edits[0].newText).toContain("react-doctor/rule-two"); - }); - - it("dedupes identical (line, ruleId) targets", () => { - const edits = buildSuppressAllTextEdits({ - documentText: "const a = 1;\n", - fsPath: "/repo/src/file.ts", - targets: [ - { line: 1, ruleId: "react-doctor/no-array-index-key" }, - { line: 1, ruleId: "react-doctor/no-array-index-key" }, - ], - }); - - expect(edits).toHaveLength(1); - const occurrenceCount = edits[0].newText.split("react-doctor/no-array-index-key").length - 1; - expect(occurrenceCount).toBe(1); - }); -}); diff --git a/packages/language-server/tsconfig.json b/packages/language-server/tsconfig.json deleted file mode 100644 index 8de23db819..0000000000 --- a/packages/language-server/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "noEmit": true, - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/language-server/vite.config.ts b/packages/language-server/vite.config.ts deleted file mode 100644 index 831a183e9d..0000000000 --- a/packages/language-server/vite.config.ts +++ /dev/null @@ -1,54 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { defineConfig } from "vite-plus"; - -const packageRoot = path.dirname(fileURLToPath(import.meta.url)); -const { version } = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")) as { - version: string; -}; - -export default defineConfig({ - pack: [ - { - entry: { index: "./src/index.ts" }, - env: { - VERSION: process.env.VERSION ?? version, - }, - deps: { - // Keep the heavy engine + LSP transport external so the - // language-server dist stays lean and runnable standalone via - // its own node_modules. The react-doctor CLI re-bundles this - // dist and decides which of these to inline at publish time. - neverBundle: [ - "@react-doctor/core", - "deslop-js", - "effect", - "oxc-parser", - "oxc-resolver", - "oxlint", - "oxlint-plugin-react-doctor", - "typescript", - "vscode-languageserver", - "vscode-languageserver-protocol", - "vscode-languageserver-textdocument", - "vscode-jsonrpc", - "vscode-uri", - ], - }, - dts: true, - target: "node20", - platform: "node", - fixedExtension: false, - }, - ], - test: { - testTimeout: 30_000, - // The integration suite boots a real LSP server subprocess and waits up - // to 20s for it to publish diagnostics inside `beforeAll`. The default - // 10s hook timeout is shorter than that wait, so a slow cold start on - // macOS / Windows CI runners trips the hook before the server is ready. - // Match it to `testTimeout` so the hook gets the same budget as the tests. - hookTimeout: 30_000, - }, -}); diff --git a/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs b/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs index 49f3a7d09f..f06e09b29d 100644 --- a/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs +++ b/packages/oxlint-plugin-react-doctor/scripts/generate-rule-registry.mjs @@ -102,6 +102,7 @@ const getRequiredCapabilities = (bucketName, ruleId) => { const BUCKET_TO_AUTO_TAGS = { design: ["design"], ink: ["ink"], + project: ["project-analysis"], "react-native": ["react-native"], r3f: ["r3f", "webgl"], webgl: ["webgl"], @@ -217,6 +218,7 @@ const BUCKET_TO_DEFAULT_CATEGORY = { nextjs: "Next.js", performance: "Performance", preact: "Preact", + project: "Architecture", "react-builtins": "Correctness", "react-native": "React Native", r3f: "Performance", @@ -498,6 +500,7 @@ const coreRuleEntries = ruleEntries.map((entry) => { : undefined, matchByOccurrence: sourceRule.matchByOccurrence, isScanRule: typeof sourceRule.scan === "function", + isProjectRule: sourceRule.execution === "project" ? true : undefined, }, }; }); diff --git a/packages/oxlint-plugin-react-doctor/src/core.ts b/packages/oxlint-plugin-react-doctor/src/core.ts index c3c5f900e9..60c9b904b2 100644 --- a/packages/oxlint-plugin-react-doctor/src/core.ts +++ b/packages/oxlint-plugin-react-doctor/src/core.ts @@ -3,6 +3,14 @@ import { CORE_REACT_DOCTOR_RULES, CORE_RULE_REGISTRY } from "./plugin/core-rule- export { EXTERNAL_RULES, REACT_COMPILER_RULES } from "./external-rules.js"; export const REACT_DOCTOR_RULES = CORE_REACT_DOCTOR_RULES; +export const REACT_DOCTOR_PROJECT_RULES = CORE_REACT_DOCTOR_RULES.filter( + (entry) => entry.rule.isProjectRule === true, +); +export const REACT_DOCTOR_OPT_IN_PROJECT_RULE_IDS: ReadonlySet<string> = new Set( + REACT_DOCTOR_PROJECT_RULES.filter((entry) => entry.rule.defaultEnabled === false).map( + (entry) => entry.id, + ), +); export const REACT_DOCTOR_RULE_REGISTRY = CORE_RULE_REGISTRY; export const ALL_REACT_DOCTOR_RULE_KEYS: ReadonlySet<string> = new Set( CORE_REACT_DOCTOR_RULES.map((entry) => entry.key), @@ -40,5 +48,5 @@ export type { Capability, CapabilityQuery, FrameworkToken } from "./plugin/utils export type { CoreRuleMetadata } from "./plugin/utils/core-rule-metadata.js"; export type { EsTreeNode } from "./plugin/utils/es-tree-node.js"; export type { FileScan, ScanFinding, ScannedFile } from "./plugin/utils/file-scan.js"; -export type { Rule, RuleFramework, RuleSeverity } from "./plugin/utils/rule.js"; +export type { Rule, RuleExecution, RuleFramework, RuleSeverity } from "./plugin/utils/rule.js"; export type { OxlintRuleSeverity } from "./types.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/index.ts b/packages/oxlint-plugin-react-doctor/src/index.ts index 8d5639a96c..d3c3b9b056 100644 --- a/packages/oxlint-plugin-react-doctor/src/index.ts +++ b/packages/oxlint-plugin-react-doctor/src/index.ts @@ -10,6 +10,8 @@ export { NEXTJS_RULES, PREACT_RULES, REACT_COMPILER_RULES, + REACT_DOCTOR_OPT_IN_PROJECT_RULE_IDS, + REACT_DOCTOR_PROJECT_RULES, REACT_DOCTOR_RULES, REACT_NATIVE_RULES, RECOMMENDED_RULES, @@ -46,6 +48,6 @@ export { FRAMEWORK_TOKENS } from "./plugin/utils/capability.js"; export type { Capability, CapabilityQuery, FrameworkToken } from "./plugin/utils/capability.js"; export type { EsTreeNode } from "./plugin/utils/es-tree-node.js"; export type { ScanFinding, FileScan, ScannedFile } from "./plugin/utils/file-scan.js"; -export type { Rule, RuleFramework, RuleSeverity } from "./plugin/utils/rule.js"; +export type { Rule, RuleExecution, RuleFramework, RuleSeverity } from "./plugin/utils/rule.js"; export type { RulePlugin } from "./plugin/utils/rule-plugin.js"; export type { RuleVisitors } from "./plugin/utils/rule-visitors.js"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts index 2eee430f10..0e925cc172 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts @@ -1,4 +1,6 @@ export const GIANT_COMPONENT_LINE_THRESHOLD = 300; +export const REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD = 15; +export const REACT_FUNCTION_COGNITIVE_COMPLEXITY_THRESHOLD = 15; export const RELATED_USE_STATE_THRESHOLD = 5; export const DEEP_NESTING_THRESHOLD = 3; export const DUPLICATE_STORAGE_READ_THRESHOLD = 2; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/core-rule-registry-data.json b/packages/oxlint-plugin-react-doctor/src/plugin/core-rule-registry-data.json index b4c83d0cd9..4adea7bbe7 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/core-rule-registry-data.json +++ b/packages/oxlint-plugin-react-doctor/src/plugin/core-rule-registry-data.json @@ -427,6 +427,24 @@ "isScanRule": false } }, + { + "key": "react-doctor/circular-dependency", + "id": "circular-dependency", + "source": "react-doctor", + "originallyExternal": false, + "rule": { + "id": "circular-dependency", + "title": "Runtime import cycle", + "severity": "warn", + "recommendation": "Break the runtime cycle by extracting shared code into a lower-level module or inverting one dependency.", + "category": "Maintainability", + "framework": "global", + "tags": ["project-analysis"], + "defaultEnabled": false, + "isScanRule": false, + "isProjectRule": true + } + }, { "key": "react-doctor/class-component-missing-component-will-unmount-teardown", "id": "class-component-missing-component-will-unmount-teardown", @@ -784,6 +802,23 @@ "isScanRule": false } }, + { + "key": "react-doctor/duplicate-jsx-subtree", + "id": "duplicate-jsx-subtree", + "source": "react-doctor", + "originallyExternal": false, + "rule": { + "id": "duplicate-jsx-subtree", + "title": "Duplicated JSX structure", + "severity": "warn", + "recommendation": "Extract a shared component when the repeated JSX trees represent the same UI concept and should evolve together.", + "category": "Maintainability", + "framework": "global", + "tags": ["project-analysis"], + "isScanRule": false, + "isProjectRule": true + } + }, { "key": "react-doctor/effect-listener-cleanup-mismatch", "id": "effect-listener-cleanup-mismatch", @@ -5041,6 +5076,22 @@ "isScanRule": false } }, + { + "key": "react-doctor/no-high-complexity-react-function", + "id": "no-high-complexity-react-function", + "source": "react-doctor", + "originallyExternal": false, + "rule": { + "id": "no-high-complexity-react-function", + "title": "React function has high control-flow complexity", + "severity": "warn", + "recommendation": "Extract independent render branches and state logic into focused components or hooks until the control flow is easy to follow.", + "category": "Maintainability", + "framework": "global", + "tags": ["test-noise", "react-jsx-only"], + "isScanRule": false + } + }, { "key": "react-doctor/no-hover-only-reveal", "id": "no-hover-only-reveal", @@ -10939,7 +10990,7 @@ "originallyExternal": false, "rule": { "id": "rendering-hydration-no-flicker", - "title": "useEffect setState flashes on mount", + "title": "useEffect state synchronization flashes after paint", "severity": "warn", "recommendation": "Initialize state from a render-safe value before the first paint, or read external mutable values with `useSyncExternalStore`.", "category": "Performance", @@ -14472,6 +14523,96 @@ "isScanRule": true } }, + { + "key": "react-doctor/unused-dependency", + "id": "unused-dependency", + "source": "react-doctor", + "originallyExternal": false, + "rule": { + "id": "unused-dependency", + "title": "Dependency has no discovered use", + "severity": "warn", + "recommendation": "Remove the dependency after checking source, scripts, configuration, generated code, and dynamic loading paths.", + "category": "Maintainability", + "framework": "global", + "tags": ["project-analysis"], + "defaultEnabled": false, + "isScanRule": false, + "isProjectRule": true + } + }, + { + "key": "react-doctor/unused-dev-dependency", + "id": "unused-dev-dependency", + "source": "react-doctor", + "originallyExternal": false, + "rule": { + "id": "unused-dev-dependency", + "title": "Development dependency has no discovered use", + "severity": "warn", + "recommendation": "Remove the development dependency after checking scripts, configuration, CI, generators, and indirect tool loading.", + "category": "Maintainability", + "framework": "global", + "tags": ["project-analysis"], + "defaultEnabled": false, + "isScanRule": false, + "isProjectRule": true + } + }, + { + "key": "react-doctor/unused-export", + "id": "unused-export", + "source": "react-doctor", + "originallyExternal": false, + "rule": { + "id": "unused-export", + "title": "Value export has no importer", + "severity": "warn", + "recommendation": "Remove the export or make it module-private after confirming that no external, generated, or dynamic consumer uses it.", + "category": "Maintainability", + "framework": "global", + "tags": ["project-analysis"], + "defaultEnabled": false, + "isScanRule": false, + "isProjectRule": true + } + }, + { + "key": "react-doctor/unused-file", + "id": "unused-file", + "source": "react-doctor", + "originallyExternal": false, + "rule": { + "id": "unused-file", + "title": "Source file is unreachable", + "severity": "warn", + "recommendation": "Delete the file after confirming that no application, package, framework, or dynamic entry point reaches it.", + "category": "Maintainability", + "framework": "global", + "tags": ["project-analysis"], + "defaultEnabled": false, + "isScanRule": false, + "isProjectRule": true + } + }, + { + "key": "react-doctor/unused-type", + "id": "unused-type", + "source": "react-doctor", + "originallyExternal": false, + "rule": { + "id": "unused-type", + "title": "Type export has no importer", + "severity": "warn", + "recommendation": "Remove the export or declaration after confirming that it is not part of a package's public type surface.", + "category": "Maintainability", + "framework": "global", + "tags": ["project-analysis"], + "defaultEnabled": false, + "isScanRule": false, + "isProjectRule": true + } + }, { "key": "react-doctor/url-prefilled-privileged-action", "id": "url-prefilled-privileged-action", diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/core-rule-registry.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/core-rule-registry.test.ts index e1e075fd83..2b795911f4 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/core-rule-registry.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/core-rule-registry.test.ts @@ -27,6 +27,9 @@ describe("core rule registry", () => { expect(coreEntry?.rule.defaultEnabled).toBe(fullEntry.rule.defaultEnabled); expect(coreEntry?.rule.matchByOccurrence).toBe(fullEntry.rule.matchByOccurrence); expect(coreEntry?.rule.isScanRule).toBe(typeof fullEntry.rule.scan === "function"); + expect(coreEntry?.rule.isProjectRule).toBe( + fullEntry.rule.execution === "project" ? true : undefined, + ); expect(Boolean(coreEntry?.rule.recommendationFor)).toBe( Boolean(fullEntry.rule.recommendationFor), ); @@ -67,4 +70,14 @@ describe("core rule registry", () => { .map((entry) => entry.id), ); }); + + it("contains exactly the full registry's project rules", () => { + expect( + CORE_REACT_DOCTOR_RULES.filter((entry) => entry.rule.isProjectRule).map((entry) => entry.id), + ).toEqual( + reactDoctorRules + .filter((entry) => entry.rule.execution === "project") + .map((entry) => entry.id), + ); + }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/liveness/known-uncovered.ts b/packages/oxlint-plugin-react-doctor/src/plugin/liveness/known-uncovered.ts index a42cf9b1be..2fcdeba543 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/liveness/known-uncovered.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/liveness/known-uncovered.ts @@ -3,6 +3,10 @@ // without a positive-control fixture in `liveness-fixtures.ts` fails // `liveness.test.ts` unless the rule is deliberately listed here. export const KNOWN_UNCOVERED: Readonly<Record<string, string>> = { + "circular-dependency": + "project rule: requires the core whole-project import graph, which the in-memory lint harness does not build", + "duplicate-jsx-subtree": + "project rule: requires the core whole-project JSX index, which the in-memory lint harness does not build", "no-cascading-set-state": "retired rule: synchronous effect setters share a React commit, so setter count does not prove redraw count", "nextjs-no-use-search-params-without-suspense": @@ -11,6 +15,16 @@ export const KNOWN_UNCOVERED: Readonly<Record<string, string>> = { "resolves the imported barrel module on the real filesystem to count its re-exports, which the in-memory liveness harness cannot fake", "rn-animate-layout-property": "retired rule: create() intentionally never reports", "rn-prefer-content-inset-adjustment": "retired rule: create() intentionally never reports", + "unused-dependency": + "project rule: requires the core whole-project dependency graph, which the in-memory lint harness does not build", + "unused-dev-dependency": + "project rule: requires the core whole-project dependency graph, which the in-memory lint harness does not build", + "unused-export": + "project rule: requires the core whole-project import graph, which the in-memory lint harness does not build", + "unused-file": + "project rule: requires the core whole-project import graph, which the in-memory lint harness does not build", + "unused-type": + "project rule: requires the core whole-project import graph, which the in-memory lint harness does not build", "ink-newline-inside-text": "retired rule: Ink supports Newline as a standalone text node", "ink-suspense-requires-concurrent": "retired rule: Ink supports Suspense fallback rendering without concurrent mode", diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/liveness/liveness-fixtures.ts b/packages/oxlint-plugin-react-doctor/src/plugin/liveness/liveness-fixtures.ts index 05f2847a23..b783c12625 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/liveness/liveness-fixtures.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/liveness/liveness-fixtures.ts @@ -1,3 +1,5 @@ +import { REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD } from "../constants/thresholds.js"; + // One positive-control fixture per registered rule: a minimal snippet the // rule MUST report at least one finding on (see liveness.test.ts). Most // snippets are lifted from the rule's own unit tests; the rest are the @@ -28,6 +30,16 @@ const giantComponentCode = [ "}", ].join("\n"); +const complexReactFunctionCode = [ + "function ComplexComponent({ value }) {", + ...Array.from( + { length: REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD }, + (_, branchIndex) => ` if (value === ${branchIndex}) return <p>${branchIndex}</p>;`, + ), + " return <p>fallback</p>;", + "}", +].join("\n"); + const reactRouterFrameworkSettings = { "react-doctor": { capabilities: ["react-router-framework"] }, }; @@ -878,6 +890,9 @@ export const livenessFixtures: Readonly<Record<string, LivenessFixture>> = { "no-giant-component": { code: giantComponentCode, }, + "no-high-complexity-react-function": { + code: complexReactFunctionCode, + }, "no-global-css-variable-animation": { code: 'requestAnimationFrame(() => {\n document.documentElement.style.setProperty("--scroll", String(window.scrollY));\n});', }, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/project-rule-registry.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/project-rule-registry.test.ts new file mode 100644 index 0000000000..eeca064e71 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/project-rule-registry.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + ALL_REACT_DOCTOR_RULES, + REACT_DOCTOR_OPT_IN_PROJECT_RULE_IDS, + REACT_DOCTOR_PROJECT_RULES, + RECOMMENDED_RULES, +} from "../rules.js"; +import { ruleRegistry } from "./rule-registry.js"; + +const PROJECT_RULE_IDS: ReadonlyArray<string> = [ + "circular-dependency", + "duplicate-jsx-subtree", + "unused-dependency", + "unused-dev-dependency", + "unused-export", + "unused-file", + "unused-type", +]; + +const OPT_IN_PROJECT_RULE_IDS = PROJECT_RULE_IDS.filter( + (ruleId) => ruleId !== "duplicate-jsx-subtree", +); + +describe("project rule registry", () => { + it("registers the seven core-owned project rules", () => { + expect(REACT_DOCTOR_PROJECT_RULES.map((entry) => entry.id)).toEqual(PROJECT_RULE_IDS); + for (const ruleId of PROJECT_RULE_IDS) { + expect(ruleRegistry[ruleId]?.execution).toBe("project"); + expect(ruleRegistry[ruleId]?.category).toBe("Maintainability"); + expect(ruleRegistry[ruleId]?.severity).toBe("warn"); + expect(ruleRegistry[ruleId]?.tags).toContain("project-analysis"); + } + }); + + it("keeps duplicate JSX on and graph hygiene rules opt-in", () => { + expect(ruleRegistry["duplicate-jsx-subtree"]?.defaultEnabled).not.toBe(false); + for (const ruleId of OPT_IN_PROJECT_RULE_IDS) { + expect(ruleRegistry[ruleId]?.defaultEnabled, ruleId).toBe(false); + } + expect(REACT_DOCTOR_OPT_IN_PROJECT_RULE_IDS).toEqual(new Set(OPT_IN_PROJECT_RULE_IDS)); + }); + + it("excludes project rules from oxlint and ESLint rule maps", () => { + for (const ruleId of PROJECT_RULE_IDS) { + const ruleKey = `react-doctor/${ruleId}`; + expect(RECOMMENDED_RULES).not.toHaveProperty(ruleKey); + expect(ALL_REACT_DOCTOR_RULES).not.toHaveProperty(ruleKey); + } + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts index 8c8c6a3b4f..2945a36f15 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts @@ -35,6 +35,7 @@ import { autocompleteValid } from "./rules/a11y/autocomplete-valid.js"; import { buildPipelineSecretBoundary } from "./rules/security-scan/build-pipeline-secret-boundary.js"; import { buttonHasType } from "./rules/react-builtins/button-has-type.js"; import { checkedRequiresOnchangeOrReadonly } from "./rules/react-builtins/checked-requires-onchange-or-readonly.js"; +import { circularDependency } from "./rules/project/circular-dependency.js"; import { classComponentMissingComponentWillUnmountTeardown } from "./rules/state-and-effects/class-component-missing-component-will-unmount-teardown.js"; import { clickEventsHaveKeyEvents } from "./rules/a11y/click-events-have-key-events.js"; import { clickjackingRedirectRisk } from "./rules/security-scan/clickjacking-redirect-risk.js"; @@ -56,6 +57,7 @@ import { noVagueButtonLabel } from "./rules/react-ui/no-vague-button-label.js"; import { detailsRequiresSummary } from "./rules/a11y/details-requires-summary.js"; import { dialogHasAccessibleName } from "./rules/a11y/dialog-has-accessible-name.js"; import { displayName } from "./rules/react-builtins/display-name.js"; +import { duplicateJsxSubtree } from "./rules/project/duplicate-jsx-subtree.js"; import { effectListenerCleanupMismatch } from "./rules/state-and-effects/effect-listener-cleanup-mismatch.js"; import { effectListenerCleanupReferenceMismatch } from "./rules/state-and-effects/effect-listener-cleanup-reference-mismatch.js"; import { effectNeedsCleanup } from "./rules/state-and-effects/effect-needs-cleanup.js"; @@ -314,6 +316,7 @@ import { noGradientText } from "./rules/design/no-gradient-text.js"; import { noGrayOnColoredBackground } from "./rules/design/no-gray-on-colored-background.js"; import { noHairlineBorderWideShadow } from "./rules/design/no-hairline-border-wide-shadow.js"; import { noHeroEyebrowChip } from "./rules/design/no-hero-eyebrow-chip.js"; +import { noHighComplexityReactFunction } from "./rules/architecture/no-high-complexity-react-function.js"; import { noHoverOnlyReveal } from "./rules/design/no-hover-only-reveal.js"; import { noHydrationBranchOnBrowserGlobal } from "./rules/performance/no-hydration-branch-on-browser-global.js"; import { noIconTileHeadingStack } from "./rules/design/no-icon-tile-heading-stack.js"; @@ -875,6 +878,11 @@ import { threeWebgpuNoLegacyMaterialApi } from "./rules/r3f/three-webgpu-no-lega import { threeWebgpuRequireInitBeforeSyncOperation } from "./rules/r3f/three-webgpu-require-init-before-sync-operation.js"; import { unsafeJsonInHtml } from "./rules/security-scan/unsafe-json-in-html.js"; import { untrustedRedirectFollowing } from "./rules/security-scan/untrusted-redirect-following.js"; +import { unusedDependency } from "./rules/project/unused-dependency.js"; +import { unusedDevDependency } from "./rules/project/unused-dev-dependency.js"; +import { unusedExport } from "./rules/project/unused-export.js"; +import { unusedFile } from "./rules/project/unused-file.js"; +import { unusedType } from "./rules/project/unused-type.js"; import { urlPrefilledPrivilegedAction } from "./rules/security-scan/url-prefilled-privileged-action.js"; import { useLazyMotion } from "./rules/bundle-size/use-lazy-motion.js"; import { valtioNoProxyReadInRender } from "./rules/valtio/valtio-no-proxy-read-in-render.js"; @@ -1210,6 +1218,18 @@ export const reactDoctorRules = [ ], }, }, + { + key: "react-doctor/circular-dependency", + id: "circular-dependency", + source: "react-doctor", + originallyExternal: false, + rule: { + ...circularDependency, + framework: "global", + category: "Maintainability", + tags: [...new Set(["project-analysis", ...(circularDependency.tags ?? [])])], + }, + }, { key: "react-doctor/class-component-missing-component-will-unmount-teardown", id: "class-component-missing-component-will-unmount-teardown", @@ -1478,6 +1498,18 @@ export const reactDoctorRules = [ requires: [...new Set<Capability>(["react", ...(displayName.requires ?? [])])], }, }, + { + key: "react-doctor/duplicate-jsx-subtree", + id: "duplicate-jsx-subtree", + source: "react-doctor", + originallyExternal: false, + rule: { + ...duplicateJsxSubtree, + framework: "global", + category: "Maintainability", + tags: [...new Set(["project-analysis", ...(duplicateJsxSubtree.tags ?? [])])], + }, + }, { key: "react-doctor/effect-listener-cleanup-mismatch", id: "effect-listener-cleanup-mismatch", @@ -4561,6 +4593,17 @@ export const reactDoctorRules = [ tags: [...new Set(["design", ...(noHeroEyebrowChip.tags ?? [])])], }, }, + { + key: "react-doctor/no-high-complexity-react-function", + id: "no-high-complexity-react-function", + source: "react-doctor", + originallyExternal: false, + rule: { + ...noHighComplexityReactFunction, + framework: "global", + category: "Maintainability", + }, + }, { key: "react-doctor/no-hover-only-reveal", id: "no-hover-only-reveal", @@ -11996,6 +12039,66 @@ export const reactDoctorRules = [ tags: [...new Set(["security-scan", ...(untrustedRedirectFollowing.tags ?? [])])], }, }, + { + key: "react-doctor/unused-dependency", + id: "unused-dependency", + source: "react-doctor", + originallyExternal: false, + rule: { + ...unusedDependency, + framework: "global", + category: "Maintainability", + tags: [...new Set(["project-analysis", ...(unusedDependency.tags ?? [])])], + }, + }, + { + key: "react-doctor/unused-dev-dependency", + id: "unused-dev-dependency", + source: "react-doctor", + originallyExternal: false, + rule: { + ...unusedDevDependency, + framework: "global", + category: "Maintainability", + tags: [...new Set(["project-analysis", ...(unusedDevDependency.tags ?? [])])], + }, + }, + { + key: "react-doctor/unused-export", + id: "unused-export", + source: "react-doctor", + originallyExternal: false, + rule: { + ...unusedExport, + framework: "global", + category: "Maintainability", + tags: [...new Set(["project-analysis", ...(unusedExport.tags ?? [])])], + }, + }, + { + key: "react-doctor/unused-file", + id: "unused-file", + source: "react-doctor", + originallyExternal: false, + rule: { + ...unusedFile, + framework: "global", + category: "Maintainability", + tags: [...new Set(["project-analysis", ...(unusedFile.tags ?? [])])], + }, + }, + { + key: "react-doctor/unused-type", + id: "unused-type", + source: "react-doctor", + originallyExternal: false, + rule: { + ...unusedType, + framework: "global", + category: "Maintainability", + tags: [...new Set(["project-analysis", ...(unusedType.tags ?? [])])], + }, + }, { key: "react-doctor/url-prefilled-privileged-action", id: "url-prefilled-privileged-action", diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-high-complexity-react-function.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-high-complexity-react-function.test.ts new file mode 100644 index 0000000000..7fd20f7aea --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-high-complexity-react-function.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD } from "../../constants/thresholds.js"; +import { noHighComplexityReactFunction } from "./no-high-complexity-react-function.js"; + +const buildSequentialBranches = (branchCount: number): string => + Array.from( + { length: branchCount }, + (_, branchIndex) => `if (value === ${branchIndex}) return <p>${branchIndex}</p>;`, + ).join("\n"); + +const buildConditionalExpressions = (branchCount: number): string => + Array.from( + { length: branchCount }, + (_, branchIndex) => + `const choice${branchIndex} = value === ${branchIndex} ? ${branchIndex} : null;`, + ).join("\n"); + +const buildOptionalMemberReads = (readCount: number): string => + Array.from( + { length: readCount }, + (_, readIndex) => `<output>{defaults?.section?.field${readIndex}?.label}</output>`, + ).join("\n"); + +const runComplexityRule = (code: string, filename = "fixture.tsx") => { + const result = runRule(noHighComplexityReactFunction, code, { filename }); + expect(result.parseErrors).toEqual([]); + return result.diagnostics; +}; + +describe("architecture/no-high-complexity-react-function", () => { + it("remains a warning", () => { + expect(noHighComplexityReactFunction.severity).toBe("warn"); + }); + + it("reports a component whose CFG has too many independent paths", () => { + const diagnostics = runComplexityRule(` + function Checkout({ value }) { + ${buildSequentialBranches(REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD)} + return <p>fallback</p>; + } + `); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain( + `cyclomatic complexity ${REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD + 1}`, + ); + }); + + it("reports nesting-heavy custom hooks", () => { + const diagnostics = runComplexityRule( + `function useSelection(value: number) { + if (value > 0) { + if (value > 1) { + if (value > 2) { + if (value > 3) { + if (value > 4) { + if (value > 5) return value; + } + } + } + } + } + return 0; + }`, + "fixture.ts", + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain("cognitive complexity 21"); + }); + + it("reports expression-heavy components even when their statement CFG is linear", () => { + const diagnostics = runComplexityRule(` + function SearchResults({ value }) { + ${buildConditionalExpressions(REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD)} + return <main>{choice0}</main>; + } + `); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain( + `cyclomatic complexity ${REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD + 1}`, + ); + }); + + it("reports an anonymous component inside nested React HOCs", () => { + const diagnostics = runComplexityRule(` + import { forwardRef, memo } from "react"; + const SearchInput = memo(forwardRef((props, reference) => { + ${buildSequentialBranches(REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD)} + return <input ref={reference} />; + })); + `); + + expect(diagnostics).toHaveLength(1); + }); + + it("reports an anonymous default-exported function component", () => { + const diagnostics = runComplexityRule(` + export default function ({ value }) { + ${buildSequentialBranches(REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD)} + return <p>fallback</p>; + } + `); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toContain("`default export`"); + }); + + it("reports an anonymous default-exported arrow component", () => { + const diagnostics = runComplexityRule(` + export default ({ value }) => { + ${buildSequentialBranches(REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD)} + return <p>fallback</p>; + }; + `); + + expect(diagnostics).toHaveLength(1); + }); + + it("allows React functions at the complexity boundary", () => { + expect( + runComplexityRule(` + function Results({ value }) { + ${buildSequentialBranches(REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD - 1)} + return <p>fallback</p>; + } + `), + ).toHaveLength(0); + }); + + it("ignores a complex PascalCase service without React output", () => { + expect( + runComplexityRule( + `function PricingService(value: number) { + ${buildSequentialBranches(REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD).replaceAll("<p>", '"').replaceAll("</p>", '"')} + return "fallback"; + }`, + "fixture.ts", + ), + ).toHaveLength(0); + }); + + it("does not attribute nested callback complexity to the component", () => { + expect( + runComplexityRule(` + function Results({ value }) { + const selectResult = () => { + ${buildSequentialBranches(REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD)} + return <p>fallback</p>; + }; + return <main>{selectResult()}</main>; + } + `), + ).toHaveLength(0); + }); + + it("does not treat repeated optional member reads as control-flow complexity", () => { + expect( + runComplexityRule(` + function SettingsForm({ defaults }) { + return ( + <form> + ${buildOptionalMemberReads(REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD + 1)} + </form> + ); + } + `), + ).toHaveLength(0); + }); + + it("ignores complex lowercase utility functions", () => { + expect( + runComplexityRule(` + function chooseValue({ value }) { + ${buildSequentialBranches(REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD)} + return <p>fallback</p>; + } + `), + ).toHaveLength(0); + }); + + it("ignores React-shaped code in a non-React JSX dialect", () => { + expect( + runComplexityRule(` + import { createSignal } from "solid-js"; + function Results({ value }) { + ${buildSequentialBranches(REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD)} + return <p>fallback</p>; + } + `), + ).toHaveLength(0); + }); + + it("ignores marker-only code in a non-React JSX dialect", () => { + expect( + runComplexityRule(` + function Results({ value }) { + ${buildSequentialBranches(REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD)} + return <main classList={{ active: true }}>fallback</main>; + } + `), + ).toHaveLength(0); + }); + + it("does not treat an unresolved classList prop as non-React ownership", () => { + expect( + runComplexityRule(` + function Results({ value, classes }) { + ${buildSequentialBranches(REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD)} + return <main classList={classes}>fallback</main>; + } + `), + ).toHaveLength(1); + }); + + it("lets an explicit React runtime override a dialect marker", () => { + expect( + runComplexityRule(` + import React from "react"; + function Results({ value }) { + ${buildSequentialBranches(REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD)} + return <main classList={{ active: true }}>fallback</main>; + } + `), + ).toHaveLength(1); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-high-complexity-react-function.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-high-complexity-react-function.ts new file mode 100644 index 0000000000..1ffa0d9a0c --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-high-complexity-react-function.ts @@ -0,0 +1,68 @@ +import { + REACT_FUNCTION_COGNITIVE_COMPLEXITY_THRESHOLD, + REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD, +} from "../../constants/thresholds.js"; +import { calculateFunctionComplexity } from "../../semantic/function-complexity.js"; +import { + componentOrHookDisplayNameForFunction, + findComponentHocExpressionRoot, +} from "../../utils/component-or-hook-display-name.js"; +import { defineRule } from "../../utils/define-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import { functionHasReactComponentEvidence } from "../../utils/function-has-react-component-evidence.js"; +import { isReactHookName } from "../../utils/is-react-hook-name.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { RuleContext } from "../../utils/rule-context.js"; + +export const noHighComplexityReactFunction = defineRule({ + id: "no-high-complexity-react-function", + title: "React function has high control-flow complexity", + severity: "warn", + tags: ["test-noise", "react-jsx-only"], + recommendation: + "Extract independent render branches and state logic into focused components or hooks until the control flow is easy to follow.", + create: (context: RuleContext) => { + const checkReactFunction = (functionNode: EsTreeNode): void => { + const expressionRoot = findComponentHocExpressionRoot(functionNode); + const isAnonymousDefaultExport = + isNodeOfType(expressionRoot.parent, "ExportDefaultDeclaration") && + expressionRoot.parent.declaration === expressionRoot; + const displayName = + componentOrHookDisplayNameForFunction(functionNode) ?? + (isAnonymousDefaultExport ? "default export" : null); + if (!displayName) return; + if ( + !isReactHookName(displayName) && + !functionHasReactComponentEvidence(functionNode, context.scopes, context.cfg) + ) { + return; + } + const functionControlFlow = context.cfg.cfgFor(functionNode); + if (!functionControlFlow) return; + const complexity = calculateFunctionComplexity(functionNode, functionControlFlow); + if ( + complexity.cyclomatic <= REACT_FUNCTION_CYCLOMATIC_COMPLEXITY_THRESHOLD && + complexity.cognitive <= REACT_FUNCTION_COGNITIVE_COMPLEXITY_THRESHOLD + ) { + return; + } + context.report({ + node: functionNode, + message: `\`${displayName}\` has cyclomatic complexity ${complexity.cyclomatic}, cognitive complexity ${complexity.cognitive}, and maximum nesting depth ${complexity.maxNestingDepth}, so its React logic is hard to understand and change. Extract independent branches into components or hooks.`, + }); + }; + + return { + ArrowFunctionExpression(node: EsTreeNodeOfType<"ArrowFunctionExpression">) { + checkReactFunction(node); + }, + FunctionDeclaration(node: EsTreeNodeOfType<"FunctionDeclaration">) { + checkReactFunction(node); + }, + FunctionExpression(node: EsTreeNodeOfType<"FunctionExpression">) { + checkReactFunction(node); + }, + }; + }, +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/circular-dependency.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/circular-dependency.ts new file mode 100644 index 0000000000..091226472f --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/circular-dependency.ts @@ -0,0 +1,11 @@ +import { defineRule } from "../../utils/define-rule.js"; + +export const circularDependency = defineRule({ + id: "circular-dependency", + title: "Runtime import cycle", + severity: "warn", + execution: "project", + defaultEnabled: false, + recommendation: + "Break the runtime cycle by extracting shared code into a lower-level module or inverting one dependency.", +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/duplicate-jsx-subtree.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/duplicate-jsx-subtree.ts new file mode 100644 index 0000000000..c75dd5300d --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/duplicate-jsx-subtree.ts @@ -0,0 +1,10 @@ +import { defineRule } from "../../utils/define-rule.js"; + +export const duplicateJsxSubtree = defineRule({ + id: "duplicate-jsx-subtree", + title: "Duplicated JSX structure", + severity: "warn", + execution: "project", + recommendation: + "Extract a shared component when the repeated JSX trees represent the same UI concept and should evolve together.", +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/unused-dependency.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/unused-dependency.ts new file mode 100644 index 0000000000..1119d14e3d --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/unused-dependency.ts @@ -0,0 +1,11 @@ +import { defineRule } from "../../utils/define-rule.js"; + +export const unusedDependency = defineRule({ + id: "unused-dependency", + title: "Dependency has no discovered use", + severity: "warn", + execution: "project", + defaultEnabled: false, + recommendation: + "Remove the dependency after checking source, scripts, configuration, generated code, and dynamic loading paths.", +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/unused-dev-dependency.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/unused-dev-dependency.ts new file mode 100644 index 0000000000..32fdd0ba73 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/unused-dev-dependency.ts @@ -0,0 +1,11 @@ +import { defineRule } from "../../utils/define-rule.js"; + +export const unusedDevDependency = defineRule({ + id: "unused-dev-dependency", + title: "Development dependency has no discovered use", + severity: "warn", + execution: "project", + defaultEnabled: false, + recommendation: + "Remove the development dependency after checking scripts, configuration, CI, generators, and indirect tool loading.", +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/unused-export.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/unused-export.ts new file mode 100644 index 0000000000..5ba58e7383 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/unused-export.ts @@ -0,0 +1,11 @@ +import { defineRule } from "../../utils/define-rule.js"; + +export const unusedExport = defineRule({ + id: "unused-export", + title: "Value export has no importer", + severity: "warn", + execution: "project", + defaultEnabled: false, + recommendation: + "Remove the export or make it module-private after confirming that no external, generated, or dynamic consumer uses it.", +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/unused-file.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/unused-file.ts new file mode 100644 index 0000000000..a07ec81db6 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/unused-file.ts @@ -0,0 +1,11 @@ +import { defineRule } from "../../utils/define-rule.js"; + +export const unusedFile = defineRule({ + id: "unused-file", + title: "Source file is unreachable", + severity: "warn", + execution: "project", + defaultEnabled: false, + recommendation: + "Delete the file after confirming that no application, package, framework, or dynamic entry point reaches it.", +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/unused-type.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/unused-type.ts new file mode 100644 index 0000000000..50a34206ba --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/project/unused-type.ts @@ -0,0 +1,11 @@ +import { defineRule } from "../../utils/define-rule.js"; + +export const unusedType = defineRule({ + id: "unused-type", + title: "Type export has no importer", + severity: "warn", + execution: "project", + defaultEnabled: false, + recommendation: + "Remove the export or declaration after confirming that it is not part of a package's public type surface.", +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.test.ts index 75ef4ae65a..911a1e6c8a 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.test.ts @@ -3,23 +3,50 @@ import { runRule } from "../../../test-utils/run-rule.js"; import { threePreferSetAnimationLoop } from "./three-prefer-set-animation-loop.js"; describe("three-prefer-set-animation-loop", () => { - it("reports recursive animation frames in a WebXR renderer", () => { + it("reports recursive animation frames that render with Three.js", () => { const code = ` import { WebGLRenderer } from "three"; const renderer = new WebGLRenderer(); - renderer.xr.enabled = true; function frame() { renderer.render(scene, camera); requestAnimationFrame(frame); } requestAnimationFrame(frame); `; expect(runRule(threePreferSetAnimationLoop, code).diagnostics).toHaveLength(1); }); - it("allows non-XR manual frames, renderer-managed frames, and unrelated callbacks", () => { + it("reports the standalone Three.js scaffold animation loop", () => { + const code = ` + import * as THREE from "three"; + const canvas = document.querySelector("#view"); + const renderer = new THREE.WebGLRenderer({ canvas }); + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(); + function frame() { + renderer.render(scene, camera); + requestAnimationFrame(frame); + } + requestAnimationFrame(frame); + `; + expect(runRule(threePreferSetAnimationLoop, code).diagnostics).toHaveLength(1); + }); + + it("reports a recursive loop that delegates rendering to an imported viewer", () => { + const code = ` + import { Viewer } from "./scene/viewer"; + const viewer = new Viewer(canvas); + function frame() { + viewer.frame(); + app.tick(); + requestAnimationFrame(frame); + } + requestAnimationFrame(frame); + `; + expect(runRule(threePreferSetAnimationLoop, code).diagnostics).toHaveLength(1); + }); + + it("allows renderer-managed frames and unrelated or shadowed callbacks", () => { const code = ` import { WebGLRenderer } from "three"; const renderer = new WebGLRenderer(); - function frame() { renderer.render(scene, camera); requestAnimationFrame(frame); } - requestAnimationFrame(frame); renderer.setAnimationLoop(() => renderer.render(scene, camera)); requestAnimationFrame(() => updateDom()); const run = (requestAnimationFrame) => requestAnimationFrame(() => renderer.render(scene, camera)); @@ -27,23 +54,18 @@ describe("three-prefer-set-animation-loop", () => { expect(runRule(threePreferSetAnimationLoop, code).diagnostics).toHaveLength(0); }); - it("recognizes an imported WebXR session button without trusting unrelated xr properties", () => { - const webXr = ` - import { WebGLRenderer } from "three"; - import { VRButton } from "three/addons/webxr/VRButton.js"; - const renderer = new WebGLRenderer(); - function frame() { renderer.render(scene, camera); requestAnimationFrame(frame); } - requestAnimationFrame(frame); - document.body.append(VRButton.createButton(renderer)); - `; - const unrelated = ` - import { WebGLRenderer } from "three"; - const renderer = new WebGLRenderer(); - state.xr.enabled = true; - function frame() { renderer.render(scene, camera); requestAnimationFrame(frame); } - requestAnimationFrame(frame); + it("allows finite animation-frame work that only reschedules conditionally", () => { + const code = ` + function runBuildChunk() { + while (stepIndex < steps.length && performance.now() < deadline) runStep(); + if (stepIndex < steps.length) { + requestAnimationFrame(runBuildChunk); + return; + } + finishBuild(); + } + requestAnimationFrame(runBuildChunk); `; - expect(runRule(threePreferSetAnimationLoop, webXr).diagnostics).toHaveLength(1); - expect(runRule(threePreferSetAnimationLoop, unrelated).diagnostics).toHaveLength(0); + expect(runRule(threePreferSetAnimationLoop, code).diagnostics).toHaveLength(0); }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.ts index adf4aad752..1d6a3e2dbe 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.ts @@ -1,20 +1,7 @@ import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; -import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; import { isGlobalAnimationFrameCallee } from "../../utils/is-global-animation-frame-callee.js"; -import { getStaticPropertyName } from "../../utils/get-static-property-name.js"; -import { isNodeOfType } from "../../utils/is-node-of-type.js"; -import type { RuleContext } from "../../utils/rule-context.js"; -import { resolveThreeAnimationLoopCallback } from "./utils/resolve-three-animation-loop-callback.js"; -import { getThreeConstructorName } from "./utils/get-three-constructor-name.js"; - -const WEB_XR_BUTTON_EXPORT_NAMES = new Set(["ARButton", "VRButton", "XRButton"]); - -const isThreeRendererXrMember = (node: EsTreeNode, context: RuleContext): boolean => { - if (!isNodeOfType(node, "MemberExpression") || getStaticPropertyName(node) !== "xr") return false; - const constructorName = getThreeConstructorName(node.object, context.scopes); - return constructorName === "WebGLRenderer" || constructorName === "WebGPURenderer"; -}; +import { resolveRecursiveAnimationFrameCallback } from "../../utils/resolve-recursive-animation-frame-callback.js"; export const threePreferSetAnimationLoop = defineRule({ id: "three-prefer-set-animation-loop", @@ -23,51 +10,21 @@ export const threePreferSetAnimationLoop = defineRule({ severity: "warn", recommendation: "Use renderer.setAnimationLoop for Three.js animation-loop compatibility, including WebXR", - create: (context: RuleContext) => { + create: (context) => { const reportedCallbacks = new Set<EsTreeNode>(); - const manualAnimationFrames: EsTreeNodeOfType<"CallExpression">[] = []; - let usesWebXr = false; return { - ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) { - if (!/\b(?:webxr|xr)\b/i.test(String(node.source.value))) return; - if ( - node.specifiers.some( - (specifier) => - isNodeOfType(specifier, "ImportSpecifier") && - isNodeOfType(specifier.imported, "Identifier") && - WEB_XR_BUTTON_EXPORT_NAMES.has(specifier.imported.name), - ) - ) { - usesWebXr = true; - } - }, - MemberExpression(node: EsTreeNodeOfType<"MemberExpression">) { - if (isThreeRendererXrMember(node, context)) usesWebXr = true; - if ( - getStaticPropertyName(node) === "xr" && - isNodeOfType(node.object, "Identifier") && - node.object.name === "navigator" && - context.scopes.isGlobalReference(node.object) - ) { - usesWebXr = true; - } - }, - CallExpression(node: EsTreeNodeOfType<"CallExpression">) { + CallExpression(node) { if (!isGlobalAnimationFrameCallee(node.callee, context.scopes)) return; - const callback = resolveThreeAnimationLoopCallback(node, context.scopes); + const callback = resolveRecursiveAnimationFrameCallback(node, context.scopes, { + requireUnconditionalSchedule: true, + }); if (!callback || reportedCallbacks.has(callback)) return; reportedCallbacks.add(callback); - manualAnimationFrames.push(node); - }, - "Program:exit"() { - if (!usesWebXr) return; - for (const node of manualAnimationFrames) { - context.report({ - node, - message: - "This WebXR-capable Three.js render loop is driven by requestAnimationFrame. Use renderer.setAnimationLoop(callback) so immersive sessions receive frames", - }); - } + context.report({ + node, + message: + "This continuous Three.js animation loop is driven by requestAnimationFrame. Use renderer.setAnimationLoop(callback) for renderer-managed timing and compatibility", + }); }, }; }, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup.performance.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup.performance.test.ts index 8b7d11f16b..94c1049865 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup.performance.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup.performance.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { effectNeedsCleanup } from "./effect-needs-cleanup.js"; -const SMALL_HANDLER_COUNT = 100; -const LARGE_HANDLER_COUNT = 500; -const MEASUREMENT_SAMPLE_COUNT = 3; +const SMALL_HANDLER_COUNT = 500; +const LARGE_HANDLER_COUNT = 2500; +const MEASUREMENT_SAMPLE_COUNT = 7; const MAXIMUM_SCALING_MULTIPLIER = 15; const buildRetainedHandlersSource = ( @@ -53,6 +53,7 @@ describe("effect-needs-cleanup performance", () => { ]) { it(`scales near-linearly across retained ${performanceCase.name}`, () => { measureDuration(SMALL_HANDLER_COUNT, performanceCase.buildRegistration); + measureDuration(LARGE_HANDLER_COUNT, performanceCase.buildRegistration); const smallDuration = measureDuration(SMALL_HANDLER_COUNT, performanceCase.buildRegistration); const largeDuration = measureDuration(LARGE_HANDLER_COUNT, performanceCase.buildRegistration); expect(largeDuration).toBeLessThan(smallDuration * MAXIMUM_SCALING_MULTIPLIER); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/constants.ts b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/constants.ts new file mode 100644 index 0000000000..93334581fc --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/constants.ts @@ -0,0 +1 @@ +export const CYCLOMATIC_CONNECTED_COMPONENT_WEIGHT = 2; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/function-complexity.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/function-complexity.test.ts new file mode 100644 index 0000000000..11da92e0a4 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/function-complexity.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vite-plus/test"; +import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { parseFixture } from "../../test-utils/parse-fixture.js"; +import type { EsTreeNode } from "../utils/es-tree-node.js"; +import { isNodeOfType } from "../utils/is-node-of-type.js"; +import { walkAst } from "../utils/walk-ast.js"; +import { analyzeControlFlow } from "./control-flow-graph.js"; +import { calculateFunctionComplexity } from "./function-complexity.js"; + +const measureNamedFunction = (code: string, functionName: string) => { + const parsed = parseFixture(code); + attachParentReferences(parsed.program); + let matchingFunction: EsTreeNode | null = null; + walkAst(parsed.program, (node) => { + if ( + isNodeOfType(node, "FunctionDeclaration") && + isNodeOfType(node.id, "Identifier") && + node.id.name === functionName + ) { + matchingFunction = node; + return false; + } + }); + if (!matchingFunction) throw new Error(`Could not find function ${functionName}`); + const functionControlFlow = analyzeControlFlow(parsed.program).cfgFor(matchingFunction); + if (!functionControlFlow) throw new Error(`Could not build control flow for ${functionName}`); + return calculateFunctionComplexity(matchingFunction, functionControlFlow); +}; + +describe("function-complexity", () => { + it("computes cyclomatic complexity from reachable CFG edges and blocks", () => { + expect( + measureNamedFunction( + `function choose(value) { + if (value > 0) return "positive"; + if (value < 0) return "negative"; + return "zero"; + }`, + "choose", + ).cyclomatic, + ).toBe(3); + }); + + it("matches the nesting-sensitive cognitive complexity example", () => { + expect( + measureNamedFunction( + `function sumOfPrimes(maximum) { + let total = 0; + OUTER: for (let outerIndex = 1; outerIndex <= maximum; outerIndex += 1) { + for (let innerIndex = 2; innerIndex < outerIndex; innerIndex += 1) { + if (outerIndex % innerIndex === 0) continue OUTER; + } + total += outerIndex; + } + return total; + }`, + "sumOfPrimes", + ), + ).toMatchObject({ cognitive: 7, maxNestingDepth: 3 }); + }); + + it("counts a switch once and does not charge each case", () => { + expect( + measureNamedFunction( + `function getWords(value) { + switch (value) { + case 1: return "one"; + case 2: return "two"; + default: return "many"; + } + }`, + "getWords", + ).cognitive, + ).toBe(1); + }); + + it("keeps nested function complexity out of the owning function", () => { + expect( + measureNamedFunction( + `function Parent() { + const nested = () => { + if (first) { + if (second) return source?.value ? true : fallback ?? false; + } + return false; + }; + return nested(); + }`, + "Parent", + ), + ).toMatchObject({ cognitive: 0, cyclomatic: 1, maxNestingDepth: 0 }); + }); + + it("counts runs of mixed logical operators", () => { + expect( + measureNamedFunction( + `function logic(first, second, third, fourth) { + return first && second && third || fourth; + }`, + "logic", + ).cognitive, + ).toBe(2); + }); + + it("counts source-order transitions back to an earlier logical operator", () => { + expect( + measureNamedFunction( + `function logic(first, second, third, fourth) { + return first || second && third || fourth; + }`, + "logic", + ).cognitive, + ).toBe(3); + }); + + it("counts logical runs nested behind non-logical expressions", () => { + expect( + measureNamedFunction( + `function logic(first, second, third) { + return first && select(second || third); + }`, + "logic", + ).cognitive, + ).toBe(2); + }); + + it("counts logical runs inside conditional branches independently", () => { + expect( + measureNamedFunction( + `function logic(first, second, third, fourth) { + return first && (second ? third || fourth : fourth); + }`, + "logic", + ).cognitive, + ).toBe(3); + }); + + it("adds expression decisions that the statement CFG does not represent", () => { + expect( + measureNamedFunction( + `function choose(first, second, third, fourth, fifth) { + if (first && second) { + return third ? fourth : fifth ?? null; + } + return null; + }`, + "choose", + ), + ).toMatchObject({ cognitive: 5, cyclomatic: 5 }); + }); + + it("counts every logical assignment as a cyclomatic decision only", () => { + expect( + measureNamedFunction( + `function assign(first, second, third) { + first &&= true; + second ||= false; + third ??= null; + }`, + "assign", + ), + ).toMatchObject({ cognitive: 0, cyclomatic: 4 }); + }); + + it("does not count optional chaining as control-flow complexity", () => { + expect( + measureNamedFunction( + `function read(value) { + return value?.one?.[0]?.(); + }`, + "read", + ), + ).toMatchObject({ cognitive: 0, cyclomatic: 1 }); + }); + + it("adds a flat cognitive point for else without changing cyclomatic paths", () => { + expect( + measureNamedFunction( + `function choose(value) { + if (value > 0) return "positive"; + else if (value < 0) return "negative"; + else return "zero"; + }`, + "choose", + ), + ).toMatchObject({ cognitive: 3, cyclomatic: 3 }); + }); + + it("treats nullish coalescing as a cognitive logical run", () => { + expect( + measureNamedFunction( + `function fallback(first, second, third, fourth) { + return first ?? second ?? third || fourth; + }`, + "fallback", + ), + ).toMatchObject({ cognitive: 2, cyclomatic: 4 }); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/function-complexity.ts b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/function-complexity.ts new file mode 100644 index 0000000000..0c040d23e9 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/function-complexity.ts @@ -0,0 +1,242 @@ +import { forEachChildNode, walkAst } from "../utils/walk-ast.js"; +import type { EsTreeNode } from "../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../utils/es-tree-node-of-type.js"; +import { isFunctionLike } from "../utils/is-function-like.js"; +import { isNodeOfType } from "../utils/is-node-of-type.js"; +import type { BasicBlock, FunctionCfg } from "./control-flow-graph.js"; +import { CYCLOMATIC_CONNECTED_COMPONENT_WEIGHT } from "./constants.js"; + +export interface FunctionComplexityMetrics { + readonly cognitive: number; + readonly cyclomatic: number; + readonly maxNestingDepth: number; +} + +interface CognitiveComplexityAccumulator { + cognitive: number; + maxNestingDepth: number; +} + +interface LogicalOperatorRunState { + previousOperator: "&&" | "||" | "??" | null; +} + +const collectReachableBlocks = (entryBlock: BasicBlock): Set<BasicBlock> => { + const reachableBlocks = new Set<BasicBlock>(); + const pendingBlocks = [entryBlock]; + while (pendingBlocks.length > 0) { + const currentBlock = pendingBlocks.pop(); + if (!currentBlock || reachableBlocks.has(currentBlock)) continue; + reachableBlocks.add(currentBlock); + for (const edge of currentBlock.successors) pendingBlocks.push(edge.to); + } + return reachableBlocks; +}; + +const countWeaklyConnectedComponents = (blocks: ReadonlySet<BasicBlock>): number => { + const visitedBlocks = new Set<BasicBlock>(); + let componentCount = 0; + for (const block of blocks) { + if (visitedBlocks.has(block)) continue; + componentCount += 1; + const pendingBlocks = [block]; + while (pendingBlocks.length > 0) { + const currentBlock = pendingBlocks.pop(); + if (!currentBlock || visitedBlocks.has(currentBlock)) continue; + visitedBlocks.add(currentBlock); + for (const edge of currentBlock.successors) { + if (blocks.has(edge.to)) pendingBlocks.push(edge.to); + } + for (const edge of currentBlock.predecessors) { + if (blocks.has(edge.from)) pendingBlocks.push(edge.from); + } + } + } + return componentCount; +}; + +const calculateCyclomaticComplexity = (functionControlFlow: FunctionCfg): number => { + const reachableBlocks = collectReachableBlocks(functionControlFlow.entry); + let edgeCount = 0; + for (const block of reachableBlocks) { + for (const edge of block.successors) { + if (reachableBlocks.has(edge.to)) edgeCount += 1; + } + } + return ( + edgeCount - + reachableBlocks.size + + CYCLOMATIC_CONNECTED_COMPONENT_WEIGHT * countWeaklyConnectedComponents(reachableBlocks) + ); +}; + +const isCognitiveLogicalOperator = (operator: string): operator is "&&" | "||" | "??" => + operator === "&&" || operator === "||" || operator === "??"; + +const isLogicalAssignmentOperator = (operator: string): boolean => + operator === "&&=" || operator === "||=" || operator === "??="; + +const countExpressionDecisionPoints = (rootNode: EsTreeNode): number => { + let decisionPointCount = 0; + walkAst(rootNode, (node) => { + if (node !== rootNode && isFunctionLike(node)) return false; + if ( + isNodeOfType(node, "ConditionalExpression") || + isNodeOfType(node, "LogicalExpression") || + (isNodeOfType(node, "AssignmentExpression") && isLogicalAssignmentOperator(node.operator)) + ) { + decisionPointCount += 1; + } + }); + return decisionPointCount; +}; + +const countLogicalOperatorRuns = (rootNode: EsTreeNode): number => { + let logicalRunCount = 0; + const visitNode = (node: EsTreeNode, runState: LogicalOperatorRunState | null): void => { + if (node !== rootNode && isFunctionLike(node)) return; + if (isNodeOfType(node, "LogicalExpression")) { + const currentRunState = runState ?? { previousOperator: null }; + const operator = isCognitiveLogicalOperator(node.operator) ? node.operator : null; + visitNode(node.left, currentRunState); + if (operator !== null && operator !== currentRunState.previousOperator) logicalRunCount += 1; + currentRunState.previousOperator = operator; + visitNode(node.right, currentRunState); + return; + } + forEachChildNode(node, (childNode) => visitNode(childNode, null)); + }; + visitNode(rootNode, null); + return logicalRunCount; +}; + +const recordNestedControlFlow = ( + accumulator: CognitiveComplexityAccumulator, + nestingDepth: number, +): void => { + accumulator.cognitive += 1 + nestingDepth; + accumulator.maxNestingDepth = Math.max(accumulator.maxNestingDepth, nestingDepth + 1); +}; + +const measureCognitiveComplexity = (rootNode: EsTreeNode): CognitiveComplexityAccumulator => { + const accumulator: CognitiveComplexityAccumulator = { + cognitive: 0, + maxNestingDepth: 0, + }; + + const visitNode = (node: EsTreeNode, nestingDepth: number): void => { + if (node !== rootNode && isFunctionLike(node)) return; + + if (isNodeOfType(node, "IfStatement")) { + const visitIfStatement = ( + ifStatement: EsTreeNodeOfType<"IfStatement">, + isElseIf: boolean, + ): void => { + if (isElseIf) { + accumulator.cognitive += 1; + accumulator.maxNestingDepth = Math.max(accumulator.maxNestingDepth, nestingDepth + 1); + } else { + recordNestedControlFlow(accumulator, nestingDepth); + } + visitNode(ifStatement.test, nestingDepth); + visitNode(ifStatement.consequent, nestingDepth + 1); + if (!ifStatement.alternate) return; + if (isNodeOfType(ifStatement.alternate, "IfStatement")) { + visitIfStatement(ifStatement.alternate, true); + } else { + accumulator.cognitive += 1; + visitNode(ifStatement.alternate, nestingDepth + 1); + } + }; + visitIfStatement(node, false); + return; + } + + if (isNodeOfType(node, "ConditionalExpression")) { + recordNestedControlFlow(accumulator, nestingDepth); + visitNode(node.test, nestingDepth); + visitNode(node.consequent, nestingDepth + 1); + visitNode(node.alternate, nestingDepth + 1); + return; + } + + if (isNodeOfType(node, "ForStatement")) { + recordNestedControlFlow(accumulator, nestingDepth); + if (node.init) visitNode(node.init, nestingDepth); + if (node.test) visitNode(node.test, nestingDepth); + if (node.update) visitNode(node.update, nestingDepth); + visitNode(node.body, nestingDepth + 1); + return; + } + + if (isNodeOfType(node, "ForInStatement") || isNodeOfType(node, "ForOfStatement")) { + recordNestedControlFlow(accumulator, nestingDepth); + visitNode(node.left, nestingDepth); + visitNode(node.right, nestingDepth); + visitNode(node.body, nestingDepth + 1); + return; + } + + if (isNodeOfType(node, "WhileStatement")) { + recordNestedControlFlow(accumulator, nestingDepth); + visitNode(node.test, nestingDepth); + visitNode(node.body, nestingDepth + 1); + return; + } + + if (isNodeOfType(node, "DoWhileStatement")) { + recordNestedControlFlow(accumulator, nestingDepth); + visitNode(node.body, nestingDepth + 1); + visitNode(node.test, nestingDepth); + return; + } + + if (isNodeOfType(node, "SwitchStatement")) { + recordNestedControlFlow(accumulator, nestingDepth); + visitNode(node.discriminant, nestingDepth); + for (const switchCase of node.cases) { + if (switchCase.test) visitNode(switchCase.test, nestingDepth); + for (const statement of switchCase.consequent) { + visitNode(statement, nestingDepth + 1); + } + } + return; + } + + if (isNodeOfType(node, "CatchClause")) { + recordNestedControlFlow(accumulator, nestingDepth); + if (node.param) visitNode(node.param, nestingDepth); + visitNode(node.body, nestingDepth + 1); + return; + } + + if ( + (isNodeOfType(node, "BreakStatement") || isNodeOfType(node, "ContinueStatement")) && + node.label + ) { + accumulator.cognitive += 1; + return; + } + + forEachChildNode(node, (childNode) => visitNode(childNode, nestingDepth)); + }; + + visitNode(rootNode, 0); + accumulator.cognitive += countLogicalOperatorRuns(rootNode); + return accumulator; +}; + +export const calculateFunctionComplexity = ( + functionNode: EsTreeNode, + functionControlFlow: FunctionCfg, +): FunctionComplexityMetrics => { + const analysisRoot = isFunctionLike(functionNode) ? functionNode.body : functionNode; + const cognitiveMetrics = measureCognitiveComplexity(analysisRoot); + return { + cognitive: cognitiveMetrics.cognitive, + cyclomatic: + calculateCyclomaticComplexity(functionControlFlow) + + countExpressionDecisionPoints(analysisRoot), + maxNestingDepth: cognitiveMetrics.maxNestingDepth, + }; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/core-rule-metadata.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/core-rule-metadata.ts index fcdecbc26c..46c23cdfa4 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/core-rule-metadata.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/core-rule-metadata.ts @@ -15,6 +15,7 @@ export interface CoreRuleMetadata { readonly defaultEnabled?: boolean; readonly matchByOccurrence?: boolean; readonly isScanRule: boolean; + readonly isProjectRule?: boolean; } export interface CoreRuleRegistryEntry { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/define-retired-rule.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/define-retired-rule.ts index 5bf5e7936b..ac03999ee9 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/define-retired-rule.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/define-retired-rule.ts @@ -3,7 +3,7 @@ import { EMPTY_RULE_VISITORS } from "./empty-rule-visitors.js"; import type { Rule } from "./rule.js"; export const defineRetiredRule = ( - rule: Omit<Rule, "create" | "defaultEnabled" | "lifecycle">, + rule: Omit<Rule, "create" | "defaultEnabled" | "execution" | "lifecycle">, ): Rule => defineRule({ ...rule, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/define-rule.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/define-rule.ts index d1c6376d45..89ab90d6f4 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/define-rule.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/define-rule.ts @@ -9,23 +9,23 @@ import type { Rule } from "./rule.js"; import type { EsTreeNodeOfType } from "./es-tree-node-of-type.js"; // A rule definition has exactly one execution mode. An AST rule provides -// `create` (per-file visitors, hosted by oxlint/ESLint); a scan rule -// provides `scan` (a project-level file scan, executed by -// @react-doctor/core's check-security-scan environment check) and gets an -// inert visitor factory injected for host compatibility. Metadata, -// registration, tags, and severity flow identically either way. -export type RuleDefinition = Rule | (Omit<Rule, "create"> & { scan: FileScan }); +// `create` (per-file visitors, hosted by oxlint/ESLint); a scan rule provides +// `scan`; and a project rule carries `execution: "project"` for a core-owned +// whole-project analyzer. Non-AST modes get an inert visitor factory for host +// compatibility while sharing the same metadata and configuration surface. +export type RuleDefinition = + | Rule + | (Omit<Rule, "create" | "execution"> & { scan: FileScan }) + | (Omit<Rule, "create" | "scan"> & { execution: "project" }); // Rules tagged `"react-jsx-only"` apply React-flavoured semantics // (a11y semantics tuned for React's synthetic-event listener naming, // React-cased prop names, etc.) and should pass through for files // authored in non-React JSX dialects: Solid.js, Qwik, Voby, Vidode. -// Detection happens lazily — we snapshot the dialect status from the -// program's import declarations on the Program visit, then short- -// circuit every other visitor when the file is Solid/Qwik. A late -// `classList=` / `class:` / `bind:` marker upgrades the dialect mid- -// file (some files import Solid via re-export and don't have an -// obvious `solid-js` import). +// Detection snapshots imports and dialect-specific JSX markers on the +// Program visit, then short-circuits every other visitor when the file is +// Solid/Qwik. The JSXOpeningElement guard preserves the same behavior for +// hosts that invoke visitors without a Program pass. type GenericVisitors = Record<string, unknown>; const wrapCreateForReactJsxOnly = < @@ -58,7 +58,9 @@ const wrapCreateForReactJsxOnly = < wrappedVisitors.Program = (node: EsTreeNodeOfType<"Program">) => { const runtimeImports = collectJsxRuntimeImports(node); fileImportsReactRuntime = runtimeImports.hasReactRuntime; - fileIsNonReactJsx = runtimeImports.hasNonReactRuntime && !runtimeImports.hasReactRuntime; + fileIsNonReactJsx = + !runtimeImports.hasReactRuntime && + (runtimeImports.hasNonReactRuntime || runtimeImports.hasNonReactMarker); (visitor as (n: EsTreeNodeOfType<"Program">) => void)(node); }; continue; @@ -86,7 +88,9 @@ const wrapCreateForReactJsxOnly = < wrappedVisitors.Program = (node: EsTreeNodeOfType<"Program">) => { const runtimeImports = collectJsxRuntimeImports(node); fileImportsReactRuntime = runtimeImports.hasReactRuntime; - fileIsNonReactJsx = runtimeImports.hasNonReactRuntime && !runtimeImports.hasReactRuntime; + fileIsNonReactJsx = + !runtimeImports.hasReactRuntime && + (runtimeImports.hasNonReactRuntime || runtimeImports.hasNonReactMarker); }; } return wrappedVisitors; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/non-react-jsx-dialect.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/non-react-jsx-dialect.ts index 35607449b7..729e7b2f4a 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/non-react-jsx-dialect.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/non-react-jsx-dialect.ts @@ -2,8 +2,10 @@ import type { EsTreeNode } from "./es-tree-node.js"; import type { EsTreeNodeOfType } from "./es-tree-node-of-type.js"; import { isNodeOfType } from "./is-node-of-type.js"; import { isTypeOnlyImport } from "./is-type-only-import.js"; +import { walkAst } from "./walk-ast.js"; export interface JsxRuntimeImports { + readonly hasNonReactMarker: boolean; readonly hasNonReactRuntime: boolean; readonly hasReactRuntime: boolean; } @@ -53,7 +55,17 @@ export const collectJsxRuntimeImports = ( let hasNonReactRuntime = false; let hasReactRuntime = false; + let hasNonReactMarker = false; for (const statement of program.body) { + if (!hasNonReactMarker) { + walkAst(statement as EsTreeNode, (node) => { + if (hasNonReactMarker) return false; + if (isNodeOfType(node, "JSXOpeningElement") && jsxAttributeIsNonReactDialectMarker(node)) { + hasNonReactMarker = true; + return false; + } + }); + } if (!isNodeOfType(statement as EsTreeNode, "ImportDeclaration")) continue; const importDeclaration = statement as EsTreeNodeOfType<"ImportDeclaration">; if (isTypeOnlyImport(importDeclaration)) continue; @@ -73,7 +85,7 @@ export const collectJsxRuntimeImports = ( hasReactRuntime = true; } } - const runtimeImports = { hasNonReactRuntime, hasReactRuntime }; + const runtimeImports = { hasNonReactMarker, hasNonReactRuntime, hasReactRuntime }; runtimeImportsByProgram.set(program, runtimeImports); return runtimeImports; }; @@ -96,11 +108,13 @@ export const jsxAttributeIsNonReactDialectMarker = ( for (const attribute of openingNode.attributes) { if (!isNodeOfType(attribute, "JSXAttribute")) continue; if (!isNodeOfType(attribute.name, "JSXIdentifier")) continue; - // `classList` (Solid), `class:hover` (svelte-jsx style — rare in - // React), `bind:value` (svelte) — collectively non-React markers. const attributeName = attribute.name.name; + const isObjectClassList = + attributeName === "classList" && + isNodeOfType(attribute.value, "JSXExpressionContainer") && + isNodeOfType(attribute.value.expression, "ObjectExpression"); if ( - attributeName === "classList" || + isObjectClassList || attributeName.startsWith("class:") || attributeName.startsWith("bind:") ) { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-nearest-package-manifest.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-nearest-package-manifest.ts index 878d09e281..3751c8ce1e 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-nearest-package-manifest.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/read-nearest-package-manifest.ts @@ -42,10 +42,9 @@ export interface PackageManifest { // filename) is essential — every file inside a package shares the same // answer, and oxlint visits many files per package per run. // -// Both memos are sound only within one scan (the filesystem is treated as -// frozen while a scan runs). A long-lived host (the LSP server) must call -// `resetManifestCaches` at each scan start so a package.json created closer -// to a source file, or an edited manifest, is picked up by the next scan. +// Both memos are sound only within one scan because the filesystem is treated +// as frozen while a scan runs. Reset them at each scan start so a closer or +// edited package manifest is picked up by the next scan. const cachedPackageDirectoryByFilename = new Map<string, string | null>(); const cachedManifestByPackageDirectory = new Map<string, PackageManifest | null>(); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-recursive-animation-frame-callback.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-recursive-animation-frame-callback.ts index 0ce632a1fc..7f7ecb9cbd 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-recursive-animation-frame-callback.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-recursive-animation-frame-callback.ts @@ -3,10 +3,15 @@ import type { EsTreeNode } from "./es-tree-node.js"; import type { EsTreeNodeOfType } from "./es-tree-node-of-type.js"; import { isFunctionLike } from "./is-function-like.js"; import { isGlobalBrowserFunctionCall } from "./is-global-browser-function-call.js"; +import { isNodeOnUnconditionalPath } from "./is-node-on-unconditional-path.js"; import { isNodeOfType } from "./is-node-of-type.js"; import { resolveExactLocalFunction } from "./resolve-exact-local-function.js"; import { walkAst } from "./walk-ast.js"; +interface ResolveRecursiveAnimationFrameCallbackOptions { + readonly requireUnconditionalSchedule?: boolean; +} + const getAnimationFrameCallback = ( call: EsTreeNodeOfType<"CallExpression">, scopes: ScopeAnalysis, @@ -17,14 +22,19 @@ const getAnimationFrameCallback = ( : null; }; -const callbackSchedulesItself = (callback: EsTreeNode, scopes: ScopeAnalysis): boolean => { +const callbackSchedulesItself = ( + callback: EsTreeNode, + scopes: ScopeAnalysis, + shouldRequireUnconditionalSchedule: boolean, +): boolean => { let doesScheduleItself = false; walkAst(callback, (candidate) => { if (doesScheduleItself || (candidate !== callback && isFunctionLike(candidate))) return false; if ( isNodeOfType(candidate, "CallExpression") && isGlobalBrowserFunctionCall(candidate, "requestAnimationFrame", scopes) && - getAnimationFrameCallback(candidate, scopes) === callback + getAnimationFrameCallback(candidate, scopes) === callback && + (!shouldRequireUnconditionalSchedule || isNodeOnUnconditionalPath(candidate, callback)) ) { doesScheduleItself = true; return false; @@ -36,8 +46,12 @@ const callbackSchedulesItself = (callback: EsTreeNode, scopes: ScopeAnalysis): b export const resolveRecursiveAnimationFrameCallback = ( call: EsTreeNodeOfType<"CallExpression">, scopes: ScopeAnalysis, + options: ResolveRecursiveAnimationFrameCallbackOptions = {}, ): EsTreeNode | null => { if (!isGlobalBrowserFunctionCall(call, "requestAnimationFrame", scopes)) return null; const callback = getAnimationFrameCallback(call, scopes); - return callback && callbackSchedulesItself(callback, scopes) ? callback : null; + return callback && + callbackSchedulesItself(callback, scopes, options.requireUnconditionalSchedule === true) + ? callback + : null; }; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/rule.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/rule.ts index 5f80b53ef0..dd2bd0b16e 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/rule.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/rule.ts @@ -5,6 +5,8 @@ import type { RuleVisitors } from "./rule-visitors.js"; export type RuleSeverity = "error" | "warn"; +export type RuleExecution = "project"; + // `global` rules are enabled on every project; the other buckets only // activate when the project actually uses that framework (detected by // `detectProject`). The framework name doubles as the ESLint flat-config @@ -82,6 +84,10 @@ export interface Rule { // Retired rules stay registered only so legacy configs and docs tooling // can resolve the id. They intentionally never report diagnostics. lifecycle?: "retired"; + // Project rules are registered for metadata, configuration, documentation, + // and reporting but execute in a whole-project @react-doctor/core analyzer + // instead of an oxlint / ESLint file visitor. + execution?: RuleExecution; // Project-level file scan. Rules with `scan` are registered for // metadata/tags/severity like any rule, but are EXCLUDED from the // generated oxlint config and executed by @react-doctor/core's @@ -97,8 +103,8 @@ export interface Rule { // Capability-conditioned override of `recommendation`, evaluated by // @react-doctor/core's diagnostic pipeline where the scanned project's // capability set is known. Return `undefined` to fall back to the static - // `recommendation` (which stays the project-agnostic prose that docs, - // the rule catalog, and LSP hover render). + // `recommendation` (which stays the project-agnostic prose used by docs and + // the rule catalog). recommendationFor?: (hasCapability: CapabilityQuery) => string | undefined; create: (context: RuleContext) => RuleVisitors; } diff --git a/packages/oxlint-plugin-react-doctor/src/rules.ts b/packages/oxlint-plugin-react-doctor/src/rules.ts index fb7f9188ce..9b311d5344 100644 --- a/packages/oxlint-plugin-react-doctor/src/rules.ts +++ b/packages/oxlint-plugin-react-doctor/src/rules.ts @@ -25,18 +25,18 @@ const toKeyedSeverity = (entries: ReadonlyArray<RegistryEntry>): ReadonlyArray<K const isRecommendedByDefault = (entry: RegistryEntry): boolean => entry.rule.defaultEnabled !== false; -// Scan rules (`scan` field) stay in the full registry exports for +// Scan and project rules stay in the full registry exports for // metadata consumers (`REACT_DOCTOR_RULES`, `ALL_REACT_DOCTOR_RULE_KEYS`) // but are excluded from the preset rule maps: their lint visitor is a -// no-op (they run via @react-doctor/core's check-security-scan -// environment check), so enabling them in an ESLint/oxlint config would -// only register dead rules. -const isScanRule = (entry: RegistryEntry): boolean => entry.rule.scan !== undefined; +// no-op because they execute in @react-doctor/core, so enabling them in an +// ESLint/oxlint config would only register dead rules. +const isLintRule = (entry: RegistryEntry): boolean => + entry.rule.scan === undefined && entry.rule.execution !== "project"; const collectReactDoctorRulesByFramework = (frameworkName: RuleFramework) => reactDoctorRules.filter( (entry) => - entry.rule.framework === frameworkName && isRecommendedByDefault(entry) && !isScanRule(entry), + entry.rule.framework === frameworkName && isRecommendedByDefault(entry) && isLintRule(entry), ); const collectFrameworkSpecificRuleKeys = (): ReadonlySet<string> => { @@ -48,6 +48,14 @@ const collectFrameworkSpecificRuleKeys = (): ReadonlySet<string> => { }; export const REACT_DOCTOR_RULES = reactDoctorRules; +export const REACT_DOCTOR_PROJECT_RULES = reactDoctorRules.filter( + (entry) => entry.rule.execution === "project", +); +export const REACT_DOCTOR_OPT_IN_PROJECT_RULE_IDS: ReadonlySet<string> = new Set( + REACT_DOCTOR_PROJECT_RULES.filter((entry) => entry.rule.defaultEnabled === false).map( + (entry) => entry.id, + ), +); export { EXTERNAL_RULES, REACT_COMPILER_RULES }; @@ -72,7 +80,7 @@ export const PREACT_RULES = toRuleMap( toKeyedSeverity(collectReactDoctorRulesByFramework("preact")), ); export const ALL_REACT_DOCTOR_RULES = toRuleMap( - toKeyedSeverity(REACT_DOCTOR_RULES.filter((entry) => !isScanRule(entry))), + toKeyedSeverity(REACT_DOCTOR_RULES.filter(isLintRule)), ); export const ALL_REACT_DOCTOR_RULE_KEYS: ReadonlySet<string> = new Set( REACT_DOCTOR_RULES.map((rule) => rule.key), diff --git a/packages/react-doctor/README.md b/packages/react-doctor/README.md index 78e1f545c8..e908f34e43 100644 --- a/packages/react-doctor/README.md +++ b/packages/react-doctor/README.md @@ -9,7 +9,7 @@ Your agent writes bad React, this catches it. -React Doctor deterministically scans your codebase and finds issues across state & effects, performance, architecture, security, and accessibility. +React Doctor deterministically scans your codebase and finds issues across state and effects, performance, architecture, security, accessibility, and maintainability. It highlights overly complex React functions and repeated JSX trees that are good candidates for composition. Works across React frameworks and React-enabled sites - Next.js, Vite, Astro, TanStack, React Native, Expo, you name it. diff --git a/packages/react-doctor/bin/react-doctor.js b/packages/react-doctor/bin/react-doctor.js index 24835733b6..5ba2da62c9 100755 --- a/packages/react-doctor/bin/react-doctor.js +++ b/packages/react-doctor/bin/react-doctor.js @@ -10,12 +10,4 @@ if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) { } } -// Fast-path the (experimental) language server so it runs without the CLI's -// commander / prompts / ora layer, which would touch process.stdin before the -// LSP connection attaches and break the stdio transport. -if (process.argv[2] === "experimental-lsp") { - const { startLanguageServer } = await import("../dist/lsp.js"); - startLanguageServer(); -} else { - await import("../dist/cli.js"); -} +await import("../dist/cli.js"); diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index 08caf37ddb..95141bac4e 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -61,25 +61,21 @@ "agent-install": "0.0.5", "conf": "^15.1.0", "confbox": "^0.2.4", - "deslop-js": "workspace:*", "eslint-plugin-react-hooks": "^7.1.1", "jiti": "^2.7.0", "magicast": "^0.5.3", + "oxc-parser": "^0.143.0", "oxc-resolver": "^11.24.2", "oxlint": ">=1.77.0 <1.78.0", "oxlint-plugin-react-doctor": "workspace:*", "prompts": "^2.4.2", "typescript": ">=5.0.4 <6", - "vscode-languageserver": "^9.0.1", - "vscode-languageserver-textdocument": "^1.0.12", - "vscode-uri": "^3.1.0", "yaml": "^2.9.0", "yoga-layout": "~3.2.1" }, "devDependencies": { "@react-doctor/api": "workspace:*", "@react-doctor/core": "workspace:*", - "@react-doctor/language-server": "workspace:*", "@types/babel__code-frame": "^7.27.0", "@types/prompts": "^2.4.9", "@types/react": "^19.2.14", diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index 06557919da..34d8e5cfd0 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -525,7 +525,9 @@ export const inspectAction = async ( isRootDeadCodeEnabled: scanOptions.deadCode ?? rootProjectScan?.config?.deadCode ?? true, }); if (workspaceDeadCodeOwner !== null) { - recordCount(METRIC.scanWorkspaceDeadCodeShared, 1, { projectCount: projectScans.length }); + recordCount(METRIC.scanWorkspaceMaintainabilityShared, 1, { + projectCount: projectScans.length, + }); } const precomputedSourceFileCounts = isMultiProject && !isDiffMode diff --git a/packages/react-doctor/src/cli/index.ts b/packages/react-doctor/src/cli/index.ts index b0414e9ec5..4fb91de709 100644 --- a/packages/react-doctor/src/cli/index.ts +++ b/packages/react-doctor/src/cli/index.ts @@ -108,12 +108,12 @@ ${formatExampleLines([ ${highlighter.dim("Scope:")} Runs every rule tagged ${highlighter.info("design")}; all design rules stay opt-in during a general health scan. - Dead-code, supply-chain, external lint-config, custom-plugin, and health-score passes are skipped. + Whole-project maintainability, supply-chain, external lint-config, custom-plugin, and health-score passes are skipped. Standard scan flags such as ${highlighter.info("--scope")}, ${highlighter.info("--project")}, ${highlighter.info("--verbose")}, and ${highlighter.info("--json")} still work. `; const MAX_DURATION_OPTION_DESCRIPTION = - "scan time budget for the whole run, shared across workspace projects: past it, queued projects, remaining lint batches, and dead-code are skipped and partial results are reported (skipped files and projects are listed in the JSON report)"; + "scan time budget for the whole run, shared across workspace projects: past it, queued projects, remaining lint batches, and maintainability analysis are skipped and partial results are reported (skipped files and projects are listed in the JSON report)"; const renderCiHelpEpilog = (): string => ` ${highlighter.dim("Examples:")} @@ -146,11 +146,8 @@ const program = new Command() .argument("[directory]", "project directory to scan", ".") .option("--lint", "enable linting") .option("--no-lint", "skip linting") - .option("--dead-code", "enable dead-code analysis (default)") - .option( - "--no-dead-code", - "skip dead-code analysis (unused files / exports / dependencies, circular imports)", - ) + .addOption(new Option("--dead-code").hideHelp()) + .addOption(new Option("--no-dead-code").hideHelp()) .option("--supply-chain", "enable the dependency supply-chain scan (default)") .option( "--no-supply-chain", @@ -278,7 +275,7 @@ program .description("Install the react-doctor skill into your coding agents and optional git hook") .option("-y, --yes", "skip prompts, install for all detected agents") .option("--dry-run", "show what would be installed without writing files") - .option("--agent-hooks", "install native non-blocking agent hooks for Claude Code and Cursor") + .option("--agent-hooks", "install end-of-turn agent hooks for Claude Code and Cursor") .option("-c, --cwd <cwd>", "working directory", process.cwd()) .option("--color", "force colored output") .option("--no-color", "disable colored output (also honors NO_COLOR)") @@ -462,19 +459,6 @@ rules return rulesUnignoreTagAction(tag, command.optsWithGlobals()); }); -// NOTE: `react-doctor experimental-lsp` is intentionally NOT wired through -// commander. The bin shim (bin/react-doctor.js) fast-paths it to a dedicated -// server entry so the CLI layer (commander / prompts / ora) never touches -// process.stdin before the LSP stdio transport attaches. This command is -// registered only so `--help` lists it; its body never runs in practice. -// It's gated behind the `experimental-` prefix because the editor language -// server is still unstable (protocol, caching, and diagnostics may change). -program - .command("experimental-lsp", { hidden: false }) - .description("[experimental] run the React Doctor language server over stdio (for editors)") - .allowUnknownOption() - .action(() => {}); - program .command("experimental-tui [directory]", { hidden: true }) .description("[experimental] interactive, scrollable scan report") @@ -484,7 +468,7 @@ program ) .option("--color", "force colored output") .option("--no-color", "disable colored output (also honors NO_COLOR)") - .option("--no-dead-code", "skip dead-code analysis") + .addOption(new Option("--no-dead-code").hideHelp()) .option("--no-supply-chain", "skip the dependency supply-chain scan") .option("--score", "only print the numeric score (for scripts and CI)") .option("--no-score", "skip the score API, the share URL, and crash reporting") diff --git a/packages/react-doctor/src/cli/ink/components/report-landing.tsx b/packages/react-doctor/src/cli/ink/components/report-landing.tsx index 00e668a27c..ca97227824 100644 --- a/packages/react-doctor/src/cli/ink/components/report-landing.tsx +++ b/packages/react-doctor/src/cli/ink/components/report-landing.tsx @@ -1,6 +1,7 @@ import { Box, Text, useInput } from "ink"; import type { ReactNode } from "react"; import { TUI_REPORT_ACTION_MENU_MARGIN_ROWS } from "../../utils/constants.js"; +import { formatSkippedCheckLabel } from "../../utils/format-skipped-check-label.js"; import type { ReportReveal } from "../hooks/use-report-reveal.js"; import { ActionMenu } from "./action-menu.js"; import type { ActionMenuAction } from "./action-menu.js"; @@ -19,6 +20,48 @@ export interface ReportLandingProps { readonly onQuit: () => void; } +interface ReportStatusProps { + readonly issueCount: number; + readonly emptyStateMessage?: string; + readonly lintFailureReason?: string; + readonly skippedChecks?: ReadonlyArray<string>; + readonly incompleteMessage?: string; +} + +const ReportStatus = ({ + issueCount, + emptyStateMessage, + lintFailureReason, + skippedChecks, + incompleteMessage, +}: ReportStatusProps) => { + const skippedCheckLabel = skippedChecks + ?.flatMap((skippedCheck) => + skippedCheck === "lint" && lintFailureReason ? [] : [formatSkippedCheckLabel(skippedCheck)], + ) + .join(" and "); + const hasIncompleteResult = Boolean(incompleteMessage || lintFailureReason || skippedCheckLabel); + if (!hasIncompleteResult && issueCount > 0) return null; + + return ( + <Box flexDirection="column" marginTop={TUI_REPORT_ACTION_MENU_MARGIN_ROWS}> + {incompleteMessage ? <Text color="yellow">⚠ {incompleteMessage}</Text> : null} + {lintFailureReason ? ( + <Text color="yellow">⚠ Lint did not run: {lintFailureReason}</Text> + ) : null} + {skippedCheckLabel ? ( + <Text color="yellow"> + ⚠ {issueCount === 0 ? "No issues detected, but " : ""} + {skippedCheckLabel} checks failed — results are incomplete. + </Text> + ) : null} + {issueCount === 0 && !hasIncompleteResult ? ( + <Text color="green">✔ {emptyStateMessage ?? "No issues found. Nice work."}</Text> + ) : null} + </Box> + ); +}; + export const ReportLanding = ({ header, phase, @@ -33,10 +76,6 @@ export const ReportLanding = ({ onQuit, }: ReportLandingProps) => { const showScore = phase === "actions" || phase === "score"; - const skippedCheckLabel = skippedChecks - ?.filter((skippedCheck) => skippedCheck !== "lint" || !lintFailureReason) - .join(" and "); - const hasIncompleteResult = Boolean(incompleteMessage || lintFailureReason || skippedCheckLabel); useInput( (input) => { if (input === "q") onQuit(); @@ -47,23 +86,13 @@ export const ReportLanding = ({ return ( <Box flexDirection="column"> {showScore ? header : null} - {hasIncompleteResult || issueCount === 0 ? ( - <Box flexDirection="column" marginTop={TUI_REPORT_ACTION_MENU_MARGIN_ROWS}> - {incompleteMessage ? <Text color="yellow">⚠ {incompleteMessage}</Text> : null} - {lintFailureReason ? ( - <Text color="yellow">⚠ Lint did not run: {lintFailureReason}</Text> - ) : null} - {skippedCheckLabel ? ( - <Text color="yellow"> - ⚠ {issueCount === 0 ? "No issues detected, but " : ""} - {skippedCheckLabel} checks failed — results are incomplete. - </Text> - ) : null} - {issueCount === 0 && !hasIncompleteResult ? ( - <Text color="green">✔ {emptyStateMessage ?? "No issues found. Nice work."}</Text> - ) : null} - </Box> - ) : null} + <ReportStatus + issueCount={issueCount} + emptyStateMessage={emptyStateMessage} + lintFailureReason={lintFailureReason} + skippedChecks={skippedChecks} + incompleteMessage={incompleteMessage} + /> {phase === "actions" ? ( <> <ActionMenu diff --git a/packages/react-doctor/src/cli/ink/components/report.tsx b/packages/react-doctor/src/cli/ink/components/report.tsx index c0d20f7fde..290c1f23fa 100644 --- a/packages/react-doctor/src/cli/ink/components/report.tsx +++ b/packages/react-doctor/src/cli/ink/components/report.tsx @@ -218,9 +218,8 @@ export const Report = ({ /> ) : null; - let activeScreenContent: ReactNode; - if (activeReportScreen === "ci") { - activeScreenContent = ( + const screenContent: Record<ReportScreen, ReactNode> = { + ci: ( <CiSetup feedback={ciSetupFeedback} onConfirm={() => { @@ -244,9 +243,8 @@ export const Report = ({ }} onQuit={onQuit} /> - ); - } else if (activeReportScreen === "handoff") { - activeScreenContent = ( + ), + handoff: ( <AgentHandoff agents={launchableAgents} onSelect={completeHandoff} @@ -254,9 +252,8 @@ export const Report = ({ onBack={() => setActiveReportScreen("landing")} onQuit={onQuit} /> - ); - } else if (activeReportScreen === "handoff-ci") { - activeScreenContent = ( + ), + "handoff-ci": ( <HandoffCiRecommendation onAddToCi={() => { onAddToCi?.(); @@ -266,9 +263,8 @@ export const Report = ({ onContinue={() => setActiveReportScreen("handoff")} onQuit={onQuit} /> - ); - } else if (activeReportScreen === "landing") { - activeScreenContent = ( + ), + landing: ( <ReportLanding header={<ScoreHeader variant="landing" {...scoreHeaderProps} />} phase={reportReveal.phase} @@ -282,9 +278,8 @@ export const Report = ({ onSelectionChange={setLandingSelectedIndex} onQuit={onQuit} /> - ); - } else { - activeScreenContent = ( + ), + issues: ( <DiagnosticList header={ reportLayout.showsViewerScoreHeader ? ( @@ -312,13 +307,13 @@ export const Report = ({ }} exitHint={`esc back · ${exitHint}`} /> - ); - } + ), + }; return ( <> {activeReportScreen === "landing" && shouldShowIssueStream ? issueStream : null} - {activeScreenContent} + {screenContent[activeReportScreen]} </> ); }; diff --git a/packages/react-doctor/src/cli/ink/components/score-header.tsx b/packages/react-doctor/src/cli/ink/components/score-header.tsx index 5cbe31e039..28424c54a0 100644 --- a/packages/react-doctor/src/cli/ink/components/score-header.tsx +++ b/packages/react-doctor/src/cli/ink/components/score-header.tsx @@ -5,6 +5,7 @@ import { doctorFace } from "../../utils/doctor-face.js"; import { SCORE_BAR_MIN_WIDTH_CHARS, TUI_HORIZONTAL_PADDING_COLUMNS, + TUI_SCORE_FACE_WIDTH_COLUMNS, TUI_SCORE_FACE_OFFSET_COLUMNS, TUI_SCORE_RIGHT_EDGE_SAFETY_COLUMNS, } from "../../utils/constants.js"; @@ -113,12 +114,17 @@ export const ScoreHeader = ({ const [eyes, mouth] = doctorFace(score.score); return ( - <Box flexDirection="column"> - <Box paddingLeft={TUI_HORIZONTAL_PADDING_COLUMNS}> - <Box flexDirection="column" marginRight={TUI_HORIZONTAL_PADDING_COLUMNS}> + <Box flexDirection="column" width={availableWidth}> + <Box paddingLeft={TUI_HORIZONTAL_PADDING_COLUMNS} width={availableWidth}> + <Box + flexDirection="column" + flexShrink={0} + width={TUI_SCORE_FACE_WIDTH_COLUMNS} + marginRight={TUI_HORIZONTAL_PADDING_COLUMNS} + > <Text color={scoreColor}>{`┌─────┐\n│ ${eyes} │\n│ ${mouth} │\n└─────┘`}</Text> </Box> - <Box flexDirection="column"> + <Box flexDirection="column" width={availableWidth - TUI_SCORE_FACE_OFFSET_COLUMNS}> {scoreSummaryLine} <Text wrap="truncate-end"> <Text color={scoreColor}>{"█".repeat(filledBarWidth)}</Text> diff --git a/packages/react-doctor/src/cli/ink/run-scan-app.tsx b/packages/react-doctor/src/cli/ink/run-scan-app.tsx index f5cd49ff1e..557dc68f7f 100644 --- a/packages/react-doctor/src/cli/ink/run-scan-app.tsx +++ b/packages/react-doctor/src/cli/ink/run-scan-app.tsx @@ -634,7 +634,7 @@ const runMultiProjectScan = async ( isRootDeadCodeEnabled: input.options?.deadCode ?? rootProjectScan?.config?.deadCode ?? true, }); if (workspaceDeadCodeOwner !== null) { - recordCount(METRIC.scanWorkspaceDeadCodeShared, 1, { + recordCount(METRIC.scanWorkspaceMaintainabilityShared, 1, { projectCount: discoveredProjectScans.length, }); } diff --git a/packages/react-doctor/src/cli/utils/build-no-score-message.ts b/packages/react-doctor/src/cli/utils/build-no-score-message.ts index 80248a2229..61f8006e70 100644 --- a/packages/react-doctor/src/cli/utils/build-no-score-message.ts +++ b/packages/react-doctor/src/cli/utils/build-no-score-message.ts @@ -20,7 +20,7 @@ export const buildNoScoreMessage = (input: BuildNoScoreMessageInput): string => reason = input.disabledMessage ?? "Score disabled by --no-score."; break; case "analysis-incomplete": - reason = "Score not shown because lint or dead-code analysis could not complete."; + reason = "Score not shown because lint or maintainability analysis could not complete."; break; case "api-unavailable": reason = "Score unavailable (could not reach the score API)."; diff --git a/packages/react-doctor/src/cli/utils/build-run-context.ts b/packages/react-doctor/src/cli/utils/build-run-context.ts index 58edb94a16..09a2739a7e 100644 --- a/packages/react-doctor/src/cli/utils/build-run-context.ts +++ b/packages/react-doctor/src/cli/utils/build-run-context.ts @@ -54,13 +54,7 @@ export interface RunContext { lintBatchOrdering: "cost" | "arrival"; } -// `experimental-lsp` is here so the language server's telemetry is attributed -// to the language server. The bin shim fast-paths it to a different entry, but -// the run context is shared, and without it every editor metric would be -// labelled `command: "inspect"` / `origin: "cli"` and disagree with the LSP's -// own Sentry scope. -const LSP_COMMAND = "experimental-lsp"; -const ROOT_SUBCOMMANDS = new Set(["design", "install", "setup", LSP_COMMAND]); +const ROOT_SUBCOMMANDS = new Set(["design", "install", "setup"]); // `npm_config_user_agent` looks like "pnpm/9.1.0 npm/? node/v22.0.0 ..."; // the leading token names the package manager that spawned the process. @@ -76,8 +70,7 @@ const detectNodeMajor = (): number => { return Number.isNaN(major) ? 0 : major; }; -const detectOrigin = (userArguments: ReadonlyArray<string>): string => { - if (detectCommand(userArguments) === LSP_COMMAND) return "lsp"; +const detectOrigin = (): string => { if (isGitHookEnvironment()) return "git-hook"; if (isCodingAgentEnvironment()) return "agent"; if (isCiEnvironment()) return "ci"; @@ -106,7 +99,7 @@ export const buildRunContext = (): RunContext => { return { version: VERSION, runId: getRunId(), - origin: detectOrigin(userArguments), + origin: detectOrigin(), command: detectCommand(userArguments), // Scrub home-directory paths so the OS username never rides along in the // argument string or working directory (e.g. a directory positional, or diff --git a/packages/react-doctor/src/cli/utils/build-run-event.ts b/packages/react-doctor/src/cli/utils/build-run-event.ts index eb77e99ee0..650e147a60 100644 --- a/packages/react-doctor/src/cli/utils/build-run-event.ts +++ b/packages/react-doctor/src/cli/utils/build-run-event.ts @@ -1,4 +1,6 @@ import { + buildRuleSeverityControls, + countOptInProjectRuleSelections, filterDiagnosticsForSurface, HTML_FILE_PATTERN, isReactDoctorError, @@ -49,8 +51,7 @@ export interface RunEventInput { readonly maxDurationMs: number | null; readonly lint: boolean; readonly deadCode: boolean; - // Whether the supply-chain scan is enabled by config/flag (the config analog - // of `lint`/`deadCode`) — not whether it ran; diff/staged mode skips it anyway. + // Whether the supply-chain scan is enabled by config or flag. readonly supplyChain: boolean; readonly scoreOnly: boolean; readonly noScore: boolean; @@ -61,7 +62,7 @@ export interface RunEventInput { readonly ignoredTagCount: number; readonly hasCustomConfig: boolean; readonly userConfig: ReactDoctorConfig | null; - // Lint / dead-code outcome — only known on the success path. The failure path + // Lint and maintainability outcomes are only known on the success path. The failure path // (the scan threw) omits these rather than asserting a benign default. readonly didLintFail?: boolean; readonly lintFailureReasonKind?: string | null; @@ -92,12 +93,6 @@ export interface RunEventInput { // a cache hit reports the healthy `false`; omitted (null) on payloads from // before the field and on the failure path. readonly securityScanFailed?: boolean; - /** - * Whether the dead-code pass ran concurrently with lint this scan. Lets a - * query compare `runInspect` wall-clock grouped by overlap, and watch for - * an OOM/timeout regression on overlapped scans. - */ - readonly deadCodeOverlapped?: boolean; // A degraded baseline run (no delta computed) skips the CI gate, so the // `wouldBlock` prediction must match — never block on its plain-diff findings. readonly gateExempt?: boolean; @@ -111,7 +106,7 @@ export interface RunEventInput { readonly suppressedRuleCounts?: ReadonlyArray<SuppressedRuleCount>; /** * `true` only when this run replayed a whole-repo scan-result payload (the - * "turbo" path, where no lint / dead-code / score work ran). The explicit + * "turbo" path, where no lint, maintainability, or score work ran). The explicit * marker for `cache.temperature = "turbo"` — never inferred from the * per-subsystem cache dims being null, which is also what a cache-off run * looks like. Omitted on the failure path. @@ -154,18 +149,6 @@ const ratioOf = ( ): number | null => denominator != null && denominator > 0 ? (numerator ?? 0) / denominator : null; -// The dead-code pass's reuse fraction: a whole-result replay is total reuse; -// a fresh analysis reuses whatever fraction of its file summaries the -// incremental store served; a consulted-but-missed result cache with no -// summary stats is zero reuse. `null` when the pass never consulted a cache. -const resolveDeadCodeReuseRatio = (result: InspectResult): number | null => { - if (result.deadCodeCacheHit === true) return 1; - const summaryTotal = - (result.deadCodeSummaryCacheHits ?? 0) + (result.deadCodeSummaryCacheMisses ?? 0); - if (summaryTotal > 0) return (result.deadCodeSummaryCacheHits ?? 0) / summaryTotal; - return result.deadCodeCacheHit === false ? 0 : null; -}; - /** * One queryable cache temperature per scan, derived from the whole stack: * @@ -173,8 +156,7 @@ const resolveDeadCodeReuseRatio = (result: InspectResult): number | null => { * `wholeRepoCacheHit` flag from the CLI's cachedPayload * branch; no scan work ran). * - `"warm"` — any incremental reuse: per-file lint hits, sidecar - * replays, a dead-code whole-result hit, or dead-code - * summary-cache hits. + * replays. * - `"disabled"` — zero reuse because the global `REACT_DOCTOR_NO_CACHE` * off-switch is on. Granular per-cache opt-outs * (`REACT_DOCTOR_NO_FILE_CACHE`, …) still read warm/cold, @@ -183,7 +165,7 @@ const resolveDeadCodeReuseRatio = (result: InspectResult): number | null => { * * `cache.warmth` is the headline reuse magnitude in [0, 1]: the plain mean of * the subsystem reuse fractions known this run (lint hit ratio, sidecar - * replay ratio, dead-code reuse), skipping subsystems that never consulted a + * replay ratio), skipping subsystems that never consulted a * cache; `1` on turbo, dropped when nothing consulted any cache. Deliberately * unweighted — the per-subsystem dims stay the precise signal; warmth is the * p50/p90-able summary. Emitted only on the success path. @@ -197,7 +179,6 @@ const buildCacheAttributes = (input: RunEventInput): RunEventAttributes => { const subsystemReuseRatios = [ ratioOf(result.lintCacheHitFileCount, result.lintCacheTotalFileCount), ratioOf(result.lintSidecarReplayedFileCount, result.lintSidecarTotalFileCount), - resolveDeadCodeReuseRatio(result), ]; let knownSubsystemCount = 0; let reuseRatioSum = 0; @@ -429,19 +410,8 @@ const buildOutcomeAttributes = (input: RunEventInput): RunEventAttributes => { result.lintSidecarTotalFileCount, ), }), - ...withNamespace("deadCode", { + ...withNamespace("maintainability", { failed: input.didDeadCodeFail, - overlapped: input.deadCodeOverlapped, - // Dead-code result cache outcome; absent when the pass never consulted - // the cache, so "no cache" reads distinctly from a miss. - cacheHit: result.deadCodeCacheHit, - // Incremental summary-cache outcome for the analysis that ran (the - // kill-criterion metric for the fill overhead: if warm scans are rare, - // hits stay near zero). Numeric so Sentry can aggregate; absent when no - // analysis consulted the incremental store (whole-result hit, cache - // off, or dead-code skipped). - summaryCacheHits: result.deadCodeSummaryCacheHits, - summaryCacheMisses: result.deadCodeSummaryCacheMisses, }), ...withNamespace("supplyChain", { overlapTimedOut: input.supplyChainOverlapTimedOut, @@ -507,7 +477,7 @@ const buildScanAttributes = (input: RunEventInput): RunEventAttributes => { workerCount: input.workerCount, maxDurationMs: input.maxDurationMs, lint: input.lint, - deadCode: input.deadCode, + maintainability: input.deadCode, supplyChain: input.supplyChain, scoreOnly: input.scoreOnly, noScore: input.noScore, @@ -518,6 +488,9 @@ const buildScanAttributes = (input: RunEventInput): RunEventAttributes => { hasCustomConfig: input.hasCustomConfig, rulesConfigured: ruleKeys.length, rulesDisabled: ruleKeys.filter((key) => ruleOverrides[key] === "off").length, + projectAnalysisRuleCount: countOptInProjectRuleSelections( + buildRuleSeverityControls(input.userConfig), + ), // Scan extent — how many files this run covered (the denominator for // `diag.affectedFiles`). Known only on the success path. fileCount: input.result?.scannedFileCount, @@ -537,7 +510,7 @@ const buildScanAttributes = (input: RunEventInput): RunEventAttributes => { * Projects a scan into the namespaced attribute set for its root span — the * canonical per-scan "wide event". Every attribute carries a dotted namespace * that groups it by concept (`scan.*` config, `action.*` CI knobs, `outcome.*` - * verdict, `diag.*` findings, `score.*`, `lint.*`, `deadCode.*`, `cache.*`, + * verdict, `diag.*` findings, `score.*`, `lint.*`, `maintainability.*`, `cache.*`, * `supplyChain.*`, `timing.*`, `migration.*`, `baseline.*`) so the attributes * tree up in Sentry's attribute browser and stay filter-/group-/aggregate-able * in the Spans dataset. Pure and exported so the projection (outcome diff --git a/packages/react-doctor/src/cli/utils/build-runtime-layers.ts b/packages/react-doctor/src/cli/utils/build-runtime-layers.ts index aa6e436e6a..74a149f61c 100644 --- a/packages/react-doctor/src/cli/utils/build-runtime-layers.ts +++ b/packages/react-doctor/src/cli/utils/build-runtime-layers.ts @@ -2,17 +2,18 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { Config, - DeadCode, Files, Git, Linter, LintPartialFailures, + Maintainability, OxlintConcurrency, OxlintSpawnSlots, Progress, Project, Reporter, Score, + shouldUseMaintainabilityLayer, SupplyChain, } from "@react-doctor/core"; import type { @@ -57,7 +58,7 @@ export interface BuildRuntimeLayersInput { */ readonly shouldComputeScore: boolean; /** - * Whether the lint + dead-code spinners should render on stderr. + * Whether the lint and maintainability spinners should render on stderr. * Set `false` for `--score-only`, `--silent`, or runs that skip * lint entirely — the orchestrator's `Progress` lifecycle becomes * a noop instead of emitting frames into a quiet stream. @@ -119,7 +120,12 @@ const buildSpinnerProgressHandle = (text: string): ProgressHandle => { */ export const buildRuntimeLayers = (input: BuildRuntimeLayersInput) => { const linterLayer = input.shouldSkipLint ? Linter.layerOf([]) : Linter.layerOxlint; - const deadCodeLayer = input.shouldRunDeadCode ? DeadCode.layerNode : DeadCode.layerOf([]); + const maintainabilityLayer = shouldUseMaintainabilityLayer({ + shouldRunDuplicateJsx: input.shouldRunDeadCode, + userConfig: input.userConfig, + }) + ? Maintainability.layerNode + : Maintainability.layerOf([]); const scoreLayer = input.shouldComputeScore ? Score.layerHttp : Score.layerOf(null); // Socket.dev supply-chain score gate runs by default (the keyless HTTP // layer); a no-op empty layer when the user opts out via @@ -157,7 +163,7 @@ export const buildRuntimeLayers = (input: BuildRuntimeLayersInput) => { Git.layerNode, linterLayer, LintPartialFailures.layerLive, - deadCodeLayer, + maintainabilityLayer, progressLayer, reporterLayer, scoreLayer, diff --git a/packages/react-doctor/src/cli/utils/build-sentry-project-context.ts b/packages/react-doctor/src/cli/utils/build-sentry-project-context.ts index 8b0d415d16..802077c294 100644 --- a/packages/react-doctor/src/cli/utils/build-sentry-project-context.ts +++ b/packages/react-doctor/src/cli/utils/build-sentry-project-context.ts @@ -1,3 +1,4 @@ +import { buildCapabilities } from "@react-doctor/core"; import type { ProjectInfo } from "@react-doctor/core"; export interface SentryProjectContext { @@ -17,33 +18,42 @@ export interface SentryProjectContext { * omits `projectName` and `rootDirectory`, the two identifying fields, so the * project can't be tied back to a specific company/repo. */ -export const buildSentryProjectContext = (projectInfo: ProjectInfo): SentryProjectContext => ({ - tags: { - "project.framework": projectInfo.framework, - "project.reactMajor": projectInfo.reactMajorVersion, - "project.typescript": projectInfo.hasTypeScript, - "project.reactCompiler": projectInfo.hasReactCompiler, - "project.expo": projectInfo.expoVersion !== null, - "project.reactNative": projectInfo.hasReactNativeWorkspace, - }, - context: { - framework: projectInfo.framework, - reactVersion: projectInfo.reactVersion, - reactMajorVersion: projectInfo.reactMajorVersion, - hasTypeScript: projectInfo.hasTypeScript, - hasReactCompiler: projectInfo.hasReactCompiler, - tanstackQueryVersion: projectInfo.tanstackQueryVersion, - mobxVersion: projectInfo.mobxVersion, - styledComponentsVersion: projectInfo.styledComponentsVersion, - tailwindVersion: projectInfo.tailwindVersion, - zodVersion: projectInfo.zodVersion, - preactVersion: projectInfo.preactVersion, - hasReactNativeWorkspace: projectInfo.hasReactNativeWorkspace, - expoVersion: projectInfo.expoVersion, - hasReanimated: projectInfo.hasReanimated, - sourceFileCount: projectInfo.sourceFileCount, - }, -}); +export const buildSentryProjectContext = (projectInfo: ProjectInfo): SentryProjectContext => { + const capabilities = buildCapabilities(projectInfo); + const supportedRuntimes: string[] = []; + if (capabilities.has("react")) supportedRuntimes.push("react"); + if (capabilities.has("three")) supportedRuntimes.push("three"); + if (capabilities.has("remotion")) supportedRuntimes.push("remotion"); + + return { + tags: { + "project.framework": projectInfo.framework, + "project.runtime": supportedRuntimes.join("+") || "unknown", + "project.reactMajor": projectInfo.reactMajorVersion, + "project.typescript": projectInfo.hasTypeScript, + "project.reactCompiler": projectInfo.hasReactCompiler, + "project.expo": projectInfo.expoVersion !== null, + "project.reactNative": projectInfo.hasReactNativeWorkspace, + }, + context: { + framework: projectInfo.framework, + reactVersion: projectInfo.reactVersion, + reactMajorVersion: projectInfo.reactMajorVersion, + hasTypeScript: projectInfo.hasTypeScript, + hasReactCompiler: projectInfo.hasReactCompiler, + tanstackQueryVersion: projectInfo.tanstackQueryVersion, + mobxVersion: projectInfo.mobxVersion, + styledComponentsVersion: projectInfo.styledComponentsVersion, + tailwindVersion: projectInfo.tailwindVersion, + zodVersion: projectInfo.zodVersion, + preactVersion: projectInfo.preactVersion, + hasReactNativeWorkspace: projectInfo.hasReactNativeWorkspace, + expoVersion: projectInfo.expoVersion, + hasReanimated: projectInfo.hasReanimated, + sourceFileCount: projectInfo.sourceFileCount, + }, + }; +}; // The project being scanned in the current run, captured as soon as it's // discovered (the `beforeLint` hook). Held at module scope so the lazy, diff --git a/packages/react-doctor/src/cli/utils/cli-migrations.ts b/packages/react-doctor/src/cli/utils/cli-migrations.ts index 0e924f3935..eec95f3b0e 100644 --- a/packages/react-doctor/src/cli/utils/cli-migrations.ts +++ b/packages/react-doctor/src/cli/utils/cli-migrations.ts @@ -3,7 +3,7 @@ import { cliLogger as logger } from "./cli-logger.js"; import { type CliStateOptions } from "./cli-state-store.js"; import { type Migration, type MigrationResult, runMigrations } from "./cli-lifecycle.js"; import { - findAgentsWithLegacyShellHooks, + findAgentsWithOutdatedReactDoctorHooks, installReactDoctorAgentHooks, } from "./install-agent-hooks.js"; import { migrateActionPin } from "./migrate-action-pin.js"; @@ -66,28 +66,22 @@ const actionPinMainToMajor: Migration = { }, }; -// Replaces the ≤0.5.8 `react-doctor.sh` shell agent hooks with the current -// Node hook by re-running the installer for exactly the agents that still -// carry a legacy entry (the installer strips the legacy entry, writes the -// `.mjs` hook, and deletes the orphaned script). A re-install migrates in -// place already; this covers everyone who never re-runs -// `install --agent-hooks`. With no legacy hooks it's a no-op that returns -// `false` (stays pending, so a legacy hook restored from an old branch later -// is still migrated). -const agentHooksShellToNode: Migration = { +// Re-runs the installer for managed hooks that predate end-of-turn scanning. +// Version 2 also moves previously installed Node hooks from per-tool events to +// Stop events; version 1 only replaced the ≤0.5.8 shell scripts. +const agentHooksToStop: Migration = { id: "agent-hooks-sh-to-mjs", + version: 2, scope: "project", run: ({ projectRoot }) => { if (projectRoot === undefined) return false; - const agents = findAgentsWithLegacyShellHooks(projectRoot); + const agents = findAgentsWithOutdatedReactDoctorHooks(projectRoot); if (agents.length === 0) return false; installReactDoctorAgentHooks({ projectRoot, agents }); - logger.success( - `Upgraded the legacy react-doctor.sh agent hook to the Node hook (${agents.join(", ")})`, - ); + logger.success(`Moved React Doctor agent hooks to end-of-turn checks (${agents.join(", ")})`); logger.dim( - " The shell hook can't run on Windows and would double-scan next to the current hook. Review and commit the change.", + " Hooks now scan changed and untracked files once when the agent stops. Review and commit the change.", ); logger.break(); return true; @@ -97,7 +91,7 @@ const agentHooksShellToNode: Migration = { const PROJECT_MIGRATIONS: ReadonlyArray<Migration> = [ legacyConfigToTypescript, actionPinMainToMajor, - agentHooksShellToNode, + agentHooksToStop, ]; // Runs every pending per-repo migration for `projectRoot` once, recording the diff --git a/packages/react-doctor/src/cli/utils/constants.ts b/packages/react-doctor/src/cli/utils/constants.ts index fb7b9c1865..9f4f22e57a 100644 --- a/packages/react-doctor/src/cli/utils/constants.ts +++ b/packages/react-doctor/src/cli/utils/constants.ts @@ -58,6 +58,7 @@ export const STAGED_SNAPSHOT_ADDITIONAL_CONFIG_FILENAMES = [ "vitest.config.ts", ] as const; export const BASELINE_FILES_TEMP_DIR_PREFIX = "react-doctor-baseline-"; +export const BASELINE_SOURCE_COPY_CONCURRENCY = 32; // Bump on any breaking change to `CachedScanPayload`'s shape or diagnostic // semantics so stale on-disk results are discarded wholesale. // Bumped to 2: `CachedScanPayload` gained the required `supplyChainOverlapTimedOut` @@ -67,7 +68,8 @@ export const BASELINE_FILES_TEMP_DIR_PREFIX = "react-doctor-baseline-"; // `lookup` verifies — pre-bump entries without it would never hit again. // Bumped to 6: declaration-file parser diagnostic compatibility filtering // changed the cached diagnostic set. -export const SCAN_RESULT_CACHE_SCHEMA_VERSION = 6; +// Bumped to 7: maintainability diagnostics replace the removed dead-code pass. +export const SCAN_RESULT_CACHE_SCHEMA_VERSION = 7; export const SCAN_RESULT_CACHE_MAX_ENTRY_COUNT = 20; export const SCAN_RESULT_CACHE_FILENAME = "scan-cache.json"; // The dirty-worktree cache-key fingerprint content-hashes every path `git @@ -91,6 +93,7 @@ export const RUN_GIT_MAX_BUFFER_BYTES = 64 * 1024 * 1024; export const GIT_HOOK_EXECUTABLE_MODE = 0o755; export const AGENT_HOOK_TIMEOUT_SECONDS = 120; +export const AGENT_HOOK_MAX_CONTINUATIONS = 1; // Hard cap on the `gh repo view` default-branch probe. A healthy gh answers // well under a second; a cold gh.exe on Windows CI has taken 30s+, and the @@ -149,6 +152,7 @@ export const TUI_REPORT_COLUMN_GUTTER_COLUMNS = 3; export const TUI_REPORT_MIN_COLUMN_WIDTH_CHARS = 20; export const TUI_REPORT_SPLIT_MARGIN_COLUMNS = 1; export const TUI_REPORT_SPLIT_PADDING_COLUMNS = 1; +export const TUI_SCORE_FACE_WIDTH_COLUMNS = 7; export const TUI_SCORE_FACE_OFFSET_COLUMNS = 11; export const TUI_SCORE_RIGHT_EDGE_SAFETY_COLUMNS = 2; export const TUI_HALF_PAGE_DIVISOR = 2; @@ -214,12 +218,6 @@ export const AXIOM_INGEST_TOKEN = "xaat-31b59107-855d-4917-8fab-6dc29fb459ce"; // Effect span clocks are epoch nanoseconds; `Date.now()` is milliseconds. export const NANOSECONDS_PER_MILLISECOND = 1_000_000n; -// The language server runs for the length of an editor session, so it exports -// on a timer rather than relying on the shutdown flush the one-shot CLI uses — -// an editor that kills the server, or a machine that sleeps, would otherwise -// lose everything recorded since startup. -export const LSP_TELEMETRY_EXPORT_INTERVAL_MS = 60_000; - export const AXIOM_TRACES_DATASET = "react-doctor"; export const AXIOM_METRICS_DATASET = "react-doctor-metrics"; @@ -280,9 +278,9 @@ export const METRIC = { // Kill metric for queued-project deadline reporting. If this never fires, // the additive JSON/TUI skipped-project surface is not carrying user value. scanProjectSkipped: "scan.project_skipped", - // Kill metric for workspace-owned dead-code analysis. If this never fires, + // Kill metric for workspace-owned maintainability analysis. If this never fires, // multi-project scans do not include their root and cannot share the pass. - scanWorkspaceDeadCodeShared: "scan.workspace_deadcode_shared", + scanWorkspaceMaintainabilityShared: "scan.workspace_maintainability_shared", // One count per completed scan where no project resolved a supported // framework or library capability. The kill metric for the // vacuous-clean-scan signal: if it never fires, the warning surface can go. @@ -297,7 +295,7 @@ export const METRIC = { ruleDisabled: "rule.disabled", ruleSuppressed: "rule.suppressed", lintFailed: "lint.failed", - deadCodeFailed: "deadcode.failed", + maintainabilityFailed: "maintainability.failed", scoreUnavailable: "score.unavailable", oxlintWorkers: "oxlint.workers", agentHandoff: "agent.handoff", @@ -317,12 +315,6 @@ export const METRIC = { ciConfigured: "ci.configured", rulesChanged: "rules.changed", rulesQueried: "rules.queried", - // Editor language server (`react-doctor experimental-lsp`). Each workspace - // scan burst is one wide-event span (op `lsp.scan`) plus these metrics. - lspSessionStarted: "lsp.session.started", - lspScanCompleted: "lsp.scan.completed", - lspScanDuration: "lsp.scan.duration", - lspScanDiagnostics: "lsp.scan.diagnostics", tuiCompactReportShown: "tui.compact_report_shown", tuiFindingNavigated: "tui.finding_navigated", tuiIssueStreamShown: "tui.issue_stream_shown", diff --git a/packages/react-doctor/src/cli/utils/copy-unchanged-baseline-sources.ts b/packages/react-doctor/src/cli/utils/copy-unchanged-baseline-sources.ts new file mode 100644 index 0000000000..c338175e1a --- /dev/null +++ b/packages/react-doctor/src/cli/utils/copy-unchanged-baseline-sources.ts @@ -0,0 +1,61 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { isPathInsideDirectory, mapWithConcurrency } from "@react-doctor/core"; +import { BASELINE_SOURCE_COPY_CONCURRENCY } from "./constants.js"; +import { toForwardSlashes } from "./path-format.js"; + +export interface CopyUnchangedBaselineSourcesInput { + readonly directory: string; + readonly sourceFiles: ReadonlyArray<string>; + readonly baseMaterializedFiles: ReadonlyArray<string>; + readonly headChangedFiles: ReadonlyArray<string>; + readonly untrackedFiles: ReadonlyArray<string>; + readonly tempDirectory: string; + readonly deadlineEpochMs: number | null; + readonly signal?: AbortSignal; +} + +export const copyUnchangedBaselineSources = async ( + input: CopyUnchangedBaselineSourcesInput, +): Promise<boolean> => { + const baseMaterializedFiles = new Set(input.baseMaterializedFiles.map(toForwardSlashes)); + const headChangedFiles = new Set(input.headChangedFiles.map(toForwardSlashes)); + const untrackedFiles = new Set(input.untrackedFiles.map(toForwardSlashes)); + const sourceDirectory = path.resolve(input.directory); + const targetDirectory = path.resolve(input.tempDirectory); + const unchangedSourceFiles = input.sourceFiles.filter((filePath) => { + const normalizedPath = toForwardSlashes(filePath); + return ( + !baseMaterializedFiles.has(normalizedPath) && + !headChangedFiles.has(normalizedPath) && + !untrackedFiles.has(normalizedPath) + ); + }); + const copiedFiles = await mapWithConcurrency( + unchangedSourceFiles, + BASELINE_SOURCE_COPY_CONCURRENCY, + async (filePath): Promise<boolean> => { + input.signal?.throwIfAborted(); + if (input.deadlineEpochMs !== null && Date.now() >= input.deadlineEpochMs) return false; + const sourcePath = path.resolve(sourceDirectory, filePath); + const targetPath = path.resolve(targetDirectory, filePath); + if ( + !isPathInsideDirectory(sourcePath, sourceDirectory) || + !isPathInsideDirectory(targetPath, targetDirectory) + ) { + return false; + } + try { + const sourceStats = await fs.lstat(sourcePath); + if (!sourceStats.isFile()) return false; + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.copyFile(sourcePath, targetPath); + return true; + } catch { + input.signal?.throwIfAborted(); + return false; + } + }, + ); + return copiedFiles.every(Boolean); +}; diff --git a/packages/react-doctor/src/cli/utils/diagnostic-grouping.ts b/packages/react-doctor/src/cli/utils/diagnostic-grouping.ts index c35f18a2df..02d214fd79 100644 --- a/packages/react-doctor/src/cli/utils/diagnostic-grouping.ts +++ b/packages/react-doctor/src/cli/utils/diagnostic-grouping.ts @@ -95,7 +95,7 @@ export const getSharedFixSiteCount = (diagnostics: ReadonlyArray<Diagnostic>): n const FIX_RECIPE_DIRECTIVE_LABEL = "Curl with no cache & follow the canonical fix and false positive check recipe before fixing"; -// `null` when the rule has no published recipe (dead-code, environment +// `null` when the rule has no published recipe (maintainability, environment // checks, adopted plugins) so callers omit the directive instead of // linking to a 404. export const formatFixRecipeLine = (diagnostic: Diagnostic): string | null => diff --git a/packages/react-doctor/src/cli/utils/find-owning-project.ts b/packages/react-doctor/src/cli/utils/find-owning-project.ts index 186c9e8a3b..3d5aa518aa 100644 --- a/packages/react-doctor/src/cli/utils/find-owning-project.ts +++ b/packages/react-doctor/src/cli/utils/find-owning-project.ts @@ -1,12 +1,12 @@ import * as path from "node:path"; -import { discoverReactSubprojects, listWorkspacePackages } from "@react-doctor/core"; +import { discoverSupportedSubprojects, listWorkspacePackages } from "@react-doctor/core"; export const findOwningProjectDirectory = (rootDirectory: string, filePath: string): string => { const absoluteFile = path.isAbsolute(filePath) ? filePath : path.resolve(rootDirectory, filePath); const workspacePackages = listWorkspacePackages(rootDirectory); const candidates = - workspacePackages.length > 0 ? workspacePackages : discoverReactSubprojects(rootDirectory); + workspacePackages.length > 0 ? workspacePackages : discoverSupportedSubprojects(rootDirectory); if (candidates.length === 0) return rootDirectory; let bestMatch: { directory: string; depth: number } | null = null; diff --git a/packages/react-doctor/src/cli/utils/format-skipped-check-label.ts b/packages/react-doctor/src/cli/utils/format-skipped-check-label.ts new file mode 100644 index 0000000000..06bd1ad7bc --- /dev/null +++ b/packages/react-doctor/src/cli/utils/format-skipped-check-label.ts @@ -0,0 +1,2 @@ +export const formatSkippedCheckLabel = (skippedCheck: string): string => + skippedCheck === "dead-code" ? "maintainability" : skippedCheck; diff --git a/packages/react-doctor/src/cli/utils/inspect-flags.ts b/packages/react-doctor/src/cli/utils/inspect-flags.ts index 47f9d8d7ab..10f7bb61d1 100644 --- a/packages/react-doctor/src/cli/utils/inspect-flags.ts +++ b/packages/react-doctor/src/cli/utils/inspect-flags.ts @@ -5,6 +5,7 @@ export interface InspectFlags { design?: boolean; lint?: boolean; + /** @deprecated Compatibility shim for the removed dead-code analyzer. */ deadCode?: boolean; // Resolved against `supplyChain.enabled` (this flag wins), like lint/deadCode. supplyChain?: boolean; diff --git a/packages/react-doctor/src/cli/utils/install-agent-hooks.ts b/packages/react-doctor/src/cli/utils/install-agent-hooks.ts index 1d4b09bf3d..ffda7565eb 100644 --- a/packages/react-doctor/src/cli/utils/install-agent-hooks.ts +++ b/packages/react-doctor/src/cli/utils/install-agent-hooks.ts @@ -1,7 +1,7 @@ import * as path from "node:path"; import type { SkillAgentType } from "agent-install"; import { isErrnoException } from "@react-doctor/core"; -import { AGENT_HOOK_TIMEOUT_SECONDS } from "./constants.js"; +import { AGENT_HOOK_MAX_CONTINUATIONS, AGENT_HOOK_TIMEOUT_SECONDS } from "./constants.js"; import * as fs from "node:fs"; import { CliInputError } from "./cli-input-error.js"; import { writeJsonFile } from "./git-hook-shared.js"; @@ -51,7 +51,6 @@ const CLAUDE_HOOK_COMMAND = 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/react-docto const CURSOR_HOOKS_RELATIVE_PATH = ".cursor/hooks.json"; const CURSOR_HOOK_RELATIVE_PATH = ".cursor/hooks/react-doctor.mjs"; const CURSOR_HOOK_COMMAND = "node .cursor/hooks/react-doctor.mjs"; -const CURSOR_HOOK_MATCHER = "Write|Edit|MultiEdit|ApplyPatch"; const CURSOR_HOOKS_SCHEMA_VERSION = 1; // Releases up to 0.5.8 installed a `react-doctor.sh` shell hook; re-installs // must replace those entries (and the orphaned script) instead of stacking a @@ -69,6 +68,9 @@ const isLegacyHookCommand = (command: string | undefined): boolean => typeof command === "string" && LEGACY_HOOK_SCRIPT_PATHS.some((legacyPath) => command.includes(legacyPath)); +const isManagedHookCommand = (command: string | undefined, installedCommand: string): boolean => + command === installedCommand || isLegacyHookCommand(command); + const isSupportedAgent = (agent: SkillAgentType): boolean => agent === CLAUDE_AGENT || agent === CURSOR_AGENT; @@ -98,29 +100,34 @@ const readJsonFileSafely = <Value>(filePath: string, fallback: Value): Value => } }; -// Detection half of the `agent-hooks-sh-to-mjs` migration (cli-migrations.ts): -// which supported agents still have a ≤0.5.8 shell hook registered. Checks -// exactly the event keys the installers strip (Claude `PostToolBatch`, Cursor -// `postToolUse`) so one install pass always clears the detection. -export const findAgentsWithLegacyShellHooks = (projectRoot: string): SkillAgentType[] => { +// Detection half of the agent-hook migration (cli-migrations.ts). It finds +// managed hooks that still run after tools, plus ≤0.5.8 shell hooks registered +// on either the old or current events, so one install pass clears the probe. +export const findAgentsWithOutdatedReactDoctorHooks = (projectRoot: string): SkillAgentType[] => { const agents: SkillAgentType[] = []; const settings = readJsonFileSafely<ClaudeSettings>( path.join(projectRoot, CLAUDE_SETTINGS_RELATIVE_PATH), {}, ); - const hasLegacyClaudeHook = (settings.hooks?.PostToolBatch ?? []).some((group) => - (group.hooks ?? []).some((hook) => isLegacyHookCommand(hook.command)), - ); - if (hasLegacyClaudeHook) agents.push(CLAUDE_AGENT); + const hasOutdatedClaudeHook = + [...(settings.hooks?.PostToolBatch ?? []), ...(settings.hooks?.Stop ?? [])].some((group) => + (group.hooks ?? []).some((hook) => isLegacyHookCommand(hook.command)), + ) || + (settings.hooks?.PostToolBatch ?? []).some((group) => + (group.hooks ?? []).some((hook) => hook.command === CLAUDE_HOOK_COMMAND), + ); + if (hasOutdatedClaudeHook) agents.push(CLAUDE_AGENT); const config = readJsonFileSafely<CursorHooksConfig>( path.join(projectRoot, CURSOR_HOOKS_RELATIVE_PATH), {}, ); - const hasLegacyCursorHook = (config.hooks?.postToolUse ?? []).some((handler) => - isLegacyHookCommand(handler.command), - ); - if (hasLegacyCursorHook) agents.push(CURSOR_AGENT); + const hasOutdatedCursorHook = + [...(config.hooks?.postToolUse ?? []), ...(config.hooks?.stop ?? [])].some((handler) => + isLegacyHookCommand(handler.command), + ) || + (config.hooks?.postToolUse ?? []).some((handler) => handler.command === CURSOR_HOOK_COMMAND); + if (hasOutdatedCursorHook) agents.push(CURSOR_AGENT); return agents; }; @@ -153,9 +160,9 @@ const writeJsonFileWithDirectoryCheck = (filePath: string, value: unknown): void writeJsonFile(filePath, value); }; -const writeHookScript = (filePath: string): void => { +const writeHookScript = (filePath: string, hookEventName: "Stop" | "stop"): void => { ensureDirectoryExists(path.dirname(filePath)); - fs.writeFileSync(filePath, buildAgentHookScript()); + fs.writeFileSync(filePath, buildAgentHookScript(hookEventName)); // Remove the orphaned ≤0.5.8 `.sh` sibling so a re-install doesn't leave it // behind. Best-effort: a stale script that can't be deleted only wastes disk. try { @@ -163,74 +170,70 @@ const writeHookScript = (filePath: string): void => { } catch {} }; -const hasClaudeHookCommand = (groups: readonly ClaudeHookGroup[]): boolean => - groups.some((group) => (group.hooks ?? []).some((hook) => hook.command === CLAUDE_HOOK_COMMAND)); - const installClaudeHook = (projectRoot: string): readonly string[] => { const settingsPath = path.join(projectRoot, CLAUDE_SETTINGS_RELATIVE_PATH); const hookPath = path.join(projectRoot, CLAUDE_HOOK_RELATIVE_PATH); const settings = readJsonFile<ClaudeSettings>(settingsPath, {}); const hooks = { ...(settings.hooks ?? {}) }; - // Strip legacy entries, dropping a group only when that strip emptied it. + // Strip managed entries, dropping a group only when that strip emptied it. // Groups react-doctor never touched (including empty or hook-less ones) pass // through verbatim — the installer must not rewrite settings it doesn't own. - const postToolBatchHooks = (hooks.PostToolBatch ?? []).flatMap((group) => { - const groupHooks = group.hooks ?? []; - const keptHooks = groupHooks.filter((hook) => !isLegacyHookCommand(hook.command)); - if (keptHooks.length === groupHooks.length) return [group]; - return keptHooks.length > 0 ? [{ ...group, hooks: keptHooks }] : []; - }); - - if (!hasClaudeHookCommand(postToolBatchHooks)) { - postToolBatchHooks.push({ - hooks: [ - { - type: "command", - command: CLAUDE_HOOK_COMMAND, - }, - ], + const stripManagedHooks = (groups: readonly ClaudeHookGroup[]): ClaudeHookGroup[] => + groups.flatMap((group) => { + const groupHooks = group.hooks ?? []; + const keptHooks = groupHooks.filter( + (hook) => !isManagedHookCommand(hook.command, CLAUDE_HOOK_COMMAND), + ); + if (keptHooks.length === groupHooks.length) return [group]; + return keptHooks.length > 0 ? [{ ...group, hooks: keptHooks }] : []; }); - } - hooks.PostToolBatch = postToolBatchHooks; + const stopHooks = stripManagedHooks(hooks.Stop ?? []); + stopHooks.push({ + hooks: [ + { + type: "command", + command: CLAUDE_HOOK_COMMAND, + }, + ], + }); + + hooks.PostToolBatch = stripManagedHooks(hooks.PostToolBatch ?? []); + hooks.Stop = stopHooks; writeJsonFileWithDirectoryCheck(settingsPath, { ...settings, hooks }); - writeHookScript(hookPath); + writeHookScript(hookPath, "Stop"); return [settingsPath, hookPath]; }; -const hasCursorHookCommand = (handlers: readonly CursorHookHandler[]): boolean => - handlers.some((handler) => handler.command === CURSOR_HOOK_COMMAND); - const installCursorHook = (projectRoot: string): readonly string[] => { const configPath = path.join(projectRoot, CURSOR_HOOKS_RELATIVE_PATH); const hookPath = path.join(projectRoot, CURSOR_HOOK_RELATIVE_PATH); const config = readJsonFile<CursorHooksConfig>(configPath, {}); const hooks = { ...(config.hooks ?? {}) }; - const postToolUseHooks = (hooks.postToolUse ?? []).filter( - (handler) => !isLegacyHookCommand(handler.command), + const stopHooks = (hooks.stop ?? []).filter( + (handler) => !isManagedHookCommand(handler.command, CURSOR_HOOK_COMMAND), ); + stopHooks.push({ + command: CURSOR_HOOK_COMMAND, + timeout: AGENT_HOOK_TIMEOUT_SECONDS, + }); - if (!hasCursorHookCommand(postToolUseHooks)) { - postToolUseHooks.push({ - command: CURSOR_HOOK_COMMAND, - matcher: CURSOR_HOOK_MATCHER, - timeout: AGENT_HOOK_TIMEOUT_SECONDS, - }); - } - - hooks.postToolUse = postToolUseHooks; + hooks.postToolUse = (hooks.postToolUse ?? []).filter( + (handler) => !isManagedHookCommand(handler.command, CURSOR_HOOK_COMMAND), + ); + hooks.stop = stopHooks; writeJsonFileWithDirectoryCheck(configPath, { ...config, version: config.version ?? CURSOR_HOOKS_SCHEMA_VERSION, hooks, }); - writeHookScript(hookPath); + writeHookScript(hookPath, "stop"); return [configPath, hookPath]; }; -const buildAgentHookScript = (): string => +const buildAgentHookScript = (hookEventName: "Stop" | "stop"): string => [ "import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';", "import { tmpdir } from 'node:os';", @@ -240,12 +243,12 @@ const buildAgentHookScript = (): string => "", "const __filename = fileURLToPath(import.meta.url);", "const __dirname = dirname(__filename);", + `const HOOK_EVENT_NAME = ${JSON.stringify(hookEventName)};`, + `const MAX_CONTINUATIONS = ${AGENT_HOOK_MAX_CONTINUATIONS};`, "", "// --verbose scans on large diffs can exceed spawnSync's 1 MiB default.", "const SPAWN_MAX_BUFFER_BYTES = 16 * 1024 * 1024;", "", - "const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'ApplyPatch']);", - "", "const readFileOrEmpty = (source) => {", " try {", " return readFileSync(source, 'utf8');", @@ -255,13 +258,10 @@ const buildAgentHookScript = (): string => "};", "", "const shouldScan = (input) => {", - " const eventName = input.hook_event_name || input.eventName || input.event_name;", - " if (eventName === 'PostToolBatch') {", - " const toolCalls = Array.isArray(input.tool_calls) ? input.tool_calls : [];", - " return toolCalls.some((toolCall) => EDIT_TOOL_NAMES.has(toolCall.tool_name));", - " }", - " const toolName = input.tool_name || input.toolName || input.tool;", - " return !toolName || EDIT_TOOL_NAMES.has(toolName);", + " if (HOOK_EVENT_NAME === 'Stop') return !input.stop_hook_active;", + " if (input.status && input.status !== 'completed') return false;", + " const loopCount = Number(input.loop_count);", + " return !Number.isFinite(loopCount) || loopCount < MAX_CONTINUATIONS;", "};", "", "const runReactDoctor = (outputPath) => {", @@ -277,11 +277,11 @@ const buildAgentHookScript = (): string => " : './node_modules/.bin/react-doctor';", " const commands = [", " ...(existsSync(localBin)", - " ? [localBin + ' --verbose --scope changed --blocking warning --no-score']", + " ? [localBin + ' --verbose --scope changed --include-untracked --blocking warning --no-score']", " : []),", - " 'react-doctor --verbose --scope changed --blocking warning --no-score',", - " 'pnpm dlx react-doctor@latest --verbose --scope changed --blocking warning --no-score',", - " 'npx --yes react-doctor@latest --verbose --scope changed --blocking warning --no-score',", + " 'react-doctor --verbose --scope changed --include-untracked --blocking warning --no-score',", + " 'pnpm dlx react-doctor@latest --verbose --scope changed --include-untracked --blocking warning --no-score',", + " 'npx --yes react-doctor@latest --verbose --scope changed --include-untracked --blocking warning --no-score',", " ];", "", " for (const command of commands) {", @@ -340,10 +340,10 @@ const buildAgentHookScript = (): string => "", " const message = `React Doctor found issues in the changed files. Review this output and fix the regressions before finishing. For confirmed issues that cannot be fixed now, create GitHub issues with the rule, file/line, confidence, impact, and proposed fix.\\n\\n${scanOutput}`;", "", - " if (input.hook_event_name === 'PostToolBatch') {", - " console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolBatch', additionalContext: message } }));", + " if (HOOK_EVENT_NAME === 'Stop') {", + " console.log(JSON.stringify({ decision: 'block', reason: message }));", " } else {", - " console.log(JSON.stringify({ additional_context: message }));", + " console.log(JSON.stringify({ followup_message: message }));", " }", "};", "", diff --git a/packages/react-doctor/src/cli/utils/install-react-doctor.ts b/packages/react-doctor/src/cli/utils/install-react-doctor.ts index 5077285704..c77e166f01 100644 --- a/packages/react-doctor/src/cli/utils/install-react-doctor.ts +++ b/packages/react-doctor/src/cli/utils/install-react-doctor.ts @@ -431,6 +431,7 @@ const installReactDoctorAgentHooksStep = ( ); recordCount(METRIC.installAgentHooks, 1, { agentsCount: hookResult.installedAgents.length, + hookEvent: "stop", }); } } catch (error) { diff --git a/packages/react-doctor/src/cli/utils/print-headless-report.ts b/packages/react-doctor/src/cli/utils/print-headless-report.ts index 683ebfac36..78cf794aa3 100644 --- a/packages/react-doctor/src/cli/utils/print-headless-report.ts +++ b/packages/react-doctor/src/cli/utils/print-headless-report.ts @@ -5,6 +5,7 @@ import type { Diagnostic, ScoreResult } from "@react-doctor/core"; import { buildSortedRuleGroups } from "./diagnostic-grouping.js"; import { formatDiagnosticSite } from "./format-diagnostic-site.js"; import { formatElapsedTime } from "./format-elapsed-time.js"; +import { formatSkippedCheckLabel } from "./format-skipped-check-label.js"; import { pluralize } from "./pluralize.js"; interface PrintHeadlessReportInput { @@ -85,7 +86,7 @@ export const printHeadlessReport = (input: PrintHeadlessReportInput): Effect.Eff yield* Console.log(""); yield* Console.warn( highlighter.warn( - `Results are incomplete: ${input.skippedChecks.join(" and ")} checks failed.`, + `Results are incomplete: ${input.skippedChecks.map(formatSkippedCheckLabel).join(" and ")} checks failed.`, ), ); } diff --git a/packages/react-doctor/src/cli/utils/record-scan-metrics.ts b/packages/react-doctor/src/cli/utils/record-scan-metrics.ts index aecf19829d..07b880ec77 100644 --- a/packages/react-doctor/src/cli/utils/record-scan-metrics.ts +++ b/packages/react-doctor/src/cli/utils/record-scan-metrics.ts @@ -119,7 +119,7 @@ export const recordScanMetrics = (input: ScanMetricsInput): void => { recordCount(METRIC.scanCompleted, 1, { mode: input.mode, lint: input.lint, - deadCode: input.deadCode, + maintainability: input.deadCode, parallel: input.parallel, scoreOnly: input.scoreOnly, didLintFail: input.didLintFail, @@ -166,9 +166,9 @@ export const recordScanMetrics = (input: ScanMetricsInput): void => { }); } // "Clean" means the scan actually completed and found nothing — not that a - // failed/incomplete run (lint or dead-code failed, a check was skipped) + // failed/incomplete run (lint or maintainability failed, a check was skipped) // happened to produce zero diagnostics. `skippedChecks` already includes - // lint/dead-code failures, so it's the single "fully completed" signal. + // lint/maintainability failures, so it's the single "fully completed" signal. if (result.diagnostics.length === 0 && !hasSkippedChecks) { recordCount(METRIC.scanClean, 1, { mode: input.mode }); } @@ -194,7 +194,7 @@ export const recordScanMetrics = (input: ScanMetricsInput): void => { recordCount(METRIC.lintFailed, 1, { reasonKind: input.lintFailureReasonKind }); } if (input.didDeadCodeFail) { - recordCount(METRIC.deadCodeFailed, 1); + recordCount(METRIC.maintainabilityFailed, 1); } for (const check of result.skippedChecks) { recordCount(METRIC.scanCheckSkipped, 1, { diff --git a/packages/react-doctor/src/cli/utils/render-and-record-scan.ts b/packages/react-doctor/src/cli/utils/render-and-record-scan.ts index f5f8003c50..c0b7d6301b 100644 --- a/packages/react-doctor/src/cli/utils/render-and-record-scan.ts +++ b/packages/react-doctor/src/cli/utils/render-and-record-scan.ts @@ -120,7 +120,6 @@ export const renderAndRecordScan = async ( didDeadCodeFail: input.payload.didDeadCodeFail, supplyChainOverlapTimedOut: input.payload.supplyChainOverlapTimedOut, securityScanFailed: input.payload.securityScanFailed, - deadCodeOverlapped: input.payload.deadCodeOverlapped, suppressedRuleCounts: input.payload.suppressedRuleCounts ?? [], }); return result; diff --git a/packages/react-doctor/src/cli/utils/run-baseline-comparison.ts b/packages/react-doctor/src/cli/utils/run-baseline-comparison.ts index a587a4d739..f05ac02162 100644 --- a/packages/react-doctor/src/cli/utils/run-baseline-comparison.ts +++ b/packages/react-doctor/src/cli/utils/run-baseline-comparison.ts @@ -5,8 +5,13 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import { computeDiagnosticDelta, + classifyFileContext, filterPathsOutsideDirectories, filterSourceFiles, + isPathInsideDirectory, + JSX_DUPLICATION_SOURCE_FILE_PATTERN, + listSourceFilesCooperative, + remainingDeadlineBudgetMs, type Diagnostic, type InspectResult, PerFileLintCacheEnabled, @@ -22,6 +27,7 @@ import { buildRuntimeLayers } from "./build-runtime-layers.js"; import { BASELINE_FILES_TEMP_DIR_PREFIX } from "./constants.js"; import { countDeadlineSkippedFiles } from "./count-deadline-skipped-files.js"; import { countDroppedLintFiles } from "./count-dropped-lint-files.js"; +import { copyUnchangedBaselineSources } from "./copy-unchanged-baseline-sources.js"; import { createDiagnosticEvidenceReader } from "./read-diagnostic-evidence.js"; import { createSourceLineReader } from "./read-source-line.js"; import { materializeBaselineFiles } from "./materialize-baseline-files.js"; @@ -59,7 +65,6 @@ export const countIncompleteLintFiles = (lintPartialFailures: ReadonlyArray<stri export const runBaselineComparison = async ( input: RunBaselineComparisonInput, ): Promise<BaselineComparison | null> => { - const temporaryDirectory = mkdtempSync(path.join(tmpdir(), BASELINE_FILES_TEMP_DIR_PREFIX)); const baselineIncludePaths = filterPathsOutsideDirectories({ rootDirectory: input.directory, relativePaths: input.options.includePaths, @@ -79,12 +84,39 @@ export const runBaselineComparison = async ( excludedDirectories: input.options.excludedProjectDirectories, }) : undefined; + const remainingBaselineBudgetMs = + input.deadlineEpochMs === null ? null : remainingDeadlineBudgetMs(input.deadlineEpochMs); + if (remainingBaselineBudgetMs === 0) return null; + const baselineDeadlineSignal = + remainingBaselineBudgetMs === null ? undefined : AbortSignal.timeout(remainingBaselineBudgetMs); + const baselineListingSignal = + baselineDeadlineSignal === undefined + ? input.oxlintRuntime.abortSignal + : input.oxlintRuntime.abortSignal === undefined + ? baselineDeadlineSignal + : AbortSignal.any([input.oxlintRuntime.abortSignal, baselineDeadlineSignal]); + let maintainabilitySourceFiles: string[] = []; + if (input.options.deadCode) { + try { + maintainabilitySourceFiles = ( + await listSourceFilesCooperative(input.directory, baselineListingSignal) + ).filter( + (filePath) => + JSX_DUPLICATION_SOURCE_FILE_PATTERN.test(filePath) && + classifyFileContext(filePath) === "production", + ); + } catch (error) { + if (baselineDeadlineSignal?.aborted) return null; + throw error; + } + } + const temporaryDirectory = mkdtempSync(path.join(tmpdir(), BASELINE_FILES_TEMP_DIR_PREFIX)); const snapshot = await materializeBaselineFiles({ directory: input.directory, ref: input.baselineRef, - files: baselineIncludePaths, - baseFiles: baselineBaseFiles, - headFiles: baselineHeadFiles, + files: input.options.deadCode ? input.options.includePaths : baselineIncludePaths, + baseFiles: input.options.deadCode ? input.baseFiles : baselineBaseFiles, + headFiles: input.options.deadCode ? input.headFiles : baselineHeadFiles, tempDirectory: temporaryDirectory, }).catch((error: unknown) => { rmSync(temporaryDirectory, { recursive: true, force: true }); @@ -97,27 +129,75 @@ export const runBaselineComparison = async ( try { if (!snapshot.isComplete) return null; + if ( + input.options.deadCode && + !(await copyUnchangedBaselineSources({ + directory: input.directory, + sourceFiles: maintainabilitySourceFiles, + baseMaterializedFiles: snapshot.materializedFiles, + headChangedFiles: snapshot.headFiles, + untrackedFiles: snapshot.untrackedFiles, + tempDirectory: snapshot.tempDirectory, + deadlineEpochMs: input.deadlineEpochMs, + signal: input.oxlintRuntime.abortSignal, + })) + ) { + return null; + } + const filteredSnapshotBaseFiles = filterPathsOutsideDirectories({ + rootDirectory: input.directory, + relativePaths: snapshot.baseFiles, + excludedDirectories: input.options.excludedProjectDirectories, + }); + const filteredSnapshotHeadFiles = filterPathsOutsideDirectories({ + rootDirectory: input.directory, + relativePaths: snapshot.headFiles, + excludedDirectories: input.options.excludedProjectDirectories, + }); const analyzedHeadFiles = new Set(input.headAnalyzedFiles.map(toForwardSlashes)); - const baseFiles = new Set(snapshot.baseFiles.map(toForwardSlashes)); - const expectedHeadFiles = new Set(snapshot.headFiles.map(toForwardSlashes)); + const baseFiles = new Set( + (baselineBaseFiles ?? filteredSnapshotBaseFiles).map(toForwardSlashes), + ); + const expectedHeadFiles = new Set( + (baselineHeadFiles ?? filteredSnapshotHeadFiles).map(toForwardSlashes), + ); for (const filePath of baselineIncludePaths) { const normalizedFilePath = toForwardSlashes(filePath); if (!baseFiles.has(normalizedFilePath)) expectedHeadFiles.add(normalizedFilePath); } if ( + input.options.lint && filterSourceFiles([...expectedHeadFiles]).some((filePath) => !analyzedHeadFiles.has(filePath)) ) { return null; } + const baselineLintPaths = new Set( + [...baselineIncludePaths, ...filteredSnapshotBaseFiles].map(toForwardSlashes), + ); + const materializedLintPaths = snapshot.materializedFiles.filter((filePath) => + baselineLintPaths.has(toForwardSlashes(filePath)), + ); + const maintainabilityFocusPaths = [ + ...new Set([...input.options.includePaths, ...snapshot.baseFiles].map(toForwardSlashes)), + ]; + const baselineExcludedProjectDirectories = input.options.excludedProjectDirectories + .map((excludedDirectory) => path.resolve(excludedDirectory)) + .filter((excludedDirectory) => isPathInsideDirectory(excludedDirectory, input.directory)) + .map((excludedDirectory) => + path.resolve(snapshot.tempDirectory, path.relative(input.directory, excludedDirectory)), + ); + const baseIncludePaths = + materializedLintPaths.length > 0 ? materializedLintPaths : maintainabilityFocusPaths; const runtimeLayers = buildRuntimeLayers({ directory: snapshot.tempDirectory, hasConfigOverride: true, userConfig: input.userConfig, configSourceDirectory: input.configSourceDirectory, projectInfoOverride: input.headProjectInfo, - shouldSkipLint: !input.options.lint || !input.resolvedNodeBinaryPath, - shouldRunDeadCode: false, + shouldSkipLint: + !input.options.lint || !input.resolvedNodeBinaryPath || materializedLintPaths.length === 0, + shouldRunDeadCode: input.options.deadCode, shouldRunSupplyChain: input.options.supplyChain, shouldComputeScore: false, shouldShowProgressSpinners: false, @@ -127,7 +207,8 @@ export const runBaselineComparison = async ( const baseProgram = runInspectEffect( { directory: snapshot.tempDirectory, - includePaths: snapshot.materializedFiles, + includePaths: baseIncludePaths, + maintainabilityFocusPaths, customRulesOnly: input.options.customRulesOnly, respectInlineDisables: input.options.respectInlineDisables, warnings: input.options.warnings, @@ -136,7 +217,7 @@ export const runBaselineComparison = async ( includedTags: input.options.includedTags, includeTagDefaults: input.options.includeTagDefaults, nodeBinaryPath: input.resolvedNodeBinaryPath ?? undefined, - runDeadCode: false, + runDeadCode: input.options.deadCode, isCi: input.options.isCi, doctorVersion: VERSION, runId: getRunId(), @@ -145,6 +226,9 @@ export const runBaselineComparison = async ( supplyChainManifestChanged: input.options.supplyChainManifestChanged, deadlineEpochMs: input.deadlineEpochMs ?? undefined, signal: input.oxlintRuntime.abortSignal, + excludedProjectDirectories: baselineExcludedProjectDirectories, + retainExcludedProjectDeadCodeDiagnostics: + input.options.retainExcludedProjectDeadCodeDiagnostics, }, {}, ); @@ -159,12 +243,20 @@ export const runBaselineComparison = async ( ), { signal: input.oxlintRuntime.abortSignal }, ); - if (baseOutput.didLintFail || countIncompleteLintFiles(baseOutput.lintPartialFailures) > 0) { + if ( + baseOutput.didLintFail || + baseOutput.didDeadCodeFail || + countIncompleteLintFiles(baseOutput.lintPartialFailures) > 0 + ) { return null; } const hasUnscannedUntrackedSourceFiles = filterSourceFiles( - snapshot.untrackedFiles.map(toForwardSlashes), + filterPathsOutsideDirectories({ + rootDirectory: input.directory, + relativePaths: snapshot.untrackedFiles, + excludedDirectories: input.options.excludedProjectDirectories, + }).map(toForwardSlashes), ).some((filePath) => !analyzedHeadFiles.has(filePath)); const diagnosticDelta = computeDiagnosticDelta({ headDiagnostics: input.headDiagnostics, diff --git a/packages/react-doctor/src/cli/utils/scan-result-cache.ts b/packages/react-doctor/src/cli/utils/scan-result-cache.ts index 2271d072dd..01742ffb18 100644 --- a/packages/react-doctor/src/cli/utils/scan-result-cache.ts +++ b/packages/react-doctor/src/cli/utils/scan-result-cache.ts @@ -68,10 +68,17 @@ const CACHE_DISABLED_VALUES = new Set(["1", "true"]); const TOOLCHAIN_PACKAGE_SPECIFIERS = [ "oxlint/package.json", "oxlint-plugin-react-doctor/package.json", - "deslop-js/package.json", "eslint-plugin-react-hooks/package.json", ] as const; const bundledRequire = createRequire(import.meta.url); +const RULE_PLUGIN_CONTENT_FINGERPRINT = (() => { + try { + const pluginEntryPath = bundledRequire.resolve("oxlint-plugin-react-doctor"); + return `oxlint-plugin-react-doctor#fingerprint=${hashFileContents(pluginEntryPath) ?? "unreadable"}`; + } catch { + return "oxlint-plugin-react-doctor#fingerprint=unresolved"; + } +})(); interface PackageVersionView { readonly version?: unknown; @@ -286,7 +293,7 @@ const isGitIdentityTrustworthy = (projectDirectory: string): boolean => { return entryLines.length > 0 && entryLines.every((line) => line[0] === "H"); }; -// Sits beside the per-file lint / sidecar / dead-code caches under the shared +// Sits beside the per-file lint and sidecar caches under the shared // cache root, so the `REACT_DOCTOR_CACHE_DIR` override the GitHub Action sets // (a `${runner.temp}` path persisted by `actions/cache`) carries the whole-repo // scan cache across CI runs too — the project-local `node_modules/.cache` @@ -325,7 +332,7 @@ const readPersistedCache = (cacheFilePath: string): PersistedScanResultCache => /** * The global cache off-switch (`REACT_DOCTOR_NO_CACHE`), which disables every * cache subsystem: this whole-repo scan cache plus core's per-file lint, - * sidecar, and dead-code caches (their `Context.Reference` defaults read the + * sidecar caches (their `Context.Reference` defaults read the * same variable). Exported for the wide event's `cache.temperature` * derivation, which reports `"disabled"` instead of `"cold"` when the switch * is on. Granular knobs (`REACT_DOCTOR_NO_FILE_CACHE`, …) are deliberately @@ -354,12 +361,9 @@ const fileFingerprint = (filePath: string): string | null => { } }; -// Versioned (not file-fingerprinted) like the lint ruleset hash, so the key -// survives a restored/re-extracted install in CI — extraction mtimes are an -// implementation detail of the installer, not toolchain identity. The one -// exception: a foreign oxlint Node (the nvm fallback) keeps the conservative -// stat identity rather than paying a version-probe subprocess here. -const resolveToolchainFingerprint = (nodeBinaryPath: string | null): ReadonlyArray<string> => { +export const resolveScanResultToolchainFingerprint = ( + nodeBinaryPath: string | null, +): ReadonlyArray<string> => { const fingerprints: string[] = []; if (nodeBinaryPath !== null) { fingerprints.push( @@ -377,6 +381,7 @@ const resolveToolchainFingerprint = (nodeBinaryPath: string | null): ReadonlyArr fingerprints.push(`${specifier}=missing`); } } + fingerprints.push(RULE_PLUGIN_CONTENT_FINGERPRINT); return fingerprints; }; @@ -398,7 +403,7 @@ export const buildScanResultCacheKey = (input: ScanResultCacheKeyInput): string dotenvFingerprint: resolveDotenvFingerprint(input.projectDirectory), reactDoctorVersion: input.version, nodeVersion: process.version, - toolchainFingerprint: resolveToolchainFingerprint(input.nodeBinaryPath), + toolchainFingerprint: resolveScanResultToolchainFingerprint(input.nodeBinaryPath), configFingerprint: computeConfigFingerprint(input.projectDirectory, input.version), hasConfigOverride: input.hasConfigOverride, configSourceDirectory: input.configSourceDirectory, @@ -438,7 +443,7 @@ export const buildScanResultCacheKey = (input: ScanResultCacheKeyInput): string supplyChainManifestChanged: input.options.supplyChainManifestChanged, // `maxDurationMs` is deliberately NOT keyed. It only changes the RESULT // when the budget is hit, and every such truncated run (lint partial or - // dead-code skipped) is barred from the cache by `shouldStoreScanPayload` + // maintainability skipped) is barred from the cache by `shouldStoreScanPayload` // below. So a stored payload is always COMPLETE, and serving it to a // `--max-duration` lookup honors the budget (a cache hit finishes well // under any ceiling) with the best possible result. Keying on it would diff --git a/packages/react-doctor/src/cli/utils/select-projects.ts b/packages/react-doctor/src/cli/utils/select-projects.ts index f6981c8a77..eea8ce9824 100644 --- a/packages/react-doctor/src/cli/utils/select-projects.ts +++ b/packages/react-doctor/src/cli/utils/select-projects.ts @@ -1,7 +1,7 @@ import * as path from "node:path"; import type { WorkspacePackage } from "@react-doctor/core"; import { - discoverReactSubprojects, + discoverSupportedSubprojects, highlighter, isDirectory, isFile, @@ -18,7 +18,7 @@ export const discoverWorkspacePackages = (rootDirectory: string): WorkspacePacka const hasRootPackageJson = isFile(path.join(rootDirectory, "package.json")); const packages = listWorkspacePackages(rootDirectory); if (packages.length === 0 && (!hasRootPackageJson || isMonorepoRoot(rootDirectory))) { - return discoverReactSubprojects(rootDirectory); + return discoverSupportedSubprojects(rootDirectory); } return packages; }; diff --git a/packages/react-doctor/src/cli/utils/sentry-config.ts b/packages/react-doctor/src/cli/utils/sentry-config.ts index ee54bd13ef..b9e83dc798 100644 --- a/packages/react-doctor/src/cli/utils/sentry-config.ts +++ b/packages/react-doctor/src/cli/utils/sentry-config.ts @@ -4,9 +4,8 @@ import { VERSION } from "./version.js"; /** * Shared Sentry configuration resolution — release, environment, and tracing * sample rate — derived from `VERSION` and the standard `SENTRY_*` env - * overrides. Lives apart from `instrument.ts` (the CLI's run-context-aware - * init) so both the CLI and the editor language server can resolve identical - * release/environment values without the LSP pulling in CLI run-context code. + * overrides. Lives apart from `instrument.ts` so this resolution remains + * independently testable. */ /** diff --git a/packages/react-doctor/src/cli/utils/telemetry-runtime.ts b/packages/react-doctor/src/cli/utils/telemetry-runtime.ts index 17dd1a2b40..bdfd71e65e 100644 --- a/packages/react-doctor/src/cli/utils/telemetry-runtime.ts +++ b/packages/react-doctor/src/cli/utils/telemetry-runtime.ts @@ -37,13 +37,8 @@ let pendingShutdown: Promise<void> | null = null; * * Returns `null` when telemetry is disabled or unconfigured, which is what keeps * `--no-telemetry` and unconfigured builds from opening a scope at all. - * - * `exportIntervalMs` lets a long-running process (the language server) ship - * telemetry periodically instead of only at shutdown. */ -export const getTelemetryContext = ( - overrides: { exportIntervalMs?: number } = {}, -): Context.Context<never> | null => { +export const getTelemetryContext = (): Context.Context<never> | null => { if (isBuilt) return telemetryContext; isBuilt = true; if (!isTelemetryEnabled()) return null; @@ -57,10 +52,9 @@ export const getTelemetryContext = ( try { const scope = Scope.makeUnsafe(); telemetryContext = Effect.runSync( - Layer.buildWithScope( - layerObservability(options === null ? null : { ...options, ...overrides }), - scope, - ) as Effect.Effect<Context.Context<never>>, + Layer.buildWithScope(layerObservability(options), scope) as Effect.Effect< + Context.Context<never> + >, ); telemetryScope = scope; } catch { diff --git a/packages/react-doctor/src/cli/utils/to-category-key.ts b/packages/react-doctor/src/cli/utils/to-category-key.ts index d61e606ffe..01cbd8c17b 100644 --- a/packages/react-doctor/src/cli/utils/to-category-key.ts +++ b/packages/react-doctor/src/cli/utils/to-category-key.ts @@ -1,8 +1,7 @@ /** * Lowercase, key-safe form of a rule category for the `diag.category.*` * telemetry attribute namespace (categories carry spaces / capitals, e.g. - * "Performance" → `performance`, "Dead Code" → `dead_code`). Shared by the - * CLI run event and the editor LSP wide event so both namespace identically. + * "Performance" → `performance`, "Dead Code" → `dead_code`). */ export const toCategoryKey = (category: string): string => category.toLowerCase().replace(/[^a-z0-9]+/g, "_"); diff --git a/packages/react-doctor/src/cli/utils/unref-stdin.ts b/packages/react-doctor/src/cli/utils/unref-stdin.ts index 950fefecd7..34f7be81d2 100644 --- a/packages/react-doctor/src/cli/utils/unref-stdin.ts +++ b/packages/react-doctor/src/cli/utils/unref-stdin.ts @@ -3,7 +3,7 @@ // as that handle is open — even though the only thing that ever reads // stdin is an interactive prompt. When the CLI is spawned by a parent // that holds the stdin write-end open (eval runners, CI harnesses, -// editor integrations), the scan finishes and the `--json` report +// process harnesses), the scan finishes and the `--json` report // flushes, yet the process never exits: the inherited `Socket fd=0` // refs the loop. Unref-ing fd 0 up front makes an idle pipe/socket // incapable of holding the process open. diff --git a/packages/react-doctor/src/inspect.ts b/packages/react-doctor/src/inspect.ts index 70cbe6fb5c..6fde938673 100644 --- a/packages/react-doctor/src/inspect.ts +++ b/packages/react-doctor/src/inspect.ts @@ -134,7 +134,7 @@ const inspectWithOxlintRuntime = async ( } catch (error) { // Emit the canonical wide event on the failure path too: the scan threw // before finalizing, so there's no `result` — just the error taxonomy - // plus the config it ran with. The lint/dead-code outcome isn't known + // plus the config it ran with. The lint/maintainability outcome isn't known // here, so it's omitted rather than asserted as a benign default. // Rethrow so error handling is unchanged. recordRunEvent(rootSpan, { @@ -229,7 +229,7 @@ const runInspectWithRuntime = async ( const cachedResult = scanResultCacheLifecycle.replay(); if (cachedResult !== null) return cachedResult; - // Suppress the orchestrator-owned lint + dead-code spinners when + // Suppress the orchestrator-owned lint + maintainability spinners when // the CLI is in score-only / silent / suppressed-rendering mode (or // when lint is skipped entirely) — suppressed-rendering scans run // concurrently in multi-project batches, where interleaved spinners @@ -264,6 +264,7 @@ const runInspectWithRuntime = async ( directory, precomputedSourceFileCount: options.precomputedSourceFileCount, includePaths: options.includePaths, + changedLineRanges: options.changedLineRanges ?? undefined, customRulesOnly: options.customRulesOnly, respectInlineDisables: options.respectInlineDisables, warnings: options.warnings, @@ -364,6 +365,7 @@ const runInspectWithRuntime = async ( options.baseline && isDiffMode && !didLintFail && + !output.didDeadCodeFail && countIncompleteLintFiles(output.lintPartialFailures) === 0 ) { const comparison = await runBaselineComparison({ diff --git a/packages/react-doctor/src/instrument.ts b/packages/react-doctor/src/instrument.ts index 3f9ae3a85a..88570f0f3a 100644 --- a/packages/react-doctor/src/instrument.ts +++ b/packages/react-doctor/src/instrument.ts @@ -7,8 +7,7 @@ import { isTelemetryEnabled } from "./cli/utils/is-telemetry-enabled.js"; import { scrubSentryEvent } from "./cli/utils/scrub-sentry-event.js"; import { resolveSentryEnvironment, resolveSentryRelease } from "./cli/utils/sentry-config.js"; -// Re-exported for back-compat: these resolvers moved to `sentry-config.ts` so -// the editor LSP can reuse them without importing this CLI-only module. +// Re-exported for back-compat after these resolvers moved to `sentry-config.ts`. export { resolveSentryEnvironment, resolveSentryRelease }; let isInitialized = false; diff --git a/packages/react-doctor/src/lsp-telemetry.ts b/packages/react-doctor/src/lsp-telemetry.ts deleted file mode 100644 index 6155f91547..0000000000 --- a/packages/react-doctor/src/lsp-telemetry.ts +++ /dev/null @@ -1,198 +0,0 @@ -import * as Sentry from "@sentry/node"; -import type { - SessionTelemetry, - Telemetry, - WorkspaceScanTelemetry, -} from "@react-doctor/language-server"; -import * as Context from "effect/Context"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; -import * as Tracer from "effect/Tracer"; -import { - LSP_TELEMETRY_EXPORT_INTERVAL_MS, - METRIC, - NANOSECONDS_PER_MILLISECOND, - SENTRY_DSN, - SENTRY_FLUSH_TIMEOUT_MS, -} from "./cli/utils/constants.js"; -import { toCategoryKey } from "./cli/utils/to-category-key.js"; -import { isEnvFlagEnabled } from "./cli/utils/is-env-flag-enabled.js"; -import { isTelemetryEnabled } from "./cli/utils/is-telemetry-enabled.js"; -import { recordCount, recordDistribution } from "./cli/utils/record-metric.js"; -import { buildSentryScope } from "./cli/utils/build-sentry-scope.js"; -import { scrubSentryEvent } from "./cli/utils/scrub-sentry-event.js"; -import { toSpanAttributes } from "./cli/utils/to-span-attributes.js"; -import { getTelemetryContext, shutdownTelemetry } from "./cli/utils/telemetry-runtime.js"; -import { resolveSentryEnvironment, resolveSentryRelease } from "./cli/utils/sentry-config.js"; - -/** - * Telemetry for the editor language server (`react-doctor experimental-lsp`). - * Mirrors the CLI's model — a per-scan wide-event span plus counters and - * distributions to Axiom, with crashes to Sentry — but with an LSP-appropriate - * scope instead of the CLI run context, since the daemon isn't a one-shot - * command. Shares the CLI's DSN, release, and the anonymization scrubbers, so - * editor telemetry honors the same privacy contract (no IP, no paths/secrets). - * - * Every emit is a guarded, swallow-on-throw no-op unless telemetry is live, so a - * telemetry failure (or an opted-out / test run) can never disrupt the editor - * session. - * - * Unlike the one-shot CLI, this process can run for hours, so its exporters use - * a short periodic interval — telemetry ships while the editor session is alive - * rather than only when the server shuts down (which it may never cleanly do). - */ - -const nodeMajorVersion = (): number => - Number.parseInt(process.versions.node.split(".", 1)[0] ?? "", 10) || 0; - -/** - * Opens the shared telemetry runtime with the daemon's periodic export - * interval. Called at startup rather than lazily from the first scan: session - * metrics are recorded before any scan completes, and if the editor kills the - * server before one does, a runtime that was never built means those counters - * are never exported. - */ -const getLspTelemetryContext = (): Context.Context<never> | null => - getTelemetryContext({ exportIntervalMs: LSP_TELEMETRY_EXPORT_INTERVAL_MS }); - -/** - * Initializes Sentry for the language server. Safe to call once at startup; a - * no-op when already initialized or when telemetry is opted out / disabled. - * - * Performance tracing is off — the `lsp.scan` wide event is an Effect span - * exported to Axiom, and Effect has a single `Tracer` reference. - */ -export const initializeLspSentry = (serverVersion: string): void => { - if (Sentry.isInitialized() || !isTelemetryEnabled()) return; - // Open the exporters up front so the periodic interval starts ticking and - // session-start counters are covered even if no scan ever completes. - getLspTelemetryContext(); - Sentry.init({ - dsn: process.env.SENTRY_DSN || SENTRY_DSN, - release: resolveSentryRelease(), - environment: resolveSentryEnvironment(), - sendDefaultPii: false, - tracesSampleRate: 0, - debug: isEnvFlagEnabled(process.env.SENTRY_DEBUG), - initialScope: { - tags: { - origin: "lsp", - command: "experimental-lsp", - serverVersion, - nodeMajor: nodeMajorVersion(), - platform: process.platform, - }, - contexts: { - lsp: { - serverVersion, - node: process.version, - platform: process.platform, - arch: process.arch, - }, - }, - }, - beforeSend: (event) => scrubSentryEvent(event), - }); -}; - -/** - * Flat attribute set for one workspace-scan wide event. Pure and exported so - * the projection (rule-category rollup, clean/degraded outcome) is testable - * without a live Sentry client. - */ -export const buildLspScanEventAttributes = ( - scan: WorkspaceScanTelemetry, -): Record<string, string | number | boolean> => { - const attributes: Record<string, string | number | boolean> = { - trigger: scan.trigger, - durationMs: scan.durationMs, - projectCount: scan.projectCount, - chunkCount: scan.chunkCount, - filesWithDiagnostics: scan.filesWithDiagnostics, - totalDiagnostics: scan.totalDiagnostics, - errorCount: scan.errorCount, - warningCount: scan.warningCount, - scanClean: scan.totalDiagnostics === 0 && !scan.lintDegraded, - lintDegraded: scan.lintDegraded, - lintIncompleteChunks: scan.lintIncompleteChunks, - }; - for (const [category, count] of Object.entries(scan.diagnosticsByCategory)) { - attributes[`diag.category.${toCategoryKey(category)}`] = count; - } - return attributes; -}; - -const emitSessionStart = (session: SessionTelemetry): void => { - recordCount(METRIC.lspSessionStarted, 1, { - nodeMajor: session.nodeMajor, - projectCount: session.projectCount, - workspaceFolderCount: session.workspaceFolderCount, - scanOnType: session.scanOnType, - lintAvailable: session.lintAvailable, - }); -}; - -const emitWorkspaceScan = (scan: WorkspaceScanTelemetry): void => { - recordCount(METRIC.lspScanCompleted, 1, { - trigger: scan.trigger, - lintDegraded: scan.lintDegraded, - }); - recordDistribution(METRIC.lspScanDuration, scan.durationMs, { - unit: "millisecond", - attributes: { trigger: scan.trigger }, - }); - recordDistribution(METRIC.lspScanDiagnostics, scan.totalDiagnostics, { - attributes: { trigger: scan.trigger }, - }); - - // The canonical wide event: one span per scan carrying the full outcome as - // attributes. - // - // The span is built straight off the tracer rather than through - // `Effect.makeSpan`, which stamps the start time from the clock. This is - // emitted once the burst has already finished, so a clock-stamped span would - // start and end at the same instant and report a duration of ~0 — the scan's - // real window has to be supplied on both ends. - const telemetryContext = getLspTelemetryContext(); - if (telemetryContext === null) return; - try { - const tracer = Context.get(telemetryContext, Tracer.Tracer); - const startTime = BigInt(scan.startedAtEpochMs) * NANOSECONDS_PER_MILLISECOND; - const span = tracer.span({ - name: "react-doctor experimental-lsp scan", - parent: Option.none(), - annotations: Context.empty(), - links: [], - startTime, - kind: "internal", - root: true, - sampled: true, - }); - // Run dimensions first, scan outcome second (so the scan wins a collision). - // Sentry attached these automatically from `initialScope`; an Effect span - // inherits no such scope, so without this the editor's traces would lack the - // `origin` / `command` / `platform` dimensions that the CLI's root span - // stamps and that LSP metrics already carry via `record-metric.ts`. - const attributes = { - ...toSpanAttributes(buildSentryScope().tags), - ...buildLspScanEventAttributes(scan), - }; - for (const [key, value] of Object.entries(attributes)) { - span.attribute(key, value); - } - span.end(startTime + BigInt(scan.durationMs) * NANOSECONDS_PER_MILLISECOND, Exit.void); - } catch {} -}; - -/** Builds the {@link Telemetry} sink the server drives. */ -export const createLspTelemetry = (): Telemetry => ({ - recordSessionStart: emitSessionStart, - recordWorkspaceScan: emitWorkspaceScan, - flush: async () => { - await shutdownTelemetry(); - if (!Sentry.isInitialized()) return; - try { - await Sentry.flush(SENTRY_FLUSH_TIMEOUT_MS); - } catch {} - }, -}); diff --git a/packages/react-doctor/src/lsp.ts b/packages/react-doctor/src/lsp.ts deleted file mode 100644 index 3a09fe3d35..0000000000 --- a/packages/react-doctor/src/lsp.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Dedicated language-server entry for `react-doctor experimental-lsp`. The bin - * shim fast-paths to this module so the server runs without loading the CLI - * (commander / prompts / ora), which would otherwise touch `process.stdin` - * before the LSP connection attaches and break the stdio transport. - * - * This thin wrapper is where Sentry telemetry is wired in: the language-server - * package stays backend-agnostic (it only calls the injected `Telemetry` - * seam), and the published CLI supplies the Sentry-backed implementation here. - */ -import { startLanguageServer as startServer } from "@react-doctor/language-server"; -import { createLspTelemetry, initializeLspSentry } from "./lsp-telemetry.js"; -import { VERSION } from "./cli/utils/version.js"; - -export const startLanguageServer = (): void => { - initializeLspSentry(VERSION); - startServer({ telemetry: createLspTelemetry() }); -}; diff --git a/packages/react-doctor/src/project-analysis-worker.ts b/packages/react-doctor/src/project-analysis-worker.ts new file mode 100644 index 0000000000..d9157d4b77 --- /dev/null +++ b/packages/react-doctor/src/project-analysis-worker.ts @@ -0,0 +1,3 @@ +import { startProjectAnalysisWorker } from "../../core/src/start-project-analysis-worker.js"; + +startProjectAnalysisWorker(); diff --git a/packages/react-doctor/tests/build-no-score-message.test.ts b/packages/react-doctor/tests/build-no-score-message.test.ts index fe5129c0e4..8da310d82e 100644 --- a/packages/react-doctor/tests/build-no-score-message.test.ts +++ b/packages/react-doctor/tests/build-no-score-message.test.ts @@ -28,7 +28,7 @@ describe("buildNoScoreMessage", () => { it("explains when incomplete analysis suppressed the score", () => { expect(buildNoScoreMessage({ isScoreDisabled: false, isAnalysisIncomplete: true })).toBe( - `Score not shown because lint or dead-code analysis could not complete. Want something custom to your company? Contact us at ${ENTERPRISE_CONTACT_URL}.`, + `Score not shown because lint or maintainability analysis could not complete. Want something custom to your company? Contact us at ${ENTERPRISE_CONTACT_URL}.`, ); }); }); diff --git a/packages/react-doctor/tests/build-run-event.test.ts b/packages/react-doctor/tests/build-run-event.test.ts index 8ffc622634..c1f879d3c3 100644 --- a/packages/react-doctor/tests/build-run-event.test.ts +++ b/packages/react-doctor/tests/build-run-event.test.ts @@ -150,38 +150,11 @@ describe("buildRunEventAttributes", () => { ).toBeUndefined(); }); - it("records whether the dead-code pass overlapped lint, and drops it on the failure path", () => { - expect( - buildRunEventAttributes(baseInput({ result: buildResult(), deadCodeOverlapped: true }))[ - "deadCode.overlapped" - ], - ).toBe(true); - // A false dimension is still emitted (toSpanAttributes only drops null), so - // overlap-adoption rate is queryable across all scans. - expect( - buildRunEventAttributes(baseInput({ result: buildResult(), deadCodeOverlapped: false }))[ - "deadCode.overlapped" - ], - ).toBe(false); - // Failure path (no result) carries no outcome dimensions, so it's dropped. - expect( - buildRunEventAttributes(baseInput({ error: new Error("boom") }))["deadCode.overlapped"], - ).toBeUndefined(); - }); - - it("records the incremental summary-cache outcome, and drops it when no analysis consulted it", () => { + it("records maintainability failure under the first-class namespace", () => { const attributes = buildRunEventAttributes( - baseInput({ - result: buildResult({ deadCodeSummaryCacheHits: 8900, deadCodeSummaryCacheMisses: 3 }), - }), + baseInput({ result: buildResult(), didDeadCodeFail: true }), ); - expect(attributes["deadCode.summaryCacheHits"]).toBe(8900); - expect(attributes["deadCode.summaryCacheMisses"]).toBe(3); - // Whole-result hit / cache off / dead-code skipped: absent, so "no cache" - // reads distinctly from a 0% hit rate. - const absentAttributes = buildRunEventAttributes(baseInput({ result: buildResult() })); - expect(absentAttributes["deadCode.summaryCacheHits"]).toBeUndefined(); - expect(absentAttributes["deadCode.summaryCacheMisses"]).toBeUndefined(); + expect(attributes["maintainability.failed"]).toBe(true); }); it("marks a finding-free run clean and drops absent CI signals", () => { @@ -581,7 +554,7 @@ describe("buildRunEventAttributes", () => { it("marks a whole-repo replay turbo with full warmth and no subsystem dims", () => { // The cachedPayload branch passes the explicit flag and none of the - // execution dims (no lint / dead-code ran), so the subsystem dims stay + // execution dims (no lint or maintainability analysis ran), so the subsystem dims stay // absent while the temperature is still unambiguous. const attributes = buildRunEventAttributes( baseInput({ result: buildResult(), wholeRepoCacheHit: true }), @@ -591,8 +564,6 @@ describe("buildRunEventAttributes", () => { expect(attributes["cache.wholeRepoHit"]).toBe(true); expect(attributes["lint.cacheHitRatio"]).toBeUndefined(); expect(attributes["lint.sidecarReplayRatio"]).toBeUndefined(); - expect(attributes["deadCode.cacheHit"]).toBeUndefined(); - expect(attributes["deadCode.summaryCacheHits"]).toBeUndefined(); }); it("marks a fresh scan with zero reuse cold, with warmth 0", () => { @@ -634,11 +605,6 @@ describe("buildRunEventAttributes", () => { overrides: { lintSidecarReplayedFileCount: 5, lintSidecarTotalFileCount: 10 }, expectedWarmth: 0.5, }, - { overrides: { deadCodeCacheHit: true }, expectedWarmth: 1 }, - { - overrides: { deadCodeSummaryCacheHits: 8, deadCodeSummaryCacheMisses: 2 }, - expectedWarmth: 0.8, - }, ]; for (const { overrides, expectedWarmth } of warmScenarios) { const attributes = buildRunEventAttributes( @@ -650,14 +616,13 @@ describe("buildRunEventAttributes", () => { }); it("computes warmth as the mean of the known subsystem ratios, skipping absent ones", () => { - // Sidecar dims absent -> skipped, not counted as 0: (0.5 + 0.8) / 2. const attributes = buildRunEventAttributes( baseInput({ result: buildResult({ lintCacheHitFileCount: 50, lintCacheTotalFileCount: 100, - deadCodeSummaryCacheHits: 8, - deadCodeSummaryCacheMisses: 2, + lintSidecarReplayedFileCount: 8, + lintSidecarTotalFileCount: 10, }), wholeRepoCacheHit: false, }), @@ -666,23 +631,6 @@ describe("buildRunEventAttributes", () => { expect(attributes["cache.warmth"]).toBeCloseTo(0.65, 10); }); - it("counts a consulted-but-missed dead-code result cache as zero reuse", () => { - // deadCodeCacheHit false with no summary stats means the analysis ran - // fully fresh: (1.0 + 0) / 2, still warm because lint reused everything. - const attributes = buildRunEventAttributes( - baseInput({ - result: buildResult({ - lintCacheHitFileCount: 100, - lintCacheTotalFileCount: 100, - deadCodeCacheHit: false, - }), - wholeRepoCacheHit: false, - }), - ); - expect(attributes["cache.temperature"]).toBe("warm"); - expect(attributes["cache.warmth"]).toBeCloseTo(0.5, 10); - }); - it("reads the legacy no-dims shape as cold and drops warmth and the flag", () => { // No wholeRepoCacheHit flag and no subsystem dims (a caller predating the // fields): nothing was reused, so the temperature still reads cold, while @@ -713,6 +661,7 @@ describe("buildRunEventAttributes", () => { expect(attributes["scan.mode"]).toBe("full"); expect(attributes["scan.rulesConfigured"]).toBe(2); expect(attributes["scan.rulesDisabled"]).toBe(1); + expect(attributes["scan.projectAnalysisRuleCount"]).toBe(0); expect(attributes["scan.ignoredTagCount"]).toBe(2); expect(attributes["scan.hasCustomConfig"]).toBe(true); expect(attributes["scan.workerCount"]).toBeUndefined(); @@ -720,6 +669,22 @@ describe("buildRunEventAttributes", () => { expect(attributes["timing.scanMs"]).toBeUndefined(); }); + it("counts explicitly enabled project graph rules", () => { + const attributes = buildRunEventAttributes( + baseInput({ + userConfig: { + categories: { Maintainability: "error" }, + rules: { + "deslop/unused-export": "warn", + "react-doctor/unused-dependency": "error", + "react-doctor/unused-type": "off", + }, + }, + }), + ); + expect(attributes["scan.projectAnalysisRuleCount"]).toBe(2); + }); + it("counts analyzed non-JSX source files for partial-scan coverage telemetry", () => { const attributes = buildRunEventAttributes( baseInput({ @@ -748,14 +713,14 @@ describe("buildRunEventAttributes", () => { it("records each scan phase's enabled state, including supply-chain", () => { const enabled = buildRunEventAttributes(baseInput()); expect(enabled["scan.lint"]).toBe(true); - expect(enabled["scan.deadCode"]).toBe(true); + expect(enabled["scan.maintainability"]).toBe(true); expect(enabled["scan.supplyChain"]).toBe(true); const disabled = buildRunEventAttributes( baseInput({ lint: false, deadCode: false, supplyChain: false }), ); expect(disabled["scan.lint"]).toBe(false); - expect(disabled["scan.deadCode"]).toBe(false); + expect(disabled["scan.maintainability"]).toBe(false); expect(disabled["scan.supplyChain"]).toBe(false); }); }); diff --git a/packages/react-doctor/tests/build-sentry-project-context.test.ts b/packages/react-doctor/tests/build-sentry-project-context.test.ts index 3f48102f2a..d6c4042d5d 100644 --- a/packages/react-doctor/tests/build-sentry-project-context.test.ts +++ b/packages/react-doctor/tests/build-sentry-project-context.test.ts @@ -39,6 +39,7 @@ describe("buildSentryProjectContext", () => { const { tags } = buildSentryProjectContext(projectInfo); expect(tags).toEqual({ "project.framework": "nextjs", + "project.runtime": "react", "project.reactMajor": 18, "project.typescript": true, "project.reactCompiler": false, @@ -47,6 +48,16 @@ describe("buildSentryProjectContext", () => { }); }); + it("classifies supported runtime combinations without project identity", () => { + expect( + buildSentryProjectContext({ + ...projectInfo, + hasRemotion: true, + hasThree: true, + }).tags["project.runtime"], + ).toBe("react+three+remotion"); + }); + it("includes the anonymous project shape (no source code) in the context block", () => { const { context } = buildSentryProjectContext(projectInfo); expect(context).toMatchObject({ diff --git a/packages/react-doctor/tests/cli-migrations.test.ts b/packages/react-doctor/tests/cli-migrations.test.ts index ed6e2c20ec..ccf94e7af1 100644 --- a/packages/react-doctor/tests/cli-migrations.test.ts +++ b/packages/react-doctor/tests/cli-migrations.test.ts @@ -106,16 +106,16 @@ describe("runProjectMigrations", () => { const report = await runProjectMigrations(projectRoot); expect(report).toContainEqual({ id: "agent-hooks-sh-to-mjs", ran: true, applied: true }); - const settings: { hooks: { PostToolBatch: Array<{ hooks: Array<{ command: string }> }> } } = + const settings: { hooks: { Stop: Array<{ hooks: Array<{ command: string }> }> } } = JSON.parse(fs.readFileSync(path.join(projectRoot, ".claude/settings.json"), "utf8")); - const hookCommands = settings.hooks.PostToolBatch.flatMap((group) => + const hookCommands = settings.hooks.Stop.flatMap((group) => group.hooks.map((hook) => hook.command), ); expect(hookCommands).toHaveLength(1); expect(hookCommands[0]).toContain("react-doctor.mjs"); expect(fs.existsSync(path.join(projectRoot, ".claude/hooks/react-doctor.sh"))).toBe(false); expect(fs.existsSync(path.join(projectRoot, ".claude/hooks/react-doctor.mjs"))).toBe(true); - expect(capturedOutput()).toContain("Upgraded the legacy react-doctor.sh agent hook"); + expect(capturedOutput()).toContain("Moved React Doctor agent hooks to end-of-turn checks"); }); it("stays pending with no legacy hooks and doesn't touch agent settings", async () => { @@ -137,6 +137,59 @@ describe("runProjectMigrations", () => { expect(logSpy.mock.calls.length).toBe(0); }); + it("moves installed Node hooks from per-tool events to Stop events", async () => { + const settingsPath = path.join(projectRoot, ".claude/settings.json"); + const configPath = path.join(projectRoot, ".cursor/hooks.json"); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + hooks: { + PostToolBatch: [ + { + hooks: [ + { + type: "command", + command: 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/react-doctor.mjs"', + }, + ], + }, + ], + }, + }), + ); + fs.writeFileSync( + configPath, + JSON.stringify({ + version: 1, + hooks: { + postToolUse: [ + { + command: "node .cursor/hooks/react-doctor.mjs", + matcher: "Write|Edit|MultiEdit|ApplyPatch", + timeout: 120, + }, + ], + }, + }), + ); + + const report = await runProjectMigrations(projectRoot); + const settings: { hooks: { PostToolBatch: unknown[]; Stop: unknown[] } } = JSON.parse( + fs.readFileSync(settingsPath, "utf8"), + ); + const config: { hooks: { postToolUse: unknown[]; stop: unknown[] } } = JSON.parse( + fs.readFileSync(configPath, "utf8"), + ); + + expect(report).toContainEqual({ id: "agent-hooks-sh-to-mjs", ran: true, applied: true }); + expect(settings.hooks.PostToolBatch).toEqual([]); + expect(settings.hooks.Stop).toHaveLength(1); + expect(config.hooks.postToolUse).toEqual([]); + expect(config.hooks.stop).toHaveLength(1); + }); + it("ignores a user's own wrapper outside our install paths (anchored detection)", async () => { const configPath = path.join(projectRoot, ".cursor/hooks.json"); fs.mkdirSync(path.dirname(configPath), { recursive: true }); diff --git a/packages/react-doctor/tests/copy-unchanged-baseline-sources.test.ts b/packages/react-doctor/tests/copy-unchanged-baseline-sources.test.ts new file mode 100644 index 0000000000..9489a83c47 --- /dev/null +++ b/packages/react-doctor/tests/copy-unchanged-baseline-sources.test.ts @@ -0,0 +1,66 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { copyUnchangedBaselineSources } from "../src/cli/utils/copy-unchanged-baseline-sources.js"; + +const temporaryDirectories: string[] = []; + +const createTemporaryDirectory = (prefix: string): string => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + temporaryDirectories.push(directory); + return directory; +}; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("copyUnchangedBaselineSources", () => { + it("copies only unchanged tracked source files", async () => { + const directory = createTemporaryDirectory("react-doctor-baseline-source-"); + const tempDirectory = createTemporaryDirectory("react-doctor-baseline-target-"); + fs.mkdirSync(path.join(directory, "src"), { recursive: true }); + fs.writeFileSync(path.join(directory, "src", "unchanged.tsx"), "unchanged"); + fs.writeFileSync(path.join(directory, "src", "changed.tsx"), "head"); + fs.writeFileSync(path.join(directory, "src", "untracked.tsx"), "untracked"); + + const didCopyComplete = await copyUnchangedBaselineSources({ + directory, + sourceFiles: ["src/unchanged.tsx", "src/changed.tsx", "src/untracked.tsx"], + baseMaterializedFiles: [], + headChangedFiles: ["src/changed.tsx"], + untrackedFiles: ["src/untracked.tsx"], + tempDirectory, + deadlineEpochMs: null, + }); + + expect(didCopyComplete).toBe(true); + expect(fs.readFileSync(path.join(tempDirectory, "src", "unchanged.tsx"), "utf-8")).toBe( + "unchanged", + ); + expect(fs.existsSync(path.join(tempDirectory, "src", "changed.tsx"))).toBe(false); + expect(fs.existsSync(path.join(tempDirectory, "src", "untracked.tsx"))).toBe(false); + }); + + it("stops copying after the scan deadline", async () => { + const directory = createTemporaryDirectory("react-doctor-baseline-deadline-source-"); + const tempDirectory = createTemporaryDirectory("react-doctor-baseline-deadline-target-"); + fs.writeFileSync(path.join(directory, "component.tsx"), "component"); + + const didCopyComplete = await copyUnchangedBaselineSources({ + directory, + sourceFiles: ["component.tsx"], + baseMaterializedFiles: [], + headChangedFiles: [], + untrackedFiles: [], + tempDirectory, + deadlineEpochMs: 0, + }); + + expect(didCopyComplete).toBe(false); + expect(fs.existsSync(path.join(tempDirectory, "component.tsx"))).toBe(false); + }); +}); diff --git a/packages/react-doctor/tests/dead-code-integration.test.ts b/packages/react-doctor/tests/dead-code-integration.test.ts deleted file mode 100644 index dbeebaa40c..0000000000 --- a/packages/react-doctor/tests/dead-code-integration.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as fs from "node:fs"; -import os from "node:os"; -import * as path from "node:path"; -import { afterAll, afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { diagnose } from "../src/index.js"; -import { setupReactProject } from "./regressions/_helpers.js"; - -// Focused end-to-end coverage for the dead-code path: run the REAL -// deslop dead-code analysis through the public `diagnose()` API and -// assert the diagnostic actually surfaces. The other react-doctor -// pipeline tests pass `deadCode: false` because they assert on lint / -// project resolution, not dead-code, and running the deslop analysis in -// every one of them is pure overhead (the `api` package tests do the -// same). The dead-code worker itself runs as a child process now, so it -// tears down cleanly even on Windows — see core's check-dead-code.ts. -const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dead-code-integration-")); - -afterAll(() => { - fs.rmSync(tempRoot, { recursive: true, force: true }); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe("diagnose() dead-code integration", () => { - it("surfaces a real deslop unused-file diagnostic end-to-end", async () => { - // Keep scoring offline — this test only exercises the dead-code path. - vi.stubGlobal( - "fetch", - vi.fn( - async () => - new Response(JSON.stringify({ score: 100, label: "Perfect" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ), - ); - - const projectDir = setupReactProject(tempRoot, "unused-file", { - packageJsonExtras: { type: "module" }, - files: { - "src/index.ts": "export const used = 1;\n", - "src/orphan.ts": "export const orphan = 1;\n", - }, - }); - - // lint:false keeps the fork to a single deslop worker spawn (no - // oxlint). deadCode is on by default; set it explicitly for intent. - const result = await diagnose(projectDir, { lint: false, deadCode: true, warnings: true }); - - const orphan = result.diagnostics.find( - (diagnostic) => - diagnostic.rule === "unused-file" && diagnostic.filePath.endsWith("orphan.ts"), - ); - expect(orphan).toBeDefined(); - expect(orphan?.plugin).toBe("deslop"); - expect(orphan?.category).toBe("Maintainability"); - // Proves the worker actually ran rather than being skipped or crashing. - expect(result.skippedChecks).not.toContain("dead-code"); - }); -}); diff --git a/packages/react-doctor/tests/diagnose.test.ts b/packages/react-doctor/tests/diagnose.test.ts index be727679b4..0e4ba86e27 100644 --- a/packages/react-doctor/tests/diagnose.test.ts +++ b/packages/react-doctor/tests/diagnose.test.ts @@ -173,12 +173,12 @@ export const Button = forwardRef<HTMLButtonElement>((_props, ref) => ( expect(result.project.reactVersion).toBe("^19.0.0"); }); - it("throws a clear error when the directory has no root package.json and no nested React project", async () => { - const emptyDir = path.join(tempRoot, "diagnose-no-react-anywhere"); + it("throws a clear error when the directory has no root package.json and no nested supported project", async () => { + const emptyDir = path.join(tempRoot, "diagnose-no-supported-project"); fs.mkdirSync(emptyDir, { recursive: true }); await expect(diagnose(emptyDir, { lint: false, deadCode: false })).rejects.toThrow( - "No React project found in", + "No React or Three.js project found in", ); }); diff --git a/packages/react-doctor/tests/diff-fast-path.test.ts b/packages/react-doctor/tests/diff-fast-path.test.ts index b0b44e319b..f0222030dd 100644 --- a/packages/react-doctor/tests/diff-fast-path.test.ts +++ b/packages/react-doctor/tests/diff-fast-path.test.ts @@ -5,18 +5,10 @@ import { afterAll, afterEach, describe, expect, it, vi } from "vite-plus/test"; import { diagnose } from "../src/index.js"; import { setupReactProject } from "./regressions/_helpers.js"; -// The GitHub Action's PR fast path forwards `--scope changed -// --changed-files-from <file>`, which the CLI turns into a diff-mode scan -// (`includePaths` non-empty) that SKIPS dead-code + supply-chain — the two -// phases that dominate full scans. That skip is why PR runs are engine-fast -// (~1-3s) and install-bound (see plan 09). It's load-bearing for CI speed and -// easy to regress (e.g. a change that drops the `!isDiffMode` gate in -// run-inspect), so lock it behaviorally: the dead-code diagnostic a full scan -// surfaces must be ABSENT from a diff-mode scan of the same project. -const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rd-diff-fast-path-")); +const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rd-diff-fast-path-")); afterAll(() => { - fs.rmSync(tempRoot, { recursive: true, force: true }); + fs.rmSync(temporaryRoot, { recursive: true, force: true }); }); afterEach(() => { @@ -35,40 +27,50 @@ const stubOfflineScore = () => ), ); -const orphanFixture = (caseId: string): string => - setupReactProject(tempRoot, caseId, { +const duplicatedJsxFixture = (caseIdentifier: string): string => + setupReactProject(temporaryRoot, caseIdentifier, { packageJsonExtras: { type: "module" }, files: { - "src/index.ts": "export const used = 1;\n", - "src/orphan.ts": "export const orphan = 1;\n", + "src/index.ts": "export const version = 1;\n", + "src/account.tsx": `export const Account = ({ value }: { value: string }) => ( + <AccountScreen><Page><section><header><Title /></header><main><Value value={value} /></main><footer><Button /></footer></section></Page></AccountScreen> +);\n`, + "src/user.tsx": `export const User = ({ name }: { name: string }) => ( + <UserScreen><Page><section><header><Title /></header><main><Value value={name} /></main><footer><Button /></footer></section></Page></UserScreen> +);\n`, }, }); -describe("diff fast path (CI scope: changed)", () => { - it("a full scan surfaces the orphan dead-code diagnostic (baseline)", async () => { +describe("diff maintainability focus", () => { + it("compares a changed file with unchanged duplicate counterparts", async () => { stubOfflineScore(); - const projectDir = orphanFixture("full"); - const result = await diagnose(projectDir, { lint: false, deadCode: true, warnings: true }); - const orphan = result.diagnostics.find( - (diagnostic) => - diagnostic.rule === "unused-file" && diagnostic.filePath.endsWith("orphan.ts"), + const projectDirectory = duplicatedJsxFixture("changed-duplicate"); + const result = await diagnose(projectDirectory, { + lint: false, + deadCode: true, + warnings: true, + includePaths: ["src/user.tsx"], + }); + + const diagnostic = result.diagnostics.find( + (candidate) => candidate.rule === "duplicate-jsx-subtree", ); - expect(orphan).toBeDefined(); + expect(diagnostic?.filePath).toBe("src/user.tsx"); + expect(diagnostic?.relatedLocations?.[0].filePath).toBe("src/account.tsx"); }); - it("a diff-mode scan (changed files only) skips dead-code — no unused-file diagnostic", async () => { + it("does not report duplicate families untouched by the diff", async () => { stubOfflineScore(); - const projectDir = orphanFixture("diff"); - // `includePaths` non-empty ⇒ diff mode. run-inspect gates BOTH dead-code - // (`shouldRunDeadCode`) and supply-chain (`shouldRunSupplyChain`) on - // `!isDiffMode`, so neither runs — the orphan unused-file the full scan - // above found must not appear. - const result = await diagnose(projectDir, { + const projectDirectory = duplicatedJsxFixture("unrelated-change"); + const result = await diagnose(projectDirectory, { lint: false, deadCode: true, warnings: true, includePaths: ["src/index.ts"], }); - expect(result.diagnostics.some((diagnostic) => diagnostic.rule === "unused-file")).toBe(false); + + expect(result.diagnostics.some((candidate) => candidate.rule === "duplicate-jsx-subtree")).toBe( + false, + ); }); }); diff --git a/packages/react-doctor/tests/ink/run-scan-app.test.ts b/packages/react-doctor/tests/ink/run-scan-app.test.ts index 9267b7ddc7..11f0ceb907 100644 --- a/packages/react-doctor/tests/ink/run-scan-app.test.ts +++ b/packages/react-doctor/tests/ink/run-scan-app.test.ts @@ -332,13 +332,13 @@ describe("runScanApp", () => { mockState.inspectResults.set(rootDirectory, { ...buildInspectResult(rootDirectory), skippedChecks: ["dead-code"], - skippedCheckReasons: { "dead-code": "Dead-code analysis failed." }, + skippedCheckReasons: { "dead-code": "Maintainability analysis failed." }, }); await runScanApp({ directory: rootDirectory, skipPrompts: true }); expect(mockState.scanStores[0]?.getSnapshot().report?.noScoreMessage).toContain( - "lint or dead-code analysis could not complete", + "lint or maintainability analysis could not complete", ); expect(mockState.scanStores[0]?.getSnapshot().report?.noScoreMessage).not.toContain( "score API", @@ -363,7 +363,7 @@ describe("runScanApp", () => { expect(mockState.scanStores[0]?.getSnapshot().report?.noScoreMessage).toContain("score API"); expect(mockState.scanStores[0]?.getSnapshot().report?.noScoreMessage).not.toContain( - "lint or dead-code analysis could not complete", + "lint or maintainability analysis could not complete", ); }); @@ -378,7 +378,12 @@ describe("runScanApp", () => { ); mockState.scanTargets.set( webDirectory, - buildScanTarget(webDirectory, webDirectory, null, webDirectory), + buildScanTarget( + webDirectory, + webDirectory, + { rules: { "react-doctor/unused-export": "warn" } }, + webDirectory, + ), ); mockState.inspectResults.set(rootDirectory, buildInspectResult(rootDirectory)); mockState.inspectResults.set(webDirectory, buildInspectResult(webDirectory)); @@ -400,6 +405,7 @@ describe("runScanApp", () => { webDirectory, expect.objectContaining({ deadCode: false, + configOverride: { rules: { "react-doctor/unused-export": "warn" } }, excludedProjectDirectories: [], retainExcludedProjectDeadCodeDiagnostics: false, }), diff --git a/packages/react-doctor/tests/ink/scan-app.test.tsx b/packages/react-doctor/tests/ink/scan-app.test.tsx index 55f8d030f4..f20f80f6bf 100644 --- a/packages/react-doctor/tests/ink/scan-app.test.tsx +++ b/packages/react-doctor/tests/ink/scan-app.test.tsx @@ -79,9 +79,9 @@ describe("ScanApp", () => { it("renders repeated live diagnostics without duplicate React keys", () => { const store = createScanStore(); const repeatedDiagnostic = makeDiagnostic({ - filePath: "package.json", - plugin: "deslop", - rule: "unused-dev-dependency", + filePath: "src/Card.tsx", + plugin: "react-doctor", + rule: "duplicate-jsx-subtree", line: 0, column: 0, }); @@ -311,7 +311,7 @@ describe("ScanApp", () => { const { lastFrame, unmount } = render(<ScanApp store={store} />); expect(lastFrame()).toContain( - "No issues detected, but dead-code checks failed — results are incomplete.", + "No issues detected, but maintainability checks failed — results are incomplete.", ); expect(lastFrame()).not.toContain("No issues found"); unmount(); @@ -403,7 +403,7 @@ describe("ScanApp", () => { const { lastFrame, unmount } = render(<ScanApp store={store} />); expect(lastFrame()).toContain("2 projects were skipped because scanning failed."); expect(lastFrame()).toContain( - "No issues detected, but dead-code checks failed — results are incomplete.", + "No issues detected, but maintainability checks failed — results are incomplete.", ); expect(lastFrame()).not.toContain("No issues found"); unmount(); @@ -946,6 +946,33 @@ describe("ScanApp", () => { unmount(); }); + it("keeps the score face intact beside a long project name", async () => { + const store = createScanStore(); + store.setReport({ + diagnostics: [makeDiagnostic({ rule: "rules-of-hooks", severity: "error" })], + score: SCORE, + projectedScore: null, + projectName: "threejs-scaffolding-with-a-long-project-name", + rootDirectory: process.cwd(), + scannedFileCount: 1, + elapsedMilliseconds: 10, + isOffline: true, + noScoreMessage: "Score unavailable.", + }); + + const { lastFrame, stdin, stdout, unmount } = render(<ScanApp store={store} />); + resizeTerminal(stdout, { columns: 120, rows: 44 }); + await flush(); + stdin.write("\r"); + await flush(); + + const frame = lastFrame() ?? ""; + expect(frame).toContain("┌─────┐"); + expect(frame).toContain("└─────┘"); + expect(frame).not.toMatch(/^\s*┘$/m); + unmount(); + }); + it("shows only usable controls when a clean report has no actions", async () => { const store = createScanStore(); store.setReport({ diff --git a/packages/react-doctor/tests/inspect.test.ts b/packages/react-doctor/tests/inspect.test.ts index 1e5dfbc6c9..e94beaaddd 100644 --- a/packages/react-doctor/tests/inspect.test.ts +++ b/packages/react-doctor/tests/inspect.test.ts @@ -21,6 +21,21 @@ const FIXTURES_DIRECTORY = path.resolve( "fixtures", ); +const buildDuplicateCard = ( + componentName: string, + title: string, + valueName: string, + rootElementName = "section", +): string => ` +export const ${componentName} = () => ( + <${rootElementName} className="card"> + <header><h2>${title}</h2></header> + <main><Value value={${valueName}} /></main> + <footer><Button /></footer> + </${rootElementName}> +); +`; + vi.mock("ora", () => ({ default: () => ({ text: "", @@ -349,6 +364,205 @@ module.exports = { } }); + it("compares focused JSX duplication against the complete baseline source tree", async () => { + clearConfigCache(); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-baseline-jsx-")); + try { + const projectDirectory = setupReactProject(projectRoot, "app", { + files: { + "src/AccountCard.tsx": buildDuplicateCard("AccountCard", "Account", "account"), + "src/UserCard.tsx": buildDuplicateCard("UserCard", "User", "user"), + }, + }); + initGitRepo(projectDirectory); + const baseRef = commitAll(projectDirectory, "base includes duplicated cards"); + writeFile( + path.join(projectDirectory, "src", "AccountCard.tsx"), + buildDuplicateCard("AccountCard", "Account", "customer"), + ); + + const result = await inspect(projectDirectory, { + lint: true, + deadCode: true, + noScore: true, + silent: true, + includePaths: ["src/AccountCard.tsx"], + baseline: { ref: baseRef }, + }); + + expect(result.baselineDelta?.baseTotalCount).toBeGreaterThan(0); + expect( + result.diagnostics.filter((diagnostic) => diagnostic.rule === "duplicate-jsx-subtree"), + ).toEqual([]); + } finally { + consoleSpy.mockRestore(); + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("surfaces a structurally different duplicate family with the same summary", async () => { + clearConfigCache(); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-baseline-jsx-change-")); + try { + const projectDirectory = setupReactProject(projectRoot, "app", { + files: { + "src/AccountCard.tsx": buildDuplicateCard("AccountCard", "Account", "account"), + "src/UserCard.tsx": buildDuplicateCard("UserCard", "User", "user"), + }, + }); + initGitRepo(projectDirectory); + const baseRef = commitAll(projectDirectory, "base includes duplicated sections"); + writeFile( + path.join(projectDirectory, "src", "AccountCard.tsx"), + buildDuplicateCard("AccountCard", "Account", "account", "article"), + ); + writeFile( + path.join(projectDirectory, "src", "UserCard.tsx"), + buildDuplicateCard("UserCard", "User", "user", "article"), + ); + + const result = await inspect(projectDirectory, { + lint: true, + deadCode: true, + noScore: true, + silent: true, + includePaths: ["src/AccountCard.tsx", "src/UserCard.tsx"], + baseline: { ref: baseRef }, + }); + + expect( + result.diagnostics.filter((diagnostic) => diagnostic.rule === "duplicate-jsx-subtree"), + ).toHaveLength(1); + } finally { + consoleSpy.mockRestore(); + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("surfaces a new occurrence added to an existing duplicate family", async () => { + clearConfigCache(); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-baseline-jsx-growth-")); + try { + const projectDirectory = setupReactProject(projectRoot, "app", { + files: { + "src/AccountCard.tsx": buildDuplicateCard("AccountCard", "Account", "account"), + "src/UserCard.tsx": buildDuplicateCard("UserCard", "User", "user"), + }, + }); + initGitRepo(projectDirectory); + const baseRef = commitAll(projectDirectory, "base includes two duplicated cards"); + writeFile( + path.join(projectDirectory, "src", "TeamCard.tsx"), + buildDuplicateCard("TeamCard", "Team", "team"), + ); + + const result = await inspect(projectDirectory, { + lint: true, + deadCode: true, + noScore: true, + silent: true, + includePaths: ["src/TeamCard.tsx"], + baseline: { ref: baseRef }, + }); + + expect( + result.diagnostics.filter((diagnostic) => diagnostic.rule === "duplicate-jsx-subtree"), + ).toHaveLength(1); + } finally { + consoleSpy.mockRestore(); + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("keeps a pure file addition in baseline diff mode", async () => { + clearConfigCache(); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-baseline-add-")); + try { + const projectDirectory = setupReactProject(projectRoot, "app", { + files: { + "src/AccountCard.tsx": buildDuplicateCard("AccountCard", "Account", "account"), + "src/UserCard.tsx": buildDuplicateCard("UserCard", "User", "user"), + }, + }); + initGitRepo(projectDirectory); + const baseRef = commitAll(projectDirectory, "base includes unrelated duplication"); + writeFile( + path.join(projectDirectory, "src", "Added.tsx"), + "export const Added = () => <main><p>New page</p></main>;\n", + ); + + const result = await inspect(projectDirectory, { + lint: true, + deadCode: true, + noScore: true, + silent: true, + includePaths: ["src/Added.tsx"], + baseline: { ref: baseRef }, + }); + + expect(result.baselineDelta?.baseTotalCount).toBe(0); + expect( + result.diagnostics.filter((diagnostic) => diagnostic.rule === "duplicate-jsx-subtree"), + ).toEqual([]); + } finally { + consoleSpy.mockRestore(); + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("keeps workspace-owned maintainability baselines across excluded projects", async () => { + clearConfigCache(); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const projectDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "react-doctor-baseline-workspace-jsx-"), + ); + const nestedProjectDirectory = path.join(projectDirectory, "packages", "web"); + try { + writeJson(path.join(projectDirectory, "package.json"), { + name: "workspace-root", + dependencies: { react: "^19.0.0", "react-dom": "^19.0.0" }, + }); + writeFile( + path.join(nestedProjectDirectory, "src", "AccountCard.tsx"), + buildDuplicateCard("AccountCard", "Account", "account"), + ); + writeFile( + path.join(nestedProjectDirectory, "src", "UserCard.tsx"), + buildDuplicateCard("UserCard", "User", "user"), + ); + initGitRepo(projectDirectory); + const baseRef = commitAll(projectDirectory, "base includes nested duplication"); + const changedPath = "packages/web/src/AccountCard.tsx"; + writeFile( + path.join(projectDirectory, changedPath), + buildDuplicateCard("AccountCard", "Account", "customer"), + ); + + const result = await inspect(projectDirectory, { + lint: true, + deadCode: true, + noScore: true, + silent: true, + includePaths: [changedPath], + baseline: { ref: baseRef }, + excludedProjectDirectories: [nestedProjectDirectory], + retainExcludedProjectDeadCodeDiagnostics: true, + }); + + expect(result.baselineDelta?.baseTotalCount).toBeGreaterThan(0); + expect( + result.diagnostics.filter((diagnostic) => diagnostic.rule === "duplicate-jsx-subtree"), + ).toEqual([]); + } finally { + consoleSpy.mockRestore(); + fs.rmSync(projectDirectory, { recursive: true, force: true }); + } + }); + it("keeps baseline comparisons complete when descendant projects are excluded", async () => { clearConfigCache(); const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); diff --git a/packages/react-doctor/tests/install-agent-hooks.test.ts b/packages/react-doctor/tests/install-agent-hooks.test.ts index adb2573501..2d8ca58aee 100644 --- a/packages/react-doctor/tests/install-agent-hooks.test.ts +++ b/packages/react-doctor/tests/install-agent-hooks.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { - findAgentsWithLegacyShellHooks, + findAgentsWithOutdatedReactDoctorHooks, installReactDoctorAgentHooks, } from "../src/cli/utils/install-agent-hooks.js"; import * as fs from "node:fs"; @@ -14,14 +14,12 @@ interface AgentHooksFixture { } interface AgentHookJsonOutput { - readonly additional_context: string; + readonly followup_message: string; } interface ClaudeAgentHookJsonOutput { - readonly hookSpecificOutput: { - readonly hookEventName: string; - readonly additionalContext: string; - }; + readonly decision: "block"; + readonly reason: string; } interface FakeBinaryOptions { @@ -115,7 +113,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () fixture.cleanup(); }); - it("installs a Claude Code PostToolBatch hook without duplicating existing hooks", () => { + it("installs a Claude Code Stop hook without duplicating existing hooks", () => { const settingsPath = path.join(fixture.projectRoot, ".claude/settings.json"); const hookPath = path.join(fixture.projectRoot, ".claude/hooks/react-doctor.mjs"); fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); @@ -144,9 +142,12 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () const settings = readJson<{ permissions: { allow: string[] }; - hooks: { PostToolBatch: Array<{ hooks: Array<{ command: string }> }> }; + hooks: { + PostToolBatch: Array<{ hooks: Array<{ command: string }> }>; + Stop: Array<{ hooks: Array<{ command: string }> }>; + }; }>(settingsPath); - const hookCommands = settings.hooks.PostToolBatch.flatMap((group) => + const hookCommands = settings.hooks.Stop.flatMap((group) => group.hooks.map((hook) => hook.command), ); const hookContent = fs.readFileSync(hookPath, "utf8"); @@ -154,11 +155,16 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () expect(result.installedAgents).toEqual(["claude-code"]); expect(result.files).toContain(settingsPath); expect(settings.permissions.allow).toEqual(["Bash(git status)"]); + expect(settings.hooks.PostToolBatch).toEqual([ + { hooks: [{ type: "command", command: "echo existing" }] }, + ]); expect(hookCommands.filter((command) => command.includes("react-doctor.mjs"))).toHaveLength(1); expect(hookContent).toContain("CLAUDE_PROJECT_DIR"); - expect(hookContent).toContain("react-doctor --verbose --scope changed --blocking warning"); + expect(hookContent).toContain( + "react-doctor --verbose --scope changed --include-untracked --blocking warning", + ); // cmd.exe signals a missing command with exit 9009 (not the POSIX 127) — - // the generated runner loop must fall through on it or every Windows edit + // the generated runner loop must fall through on it or every Windows check // reports shell noise as scan findings. expect(hookContent).toContain("9009"); expect(hookContent).toContain("maxBuffer"); @@ -193,12 +199,16 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () }); const settings = readJson<{ - hooks: { PostToolBatch: Array<{ hooks: Array<{ command: string }> }> }; + hooks: { + PostToolBatch: Array<{ hooks: Array<{ command: string }> }>; + Stop: Array<{ hooks: Array<{ command: string }> }>; + }; }>(settingsPath); - const hookCommands = settings.hooks.PostToolBatch.flatMap((group) => + const hookCommands = settings.hooks.Stop.flatMap((group) => group.hooks.map((hook) => hook.command), ); + expect(settings.hooks.PostToolBatch).toEqual([]); expect(hookCommands).toHaveLength(1); expect(hookCommands[0]).toContain("react-doctor.mjs"); expect(fs.existsSync(legacyScriptPath)).toBe(false); @@ -222,10 +232,10 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () }); }).not.toThrow(); - const config = readJson<{ hooks: { postToolUse: Array<{ command?: string }> } }>(configPath); - expect( - config.hooks.postToolUse.some((handler) => handler.command?.includes("react-doctor.mjs")), - ).toBe(true); + const config = readJson<{ hooks: { stop: Array<{ command?: string }> } }>(configPath); + expect(config.hooks.stop.some((handler) => handler.command?.includes("react-doctor.mjs"))).toBe( + true, + ); }); it("replaces a legacy .sh Cursor hook instead of stacking a second entry", () => { @@ -249,11 +259,15 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () }); const config = readJson<{ - hooks: { postToolUse: Array<{ command: string }> }; + hooks: { + postToolUse: Array<{ command: string }>; + stop: Array<{ command: string }>; + }; }>(configPath); - expect(config.hooks.postToolUse).toHaveLength(1); - expect(config.hooks.postToolUse[0].command).toContain("react-doctor.mjs"); + expect(config.hooks.postToolUse).toEqual([]); + expect(config.hooks.stop).toHaveLength(1); + expect(config.hooks.stop[0].command).toContain("react-doctor.mjs"); expect(fs.existsSync(legacyScriptPath)).toBe(false); }); @@ -275,21 +289,22 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () }); const settings = readJson<{ - hooks: { PostToolBatch: Array<{ matcher?: string; hooks?: Array<{ command: string }> }> }; + hooks: { + PostToolBatch: Array<{ matcher?: string; hooks?: Array<{ command: string }> }>; + Stop: Array<{ hooks: Array<{ command: string }> }>; + }; }>(settingsPath); - expect(settings.hooks.PostToolBatch).toHaveLength(3); + expect(settings.hooks.PostToolBatch).toHaveLength(2); expect(settings.hooks.PostToolBatch[0]).toEqual({ matcher: "Bash" }); expect(settings.hooks.PostToolBatch[1]).toEqual({ matcher: "Write", hooks: [] }); expect( - settings.hooks.PostToolBatch[2].hooks?.some((hook) => - hook.command.includes("react-doctor.mjs"), - ), + settings.hooks.Stop[0].hooks.some((hook) => hook.command.includes("react-doctor.mjs")), ).toBe(true); }); - it("detects legacy shell hooks per agent and tolerates invalid settings JSON", () => { - expect(findAgentsWithLegacyShellHooks(fixture.projectRoot)).toEqual([]); + it("detects outdated hooks per agent and tolerates invalid settings JSON", () => { + expect(findAgentsWithOutdatedReactDoctorHooks(fixture.projectRoot)).toEqual([]); const settingsPath = path.join(fixture.projectRoot, ".claude/settings.json"); fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); @@ -319,11 +334,14 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () hooks: { postToolUse: [{ command: ".cursor/hooks/react-doctor.sh", matcher: "Write" }] }, }), ); - expect(findAgentsWithLegacyShellHooks(fixture.projectRoot)).toEqual(["claude-code", "cursor"]); + expect(findAgentsWithOutdatedReactDoctorHooks(fixture.projectRoot)).toEqual([ + "claude-code", + "cursor", + ]); // A probe must never crash a scan on a user-mangled file. fs.writeFileSync(settingsPath, "{ not json"); - expect(findAgentsWithLegacyShellHooks(fixture.projectRoot)).toEqual(["cursor"]); + expect(findAgentsWithOutdatedReactDoctorHooks(fixture.projectRoot)).toEqual(["cursor"]); }); it("leaves a user's own wrapper referencing a react-doctor.sh outside our install paths", () => { @@ -343,13 +361,21 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () agents: ["cursor"], }); - const config = readJson<{ hooks: { postToolUse: Array<{ command: string }> } }>(configPath); - const commands = config.hooks.postToolUse.map((handler) => handler.command); - expect(commands).toContain(userWrapperCommand); - expect(commands.some((command) => command.includes("react-doctor.mjs"))).toBe(true); + const config = readJson<{ + hooks: { + postToolUse: Array<{ command: string }>; + stop: Array<{ command: string }>; + }; + }>(configPath); + expect(config.hooks.postToolUse.map((handler) => handler.command)).toContain( + userWrapperCommand, + ); + expect(config.hooks.stop.some((handler) => handler.command.includes("react-doctor.mjs"))).toBe( + true, + ); }); - it("installs a Cursor postToolUse hook and preserves existing hook config", () => { + it("installs a Cursor stop hook and preserves existing hook config", () => { const configPath = path.join(fixture.projectRoot, ".cursor/hooks.json"); const hookPath = path.join(fixture.projectRoot, ".cursor/hooks/react-doctor.mjs"); fs.mkdirSync(path.dirname(configPath), { recursive: true }); @@ -376,7 +402,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () version: number; hooks: { sessionStart: Array<{ command: string }>; - postToolUse: Array<{ command: string; matcher: string; timeout: number }>; + stop: Array<{ command: string; timeout: number }>; }; }>(configPath); @@ -384,15 +410,14 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () const hookContent = fs.readFileSync(hookPath, "utf8"); expect(config.version).toBe(1); expect(config.hooks.sessionStart).toEqual([{ command: ".cursor/hooks/bootstrap.sh" }]); - expect(config.hooks.postToolUse).toHaveLength(1); - expect(config.hooks.postToolUse[0]).toEqual({ + expect(config.hooks.stop).toHaveLength(1); + expect(config.hooks.stop[0]).toEqual({ command: "node .cursor/hooks/react-doctor.mjs", - matcher: "Write|Edit|MultiEdit|ApplyPatch", timeout: 120, }); expect(fs.existsSync(hookPath)).toBe(true); expect(hookContent).toContain("__dirname"); - expect(hookContent).toContain("additional_context"); + expect(hookContent).toContain("followup_message"); }); it("runs generated agent hooks from the project root and returns scan context", () => { @@ -409,7 +434,9 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () const output = execFileSync(process.execPath, [hookPath], { cwd: nestedDirectory, input: JSON.stringify({ - tool_name: "Write", + hook_event_name: "stop", + status: "completed", + loop_count: 0, }), encoding: "utf8", }); @@ -425,8 +452,8 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () expect( fs.readFileSync(path.join(fixture.projectRoot, ".react-doctor/agent-hook-args.txt"), "utf8"), ).toContain("--verbose"); - expect(parsedOutput.additional_context).toContain("fake scan output"); - expect(parsedOutput.additional_context).toContain("create GitHub issues"); + expect(parsedOutput.followup_message).toContain("fake scan output"); + expect(parsedOutput.followup_message).toContain("create GitHub issues"); }); it("uses CLAUDE_PROJECT_DIR when a generated Claude hook runs outside the repo", () => { @@ -447,8 +474,8 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () CLAUDE_PROJECT_DIR: fixture.projectRoot, }, input: JSON.stringify({ - hook_event_name: "PostToolBatch", - tool_calls: [{ tool_name: "Write" }], + hook_event_name: "Stop", + stop_hook_active: false, }), encoding: "utf8", }); @@ -461,11 +488,9 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () .trim(), ), ).toBe(fs.realpathSync(fixture.projectRoot)); - expect(parsedOutput.hookSpecificOutput).toEqual({ - hookEventName: "PostToolBatch", - additionalContext: expect.stringContaining("fake scan output"), - }); - expect(parsedOutput.hookSpecificOutput.additionalContext).toContain("create GitHub issues"); + expect(parsedOutput.decision).toBe("block"); + expect(parsedOutput.reason).toContain("fake scan output"); + expect(parsedOutput.reason).toContain("create GitHub issues"); }); it("uses a PATH react-doctor binary when the local binary is missing", () => { @@ -494,7 +519,9 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () ].join(path.delimiter), }, input: JSON.stringify({ - tool_name: "Write", + hook_event_name: "stop", + status: "completed", + loop_count: 0, }), encoding: "utf8", }); @@ -506,7 +533,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () "utf8", ), ).toContain("--verbose"); - expect(parsedOutput.additional_context).toContain("path scan output"); + expect(parsedOutput.followup_message).toContain("path scan output"); }); it("exits quietly when no react-doctor runner is available", () => { @@ -525,7 +552,9 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () PATH: "/usr/bin:/bin", }, input: JSON.stringify({ - tool_name: "Write", + hook_event_name: "stop", + status: "completed", + loop_count: 0, }), encoding: "utf8", }); @@ -534,7 +563,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () expect(fs.existsSync(invocationPath)).toBe(false); }); - it("skips generated agent hooks for non-edit tool batches", () => { + it("does not re-run a Claude hook continuation", () => { const hookPath = path.join(fixture.projectRoot, ".claude/hooks/react-doctor.mjs"); const invocationPath = path.join(fixture.projectRoot, ".react-doctor/agent-hook-args.txt"); fs.mkdirSync(path.join(fixture.projectRoot, ".react-doctor"), { recursive: true }); @@ -547,8 +576,8 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () const output = execFileSync(process.execPath, [hookPath], { cwd: path.join(fixture.projectRoot, ".claude/hooks"), input: JSON.stringify({ - hook_event_name: "PostToolBatch", - tool_calls: [{ tool_name: "Read" }], + hook_event_name: "Stop", + stop_hook_active: true, }), encoding: "utf8", }); @@ -569,7 +598,9 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () const output = execFileSync(process.execPath, [hookPath], { cwd: fixture.projectRoot, input: JSON.stringify({ - tool_name: "Write", + hook_event_name: "stop", + status: "completed", + loop_count: 0, }), encoding: "utf8", }); @@ -580,7 +611,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () ).toContain("--verbose"); }); - it("skips generated agent hooks for non-edit single tool events", () => { + it("does not re-run a Cursor hook continuation", () => { const hookPath = path.join(fixture.projectRoot, ".cursor/hooks/react-doctor.mjs"); const invocationPath = path.join(fixture.projectRoot, ".react-doctor/agent-hook-args.txt"); fs.mkdirSync(path.join(fixture.projectRoot, ".react-doctor"), { recursive: true }); @@ -593,7 +624,9 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () const output = execFileSync(process.execPath, [hookPath], { cwd: fixture.projectRoot, input: JSON.stringify({ - tool_name: "Read", + hook_event_name: "stop", + status: "completed", + loop_count: 1, }), encoding: "utf8", }); @@ -618,7 +651,7 @@ describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () }); const parsedOutput: AgentHookJsonOutput = JSON.parse(output); - expect(parsedOutput.additional_context).toContain("fake scan output"); + expect(parsedOutput.followup_message).toContain("fake scan output"); }); it("ignores agents without native hook support", () => { diff --git a/packages/react-doctor/tests/install-react-doctor.test.ts b/packages/react-doctor/tests/install-react-doctor.test.ts index 52ac3a8c7f..4a00d088c8 100644 --- a/packages/react-doctor/tests/install-react-doctor.test.ts +++ b/packages/react-doctor/tests/install-react-doctor.test.ts @@ -667,9 +667,9 @@ describe("runInstallReactDoctor", () => { ).toBe(true); expect( fs.readFileSync(path.join(fixture.projectRoot, ".claude/settings.json"), "utf8"), - ).toContain("PostToolBatch"); + ).toContain('"Stop"'); expect(fs.readFileSync(path.join(fixture.projectRoot, ".cursor/hooks.json"), "utf8")).toContain( - "postToolUse", + '"stop"', ); expect(fs.existsSync(path.join(fixture.projectRoot, ".codex/hooks.json"))).toBe(false); }); @@ -888,11 +888,11 @@ describe("runInstallReactDoctor", () => { false, ); expect(fs.readFileSync(path.join(fixture.projectRoot, ".cursor/hooks.json"), "utf8")).toContain( - "postToolUse", + '"stop"', ); expect( fs.readFileSync(path.join(fixture.projectRoot, ".claude/settings.json"), "utf8"), - ).toContain("PostToolBatch"); + ).toContain('"Stop"'); }); it("--yes upgrades an existing @v1 workflow to @v2 in place", async () => { @@ -1112,7 +1112,7 @@ describe("runInstallReactDoctor", () => { fs.existsSync(path.join(fixture.projectRoot, ".agents/skills/react-doctor/SKILL.md")), ).toBe(true); expect(fs.readFileSync(path.join(fixture.projectRoot, ".cursor/hooks.json"), "utf8")).toContain( - "postToolUse", + '"stop"', ); expect(fs.existsSync(path.join(fixture.projectRoot, ".git/hooks/pre-commit"))).toBe(false); expect(fs.existsSync(path.join(fixture.projectRoot, ".react-doctor/hooks/pre-commit"))).toBe( diff --git a/packages/react-doctor/tests/lsp-telemetry.test.ts b/packages/react-doctor/tests/lsp-telemetry.test.ts deleted file mode 100644 index 4b32ac27e5..0000000000 --- a/packages/react-doctor/tests/lsp-telemetry.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import type { WorkspaceScanTelemetry } from "@react-doctor/language-server"; -import { buildLspScanEventAttributes } from "../src/lsp-telemetry.js"; - -const scan = (overrides: Partial<WorkspaceScanTelemetry> = {}): WorkspaceScanTelemetry => ({ - trigger: "initial", - startedAtEpochMs: 1000, - durationMs: 2500, - projectCount: 2, - chunkCount: 3, - filesWithDiagnostics: 4, - totalDiagnostics: 5, - errorCount: 2, - warningCount: 3, - diagnosticsByCategory: { Performance: 4, "Dead Code": 1 }, - lintDegraded: false, - lintIncompleteChunks: 0, - ...overrides, -}); - -describe("buildLspScanEventAttributes", () => { - it("projects the scan into flat span attributes", () => { - const attributes = buildLspScanEventAttributes(scan()); - expect(attributes).toMatchObject({ - trigger: "initial", - durationMs: 2500, - projectCount: 2, - chunkCount: 3, - filesWithDiagnostics: 4, - totalDiagnostics: 5, - errorCount: 2, - warningCount: 3, - lintDegraded: false, - lintIncompleteChunks: 0, - }); - }); - - it("namespaces per-category counts with key-safe names", () => { - const attributes = buildLspScanEventAttributes(scan()); - expect(attributes["diag.category.performance"]).toBe(4); - expect(attributes["diag.category.dead_code"]).toBe(1); - }); - - it("marks a zero-diagnostic, healthy scan as clean", () => { - const attributes = buildLspScanEventAttributes( - scan({ totalDiagnostics: 0, errorCount: 0, warningCount: 0, diagnosticsByCategory: {} }), - ); - expect(attributes.scanClean).toBe(true); - }); - - it("is not clean when lint was degraded even with zero diagnostics", () => { - const attributes = buildLspScanEventAttributes( - scan({ totalDiagnostics: 0, diagnosticsByCategory: {}, lintDegraded: true }), - ); - expect(attributes.scanClean).toBe(false); - expect(attributes.lintDegraded).toBe(true); - }); -}); diff --git a/packages/react-doctor/tests/maintainability-integration.test.ts b/packages/react-doctor/tests/maintainability-integration.test.ts new file mode 100644 index 0000000000..176165526b --- /dev/null +++ b/packages/react-doctor/tests/maintainability-integration.test.ts @@ -0,0 +1,62 @@ +import * as fs from "node:fs"; +import os from "node:os"; +import * as path from "node:path"; +import { afterAll, afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { diagnose } from "../src/index.js"; +import { setupReactProject } from "./regressions/_helpers.js"; + +const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rd-maintainability-integration-")); + +afterAll(() => { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("diagnose() maintainability integration", () => { + it("surfaces duplicated JSX trees through the public API", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify({ score: 100, label: "Perfect" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + const projectDirectory = setupReactProject(temporaryRoot, "duplicated-jsx", { + packageJsonExtras: { type: "module" }, + files: { + "src/account-card.tsx": `export const AccountCard = ({ title, description }: { title: string; description: string }) => ( + <section className="card"> + <header><h2>{title}</h2></header> + <div><article><p>{description}</p></article></div> + </section> +);\n`, + "src/project-card.tsx": `export const ProjectCard = ({ name, summary }: { name: string; summary: string }) => ( + <section className="card"> + <header><h2>{name}</h2></header> + <div><article><p>{summary}</p></article></div> + </section> +);\n`, + }, + }); + + const result = await diagnose(projectDirectory, { + lint: false, + deadCode: true, + warnings: true, + }); + + const diagnostic = result.diagnostics.find( + (candidate) => candidate.rule === "duplicate-jsx-subtree", + ); + expect(diagnostic?.plugin).toBe("react-doctor"); + expect(diagnostic?.category).toBe("Maintainability"); + expect(diagnostic?.relatedLocations).toHaveLength(1); + }); +}); diff --git a/packages/react-doctor/tests/performance-harness.test.ts b/packages/react-doctor/tests/performance-harness.test.ts index 63cba7e397..48b220199d 100644 --- a/packages/react-doctor/tests/performance-harness.test.ts +++ b/packages/react-doctor/tests/performance-harness.test.ts @@ -462,14 +462,8 @@ describe("performance harness", () => { ); expect(cpuProcessRoles).toContain("react-doctor"); expect(cpuProcessRoles).toContain("oxlint"); - if (process.allowedNodeEnvironmentFlags.has("--cpu-prof")) { - expect(cpuProcessRoles).toContain("dead-code"); - } else { - expect(cpuProcessRoles.size).toBeGreaterThanOrEqual(2); - } - expect(heapAnalysis.processes.length).toBeGreaterThanOrEqual( - process.allowedNodeEnvironmentFlags.has("--heap-prof") ? 3 : 2, - ); + expect(cpuProcessRoles.size).toBeGreaterThanOrEqual(2); + expect(heapAnalysis.processes.length).toBeGreaterThanOrEqual(2); }, ); }); diff --git a/packages/react-doctor/tests/run-oxlint/three.test.ts b/packages/react-doctor/tests/run-oxlint/three.test.ts new file mode 100644 index 0000000000..b630b1089c --- /dev/null +++ b/packages/react-doctor/tests/run-oxlint/three.test.ts @@ -0,0 +1,54 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { discoverProject, runOxlint } from "@react-doctor/core"; + +describe("runOxlint standalone Three.js support", () => { + let rootDirectory: string; + + beforeEach(() => { + rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-three-")); + fs.mkdirSync(path.join(rootDirectory, "src")); + }); + + afterEach(() => { + fs.rmSync(rootDirectory, { recursive: true, force: true }); + }); + + it("reports Three.js animation-loop issues without React", async () => { + fs.writeFileSync( + path.join(rootDirectory, "package.json"), + JSON.stringify({ name: "standalone-three", dependencies: { three: "0.185.1" } }), + ); + fs.writeFileSync( + path.join(rootDirectory, "src/main.ts"), + ` + import * as THREE from "three"; + const renderer = new THREE.WebGLRenderer(); + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(); + const frame = () => { + renderer.render(scene, camera); + requestAnimationFrame(frame); + }; + requestAnimationFrame(frame); + `, + ); + + const project = discoverProject(rootDirectory); + const diagnostics = await runOxlint({ + rootDirectory, + project, + includePaths: ["src/main.ts"], + perFileLintCacheEnabled: false, + }); + + expect(project).toMatchObject({ hasThree: true, reactVersion: null }); + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ rule: "three-prefer-set-animation-loop" }), + ]), + ); + }); +}); diff --git a/packages/react-doctor/tests/scan-result-cache.test.ts b/packages/react-doctor/tests/scan-result-cache.test.ts index 6467654b74..2b7a8638e9 100644 --- a/packages/react-doctor/tests/scan-result-cache.test.ts +++ b/packages/react-doctor/tests/scan-result-cache.test.ts @@ -9,6 +9,7 @@ import { buildScanResultCacheKey, createScanResultCacheInvocationState, createScanResultCache, + resolveScanResultToolchainFingerprint, shouldStoreScanPayload, type CachedScanPayload, } from "../src/cli/utils/scan-result-cache.js"; @@ -147,6 +148,14 @@ afterEach(() => { }); describe("scan result cache", () => { + it("fingerprints the bundled rule implementation", () => { + const fingerprint = resolveScanResultToolchainFingerprint(null).find((entry) => + entry.startsWith("oxlint-plugin-react-doctor#fingerprint="), + ); + + expect(fingerprint).toMatch(/^oxlint-plugin-react-doctor#fingerprint=[0-9a-f]+$/); + }); + it("reuses one repository identity across workspace projects", () => { const firstProjectDirectory = setupReactProject(tempDirectory, "apps/first", { files: { "src/App.tsx": "export const App = () => <div />;\n" }, diff --git a/packages/react-doctor/tests/select-projects.test.ts b/packages/react-doctor/tests/select-projects.test.ts index e1a388a09b..d7f15493dd 100644 --- a/packages/react-doctor/tests/select-projects.test.ts +++ b/packages/react-doctor/tests/select-projects.test.ts @@ -253,6 +253,22 @@ describe("selectProjects", () => { expect(cliLogger.log).toHaveBeenCalledWith(expect.stringContaining("mobile")); }); + it("discovers nested standalone Three.js projects", async () => { + const tempDirectory = createTempDirectory(); + const gameDirectory = path.join(tempDirectory, "results", "viewer"); + fs.mkdirSync(gameDirectory, { recursive: true }); + writeJson(path.join(gameDirectory, "package.json"), { + name: "viewer", + dependencies: { three: "^0.180.0" }, + }); + + const selectedDirectories = await selectProjects(tempDirectory, undefined, true); + + expect(selectedDirectories).toEqual([gameDirectory]); + expect(prompts).not.toHaveBeenCalled(); + expect(cliLogger.log).toHaveBeenCalledWith(expect.stringContaining("viewer")); + }); + it("resolves --project to the current directory when the name matches the directory basename", async () => { const tempDirectory = createTempDirectory(); writeJson(path.join(tempDirectory, "package.json"), { diff --git a/packages/react-doctor/vite.config.ts b/packages/react-doctor/vite.config.ts index 6ee26f0426..346e43e839 100644 --- a/packages/react-doctor/vite.config.ts +++ b/packages/react-doctor/vite.config.ts @@ -61,7 +61,10 @@ const copySkillsToDist = () => { export default defineConfig({ pack: [ { - entry: { cli: "./src/cli/index.ts" }, + entry: { + cli: "./src/cli/index.ts", + "project-analysis-worker": "./src/project-analysis-worker.ts", + }, deps: { // Inline pure-JS CLI deps and the Ink/React renderer so the inspected // project cannot supply a missing or incompatible React peer. Native @@ -98,28 +101,14 @@ export default defineConfig({ "confbox", "jiti", "magicast", - // The vscode-* LSP libs back `react-doctor experimental-lsp` (pulled - // in via @react-doctor/language-server). They MUST stay external: - // vscode-jsonrpc uses dynamic requires that break when bundled - // (the server would start and exit immediately). They're - // declared as runtime dependencies so the published tarball - // resolves them. - "vscode-languageserver", - "vscode-languageserver-protocol", - "vscode-languageserver-textdocument", - "vscode-jsonrpc", - "vscode-uri", - // HACK: deslop-js wraps oxc-parser / oxc-resolver, both of - // which load platform-specific NAPI bindings via require(). + // HACK: oxc-parser / oxc-resolver load platform-specific NAPI bindings via require(). // Rollup happily inlines the JS loader chain but rewrites // the native lookups to fingerprinted `./assets/*.node` // paths that never make it into the published tarball (and // also strips the standard `@oxc-{parser,resolver}/binding- - // <platform>` fallback). Keep deslop-js (and its native - // siblings) external so the loaders run untouched and Node - // resolves the bindings from the deslop-js node_modules - // tree on install — see issue #404. - "deslop-js", + // <platform>` fallback). Keep the native packages external so + // the loaders run untouched and Node resolves their bindings + // from node_modules on install — see issue #404. "oxc-parser", "oxc-resolver", "oxlint", @@ -166,7 +155,6 @@ export default defineConfig({ "confbox", "jiti", "magicast", - "deslop-js", "oxc-parser", "oxc-resolver", "oxlint", @@ -180,51 +168,8 @@ export default defineConfig({ platform: "node", fixedExtension: false, }, - { - // Dedicated language-server entry the bin shim fast-paths to for - // `react-doctor experimental-lsp`. Inlines @react-doctor/language-server + core; - // keeps the engine + LSP transport external (the vscode-* libs use - // dynamic requires that break when bundled). - entry: { lsp: "./src/lsp.ts" }, - deps: { - neverBundle: [ - "@astrojs/compiler", - // Sentry telemetry for `experimental-lsp` — kept external for the - // same reason as the CLI pack (it resolves its own OTel/native deps - // via require() at runtime). - "@sentry/node", - "deslop-js", - "oxc-parser", - "oxc-resolver", - "oxlint", - "oxlint-plugin-react-doctor", - "typescript", - "vscode-languageserver", - "vscode-languageserver-protocol", - "vscode-languageserver-textdocument", - "vscode-jsonrpc", - "vscode-uri", - ], - }, - dts: false, - target: "node20", - platform: "node", - fixedExtension: false, - }, ], test: { testTimeout: TEST_TIMEOUT_MS, - // NOTE: do NOT pin Windows onto a single serial fork - // (`singleFork` / `maxWorkers: 1` / `fileParallelism: false`). - // This suite drives the real `oxlint` binary and per-test deslop - // `worker_threads` thousands of times; funneling all ~105 test - // files through one long-lived worker lets that process accumulate - // memory/handles across the whole run and crash near the end, which - // vitest reports as "Worker exited unexpectedly" (Worker forks - // emitted error) and fails the job with 0 failed assertions. The - // default parallel + isolated forks keep each worker short-lived so - // memory is reclaimed between files — Windows CI was green 16/16 - // with this default and started crashing the moment the override - // landed. Keep Windows on the default pool. }, }); diff --git a/packages/vscode-react-doctor/.vscodeignore b/packages/vscode-react-doctor/.vscodeignore deleted file mode 100644 index dc4826e497..0000000000 --- a/packages/vscode-react-doctor/.vscodeignore +++ /dev/null @@ -1,8 +0,0 @@ -src/** -node_modules/** -tsconfig.json -.vscode/** -**/*.ts -**/*.map -.gitignore -.eslintrc* diff --git a/packages/vscode-react-doctor/README.md b/packages/vscode-react-doctor/README.md deleted file mode 100644 index 38882f3dd0..0000000000 --- a/packages/vscode-react-doctor/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# React Doctor for VS Code & Cursor - -Live React Doctor diagnostics, hovers, and quick fixes in your editor, -powered by the React Doctor language server. Works in VS Code and Cursor -(Cursor runs VS Code extensions). - -## What you get - -- **Live diagnostics** — files are re-scanned as you type, from the - unsaved buffer, with the underline on the exact offending token. -- **Hovers** — rule id, category, the rule's recommendation, and a docs - link. -- **Quick fixes** — disable a rule for the current line (with the right - `//` or `{/* … */}` comment), suppress all issues in a file, explain a - finding, open its docs, or report a false positive. -- **Commands** — Scan Workspace, Scan Current File, Suppress All Issues - in File, Restart Server, Show Output. - -## How it runs the server - -The extension launches `react-doctor experimental-lsp --stdio`. It does **not** bundle -the engine; it uses your project's own version so diagnostics match the -CLI and CI: - -1. `reactDoctor.serverPath` (if set) -2. the project's `node_modules/.bin/react-doctor` -3. `npx react-doctor@latest` (zero-config fallback) - -Add `react-doctor` to your project (`npm i -D react-doctor`) for the -fastest startup and version pinning. - -## Settings - -- `reactDoctor.enable` — turn the extension on/off (default `true`). -- `reactDoctor.serverPath` — explicit path to the `react-doctor` binary. -- `reactDoctor.scanOnType` — re-scan live as you type (default `true`); - disable to scan only on open and save. -- `reactDoctor.trace.server` — LSP trace verbosity (`off` / `messages` / - `verbose`). - -## Configuration - -The server honors your project's `react-doctor.config.json` — the same -configuration the CLI uses. No editor-specific config is required. - -## Packaging - -`pnpm run package` builds a self-contained `.vsix` (the client bundle is -produced with esbuild). Publishing to the VS Code Marketplace / Open VSX -is a follow-up. diff --git a/packages/vscode-react-doctor/package.json b/packages/vscode-react-doctor/package.json deleted file mode 100644 index 89928840a9..0000000000 --- a/packages/vscode-react-doctor/package.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "name": "vscode-react-doctor", - "displayName": "React Doctor", - "version": "0.2.11", - "private": true, - "description": "React Doctor for VS Code.", - "categories": [ - "Linters", - "Programming Languages" - ], - "keywords": [ - "diagnostics", - "lint", - "lsp", - "react", - "react-doctor" - ], - "homepage": "https://react.doctor", - "bugs": { - "url": "https://github.com/millionco/react-doctor/issues" - }, - "license": "SEE LICENSE IN LICENSE", - "repository": { - "type": "git", - "url": "https://github.com/millionco/react-doctor.git", - "directory": "packages/vscode-react-doctor" - }, - "publisher": "millionco", - "main": "./dist/extension.js", - "scripts": { - "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && esbuild src/extension.ts --bundle --platform=node --format=cjs --external:vscode --outfile=dist/extension.js", - "typecheck": "tsc --noEmit", - "dev": "esbuild src/extension.ts --bundle --platform=node --format=cjs --external:vscode --outfile=dist/extension.js --watch", - "package": "pnpm run build && npx --yes @vscode/vsce package --no-dependencies -o react-doctor.vsix" - }, - "dependencies": { - "vscode-languageclient": "^9.0.1" - }, - "devDependencies": { - "@types/node": "^25.6.0", - "@types/vscode": "^1.85.0", - "esbuild": "^0.25.0", - "typescript": "^6.0.3" - }, - "contributes": { - "commands": [ - { - "command": "react-doctor.scanWorkspace", - "title": "React Doctor: Scan Workspace" - }, - { - "command": "react-doctor.scanFile", - "title": "React Doctor: Scan Current File" - }, - { - "command": "react-doctor.fixAll", - "title": "React Doctor: Suppress All Issues in File" - }, - { - "command": "react-doctor.restart", - "title": "React Doctor: Restart Server" - }, - { - "command": "react-doctor.showOutput", - "title": "React Doctor: Show Output" - } - ], - "configuration": { - "title": "React Doctor", - "properties": { - "reactDoctor.enable": { - "type": "boolean", - "default": true, - "description": "Enable React Doctor diagnostics, hovers, and quick fixes." - }, - "reactDoctor.serverPath": { - "type": "string", - "default": "", - "scope": "machine-overridable", - "description": "Optional path to the react-doctor executable. When empty, the project's local install is used, falling back to `npx react-doctor@latest`." - }, - "reactDoctor.scanOnType": { - "type": "boolean", - "default": true, - "description": "Re-scan files live as you type (from the unsaved buffer). Disable to scan only on open and save." - }, - "reactDoctor.trace.server": { - "type": "string", - "enum": [ - "off", - "messages", - "verbose" - ], - "default": "off", - "scope": "window", - "description": "Trace the communication between the editor and the React Doctor language server." - } - } - } - }, - "activationEvents": [ - "onLanguage:html", - "onLanguage:javascript", - "onLanguage:javascriptreact", - "onLanguage:typescript", - "onLanguage:typescriptreact" - ], - "engines": { - "node": "^20.19.0 || >=22.13.0", - "vscode": "^1.85.0" - } -} diff --git a/packages/vscode-react-doctor/src/extension.ts b/packages/vscode-react-doctor/src/extension.ts deleted file mode 100644 index 6e201ab93b..0000000000 --- a/packages/vscode-react-doctor/src/extension.ts +++ /dev/null @@ -1,188 +0,0 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as vscode from "vscode"; -import { - LanguageClient, - TransportKind, - type ClientCapabilities, - type Executable, - type FeatureState, - type LanguageClientOptions, - type ServerOptions, - type StaticFeature, -} from "vscode-languageclient/node"; - -const CLIENT_ID = "reactDoctor"; -const CLIENT_NAME = "React Doctor"; -const COMMAND_SCAN_FILE = "react-doctor.scanFile"; -const COMMAND_FIX_ALL = "react-doctor.fixAll"; -const COMMAND_RESTART = "react-doctor.restart"; -const COMMAND_SHOW_OUTPUT = "react-doctor.showOutput"; -const SERVER_STATUS_METHOD = "experimental/serverStatus"; - -interface ServerStatusParams { - readonly health: "ok" | "warning" | "error"; - readonly quiescent: boolean; - readonly message?: string; -} - -/** Reflects the server's rust-analyzer-style status in the editor footer. */ -const renderStatus = (item: vscode.StatusBarItem, status: ServerStatusParams): void => { - if (!status.quiescent) { - item.text = "$(sync~spin) React Doctor"; - item.tooltip = `${CLIENT_NAME}: scanning…`; - return; - } - if (status.health === "error") item.text = "$(error) React Doctor"; - else if (status.health === "warning") item.text = "$(warning) React Doctor"; - else item.text = "$(check) React Doctor"; - item.tooltip = status.message ?? `${CLIENT_NAME}: ready`; -}; - -/** Opts into the server's `experimental/serverStatus` notification. */ -const createServerStatusFeature = (): StaticFeature => ({ - fillClientCapabilities(capabilities: ClientCapabilities) { - const experimental = - typeof capabilities.experimental === "object" && capabilities.experimental !== null - ? capabilities.experimental - : {}; - Reflect.set(experimental, "serverStatusNotification", true); - capabilities.experimental = experimental; - }, - initialize() {}, - getState(): FeatureState { - return { kind: "static" }; - }, - clear() {}, -}); - -const DOCUMENT_LANGUAGE_IDS = [ - "typescript", - "typescriptreact", - "javascript", - "javascriptreact", - "html", -] as const; -const ACTIVE_FILE_COMMANDS = new Set([COMMAND_SCAN_FILE, COMMAND_FIX_ALL]); -const IS_WINDOWS = process.platform === "win32"; - -let client: LanguageClient | undefined; - -interface ResolvedServer { - readonly command: string; - readonly args: string[]; - readonly shell: boolean; -} - -/** - * Resolves how to launch `react-doctor experimental-lsp --stdio`, preferring - * the project's own install so the editor uses the exact version pinned in - * the repo, then falling back to `npx` so the extension works with zero - * setup: - * 1. `reactDoctor.serverPath` setting (explicit override) - * 2. workspace `node_modules/.bin/react-doctor` - * 3. `npx react-doctor@latest` - */ -const resolveServer = (configuration: vscode.WorkspaceConfiguration): ResolvedServer => { - const explicitPath = configuration.get<string>("serverPath", "").trim(); - if (explicitPath.length > 0) { - return { command: explicitPath, args: ["experimental-lsp", "--stdio"], shell: false }; - } - - const binName = IS_WINDOWS ? "react-doctor.cmd" : "react-doctor"; - for (const folder of vscode.workspace.workspaceFolders ?? []) { - const localBin = path.join(folder.uri.fsPath, "node_modules", ".bin", binName); - if (fs.existsSync(localBin)) { - return { command: localBin, args: ["experimental-lsp", "--stdio"], shell: IS_WINDOWS }; - } - } - - return { - command: IS_WINDOWS ? "npx.cmd" : "npx", - args: ["-y", "react-doctor@latest", "experimental-lsp", "--stdio"], - shell: IS_WINDOWS, - }; -}; - -export const activate = async (context: vscode.ExtensionContext): Promise<void> => { - const configuration = vscode.workspace.getConfiguration(CLIENT_ID); - if (!configuration.get<boolean>("enable", true)) return; - - const outputChannel = vscode.window.createOutputChannel(CLIENT_NAME); - const resolved = resolveServer(configuration); - const executable: Executable = { - command: resolved.command, - args: resolved.args, - transport: TransportKind.stdio, - options: { shell: resolved.shell }, - }; - const serverOptions: ServerOptions = { run: executable, debug: executable }; - - const clientOptions: LanguageClientOptions = { - documentSelector: DOCUMENT_LANGUAGE_IDS.map((language) => ({ scheme: "file", language })), - outputChannel, - traceOutputChannel: outputChannel, - initializationOptions: { scanOnType: configuration.get<boolean>("scanOnType", true) }, - // The server advertises its commands, so vscode-languageclient already - // registers them as editor commands. Intercept to fill the active file - // for file-scoped commands and to restart the client process itself. - middleware: { - executeCommand: (command, commandArguments, forwardToServer) => { - if (command === COMMAND_RESTART) return client?.restart(); - if (!ACTIVE_FILE_COMMANDS.has(command) || commandArguments.length > 0) { - return forwardToServer(command, commandArguments); - } - const activeDocumentUri = vscode.window.activeTextEditor?.document.uri.toString(); - if (activeDocumentUri === undefined) { - void vscode.window.showInformationMessage( - `${CLIENT_NAME}: open a file in the editor to run this command.`, - ); - return undefined; - } - return forwardToServer(command, [{ uri: activeDocumentUri }]); - }, - }, - }; - - const languageClient = new LanguageClient(CLIENT_ID, CLIENT_NAME, serverOptions, clientOptions); - client = languageClient; - languageClient.registerFeature(createServerStatusFeature()); - - const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); - statusBarItem.command = COMMAND_SHOW_OUTPUT; - renderStatus(statusBarItem, { - health: "ok", - quiescent: false, - message: `${CLIENT_NAME}: starting…`, - }); - statusBarItem.show(); - - context.subscriptions.push( - outputChannel, - languageClient, - statusBarItem, - vscode.commands.registerCommand(COMMAND_SHOW_OUTPUT, () => outputChannel.show()), - ); - - try { - await languageClient.start(); - languageClient.onNotification(SERVER_STATUS_METHOD, (status: ServerStatusParams) => - renderStatus(statusBarItem, status), - ); - renderStatus(statusBarItem, { health: "ok", quiescent: true }); - } catch (error) { - renderStatus(statusBarItem, { - health: "error", - quiescent: true, - message: `${CLIENT_NAME}: failed to start`, - }); - outputChannel.appendLine( - `Failed to start the React Doctor language server: ${error instanceof Error ? error.message : String(error)}`, - ); - void vscode.window.showErrorMessage( - `${CLIENT_NAME}: failed to start. Ensure Node.js is installed and "react-doctor" is available (npm i -D react-doctor).`, - ); - } -}; - -export const deactivate = (): Thenable<void> | undefined => client?.stop(); diff --git a/packages/vscode-react-doctor/tsconfig.json b/packages/vscode-react-doctor/tsconfig.json deleted file mode 100644 index f1d4c2e881..0000000000 --- a/packages/vscode-react-doctor/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022"], - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true, - "types": ["node", "vscode"] - }, - "include": ["src"] -} diff --git a/packages/zed-react-doctor/.gitignore b/packages/zed-react-doctor/.gitignore deleted file mode 100644 index 3d48c45603..0000000000 --- a/packages/zed-react-doctor/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -target/ -*.wasm diff --git a/packages/zed-react-doctor/Cargo.lock b/packages/zed-react-doctor/Cargo.lock deleted file mode 100644 index 95ccc06989..0000000000 --- a/packages/zed-react-doctor/Cargo.lock +++ /dev/null @@ -1,817 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "auditable-serde" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7bf8143dfc3c0258df908843e169b5cc5fcf76c7718bd66135ef4a9cd558c5" -dependencies = [ - "semver", - "serde", - "serde_json", - "topological-sort", -] - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "displaydoc" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "log" -version = "0.4.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" - -[[package]] -name = "memchr" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "spdx" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" -dependencies = [ - "smallvec", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "topological-sort" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "wasm-encoder" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80bb72f02e7fbf07183443b27b0f3d4144abf8c114189f2e088ed95b696a7822" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1ef0faabbbba6674e97a56bee857ccddf942785a336c8b47b42373c922a91d" -dependencies = [ - "anyhow", - "auditable-serde", - "flate2", - "indexmap", - "serde", - "serde_derive", - "serde_json", - "spdx", - "url", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "wit-bindgen" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10fb6648689b3929d56bbc7eb1acf70c9a42a29eb5358c67c10f54dbd5d695de" -dependencies = [ - "wit-bindgen-rt", - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92fa781d4f2ff6d3f27f3cc9b74a73327b31ca0dc4a3ef25a0ce2983e0e5af9b" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rt" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621" -dependencies = [ - "bitflags", - "futures", - "once_cell", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad19eec017904e04c60719592a803ee5da76cb51c81e3f6fbf9457f59db49799" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zed-react-doctor" -version = "0.1.0" -dependencies = [ - "zed_extension_api", -] - -[[package]] -name = "zed_extension_api" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0" -dependencies = [ - "serde", - "serde_json", - "wit-bindgen", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/zed-react-doctor/Cargo.toml b/packages/zed-react-doctor/Cargo.toml deleted file mode 100644 index 2062824e57..0000000000 --- a/packages/zed-react-doctor/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "zed-react-doctor" -version = "0.1.0" -edition = "2021" -publish = false - -[lib] -crate-type = ["cdylib"] - -[dependencies] -zed_extension_api = "0.7.0" - -# Detach from any parent Cargo workspace: this crate is compiled standalone by -# Zed when installed as a dev extension. -[workspace] diff --git a/packages/zed-react-doctor/LICENSE b/packages/zed-react-doctor/LICENSE deleted file mode 120000 index 30cff7403d..0000000000 --- a/packages/zed-react-doctor/LICENSE +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE \ No newline at end of file diff --git a/packages/zed-react-doctor/README.md b/packages/zed-react-doctor/README.md deleted file mode 100644 index 2fa4311ce7..0000000000 --- a/packages/zed-react-doctor/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# React Doctor for Zed - -A [Zed](https://zed.dev) extension that runs the [React Doctor](https://github.com/millionco/react-doctor) language server, giving you React-specific lint, accessibility, bundle-size, and architecture diagnostics directly in the editor. - -## What it provides - -- **Live diagnostics** as you type, including unsaved buffers (no save required). -- **Precise ranges** that point at the exact offending node, not whole lines. -- **Hovers** with rule documentation for each diagnostic. -- **Quick fixes** to suppress a diagnostic inline, for `.ts`, `.tsx`, `.js`, and `.jsx` files. - -It activates for the **TypeScript**, **TSX**, and **JavaScript** languages (Zed treats JSX as part of JavaScript). - -## Requirements - -The extension launches `react-doctor experimental-lsp --stdio` and resolves the binary in this order: - -1. The project-local CLI at `node_modules/.bin/react-doctor` (when `react-doctor` is installed in the worktree). -2. A `react-doctor` binary on your `PATH`. -3. A fallback to `npx -y react-doctor@latest`. - -So the only hard requirement is **Node.js on your `PATH`**. For the fastest, version-pinned experience, install React Doctor in your project: - -```bash -npm i -D react-doctor -# or: pnpm add -D react-doctor / yarn add -D react-doctor -``` - -If neither a `react-doctor` binary nor `npx` can be found, the extension reports an error asking you to install it. - -## Installing (dev extension) - -This extension is not yet published to the Zed extension registry, so install it as a dev extension: - -1. Open Zed. -2. Open the command palette and run **`zed: extensions`** (or use the menu: **Zed → Extensions**). -3. Click **Install Dev Extension**. -4. Select this folder: `packages/zed-react-doctor`. - -Zed compiles the Rust extension to WebAssembly on install. Reload the extension from the same Extensions view after pulling changes. - -## Roadmap - -- Publishing to the Zed extension registry is a planned follow-up. diff --git a/packages/zed-react-doctor/extension.toml b/packages/zed-react-doctor/extension.toml deleted file mode 100644 index 73b128ebb2..0000000000 --- a/packages/zed-react-doctor/extension.toml +++ /dev/null @@ -1,11 +0,0 @@ -id = "react-doctor" -name = "React Doctor" -version = "0.1.0" -schema_version = 1 -authors = ["Aiden Bai"] -description = "React Doctor diagnostics, hovers, and quick fixes via the React Doctor language server." -repository = "https://github.com/millionco/react-doctor" - -[language_servers.react-doctor] -name = "React Doctor" -languages = ["TypeScript", "TSX", "JavaScript"] diff --git a/packages/zed-react-doctor/src/lib.rs b/packages/zed-react-doctor/src/lib.rs deleted file mode 100644 index 6e5d28e8a3..0000000000 --- a/packages/zed-react-doctor/src/lib.rs +++ /dev/null @@ -1,83 +0,0 @@ -use zed_extension_api::{self as zed, Command, LanguageServerId, Result, Worktree}; - -const SERVER_BINARY: &str = "react-doctor"; -const NPX_BINARY: &str = "npx"; -const NPX_PACKAGE_SPEC: &str = "react-doctor@latest"; -const LOCAL_PACKAGE_MANIFEST: &str = "node_modules/react-doctor/package.json"; -const LOCAL_BIN_DIR: &str = "node_modules/.bin"; -const WINDOWS_BIN_SHIM: &str = "react-doctor.cmd"; - -struct ReactDoctorExtension; - -impl ReactDoctorExtension { - /// Resolves the project-pinned CLI shim under `node_modules/.bin`, but only - /// when the package is actually installed in the worktree. Reading the - /// manifest doubles as the existence check, since the worktree API exposes - /// no direct stat. - fn local_server(worktree: &Worktree) -> Option<String> { - worktree.read_text_file(LOCAL_PACKAGE_MANIFEST).ok()?; - - // The extension runs as Wasm, so the host platform must be queried at - // runtime rather than via compile-time `cfg!`. - let shim = match zed::current_platform() { - (zed::Os::Windows, _) => WINDOWS_BIN_SHIM, - _ => SERVER_BINARY, - }; - - Some(format!("{}/{}/{}", worktree.root_path(), LOCAL_BIN_DIR, shim)) - } -} - -impl zed::Extension for ReactDoctorExtension { - fn new() -> Self { - Self - } - - fn language_server_command( - &mut self, - _language_server_id: &LanguageServerId, - worktree: &Worktree, - ) -> Result<Command> { - let env = worktree.shell_env(); - - if let Some(command) = Self::local_server(worktree) { - return Ok(Command { - command, - args: stdio_args(), - env, - }); - } - - if let Some(command) = worktree.which(SERVER_BINARY) { - return Ok(Command { - command, - args: stdio_args(), - env, - }); - } - - if let Some(command) = worktree.which(NPX_BINARY) { - return Ok(Command { - command, - args: npx_args(), - env, - }); - } - - Err(format!( - "react-doctor language server not found. install it in your project (npm i -D {SERVER_BINARY}) or make `{NPX_BINARY}` available on your PATH." - )) - } -} - -fn stdio_args() -> Vec<String> { - vec!["experimental-lsp".into(), "--stdio".into()] -} - -fn npx_args() -> Vec<String> { - let mut args = vec!["-y".into(), NPX_PACKAGE_SPEC.into()]; - args.extend(stdio_args()); - args -} - -zed::register_extension!(ReactDoctorExtension); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cb2a6f0a6..df77fd6527 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,24 +80,54 @@ importers: '@jridgewell/trace-mapping': specifier: ^0.3.31 version: 0.3.31 + acorn: + specifier: ^8.18.0 + version: 8.18.0 + acorn-jsx: + specifier: ^5.3.2 + version: 5.3.2(acorn@8.18.0) browserslist: specifier: ^4.28.1 version: 4.28.1 confbox: specifier: ^0.2.4 version: 0.2.4 - deslop-js: - specifier: workspace:* - version: link:../deslop-js effect: specifier: 4.0.0-beta.102 version: 4.0.0-beta.102 eslint-plugin-react-hooks: specifier: ^7.1.1 version: 7.1.1(eslint@9.39.2(jiti@2.7.0)) + fast-glob: + specifier: ^3.3.3 + version: 3.3.3 jiti: specifier: ^2.7.0 version: 2.7.0 + mdast-util-from-markdown: + specifier: ^2.0.3 + version: 2.0.3 + mdast-util-mdx-expression: + specifier: ^2.0.1 + version: 2.0.1 + mdast-util-mdx-jsx: + specifier: ^3.2.0 + version: 3.2.0 + mdast-util-mdxjs-esm: + specifier: ^2.0.1 + version: 2.0.1 + micromark-extension-mdx-expression: + specifier: ^3.0.1 + version: 3.0.1 + micromark-extension-mdx-jsx: + specifier: ^3.0.2 + version: 3.0.2 + micromark-extension-mdxjs-esm: + specifier: ^3.0.0 + version: 3.0.0 + oxc-parser: + specifier: ^0.143.0 + version: 0.143.0 oxc-resolver: specifier: ^11.24.2 version: 11.24.2 @@ -107,6 +137,9 @@ importers: oxlint-plugin-react-doctor: specifier: workspace:* version: link:../oxlint-plugin-react-doctor + parse5: + specifier: ^8.0.1 + version: 8.0.1 picomatch: specifier: ^4.0.4 version: 4.0.4 @@ -130,50 +163,6 @@ importers: specifier: ^7.7.1 version: 7.7.1 - packages/deslop-cli: - dependencies: - commander: - specifier: ^14.0.3 - version: 14.0.3 - deslop-js: - specifier: workspace:* - version: link:../deslop-js - devDependencies: - '@types/node': - specifier: ^25.6.0 - version: 25.6.0 - - packages/deslop-js: - dependencies: - '@oxc-project/types': - specifier: ^0.143.0 - version: 0.143.0 - fast-glob: - specifier: ^3.3.3 - version: 3.3.3 - minimatch: - specifier: ^10.2.5 - version: 10.2.5 - oxc-parser: - specifier: ^0.143.0 - version: 0.143.0 - oxc-resolver: - specifier: ^11.24.2 - version: 11.24.2 - typescript: - specifier: '>=5.0.4 <6' - version: 5.9.3 - devDependencies: - '@types/minimatch': - specifier: ^5.1.2 - version: 5.1.2 - '@types/node': - specifier: ^25.6.0 - version: 25.6.0 - tsx: - specifier: ^4.21.0 - version: 4.22.4 - packages/eslint-plugin-react-doctor: dependencies: oxlint-plugin-react-doctor: @@ -210,25 +199,6 @@ importers: specifier: ^10.1.0 version: 10.1.0 - packages/language-server: - dependencies: - '@react-doctor/core': - specifier: workspace:* - version: link:../core - vscode-languageserver: - specifier: ^9.0.1 - version: 9.0.1 - vscode-languageserver-textdocument: - specifier: ^1.0.12 - version: 1.0.12 - vscode-uri: - specifier: ^3.1.0 - version: 3.1.0 - devDependencies: - '@types/node': - specifier: ^25.6.0 - version: 25.6.0 - packages/oxlint-plugin-react-doctor: dependencies: '@shaderfrog/glsl-parser': @@ -274,9 +244,6 @@ importers: confbox: specifier: ^0.2.4 version: 0.2.4 - deslop-js: - specifier: workspace:* - version: link:../deslop-js eslint-plugin-react-hooks: specifier: ^7.1.1 version: 7.1.1(eslint@9.39.2(jiti@2.7.0)) @@ -286,6 +253,9 @@ importers: magicast: specifier: ^0.5.3 version: 0.5.3 + oxc-parser: + specifier: ^0.143.0 + version: 0.143.0 oxc-resolver: specifier: ^11.24.2 version: 11.24.2 @@ -301,15 +271,6 @@ importers: typescript: specifier: '>=5.0.4 <6' version: 5.9.3 - vscode-languageserver: - specifier: ^9.0.1 - version: 9.0.1 - vscode-languageserver-textdocument: - specifier: ^1.0.12 - version: 1.0.12 - vscode-uri: - specifier: ^3.1.0 - version: 3.1.0 yaml: specifier: ^2.9.0 version: 2.9.0 @@ -323,9 +284,6 @@ importers: '@react-doctor/core': specifier: workspace:* version: link:../core - '@react-doctor/language-server': - specifier: workspace:* - version: link:../language-server '@types/babel__code-frame': specifier: ^7.27.0 version: 7.27.0 @@ -357,25 +315,6 @@ importers: specifier: ^7.0.1 version: 7.0.1 - packages/vscode-react-doctor: - dependencies: - vscode-languageclient: - specifier: ^9.0.1 - version: 9.0.1 - devDependencies: - '@types/node': - specifier: ^25.6.0 - version: 25.6.0 - '@types/vscode': - specifier: ^1.85.0 - version: 1.120.0 - esbuild: - specifier: ^0.25.0 - version: 0.25.12 - typescript: - specifier: ^6.0.3 - version: 6.0.3 - packages: '@alcalzone/ansi-tokenize@0.3.0': @@ -660,12 +599,6 @@ packages: '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -678,12 +611,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} @@ -696,12 +623,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} @@ -714,12 +635,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} @@ -732,12 +647,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} @@ -750,12 +659,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} @@ -768,12 +671,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} @@ -786,12 +683,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} @@ -804,12 +695,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} @@ -822,12 +707,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} @@ -840,12 +719,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} @@ -858,12 +731,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} @@ -876,12 +743,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} @@ -894,12 +755,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} @@ -912,12 +767,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} @@ -930,12 +779,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} @@ -948,12 +791,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} @@ -966,12 +803,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} @@ -984,12 +815,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} @@ -1002,12 +827,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} @@ -1020,12 +839,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} @@ -1038,12 +851,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} @@ -1056,12 +863,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} @@ -1074,12 +875,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} @@ -1092,12 +887,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} @@ -1110,12 +899,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} @@ -2487,20 +2270,32 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/minimatch@5.1.2': - resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -2520,8 +2315,11 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - '@types/vscode@1.120.0': - resolution: {integrity: sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -2717,6 +2515,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -2799,10 +2602,6 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2820,13 +2619,6 @@ packages: brace-expansion@1.1.13: resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - brace-expansion@2.1.1: - resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} - - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} - braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2857,6 +2649,9 @@ packages: caniuse-lite@1.0.30001769: resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -2869,6 +2664,18 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -2974,6 +2781,9 @@ packages: supports-color: optional: true + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2981,6 +2791,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -2989,6 +2803,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3022,6 +2839,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3055,11 +2876,6 @@ packages: es-toolkit@1.48.1: resolution: {integrity: sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==} - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -3139,6 +2955,12 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -3395,6 +3217,15 @@ packages: react-devtools-core: optional: true + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3411,6 +3242,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ci@2.0.0: resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} engines: {node: '>=20'} @@ -3504,73 +3338,36 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - lightningcss-darwin-arm64@1.33.0: resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - lightningcss-darwin-x64@1.33.0: resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - lightningcss-freebsd-x64@1.33.0: resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - lightningcss-linux-arm-gnueabihf@1.33.0: resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - lightningcss-linux-arm64-gnu@1.33.0: resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} @@ -3578,13 +3375,6 @@ packages: os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} @@ -3592,13 +3382,6 @@ packages: os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} @@ -3606,13 +3389,6 @@ packages: os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} @@ -3620,34 +3396,18 @@ packages: os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.30.2: - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - lightningcss-win32-arm64-msvc@1.33.0: resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - lightningcss-win32-x64-msvc@1.33.0: resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} - engines: {node: '>= 12.0.0'} - lightningcss@1.33.0: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} @@ -3676,6 +3436,9 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3689,10 +3452,109 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -3713,17 +3575,9 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@5.1.9: - resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} - engines: {node: '>=10'} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -3870,10 +3724,16 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-passwd@1.0.0: resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} engines: {node: '>=0.10.0'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + patch-console@2.0.0: resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4137,6 +3997,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -4189,11 +4052,7 @@ packages: tinyexec@1.1.1: resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} - engines: {node: '>=18'} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} + engines: {node: '>=18'} tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} @@ -4263,6 +4122,21 @@ packages: resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} engines: {node: '>=18.17'} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -4283,6 +4157,9 @@ packages: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + vite-plus@0.1.20: resolution: {integrity: sha512-hxJqXTxiiFhszwAeD0MvKlztVuXE4TztTdJ64BPxGqgY67F0PDa5eZkUsrN91Ae8aYUMfweW6V/J57OUO9/0zw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4369,30 +4246,6 @@ packages: jsdom: optional: true - vscode-jsonrpc@8.2.0: - resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} - engines: {node: '>=14.0.0'} - - vscode-languageclient@9.0.1: - resolution: {integrity: sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==} - engines: {vscode: ^1.82.0} - - vscode-languageserver-protocol@3.17.5: - resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} - - vscode-languageserver-textdocument@1.0.12: - resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} - - vscode-languageserver-types@3.17.5: - resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} - - vscode-languageserver@9.0.1: - resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} - hasBin: true - - vscode-uri@3.1.0: - resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} - webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -4508,6 +4361,9 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@alcalzone/ansi-tokenize@0.3.0': @@ -5063,234 +4919,156 @@ snapshots: '@epic-web/invariant@1.0.0': {} - '@esbuild/aix-ppc64@0.25.12': - optional: true - '@esbuild/aix-ppc64@0.27.3': optional: true '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.25.12': - optional: true - '@esbuild/android-arm64@0.27.3': optional: true '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.25.12': - optional: true - '@esbuild/android-arm@0.27.3': optional: true '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.25.12': - optional: true - '@esbuild/android-x64@0.27.3': optional: true '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.25.12': - optional: true - '@esbuild/darwin-arm64@0.27.3': optional: true '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.25.12': - optional: true - '@esbuild/darwin-x64@0.27.3': optional: true '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.25.12': - optional: true - '@esbuild/freebsd-arm64@0.27.3': optional: true '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.25.12': - optional: true - '@esbuild/freebsd-x64@0.27.3': optional: true '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.25.12': - optional: true - '@esbuild/linux-arm64@0.27.3': optional: true '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.25.12': - optional: true - '@esbuild/linux-arm@0.27.3': optional: true '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.25.12': - optional: true - '@esbuild/linux-ia32@0.27.3': optional: true '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.25.12': - optional: true - '@esbuild/linux-loong64@0.27.3': optional: true '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.25.12': - optional: true - '@esbuild/linux-mips64el@0.27.3': optional: true '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.25.12': - optional: true - '@esbuild/linux-ppc64@0.27.3': optional: true '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.25.12': - optional: true - '@esbuild/linux-riscv64@0.27.3': optional: true '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.25.12': - optional: true - '@esbuild/linux-s390x@0.27.3': optional: true '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.25.12': - optional: true - '@esbuild/linux-x64@0.27.3': optional: true '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.25.12': - optional: true - '@esbuild/netbsd-arm64@0.27.3': optional: true '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.25.12': - optional: true - '@esbuild/netbsd-x64@0.27.3': optional: true '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.25.12': - optional: true - '@esbuild/openbsd-arm64@0.27.3': optional: true '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.25.12': - optional: true - '@esbuild/openbsd-x64@0.27.3': optional: true '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.25.12': - optional: true - '@esbuild/openharmony-arm64@0.27.3': optional: true '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.25.12': - optional: true - '@esbuild/sunos-x64@0.27.3': optional: true '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.25.12': - optional: true - '@esbuild/win32-arm64@0.27.3': optional: true '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.25.12': - optional: true - '@esbuild/win32-ia32@0.27.3': optional: true '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.25.12': - optional: true - '@esbuild/win32-x64@0.27.3': optional: true @@ -6344,15 +6122,31 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} '@types/esrecurse@4.3.1': {} + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + '@types/estree@1.0.8': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} - '@types/minimatch@5.1.2': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} '@types/node@12.20.55': {} @@ -6373,7 +6167,9 @@ snapshots: '@types/semver@7.7.1': {} - '@types/vscode@1.120.0': {} + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} '@types/ws@8.18.1': dependencies: @@ -6426,7 +6222,7 @@ snapshots: dependencies: '@oxc-project/runtime': 0.127.0 '@oxc-project/types': 0.127.0 - lightningcss: 1.30.2 + lightningcss: 1.33.0 postcss: 8.5.6 optionalDependencies: '@types/node': 25.6.0 @@ -6443,7 +6239,7 @@ snapshots: dependencies: '@oxc-project/runtime': 0.127.0 '@oxc-project/types': 0.127.0 - lightningcss: 1.30.2 + lightningcss: 1.33.0 postcss: 8.5.6 optionalDependencies: '@types/node': 25.6.0 @@ -6486,9 +6282,9 @@ snapshots: std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.1.1 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 vite: 7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0) - ws: 8.20.0 + ws: 8.21.1 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 25.6.0 @@ -6527,9 +6323,9 @@ snapshots: std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.1.1 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 vite: 7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0) - ws: 8.20.0 + ws: 8.21.1 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 25.6.0 @@ -6564,12 +6360,14 @@ snapshots: dependencies: acorn: 8.16.0 - acorn-jsx@5.3.2(acorn@8.16.0): + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.16.0 + acorn: 8.18.0 acorn@8.16.0: {} + acorn@8.18.0: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -6660,8 +6458,6 @@ snapshots: balanced-match@1.0.2: {} - balanced-match@4.0.4: {} - base64-js@1.5.1: {} baseline-browser-mapping@2.9.19: {} @@ -6677,14 +6473,6 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.1: - dependencies: - balanced-match: 1.0.2 - - brace-expansion@5.0.6: - dependencies: - balanced-match: 4.0.4 - braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -6718,6 +6506,8 @@ snapshots: caniuse-lite@1.0.30001769: {} + ccount@2.0.1: {} + chai@6.2.2: {} chalk@4.1.2: @@ -6727,6 +6517,14 @@ snapshots: chalk@5.6.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + chardet@2.1.1: {} chownr@3.0.0: {} @@ -6820,14 +6618,24 @@ snapshots: dependencies: ms: 2.1.3 + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + deep-is@0.1.4: {} delayed-stream@1.0.0: {} + dequal@2.0.3: {} + detect-indent@6.1.0: {} detect-libc@2.1.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -6868,6 +6676,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@8.0.0: {} + env-paths@3.0.0: {} environment@1.1.0: {} @@ -6893,35 +6703,6 @@ snapshots: es-toolkit@1.48.1: {} - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -7058,8 +6839,8 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 4.2.1 esprima@4.0.1: {} @@ -7074,6 +6855,13 @@ snapshots: estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -7327,6 +7115,15 @@ snapshots: - bufferutil - utf-8-validate + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -7339,6 +7136,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-in-ci@2.0.0: {} is-interactive@2.0.0: {} @@ -7405,88 +7204,39 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-android-arm64@1.30.2: - optional: true - lightningcss-android-arm64@1.33.0: optional: true - lightningcss-darwin-arm64@1.30.2: - optional: true - lightningcss-darwin-arm64@1.33.0: optional: true - lightningcss-darwin-x64@1.30.2: - optional: true - lightningcss-darwin-x64@1.33.0: optional: true - lightningcss-freebsd-x64@1.30.2: - optional: true - lightningcss-freebsd-x64@1.33.0: optional: true - lightningcss-linux-arm-gnueabihf@1.30.2: - optional: true - lightningcss-linux-arm-gnueabihf@1.33.0: optional: true - lightningcss-linux-arm64-gnu@1.30.2: - optional: true - lightningcss-linux-arm64-gnu@1.33.0: optional: true - lightningcss-linux-arm64-musl@1.30.2: - optional: true - lightningcss-linux-arm64-musl@1.33.0: optional: true - lightningcss-linux-x64-gnu@1.30.2: - optional: true - lightningcss-linux-x64-gnu@1.33.0: optional: true - lightningcss-linux-x64-musl@1.30.2: - optional: true - lightningcss-linux-x64-musl@1.33.0: optional: true - lightningcss-win32-arm64-msvc@1.30.2: - optional: true - lightningcss-win32-arm64-msvc@1.33.0: optional: true - lightningcss-win32-x64-msvc@1.30.2: - optional: true - lightningcss-win32-x64-msvc@1.33.0: optional: true - lightningcss@1.30.2: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.30.2 - lightningcss-darwin-arm64: 1.30.2 - lightningcss-darwin-x64: 1.30.2 - lightningcss-freebsd-x64: 1.30.2 - lightningcss-linux-arm-gnueabihf: 1.30.2 - lightningcss-linux-arm64-gnu: 1.30.2 - lightningcss-linux-arm64-musl: 1.30.2 - lightningcss-linux-x64-gnu: 1.30.2 - lightningcss-linux-x64-musl: 1.30.2 - lightningcss-win32-arm64-msvc: 1.30.2 - lightningcss-win32-x64-msvc: 1.30.2 - lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 @@ -7524,6 +7274,8 @@ snapshots: long@5.3.2: {} + longest-streak@3.1.0: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -7540,8 +7292,276 @@ snapshots: math-intrinsics@1.1.0: {} + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + merge2@1.4.1: {} + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.8 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.8 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.8 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.8 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.8 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -7557,18 +7577,10 @@ snapshots: mimic-function@5.0.1: {} - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.6 - minimatch@3.1.5: dependencies: brace-expansion: 1.1.13 - minimatch@5.1.9: - dependencies: - brace-expansion: 2.1.1 - minipass@7.1.3: {} minizlib@3.1.0: @@ -7832,8 +7844,22 @@ snapshots: dependencies: callsites: 3.1.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse-passwd@1.0.0: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + patch-console@2.0.0: {} path-exists@4.0.0: {} @@ -8091,6 +8117,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -8130,7 +8161,7 @@ snapshots: terser@5.46.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.16.0 + acorn: 8.18.0 commander: 2.20.3 source-map-support: 0.5.21 optional: true @@ -8139,11 +8170,6 @@ snapshots: tinyexec@1.1.1: {} - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -8198,6 +8224,29 @@ snapshots: undici@6.26.0: {} + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universalify@0.1.2: {} update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -8214,6 +8263,11 @@ snapshots: uuid@14.0.1: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + vite-plus@0.1.20(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0))(yaml@2.9.0): dependencies: '@oxc-project/types': 0.127.0 @@ -8354,29 +8408,6 @@ snapshots: transitivePeerDependencies: - msw - vscode-jsonrpc@8.2.0: {} - - vscode-languageclient@9.0.1: - dependencies: - minimatch: 5.1.9 - semver: 7.7.4 - vscode-languageserver-protocol: 3.17.5 - - vscode-languageserver-protocol@3.17.5: - dependencies: - vscode-jsonrpc: 8.2.0 - vscode-languageserver-types: 3.17.5 - - vscode-languageserver-textdocument@1.0.12: {} - - vscode-languageserver-types@3.17.5: {} - - vscode-languageserver@9.0.1: - dependencies: - vscode-languageserver-protocol: 3.17.5 - - vscode-uri@3.1.0: {} - webidl-conversions@3.0.1: {} whatwg-url@5.0.0: @@ -8450,3 +8481,5 @@ snapshots: zod: 4.3.6 zod@4.3.6: {} + + zwitch@2.0.4: {} diff --git a/scripts/performance/build-benchmark-environment.ts b/scripts/performance/build-benchmark-environment.ts index e7ecdac963..cc6cc87826 100644 --- a/scripts/performance/build-benchmark-environment.ts +++ b/scripts/performance/build-benchmark-environment.ts @@ -32,7 +32,6 @@ export const buildBenchmarkEnvironment = ( return { ...input.baseEnvironment, CI: "1", - DESLOP_PARSE_CONCURRENCY: undefined, GIT_TERMINAL_PROMPT: "0", LC_ALL: "C", NODE_COMPILE_CACHE: path.join(input.cacheDirectory, "node-compile"), diff --git a/scripts/performance/profile-frames.ts b/scripts/performance/profile-frames.ts index 3b1716ca56..9a3d89a4ff 100644 --- a/scripts/performance/profile-frames.ts +++ b/scripts/performance/profile-frames.ts @@ -73,13 +73,6 @@ export const resolveProfileProcessRole = ( ): string => { const urls = callFrames.map((callFrame) => callFrame.url).join("\n"); if (urls.includes("packages/react-doctor/dist/cli.js")) return "react-doctor"; - if ( - urls.includes("deslop-js") || - urls.includes("entries-worker") || - urls.includes("parse-worker") - ) { - return "dead-code"; - } if (urls.includes("oxlint") || urls.includes("oxlint-plugin-react-doctor")) return "oxlint"; return "node"; }; diff --git a/scripts/smoke-packed-cli-install.ts b/scripts/smoke-packed-cli-install.ts index 42696d6e87..55f1c483c0 100644 --- a/scripts/smoke-packed-cli-install.ts +++ b/scripts/smoke-packed-cli-install.ts @@ -21,13 +21,22 @@ interface StringRecord { const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); const REPOSITORY_ROOT = path.resolve(SCRIPT_DIRECTORY, ".."); const FIXTURE_DIRECTORY = path.resolve(REPOSITORY_ROOT, "packages/core/tests/fixtures/basic-react"); +const PACKED_PACKAGE_NAMES: readonly string[] = ["react-doctor", "oxlint-plugin-react-doctor"]; const FORBIDDEN_INSTALLED_PACKAGES: readonly string[] = [ "ini", "effect", "@effect/platform-node-shared", + "deslop-cli", + "deslop-js", "ink", "ink-link", "ink-spinner", + "vscode-jsonrpc", + "vscode-languageserver", + "vscode-languageserver-protocol", + "vscode-languageserver-textdocument", + "vscode-languageserver-types", + "vscode-uri", "react-devtools-core", "react-reconciler", ]; @@ -125,23 +134,12 @@ const main = (): void => { )}\n`, ); - // Pack the CLI together with its unbundled workspace dependencies: - // changesets version-bumps and publishes them as a pinned set, so - // installing the tarballs mirrors what a release ships. The CLI keeps - // `oxlint-plugin-react-doctor` and `deslop-js` external (neverBundle — - // both wrap native binaries), so installing only the CLI tarball would - // resolve them from the registry and reject any PR before their matching - // versions are published (e.g. a workspace-locked `deslop-js@0.5.x` that - // npm has never seen). + // Pack the CLI with its unbundled rule plugin so the install mirrors the + // version-pinned packages that Changesets publishes together. runCommand({ command: "pnpm", args: [ - "--filter", - "react-doctor", - "--filter", - "oxlint-plugin-react-doctor", - "--filter", - "deslop-js", + ...PACKED_PACKAGE_NAMES.flatMap((packageName) => ["--filter", packageName]), "pack", "--pack-destination", packDirectory, @@ -151,9 +149,9 @@ const main = (): void => { }); const tarballs = fs.readdirSync(packDirectory).filter((fileName) => fileName.endsWith(".tgz")); - if (tarballs.length !== 3) { + if (tarballs.length !== PACKED_PACKAGE_NAMES.length) { console.error( - `Expected exactly three packed tarballs in ${packDirectory}, found ${tarballs.length}.`, + `Expected exactly ${PACKED_PACKAGE_NAMES.length} packed tarballs in ${packDirectory}, found ${tarballs.length}.`, ); process.exit(1); } @@ -169,6 +167,15 @@ const main = (): void => { const installedPackages = collectInstalledPackageNames( path.join(installDirectory, "node_modules"), ); + const missingPackedPackages = PACKED_PACKAGE_NAMES.filter( + (packageName) => !installedPackages.has(packageName), + ); + if (missingPackedPackages.length > 0) { + console.error( + `Packed install is missing expected package(s): ${missingPackedPackages.join(", ")}`, + ); + process.exit(1); + } const forbiddenPackages = FORBIDDEN_INSTALLED_PACKAGES.filter((packageName) => installedPackages.has(packageName), ); @@ -186,6 +193,17 @@ const main = (): void => { "bin", "react-doctor.js", ); + const projectAnalysisWorkerPath = path.join( + installDirectory, + "node_modules", + "react-doctor", + "dist", + "project-analysis-worker.js", + ); + if (!fs.existsSync(projectAnalysisWorkerPath)) { + console.error(`Packed install is missing ${projectAnalysisWorkerPath}.`); + process.exit(1); + } const versionResult = runCommand({ command: process.execPath, args: [binaryPath, "--version"], @@ -197,6 +215,52 @@ const main = (): void => { process.exit(1); } + const projectAnalysisFixtureDirectory = path.join(installDirectory, "project-analysis"); + const projectAnalysisSourceDirectory = path.join(projectAnalysisFixtureDirectory, "src"); + fs.mkdirSync(projectAnalysisSourceDirectory, { recursive: true }); + fs.writeFileSync( + path.join(projectAnalysisFixtureDirectory, "package.json"), + `${JSON.stringify({ name: "project-analysis-smoke", private: true, main: "src/index.ts", dependencies: { react: "19.2.5" } })}\n`, + ); + fs.writeFileSync( + path.join(projectAnalysisFixtureDirectory, "doctor.config.json"), + `${JSON.stringify({ rules: { "react-doctor/unused-export": "warn" } })}\n`, + ); + fs.writeFileSync( + path.join(projectAnalysisSourceDirectory, "index.ts"), + 'export { usedValue } from "./library.js";\n', + ); + fs.writeFileSync( + path.join(projectAnalysisSourceDirectory, "library.ts"), + "export const usedValue = 1;\nexport const unusedValue = 2;\n", + ); + const projectAnalysisResult = runCommand({ + command: process.execPath, + args: [ + binaryPath, + projectAnalysisFixtureDirectory, + "--no-score", + "--no-dead-code", + "--blocking", + "none", + "--json", + ], + cwd: installDirectory, + allowedStatuses: [0, 1], + }); + const projectAnalysisReport = Schema.decodeUnknownSync(JsonReport)( + JSON.parse(projectAnalysisResult.stdout), + ); + if ( + !projectAnalysisReport.diagnostics.some((diagnostic) => diagnostic.rule === "unused-export") + ) { + console.error("Packed CLI did not run the opt-in project analysis worker."); + console.error( + `Received rules: ${projectAnalysisReport.diagnostics.map((diagnostic) => diagnostic.rule).join(", ")}`, + ); + process.exit(1); + } + const scanResult = runCommand({ command: process.execPath, args: [ diff --git a/vite.config.ts b/vite.config.ts index 9a8a89b67b..1816a3d168 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,11 +11,8 @@ export default defineConfig({ "dist", "build", "node_modules", - "packages/zed-react-doctor/**", "packages/core/tests/fixtures/**", "packages/react-doctor/tests/fixtures/**", - "packages/language-server/tests/fixtures/**", - "packages/deslop-js/tests/fixtures/**", "packages/fuzz/corpus/react-bench-0.9.7-audit/**", ], plugins: ["typescript", "react", "import"], @@ -30,9 +27,6 @@ export default defineConfig({ "dist", "build", "pnpm-lock.yaml", - "packages/zed-react-doctor/**", - "packages/language-server/tests/fixtures/**", - "packages/deslop-js/tests/fixtures/**", "packages/fuzz/corpus/react-bench-0.9.7-audit*", ], },