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.

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

  1. anthropicfrom '.'from './index'
  2. bedrockfrom './model-catalog.generated'from './model-catalog.generated.js'
  3. ollama — append explicit .js on 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions