From 2a40b1ecae692d28abc99021c195247dee446bd7 Mon Sep 17 00:00:00 2001 From: Mathis Pinsault Date: Thu, 20 Aug 2026 16:16:30 +0200 Subject: [PATCH 1/2] fix: erase type-only imports in .ts/.mts/.cts before extraction Run esbuild's ts loader for plain TypeScript files too (previously only .tsx/.jsx), with verbatimModuleSyntax so only type-only imports are dropped and every value import is preserved. Co-Authored-By: Claude Fable 5 --- .changeset/erase-type-imports.md | 7 +++ docs/guide/how-it-works.md | 7 ++- docs/guide/usage.md | 7 ++- src/lib/preprocess.ts | 48 ++++++++++++------- tests/examples.test.ts | 5 +- tests/fixtures/type-only-imports/entry.ts | 8 ++++ .../fixtures/type-only-imports/local-types.ts | 1 + .../type-only-imports/reexported-types.ts | 1 + .../fixtures/type-only-imports/side-effect.ts | 1 + tests/fixtures/type-only-imports/unused.ts | 1 + tests/fixtures/type-only-imports/value.ts | 2 + tests/type-only-imports.test.ts | 43 +++++++++++++++++ tsconfig.json | 3 +- vite.config.ts | 1 + 14 files changed, 111 insertions(+), 24 deletions(-) create mode 100644 .changeset/erase-type-imports.md create mode 100644 tests/fixtures/type-only-imports/entry.ts create mode 100644 tests/fixtures/type-only-imports/local-types.ts create mode 100644 tests/fixtures/type-only-imports/reexported-types.ts create mode 100644 tests/fixtures/type-only-imports/side-effect.ts create mode 100644 tests/fixtures/type-only-imports/unused.ts create mode 100644 tests/fixtures/type-only-imports/value.ts create mode 100644 tests/type-only-imports.test.ts diff --git a/.changeset/erase-type-imports.md b/.changeset/erase-type-imports.md new file mode 100644 index 0000000..1d9d00d --- /dev/null +++ b/.changeset/erase-type-imports.md @@ -0,0 +1,7 @@ +--- +"@maastrich/hashup": patch +--- + +Erase type-only imports in `.ts` / `.mts` / `.cts` files before import extraction. Previously only `.tsx` / `.jsx` went through esbuild, so `import type { X } from "pkg/types"` in a plain `.ts` file was walked like a value import — and, when the specifier had no runtime module (a types-only package export), counted as unresolved. All TypeScript now goes through esbuild with `verbatimModuleSyntax` semantics: `import type` / `export type` / `import { type X }` vanish, every value import (unused or side-effect) is kept. + +Hashes of entries whose `.ts` graph contained type-only edges to real files change, since those files no longer contribute. diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md index c77e89d..293faef 100644 --- a/docs/guide/how-it-works.md +++ b/docs/guide/how-it-works.md @@ -5,7 +5,8 @@ 1. **Resolve the entry file** against `baseDir` (defaults to `process.cwd()`). 2. **Walk the import graph** starting at the entry. Each file is parsed with [`es-module-lexer`](https://github.com/guybedford/es-module-lexer) to extract - its static imports, plus a small dedicated parser for + its static imports (TypeScript and JSX are lowered with esbuild first, + which erases type-only imports), plus a small dedicated parser for `import.meta.glob(...)` calls. `?query` / `#fragment` suffixes are stripped, bare specifiers are mapped through the nearest `tsconfig.json` (`paths` / `baseUrl`, following `extends`), and the @@ -37,6 +38,10 @@ It does **not** depend on: ## What Is Not Included +- Type-only imports (`import type`, `export type`, `import { type X }`) in + any TypeScript file — esbuild erases them before extraction, so they are + neither walked nor reported as unresolved. Value imports are always kept, + even when unused (`verbatimModuleSyntax` semantics). - Dynamic imports and `import.meta.glob` calls whose argument is not a string literal (these are reported in `result.unresolved`). - Files outside the reachable import graph, unless passed via `extras`. diff --git a/docs/guide/usage.md b/docs/guide/usage.md index b6a42e8..38dc355 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -197,8 +197,11 @@ The resolver handles the common web/Node module formats: - JavaScript: `.js`, `.jsx`, `.mjs`, `.cjs` - JSON: `.json` -Type-only imports (`import type { ... }`) are not included in the hash because -they are erased at compile time and do not affect runtime behavior. +Type-only imports (`import type { ... }`, `export type { ... }`, +`import { type X }`) are not included in the hash — every TypeScript file is +lowered with esbuild before extraction, which erases them. They are not +reported as unresolved either, even when the specifier has no runtime +module (a types-only package export). Files of other types reached through an import (`.json`, `.yml?raw`, `.svg?url`, `import.meta.glob` matches) are hashed by content but not diff --git a/src/lib/preprocess.ts b/src/lib/preprocess.ts index 9e1f88a..95ce59a 100644 --- a/src/lib/preprocess.ts +++ b/src/lib/preprocess.ts @@ -1,24 +1,36 @@ -import { transform } from "esbuild"; +import { transform, type Loader } from "esbuild"; +const LOADERS: Record = { + ".ts": "ts", + ".mts": "ts", + ".cts": "ts", + ".tsx": "tsx", + ".jsx": "jsx", +}; + +/** + * Lower TypeScript / JSX to plain ESM before import extraction. + * + * es-module-lexer does not understand TypeScript, so `import type` and + * `export type` would otherwise surface as real imports — edges that do + * not exist at runtime (and often resolve to nothing, e.g. a types-only + * package export). esbuild erases them. `verbatimModuleSyntax` keeps + * every *value* import, even unused ones, so a side-effect import is + * never dropped from the graph. + * + * On any transform error the raw source is returned and the lexer gets + * its chance; a parse failure there is reported by the caller. + */ export async function preprocess(content: string, extension: string): Promise { + const loader = LOADERS[extension]; + if (loader === undefined) return content; try { - switch (extension) { - case ".jsx": - case ".tsx": { - const result = await transform(content, { - define: {}, - format: "esm", - loader: extension === ".jsx" ? "jsx" : "tsx", - }); - if (!result) { - return content; - } - return result.code; - } - default: { - return content; - } - } + const result = await transform(content, { + loader, + format: "esm", + tsconfigRaw: { compilerOptions: { verbatimModuleSyntax: true } }, + }); + return result.code; } catch { return content; } diff --git a/tests/examples.test.ts b/tests/examples.test.ts index 7a8083b..0049a5d 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -14,7 +14,8 @@ describe("hashup with example files", () => { // Should include dependencies expect(result.files.some((f) => f.includes("math"))).toBe(true); expect(result.files.some((f) => f.includes("helpers"))).toBe(true); - expect(result.files.some((f) => f.includes("user"))).toBe(true); + // `import type { User }` is erased before extraction — compile-time only + expect(result.files.some((f) => f.includes("user"))).toBe(false); }); test("should hash JavaScript files", async () => { @@ -105,7 +106,7 @@ describe("hashup with example files", () => { const result = await hashup("./examples/src/index.ts"); expect(result.hash).toMatchInlineSnapshot( - `"ed1c4758b6b759306f2b44feee0bbc2d06291ae490d97367043ab188ce670770"`, + `"7159cc21c362448218a62500c883524aa9708e2d8bbf2c900525c62ee8a5309f"`, ); }); }); diff --git a/tests/fixtures/type-only-imports/entry.ts b/tests/fixtures/type-only-imports/entry.ts new file mode 100644 index 0000000..e58b10e --- /dev/null +++ b/tests/fixtures/type-only-imports/entry.ts @@ -0,0 +1,8 @@ +import type { Recipe } from "@types-only/pkg/types"; +import type { Local } from "./local-types"; +import { type Inline, value } from "./value"; +import "./side-effect"; +import unused from "./unused"; +export type { Reexported } from "./reexported-types"; + +export const all: [Recipe | Local | Inline | undefined, string] = [undefined, value]; diff --git a/tests/fixtures/type-only-imports/local-types.ts b/tests/fixtures/type-only-imports/local-types.ts new file mode 100644 index 0000000..1f1b15b --- /dev/null +++ b/tests/fixtures/type-only-imports/local-types.ts @@ -0,0 +1 @@ +export type Local = string; diff --git a/tests/fixtures/type-only-imports/reexported-types.ts b/tests/fixtures/type-only-imports/reexported-types.ts new file mode 100644 index 0000000..c9b7396 --- /dev/null +++ b/tests/fixtures/type-only-imports/reexported-types.ts @@ -0,0 +1 @@ +export type Reexported = boolean; diff --git a/tests/fixtures/type-only-imports/side-effect.ts b/tests/fixtures/type-only-imports/side-effect.ts new file mode 100644 index 0000000..0361d75 --- /dev/null +++ b/tests/fixtures/type-only-imports/side-effect.ts @@ -0,0 +1 @@ +globalThis.__sideEffect = true; diff --git a/tests/fixtures/type-only-imports/unused.ts b/tests/fixtures/type-only-imports/unused.ts new file mode 100644 index 0000000..7dba35a --- /dev/null +++ b/tests/fixtures/type-only-imports/unused.ts @@ -0,0 +1 @@ +export default "unused"; diff --git a/tests/fixtures/type-only-imports/value.ts b/tests/fixtures/type-only-imports/value.ts new file mode 100644 index 0000000..7082ec6 --- /dev/null +++ b/tests/fixtures/type-only-imports/value.ts @@ -0,0 +1,2 @@ +export type Inline = number; +export const value = "v"; diff --git a/tests/type-only-imports.test.ts b/tests/type-only-imports.test.ts new file mode 100644 index 0000000..79da57b --- /dev/null +++ b/tests/type-only-imports.test.ts @@ -0,0 +1,43 @@ +import { resolve } from "node:path"; +import { describe, expect, test } from "vite-plus/test"; +import { extractImports, hashup } from "../src/index.js"; + +const ENTRY = resolve("tests/fixtures/type-only-imports/entry.ts"); +const has = (files: string[], suffix: string) => files.some((f) => f.endsWith(suffix)); + +describe("type-only imports in .ts files", () => { + test("import type / export type are erased before import extraction", async () => { + const imports = await extractImports( + "x.ts", + [ + 'import type { A } from "./a";', + 'import { type B, b } from "./b";', + 'export type { C } from "./c";', + 'import "./d";', + 'import e from "./e";', + "void b;", + ].join("\n"), + ); + expect(imports).toEqual(["./b", "./d", "./e"]); + }); + + test("same for .mts and .cts", async () => { + const src = 'import type { A } from "./a";\nimport { b } from "./b";\nvoid b;'; + expect(await extractImports("x.mts", src)).toEqual(["./b"]); + expect(await extractImports("x.cts", src)).toEqual(["./b"]); + }); + + test("unresolvable type-only specifier is neither walked nor reported", async () => { + const result = await hashup(ENTRY); + expect(result.unresolved).toEqual([]); + expect(has(result.files, "local-types.ts")).toBe(false); + expect(has(result.files, "reexported-types.ts")).toBe(false); + }); + + test("value imports survive, including unused and side-effect ones", async () => { + const result = await hashup(ENTRY); + expect(has(result.files, "value.ts")).toBe(true); + expect(has(result.files, "side-effect.ts")).toBe(true); + expect(has(result.files, "unused.ts")).toBe(true); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index fa4e5e0..33ddcc0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,7 @@ "tests/fixtures/import-meta-glob", "tests/fixtures/query-imports", "tests/fixtures/unresolved-report", - "tests/fixtures/tsconfig-paths" + "tests/fixtures/tsconfig-paths", + "tests/fixtures/type-only-imports" ] } diff --git a/vite.config.ts b/vite.config.ts index ad3288e..f57e871 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -38,6 +38,7 @@ export default defineConfig({ "tests/fixtures/query-imports", "tests/fixtures/unresolved-report", "tests/fixtures/tsconfig-paths", + "tests/fixtures/type-only-imports", ], env: { node: true, es2024: true }, globals: { NodeJS: "readonly" }, From fa9a9b1fb25a252f4384be8acfcbfe8b04d13f70 Mon Sep 17 00:00:00 2001 From: Mathis Pinsault Date: Thu, 20 Aug 2026 16:18:19 +0200 Subject: [PATCH 2/2] Delete PROMPT.md --- PROMPT.md | 112 ------------------------------------------------------ 1 file changed, 112 deletions(-) delete mode 100644 PROMPT.md diff --git a/PROMPT.md b/PROMPT.md deleted file mode 100644 index f590af5..0000000 --- a/PROMPT.md +++ /dev/null @@ -1,112 +0,0 @@ -# Task: close the fingerprint gaps found in a real monorepo - -Read `AGENTS.md` first and follow it (layout, tests, changesets). - -## Why - -`hashup` is used as the cache key for vitest runs in a large pnpm/moon monorepo -(`mobsuccess-front`, ~60 projects, ~2 900 test files). A review on 2026-08-20 -ran `hashup -c .config/hashup.json --log-level debug` per project and found -that a large share of each test's real import graph never reaches the hash. -When that happens the monorepo's task runner keeps a stale "tests passed" cache -entry for a change that should have re-run them. The measured escapes: - -| escape | measured in that repo | -| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| tsconfig `paths` aliases (`@/features/*`) | ≈ 5 185 import sites; 445 unresolved edges in one webapp's test closures, 141 / 115 / 85 / 36 in four others | -| query-suffixed imports (`./en.json?lingui`, `?raw`, `?url`) | resolver returns `…/en.json?lingui`, `readFile` throws, file is dropped with a warn-level log only | -| `import.meta.glob([...], { eager: true })` | 14 call sites (locale catalogs, image sets); matched files never walked | -| silent narrowing | all of the above is visible only at `--log-level debug`; the CLI exits 0 and prints a confident hash | - -Concrete example: for `GoToV2Link.test.tsx` the fingerprint contained the test -and the component, but not the hook the component calls -(`@/features/navigation/use-migrate-to-v2`) nor anything under it. - -## What to implement - -Keep the public API and the hash algorithm stable (sha256 over sorted per-file -content hashes). Everything below is about making the _file set_ correct. - -### 1. tsconfig `paths` (and `baseUrl`) resolution - -- For each source file, locate the nearest `tsconfig.json` walking up from the - file's directory, honouring `extends` (chains can cross package boundaries, - e.g. `configs/ts-config/tsconfig.json` extended by `webapps/*/tsconfig.json`). -- Resolve `compilerOptions.paths` / `baseUrl` with standard TypeScript semantics: - longest-prefix match, `*` capture, multiple candidate targets tried in order - (the repo uses a two-candidate fallback: - `"@/x.json": ["../../a.json", "../../b.json"]`). -- Prefer wiring this into enhanced-resolve (`plugins: [TsconfigPathsPlugin]` or - an `alias` map derived per tsconfig) over re-implementing resolution; keep - `createResolver()` cacheable — a resolver per tsconfig file, memoised by - path, is fine. -- Cache tsconfig lookups and parsed results across the run (`HashupCache` is - the natural home). Never read the same tsconfig twice. -- Option to disable: `tsconfig: false` in config / `--no-tsconfig` in the CLI, - default on. - -### 2. Query strings and fragments on specifiers - -- Strip `?query` and `#fragment` from the _specifier_ before resolving, and - from the _resolved path_ before reading. Hash the file by its real path, once, - regardless of how many query variants import it. -- Specifiers that are only meaningful with the query (e.g. `?raw` on a `.yml`) - still point at a real file: include it. - -### 3. `import.meta.glob` - -- `es-module-lexer` does not surface it. Detect `import.meta.glob(` (and - `globEager` for older code) with a small, conservative parser: accept a - string literal or an array of string literals as the first argument, including - `!` negations; ignore the options object. Anything non-literal → skip and log. -- Expand patterns relative to the importing file with `tinyglobby` - (`onlyFiles: true`), add every match as a dependency edge and walk it like a - normal import (matches may be `.ts` modules with their own imports). - -### 4. Make narrowing loud - -- Collect every unresolved specifier and every unreadable resolved path per - entry. Expose them in the programmatic result - (`{ hash, files, unresolved: [{ from, specifier, reason }] }`) and in - `--json` output. -- At default log level, print a one-line summary to stderr when the count is - non-zero (`hashup: 445 unresolved imports (run with --log-level info to -list)`); list them at `info`. -- New CLI flag `--fail-on-unresolved[=]`: exit non-zero when the count - exceeds `n` (default 0 when the flag is present). Also accepted in config as - `failOnUnresolved`. Do not count bare package specifiers that resolve into - `node_modules` — those are intentionally opaque. - -### 5. `extras` per entry already exist — document them for non-import inputs - -No code change needed, but add to `docs/`: how to fold snapshot / screenshot / -`.env*` files into an entry's hash via `extras` globs, and the trade-off vs -putting them in the task runner's own inputs. - -## Tests to add (`tests/`, fixtures under `tests/fixtures/`) - -- `tsconfig-paths.test.ts`: nested tsconfig with `extends`, wildcard and - non-wildcard aliases, two-candidate fallback, alias target outside the - importing package; assert the aliased files are in `files` and that the hash - changes when an aliased file changes. -- `query-imports.test.ts`: `./a.json?lingui`, `./b.yml?raw`, `./c.svg?url`; - same file imported with and without a query contributes once. -- `import-meta-glob.test.ts`: literal string, literal array with negation, - matched `.ts` module whose own import must be walked; non-literal argument - is skipped and reported. -- `unresolved-report.test.ts`: `unresolved` list shape, stderr summary at - default level, `--fail-on-unresolved` exit code, `node_modules` hits not - counted. -- Extend `examples.test.ts` if the examples directory gains a monorepo-style - example (recommended: a tiny `packages/a`, `webapps/b` with `@/` alias and a - `?lingui` import). - -## Done when - -- `pnpm test` green, `pnpm lint`/typecheck green, changeset added (minor). -- Re-running the monorepo's `hashup -c .config/hashup.json --log-level info` - in `webapps/lcm` reports 0 unresolved `@/…` edges and no - `Failed to hash file …?lingui` lines. (If you do not have that repo, the - examples-based test above is the proxy.) -- `README.md` / `docs/` updated: tsconfig support, query handling, - `import.meta.glob`, the unresolved report and flag.