Skip to content

Sweep: fix skipLibCheck-masked dangling .d.ts imports across packages (+ add a guardrail) #920

Description

@tombeckenham

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-party node_modules noise; 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 a test:dts Nx target or fold into test:build.
  • Alternative — flip skipLibCheck: false on 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. paths mapping only our packages) to avoid false positives.
  • Also worth evaluating @arethetypeswrong/cli as 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

Context: follow-up to #919.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions