Repository navigation
Sweep: fix skipLibCheck-masked dangling .d.ts imports across packages (+ add a guardrail) #920
Description
Summary
Several packages emit dangling relative imports in their published .d.ts files — declaration imports that point at a file that doesn't exist. Consumers compile with skipLibCheck: true (the default in most app tsconfigs), which silently swallows these unresolved imports: the imported symbol resolves to any instead of erroring. That degrades public types without any signal.
This is the root cause of the bug fixed in #919: the Gemini adapters imported the utils directory barrel as '../utils', which the declaration build emitted as '../utils.js'. But utils builds to utils/index.js — no utils.js exists — so under bundler/node16/nodenext resolution the specifier doesn't resolve (an explicit .js extension is remapped to a sibling .d.ts and does not fall back to /index). GeminiClientConfig collapsed to any in consumers, dropping every inherited GoogleGenAIOptions field (httpOptions, apiVersion, vertexai, …) and producing a spurious `httpOptions` does not exist error at call sites.
#919 fixed the 6 main Gemini adapters. A full sweep shows the same class of bug exists in 10 packages (28 specifiers total), including one still-unfixed Gemini file (experimental/text-interactions/adapter.ts).
Confirmed findings
Scanning every built packages/*/dist/**/*.d.ts for relative imports whose target file doesn't exist:
### packages/ai (5)
../middleware.js <- activities/generateAudio/index.d.ts (real barrel: activities/middleware/)
../middleware.js <- activities/generateImage/index.d.ts
../middleware.js <- activities/generateSpeech/index.d.ts
../middleware.js <- activities/generateTranscription/index.d.ts
../middleware.js <- activities/generateVideo/index.d.ts
### packages/ai-anthropic (4)
../tools.js <- text/text-provider-options.d.ts
../utils.js <- adapters/summarize.d.ts
../utils.js <- adapters/text.d.ts
./tools.js <- index.d.ts
### packages/ai-bedrock (1)
./utils.js <- index.d.ts
### packages/ai-fal (6)
../utils.js <- adapters/audio.d.ts | image.d.ts | speech.d.ts | transcription.d.ts | video.d.ts
./utils.js <- index.d.ts
### packages/ai-gemini (1)
../../utils.js <- experimental/text-interactions/adapter.d.ts (missed by #919)
### packages/ai-grok (4)
../utils.js <- adapters/image.d.ts | summarize.d.ts | text.d.ts | video.d.ts
### packages/ai-groq (2)
../utils.js <- adapters/text.d.ts | tts.d.ts
### packages/ai-mistral (1)
../utils.js <- adapters/text.d.ts
### packages/ai-ollama (1)
./tools.js <- index.d.ts
### packages/ai-openrouter (3)
../utils.js <- adapters/image.d.ts
./tools.js <- index.d.ts
./utils.js <- index.d.ts
TOTAL: 28 dangling specifiers across 10 packages
Each one is a directory barrel (utils/, tools/, middleware/) imported bare and emitted with a .js extension that resolves to nothing. Whether it produces a visible symptom depends on what the barrel re-exports (a re-exported type that extends a third-party interface is the worst case — it becomes any and drops fields, exactly like GeminiClientConfig), but all 28 are latent public-API-type defects.
Proposed work
1. Fix all 28 dangling imports
Switch bare directory-barrel imports to concrete module paths so the emitted declaration resolves, matching the convention already used in ai-openai (import … from '../utils/client'). Per-package, low-risk (this is exactly what #919 did for the main Gemini adapters). Alternatively use explicit '../utils/index'.
2. Add a guardrail so it can't regress
skipLibCheck on the consumer side is why this went unnoticed, so we need a check on the producer side:
- Recommended — declaration-import lint over built
dist/**/*.d.ts. Deterministic, fast, no third-partynode_modulesnoise; fails CI if any relative declaration import doesn't resolve to a real file (respecting the no-/index-fallback rule for explicit.js). A working scanner is below; it could become atest:dtsNx target or fold intotest:build. - Alternative — flip
skipLibCheck: falseon an isolated declarations-only consumer project per package. Closer to "turn skipLibCheck off" but noisier: with it off, tsc also checks third-party dependency.d.ts, which routinely have their own errors — so it needs careful scoping (e.g.pathsmapping only our packages) to avoid false positives. - Also worth evaluating
@arethetypeswrong/clias a published-types check under multiple resolution modes.
3. (Optional) fix it centrally upstream
The .js-for-a-directory emit comes from the shared @tanstack/vite-config declaration step. If that can emit '../utils/index.js' for directory barrels, the whole class is fixed at the source without touching each import. That package lives outside this repo, so per-package fixes (step 1) are the practical path here; note it as a follow-up.
Reproduction / scanner
// scan-dangling-dts.mjs — run from repo root after `nx run-many --targets=build --exclude='examples/**,testing/**'`
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
import { dirname, resolve, join } from 'node:path'
const ROOT = process.cwd()
function walk(dir, out = []) {
for (const e of readdirSync(dir)) {
const p = join(dir, e); const st = statSync(p)
if (st.isDirectory()) { if (e !== 'node_modules') walk(p, out) }
else if (e.endsWith('.d.ts')) out.push(p)
}
return out
}
const RE = /(?:from|import)\s*\(?\s*['"](\.\.?\/[^'"]+)['"]/g
const findings = []
const dists = readdirSync(join(ROOT, 'packages'))
.map((p) => join(ROOT, 'packages', p, 'dist')).filter((d) => existsSync(d))
for (const dist of dists) for (const file of walk(dist)) {
const src = readFileSync(file, 'utf8'); let m
while ((m = RE.exec(src))) {
const spec = m[1]; const abs = resolve(dirname(file), spec); let ok
if (/\.(js|mjs|cjs)$/.test(spec)) {
const noext = abs.replace(/\.(js|mjs|cjs)$/, '')
ok = ['.d.ts', '.d.mts', '.d.cts', '.ts', '.tsx'].some((x) => existsSync(noext + x)) // no /index fallback
} else {
ok = ['.d.ts', '.ts', '.tsx'].some((x) => existsSync(abs + x)) ||
['/index.d.ts', '/index.ts'].some((x) => existsSync(abs + x))
}
if (!ok) findings.push(`${spec} <- ${file.replace(ROOT + '/', '')}`)
}
}
console.log(findings.sort().join('\n') || 'clean')
console.log(`\n${findings.length} dangling specifiers`)Acceptance criteria
- All 28 dangling declaration imports resolve (scanner reports clean).
- A CI check fails on any new dangling
.d.tsimport (pnpm test:dts/scripts/scan-dangling-dts.mjs, wired intotest:pr/test:ci). -
ai-gemini/experimental/text-interactions/adapter.tsis fixed (the one fix(ai-gemini): resolve GeminiClientConfig from concrete module in adapter types #919 missed).
Context: follow-up to #919.
Progress
Barrel-import sweep + test:dts guardrail landed on branch 920-sweep-fix-skiplibcheck-masked-dangling-dts-imports-across-packages-+-add-a-guardrail.
Also ran @arethetypeswrong/cli (attw --pack) as the issue suggested. With default/strict profile every package fails for pure-ESM packaging reasons we do not care about (CJSResolvesToESM, node10 NoResolution on subpath exports). Use:
attw --pack --profile esm-only .
# optional further silence:
# attw --pack --profile esm-only --ignore-rules cjs-resolves-to-esm no-resolution .Under --profile esm-only, the barrel class is clean, but three real InternalResolutionErrors remain (node16/bundler cannot resolve these internal declaration imports):
Remaining attw InternalResolutionError (ESM-relevant)
| Package | Specifier | File | Root cause |
|---|---|---|---|
@tanstack/ai-anthropic |
'.' |
dist/esm/tools/tool-converter.d.ts |
Source import type { AnthropicTool } from '.' — bare '.' does not resolve under node16 ESM |
@tanstack/ai-bedrock |
'./model-catalog.generated' |
dist/esm/model-meta.d.ts |
Extensionless import; node16 treats .generated as the extension and looks for the wrong file (emit does not append .js because it already sees a "extension") |
@tanstack/ai-ollama |
'./meta/model-meta-llama3.1' (and siblings: deepseek-v3.1, exaone3.5, granite3.1-*, llama3.2*, llama3.3, qwen2.5*) |
dist/esm/model-meta.d.ts |
Same class: basenames contain a .N version segment, so declaration emit omits .js and node16 strips the wrong "extension" |
Note: pnpm test:dts does not catch these — it only checks that a sibling .d.ts exists via a naive path rewrite, and model-meta-llama3.1.d.ts / model-catalog.generated.d.ts do exist on disk. attw/tsc node16 resolution is stricter.
Fix plan for remaining
- anthropic —
from '.'→from './index'✅ - bedrock —
from './model-catalog.generated'→from './model-catalog.generated.js'✅ - ollama — append explicit
.json every meta import whose basename contains a version dot ✅
All three packages now pass attw --pack --profile esm-only (exit 0). pnpm test:dts remains clean.
Optional follow-up: teach scan-dangling-dts a node16-accurate rule for extensionless relative imports (or gate attw --profile esm-only in CI).
Activity
fix: resolve dangling .d.ts imports across packages (#920) (#922)