From f86746167501636aec662a0244382834fd68680c Mon Sep 17 00:00:00 2001 From: Jason Duffett Date: Tue, 18 Aug 2026 08:32:18 +0100 Subject: [PATCH] fix: add .js extensions to relative imports (#238) Since 4.0.5 the package is "type": "module", but the emitted .d.ts files still used extensionless relative specifiers. Under moduleResolution node16/nodenext those are invalid, so consumers get TS2834 on dist/types/index.d.ts -- or, with the common skipLibCheck default, no error at all while every graphlib export silently degrades to `any`. Add the .js extension to every relative specifier in lib/** and index.ts. Only index.ts's type-only re-export had one already, so the convention was already half-applied. The change is additive: extensionless specifiers were valid only in some resolution modes, whereas .js resolves in all of them. The runtime bundles are unaffected, as esbuild inlines every relative import. To keep the convention applied, compile tsconfig.build.json with nodenext. It already scopes itself to the shipped surface (lib/** and index.ts, excluding test/**), so the build now rejects a bad specifier at the source line rather than emitting declarations that break consumers. Output is byte-identical to the previous bundler-resolved emit. tsconfig.json stays on "bundler", leaving the extensionless imports under test/** and src/** untouched. Add test/dist-types.test.ts for the half a compile of this repo cannot cover: that a dependent resolving the package through node_modules and the "exports" map gets working types. It builds declarations into a temp package alongside the real package.json, then type-checks a consumer against it under nodenext with skipLibCheck off; a @ts-expect-error on a deliberate misuse catches the degrade-to-any case. Verified: * Reproduced first: a consumer under nodenext fails with TS2834, and with skipLibCheck on loses its types silently. Both clear after. * The new test and the nodenext build config each fail when a single specifier is reverted, so both guards are load-bearing. * Backward compatibility: a consumer type-checks clean under moduleResolution node10 (commonjs and esnext), bundler, node16 and nodenext, on TypeScript 4.0, 4.5, 5.0 and 5.9. Only "classic" fails, identically before and after, as it cannot resolve node_modules. * Runtime unchanged: no relative specifier survives into any bundle, the unminified bundle differs only by the pending version string, and CJS, ESM and both IIFE bundles smoke-test working. * make lint, make build and make test all pass (259 tests). dist/ is left to be regenerated by the maintainer's release build. Closes #238 --- index.ts | 8 +-- lib/alg/bellman-ford.ts | 4 +- lib/alg/components.ts | 2 +- lib/alg/dfs.ts | 4 +- lib/alg/dijkstra-all.ts | 6 +-- lib/alg/dijkstra.ts | 6 +-- lib/alg/extract-path.ts | 2 +- lib/alg/find-cycles.ts | 4 +- lib/alg/floyd-warshall.ts | 4 +- lib/alg/index.ts | 26 +++++----- lib/alg/is-acyclic.ts | 4 +- lib/alg/postorder.ts | 4 +- lib/alg/preorder.ts | 4 +- lib/alg/prim.ts | 6 +-- lib/alg/reduce.ts | 2 +- lib/alg/shortest-paths.ts | 8 +-- lib/alg/tarjan.ts | 2 +- lib/alg/topsort.ts | 2 +- lib/graph.ts | 2 +- lib/index.ts | 4 +- lib/json.ts | 4 +- test/dist-types.test.ts | 103 ++++++++++++++++++++++++++++++++++++++ tsconfig.build.json | 2 + 23 files changed, 159 insertions(+), 54 deletions(-) create mode 100644 test/dist-types.test.ts diff --git a/index.ts b/index.ts index 9089f8f9..21ed4723 100644 --- a/index.ts +++ b/index.ts @@ -28,9 +28,9 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -export {Graph} from './lib/graph'; -export {version} from './lib/version'; -export * as json from './lib/json'; -export * as alg from './lib/alg/index'; +export {Graph} from './lib/graph.js'; +export {version} from './lib/version.js'; +export * as json from './lib/json.js'; +export * as alg from './lib/alg/index.js'; export type {GraphOptions, Edge, Path, WeightFunction, EdgeFunction, Label} from './lib/types.js'; diff --git a/lib/alg/bellman-ford.ts b/lib/alg/bellman-ford.ts index 8b4273f7..37c9086b 100644 --- a/lib/alg/bellman-ford.ts +++ b/lib/alg/bellman-ford.ts @@ -1,5 +1,5 @@ -import {Graph} from '../graph'; -import type {Edge, EdgeFunction, Path, WeightFunction} from '../types'; +import {Graph} from '../graph.js'; +import type {Edge, EdgeFunction, Path, WeightFunction} from '../types.js'; const DEFAULT_WEIGHT_FUNC: WeightFunction = () => 1; diff --git a/lib/alg/components.ts b/lib/alg/components.ts index 9f2fd447..d3f897cf 100644 --- a/lib/alg/components.ts +++ b/lib/alg/components.ts @@ -1,4 +1,4 @@ -import {Graph} from '../graph'; +import {Graph} from '../graph.js'; /** * Finds all connected components in a graph and returns an array of these components. diff --git a/lib/alg/dfs.ts b/lib/alg/dfs.ts index d3721497..0a1c6c93 100644 --- a/lib/alg/dfs.ts +++ b/lib/alg/dfs.ts @@ -1,5 +1,5 @@ -import {Graph} from '../graph'; -import {reduce} from './reduce'; +import {Graph} from '../graph.js'; +import {reduce} from './reduce.js'; /* * Pre- or post-order traversal on the input graph. diff --git a/lib/alg/dijkstra-all.ts b/lib/alg/dijkstra-all.ts index fde0a17c..8f6e7ddb 100644 --- a/lib/alg/dijkstra-all.ts +++ b/lib/alg/dijkstra-all.ts @@ -1,6 +1,6 @@ -import {Graph} from '../graph'; -import type {EdgeFunction, Path, WeightFunction} from '../types'; -import {dijkstra} from './dijkstra'; +import {Graph} from '../graph.js'; +import type {EdgeFunction, Path, WeightFunction} from '../types.js'; +import {dijkstra} from './dijkstra.js'; /** * This function finds the shortest path from each node to every other reachable node in diff --git a/lib/alg/dijkstra.ts b/lib/alg/dijkstra.ts index f78be251..3d97274e 100644 --- a/lib/alg/dijkstra.ts +++ b/lib/alg/dijkstra.ts @@ -1,6 +1,6 @@ -import {Graph} from '../graph'; -import {PriorityQueue} from '../data/priority-queue'; -import type {Edge, EdgeFunction, Path, WeightFunction} from '../types'; +import {Graph} from '../graph.js'; +import {PriorityQueue} from '../data/priority-queue.js'; +import type {Edge, EdgeFunction, Path, WeightFunction} from '../types.js'; const DEFAULT_WEIGHT_FUNC: WeightFunction = () => 1; diff --git a/lib/alg/extract-path.ts b/lib/alg/extract-path.ts index fba4b938..fe488c3c 100644 --- a/lib/alg/extract-path.ts +++ b/lib/alg/extract-path.ts @@ -1,4 +1,4 @@ -import type {Path} from '../types'; +import type {Path} from '../types.js'; interface ExtractedPath { weight: number; diff --git a/lib/alg/find-cycles.ts b/lib/alg/find-cycles.ts index 56549700..af2bbe6e 100644 --- a/lib/alg/find-cycles.ts +++ b/lib/alg/find-cycles.ts @@ -1,5 +1,5 @@ -import {Graph} from '../graph'; -import {tarjan} from './tarjan'; +import {Graph} from '../graph.js'; +import {tarjan} from './tarjan.js'; /** * Given a Graph, graph, this function returns all nodes that are part of a cycle. As there diff --git a/lib/alg/floyd-warshall.ts b/lib/alg/floyd-warshall.ts index 038f0b0e..1781c875 100644 --- a/lib/alg/floyd-warshall.ts +++ b/lib/alg/floyd-warshall.ts @@ -1,5 +1,5 @@ -import {Graph} from '../graph'; -import type {EdgeFunction, Path, WeightFunction} from '../types'; +import {Graph} from '../graph.js'; +import type {EdgeFunction, Path, WeightFunction} from '../types.js'; const DEFAULT_WEIGHT_FUNC: WeightFunction = () => 1; diff --git a/lib/alg/index.ts b/lib/alg/index.ts index a09a0a27..c5ae5afe 100644 --- a/lib/alg/index.ts +++ b/lib/alg/index.ts @@ -1,13 +1,13 @@ -export {bellmanFord} from './bellman-ford'; -export {components} from './components'; -export {dijkstra} from './dijkstra'; -export {dijkstraAll} from './dijkstra-all'; -export {findCycles} from './find-cycles'; -export {floydWarshall} from './floyd-warshall'; -export {isAcyclic} from './is-acyclic'; -export {postorder} from './postorder'; -export {preorder} from './preorder'; -export {prim} from './prim'; -export {shortestPaths} from './shortest-paths'; -export {tarjan} from './tarjan'; -export {topsort, CycleException} from './topsort'; +export {bellmanFord} from './bellman-ford.js'; +export {components} from './components.js'; +export {dijkstra} from './dijkstra.js'; +export {dijkstraAll} from './dijkstra-all.js'; +export {findCycles} from './find-cycles.js'; +export {floydWarshall} from './floyd-warshall.js'; +export {isAcyclic} from './is-acyclic.js'; +export {postorder} from './postorder.js'; +export {preorder} from './preorder.js'; +export {prim} from './prim.js'; +export {shortestPaths} from './shortest-paths.js'; +export {tarjan} from './tarjan.js'; +export {topsort, CycleException} from './topsort.js'; diff --git a/lib/alg/is-acyclic.ts b/lib/alg/is-acyclic.ts index e6f149b2..ee013ba9 100644 --- a/lib/alg/is-acyclic.ts +++ b/lib/alg/is-acyclic.ts @@ -1,5 +1,5 @@ -import {Graph} from '../graph'; -import {CycleException, topsort} from './topsort'; +import {Graph} from '../graph.js'; +import {CycleException, topsort} from './topsort.js'; /** * Given a Graph, graph, this function returns true if the graph has no cycles and returns false if it diff --git a/lib/alg/postorder.ts b/lib/alg/postorder.ts index 19cdbcc9..c03d11db 100644 --- a/lib/alg/postorder.ts +++ b/lib/alg/postorder.ts @@ -1,5 +1,5 @@ -import {Graph} from '../graph'; -import {dfs} from './dfs'; +import {Graph} from '../graph.js'; +import {dfs} from './dfs.js'; /** * Performs post-order depth first traversal on the input graph. If the graph is diff --git a/lib/alg/preorder.ts b/lib/alg/preorder.ts index 38265b3e..267b71b5 100644 --- a/lib/alg/preorder.ts +++ b/lib/alg/preorder.ts @@ -1,5 +1,5 @@ -import {Graph} from '../graph'; -import {dfs} from './dfs'; +import {Graph} from '../graph.js'; +import {dfs} from './dfs.js'; /** * Performs pre-order depth first traversal on the input graph. If the graph is diff --git a/lib/alg/prim.ts b/lib/alg/prim.ts index 887059b9..8c3f1720 100644 --- a/lib/alg/prim.ts +++ b/lib/alg/prim.ts @@ -1,6 +1,6 @@ -import {Graph} from '../graph'; -import {PriorityQueue} from '../data/priority-queue'; -import type {Edge, WeightFunction} from '../types'; +import {Graph} from '../graph.js'; +import {PriorityQueue} from '../data/priority-queue.js'; +import type {Edge, WeightFunction} from '../types.js'; /** * Prim's algorithm takes a connected undirected graph and generates a minimum spanning tree. This diff --git a/lib/alg/reduce.ts b/lib/alg/reduce.ts index a2930373..e3f60c0a 100644 --- a/lib/alg/reduce.ts +++ b/lib/alg/reduce.ts @@ -1,4 +1,4 @@ -import {Graph} from '../graph'; +import {Graph} from '../graph.js'; /* * A helper that preforms a pre- or post-order traversal on the input graph diff --git a/lib/alg/shortest-paths.ts b/lib/alg/shortest-paths.ts index 5e31690b..00b0ba91 100644 --- a/lib/alg/shortest-paths.ts +++ b/lib/alg/shortest-paths.ts @@ -1,7 +1,7 @@ -import {dijkstra} from './dijkstra'; -import {bellmanFord} from './bellman-ford'; -import {Graph} from '../graph'; -import type {EdgeFunction, Path, WeightFunction} from '../types'; +import {dijkstra} from './dijkstra.js'; +import {bellmanFord} from './bellman-ford.js'; +import {Graph} from '../graph.js'; +import type {EdgeFunction, Path, WeightFunction} from '../types.js'; export function shortestPaths( g: Graph, diff --git a/lib/alg/tarjan.ts b/lib/alg/tarjan.ts index d041c966..e943c1b3 100644 --- a/lib/alg/tarjan.ts +++ b/lib/alg/tarjan.ts @@ -1,4 +1,4 @@ -import {Graph} from '../graph'; +import {Graph} from '../graph.js'; interface VisitedEntry { onStack: boolean; diff --git a/lib/alg/topsort.ts b/lib/alg/topsort.ts index 1e601b84..9182fc4e 100644 --- a/lib/alg/topsort.ts +++ b/lib/alg/topsort.ts @@ -1,4 +1,4 @@ -import {Graph} from '../graph'; +import {Graph} from '../graph.js'; export class CycleException extends Error { constructor(message?: string) { diff --git a/lib/graph.ts b/lib/graph.ts index 60067ac0..71a2784b 100644 --- a/lib/graph.ts +++ b/lib/graph.ts @@ -1,4 +1,4 @@ -import type {Edge, EdgeLabelFactory, GraphOptions, Label, NodeLabelFactory} from './types'; +import type {Edge, EdgeLabelFactory, GraphOptions, Label, NodeLabelFactory} from './types.js'; const DEFAULT_EDGE_NAME = "\x00"; const GRAPH_NODE = "\x00"; diff --git a/lib/index.ts b/lib/index.ts index b21cadb5..162b801c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,2 +1,2 @@ -export {Graph} from './graph'; -export {version} from './version'; +export {Graph} from './graph.js'; +export {version} from './version.js'; diff --git a/lib/json.ts b/lib/json.ts index 34af61a4..fda515ea 100644 --- a/lib/json.ts +++ b/lib/json.ts @@ -1,5 +1,5 @@ -import {Graph} from './graph'; -import type {GraphOptions, Label} from './types'; +import {Graph} from './graph.js'; +import type {GraphOptions, Label} from './types.js'; interface JsonGraph { options: GraphOptions; diff --git a/test/dist-types.test.ts b/test/dist-types.test.ts new file mode 100644 index 00000000..c9b1fb82 --- /dev/null +++ b/test/dist-types.test.ts @@ -0,0 +1,103 @@ +// Regression test for the published type declarations (dagrejs/graphlib #238). +// +// tsconfig.build.json compiles with moduleResolution "nodenext", so the build +// itself now rejects an extensionless relative specifier in lib/** or index.ts. +// This test covers the half that a compile of this repo cannot: that a real +// dependent resolving @dagrejs/graphlib through node_modules and the package +// "exports" map gets working types out of the generated declarations. That +// wiring (package.json "exports" -> "types") can regress on its own, as #233 +// showed. +// +// The failure mode has two faces, and the test catches both. With skipLibCheck +// off, tsc reports TS2834 against dist/types/index.d.ts. With it on -- the +// common default -- that error is silenced while every graphlib export quietly +// degrades to `any`; the @ts-expect-error in the consumer below is what catches +// that quieter case, since it only holds if the types resolved. +// +// Declarations are built fresh into a temp package rather than read from the +// committed dist/, so this tests the current source and leaves the working tree +// alone. The real package.json is copied in unmodified, so the "exports" map +// under test is the one that ships. + +import {execFileSync} from 'child_process'; +import {copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync} from 'fs'; +import {tmpdir} from 'os'; +import {join} from 'path'; + +const repoRoot = process.cwd(); +// Resolved rather than hardcoded so this survives a hoisted or pnpm layout. +const tsc = require.resolve('typescript/bin/tsc'); + +const consumerTsconfig = { + compilerOptions: { + module: 'nodenext', + moduleResolution: 'nodenext', + target: 'es2022', + strict: true, + skipLibCheck: false, + noEmit: true, + // The consumer lives under the OS temp dir, so tsc's automatic @types + // lookup would walk up into directories we do not control and check + // whatever it found. Pin it to nothing; graphlib needs no ambient types. + types: [], + }, + include: ['src'], +}; + +// Mirrors the #238 repro: a plain consumer calling into the public API. +const consumerSource = `import {Graph, alg, json} from '@dagrejs/graphlib'; +import type {Edge} from '@dagrejs/graphlib'; + +const g = new Graph(); +g.setEdge('a', 'b'); +const edges: Edge[] = g.edges(); +alg.isAcyclic(g); +json.write(g); + +// If the declarations failed to resolve, Graph is \`any\`, this assignment stops +// erroring, and tsc flags the directive as unused (TS2578). +// @ts-expect-error nodes() returns string[], not number +const nodes: number = g.nodes(); +`; + +// tsc exits non-zero on any diagnostic, so execFileSync throws and fails the +// test; 'inherit' puts the diagnostics themselves in the jest output. +function runTsc(args: string[], cwd: string): void { + execFileSync(process.execPath, [tsc, ...args], {cwd, stdio: ['ignore', 'inherit', 'inherit']}); +} + +describe('dist type declarations', () => { + let tmpDir: string; + let consumerDir: string; + + beforeAll(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'graphlib-dist-types-')); + const packageDir = join(tmpDir, 'package'); + consumerDir = join(tmpDir, 'consumer'); + + // A stand-in for the published package: the real package.json plus + // declarations emitted from the current source. + mkdirSync(packageDir); + copyFileSync(join(repoRoot, 'package.json'), join(packageDir, 'package.json')); + runTsc(['-p', 'tsconfig.build.json', '--outDir', join(packageDir, 'dist', 'types')], repoRoot); + + // Resolve that package the way a dependent does, through node_modules + // and the "exports" map, rather than by pointing tsc at the .d.ts. + mkdirSync(join(consumerDir, 'node_modules', '@dagrejs'), {recursive: true}); + mkdirSync(join(consumerDir, 'src')); + symlinkSync(packageDir, join(consumerDir, 'node_modules', '@dagrejs', 'graphlib'), 'junction'); + writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({name: 'consumer', private: true, type: 'module'})); + writeFileSync(join(consumerDir, 'tsconfig.json'), JSON.stringify(consumerTsconfig)); + writeFileSync(join(consumerDir, 'src', 'index.ts'), consumerSource); + }, 30000); + + afterAll(() => { + if (tmpDir) { + rmSync(tmpDir, {recursive: true, force: true}); + } + }); + + it('type-check cleanly in a NodeNext consumer', () => { + runTsc(['-p', 'tsconfig.json'], consumerDir); + }, 30000); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index 0a1e4701..88d9770c 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,6 +1,8 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", "declaration": true, "declarationMap": true, "emitDeclarationOnly": true,