Skip to content

Commit fdb566f

Browse files
escapedcatclaude
andauthored
feat(resolve-extends): resolve pure-ESM presets (conventional-changelog v7/v9/v10) (#4859)
* feat(resolve-extends): resolve pure-ESM parser presets Parser presets published as pure ESM expose an exports map with only the import condition (no require/default/main). The CommonJS preset resolver could not read these and threw "No exports main defined". Add an ESM-aware node_modules fallback that runs only after CommonJS resolution fails, plus the same fallback in the test bootstrap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: update dependency conventional-commits-parser to v7 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: update dependency conventional-changelog-conventionalcommits to v10 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: update dependency conventional-changelog-angular to v9 v9 ships its own typings: createPreset() is typed as {} and the named parser export was dropped (still present at runtime). Read the parser options via createPreset().parser and drop the now-unused @ts-expect-error directives. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8a5effa commit fdb566f

16 files changed

Lines changed: 285 additions & 55 deletions

File tree

@commitlint/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"@commitlint/utils": "workspace:^",
5252
"@types/node": "^22.0.0",
5353
"@types/yargs": "^17.0.29",
54-
"conventional-commits-parser": "^6.3.0",
54+
"conventional-commits-parser": "^7.0.0",
5555
"es-toolkit": "^1.46.0"
5656
},
5757
"engines": {

@commitlint/config-conventional/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
},
3535
"dependencies": {
3636
"@commitlint/types": "workspace:^",
37-
"conventional-changelog-conventionalcommits": "^9.2.0"
37+
"conventional-changelog-conventionalcommits": "^10.0.0"
3838
},
3939
"devDependencies": {
4040
"@commitlint/lint": "workspace:^",

@commitlint/load/fixtures/parser-preset-angular/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
"name": "parser-preset-angular",
33
"version": "1.0.0",
44
"devDependencies": {
5-
"conventional-changelog-angular": "^7.0.0"
5+
"conventional-changelog-angular": "^9.0.0"
66
}
77
}

@commitlint/load/fixtures/parser-preset-conventional-without-factory/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
"name": "parser-preset-conventional-without-factory",
33
"version": "1.0.0",
44
"devDependencies": {
5-
"conventional-changelog-conventionalcommits": "^9.2.0"
5+
"conventional-changelog-conventionalcommits": "^10.0.0"
66
}
77
}

@commitlint/load/fixtures/parser-preset-conventionalcommits/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
"name": "parser-preset-conventionalcommits",
33
"version": "1.0.0",
44
"devDependencies": {
5-
"conventional-changelog-conventionalcommits": "^9.2.0"
5+
"conventional-changelog-conventionalcommits": "^10.0.0"
66
}
77
}

@commitlint/parse/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@
3535
},
3636
"dependencies": {
3737
"@commitlint/types": "workspace:^",
38-
"conventional-changelog-angular": "^8.2.0",
39-
"conventional-commits-parser": "^6.3.0"
38+
"conventional-changelog-angular": "^9.0.0",
39+
"conventional-commits-parser": "^7.0.0"
4040
},
4141
"devDependencies": {
4242
"@commitlint/test": "workspace:^",

@commitlint/parse/src/index.test.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import { test, expect } from "vitest";
2+
import type { ParserOptions } from "conventional-commits-parser";
3+
24
import parse from "./index.js";
35

6+
// conventional-changelog-angular@>=9 exposes a single default `createPreset()`
7+
// whose return is typed as `{}`; the parser options live under `.parser`.
8+
const angularParserOpts = async (): Promise<ParserOptions> => {
9+
const { default: createPreset } = await import("conventional-changelog-angular");
10+
const { parser } = (await createPreset()) as { parser: ParserOptions };
11+
return parser;
12+
};
13+
414
test("throws when called without params", async () => {
515
await expect((parse as any)()).rejects.toThrow("Expected a raw commit");
616
});
@@ -143,10 +153,8 @@ test("supports scopes with / and empty parserOpts", async () => {
143153

144154
test("ignores comments", async () => {
145155
const message = "type(some/scope): subject\n# some comment";
146-
// @ts-expect-error -- no typings
147-
const changelogOpts = await import("conventional-changelog-angular");
148156
const opts = {
149-
...changelogOpts.parser,
157+
...(await angularParserOpts()),
150158
commentChar: "#",
151159
};
152160
const actual = await parse(message, undefined, opts);
@@ -158,9 +166,7 @@ test("ignores comments", async () => {
158166

159167
test("parses inline references in subject and body", async () => {
160168
const message = "type(some/scope): subject #reference\n\nthings #reference";
161-
// @ts-expect-error -- no typings
162-
const changelogOpts = await import("conventional-changelog-angular");
163-
const actual = await parse(message, undefined, changelogOpts.parser);
169+
const actual = await parse(message, undefined, await angularParserOpts());
164170

165171
expect(actual.subject).toBe("subject #reference");
166172
expect(actual.body).toBe("");
@@ -186,10 +192,8 @@ test("parses inline references in subject and body", async () => {
186192

187193
test("filters comment lines when commentChar is set", async () => {
188194
const message = "type(scope): subject\n# this is a comment\nbody content";
189-
// @ts-expect-error -- no typings
190-
const changelogOpts = await import("conventional-changelog-angular");
191195
const opts = {
192-
...changelogOpts.parser,
196+
...(await angularParserOpts()),
193197
commentChar: "#",
194198
};
195199
const actual = await parse(message, undefined, opts);
@@ -235,11 +239,9 @@ test("allows separating -side nodes- by setting parserOpts.fieldPattern", async
235239

236240
test("parses references leading subject", async () => {
237241
const message = "#1 some subject";
238-
// @ts-expect-error -- no typings
239-
const opts = await import("conventional-changelog-angular");
240242
const {
241243
references: [actual],
242-
} = await parse(message, undefined, opts);
244+
} = await parse(message, undefined, await angularParserOpts());
243245

244246
expect(actual.issue).toBe("1");
245247
});

@commitlint/parse/src/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { Parser } from "@commitlint/types";
22

33
import { type Commit, type ParserOptions, CommitParser } from "conventional-commits-parser";
4-
// @ts-expect-error -- no typings
54
import defaultChangelogOpts from "conventional-changelog-angular";
65

76
const defaultParser: Parser = (message, options) => {
@@ -21,7 +20,12 @@ export async function parse(
2120
parser: Parser = defaultParser,
2221
parserOpts?: ParserOptions,
2322
): Promise<Commit> {
24-
const preset = await defaultChangelogOpts();
23+
// conventional-changelog-angular@>=9 ships typings that declare the preset as
24+
// `{}`; the parser options live under `.parser` at runtime.
25+
const preset = (await defaultChangelogOpts()) as {
26+
parser?: ParserOptions;
27+
parserOpts?: ParserOptions;
28+
};
2529
const defaultOpts = preset.parser || preset.parserOpts;
2630
// Support user-provided parser options passed either flat or nested under a 'parser' key
2731
const userOpts = (parserOpts as any)?.parser || parserOpts || {};

@commitlint/resolve-extends/src/index.test.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import { test, expect, vi } from "vitest";
1+
import { test, expect, vi, afterEach } from "vitest";
22
import { createRequire } from "node:module";
3+
import fs from "node:fs";
4+
import os from "node:os";
5+
import path from "node:path";
36
import { RuleConfigSeverity, UserConfig } from "@commitlint/types";
47

5-
import resolveExtends, { ResolveExtendsContext } from "./index.js";
8+
import resolveExtends, { ResolveExtendsContext, resolveFrom } from "./index.js";
69

710
const require = createRequire(import.meta.url);
811

@@ -628,3 +631,73 @@ test("should correctly merge nested configs", async () => {
628631

629632
expect(actual).toEqual(expected);
630633
});
634+
635+
const scaffoldedRoots: string[] = [];
636+
637+
afterEach(() => {
638+
for (const root of scaffoldedRoots.splice(0)) {
639+
fs.rmSync(root, { recursive: true, force: true });
640+
}
641+
});
642+
643+
// Create a throwaway `<root>/node_modules/<pkg>` layout and return <root>.
644+
const scaffoldModule = (
645+
pkg: string,
646+
manifest: Record<string, unknown>,
647+
files: Record<string, string>,
648+
): string => {
649+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "resolve-from-"));
650+
scaffoldedRoots.push(root);
651+
const pkgDir = path.join(root, "node_modules", pkg);
652+
fs.mkdirSync(pkgDir, { recursive: true });
653+
fs.writeFileSync(path.join(pkgDir, "package.json"), JSON.stringify(manifest));
654+
for (const [relative, content] of Object.entries(files)) {
655+
const file = path.join(pkgDir, relative);
656+
fs.mkdirSync(path.dirname(file), { recursive: true });
657+
fs.writeFileSync(file, content);
658+
}
659+
return root;
660+
};
661+
662+
// CommonJS resolvers cannot read an `exports` map that only declares the `import`
663+
// condition (no require/default/main), so resolveFrom must fall back to reading the
664+
// manifest itself. See conventional-changelog-angular@>=9 / conventionalcommits@>=10.
665+
test("resolveFrom resolves a pure-ESM package exposing only the import condition", () => {
666+
const root = scaffoldModule(
667+
"esm-only-preset",
668+
{
669+
name: "esm-only-preset",
670+
type: "module",
671+
exports: { types: "./index.d.ts", import: "./index.js" },
672+
},
673+
{ "index.js": "export default {};" },
674+
);
675+
676+
expect(resolveFrom("esm-only-preset", root)).toBe(
677+
path.join(root, "node_modules", "esm-only-preset", "index.js"),
678+
);
679+
});
680+
681+
test("resolveFrom resolves a subpath of a pure-ESM package", () => {
682+
const root = scaffoldModule(
683+
"esm-only-preset",
684+
{ name: "esm-only-preset", type: "module", exports: { import: "./index.js" } },
685+
{ "index.js": "export default {};", "feature.js": "export default {};" },
686+
);
687+
688+
expect(resolveFrom("esm-only-preset/feature", root)).toBe(
689+
path.join(root, "node_modules", "esm-only-preset", "feature.js"),
690+
);
691+
});
692+
693+
test("resolveFrom falls back to main when the exports map has no runtime condition", () => {
694+
const root = scaffoldModule(
695+
"types-only-preset",
696+
{ name: "types-only-preset", exports: { types: "./index.d.ts" }, main: "./main.js" },
697+
{ "main.js": "module.exports = {};" },
698+
);
699+
700+
expect(resolveFrom("types-only-preset", root)).toBe(
701+
path.join(root, "node_modules", "types-only-preset", "main.js"),
702+
);
703+
});

@commitlint/resolve-extends/src/index.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,90 @@ const pathSuffixes = ["", ".js", ".json", `${path.sep}index.js`, `${path.sep}ind
2525

2626
const specifierSuffixes = ["", ".js", ".json", "/index.js", "/index.json"];
2727

28+
/**
29+
* Recover the entry point from a package manifest, preferring the ESM
30+
* (`import`) condition of the `exports` map and falling back to `module`/`main`.
31+
*/
32+
const resolveExportsEntry = (manifest: Record<string, unknown>): string => {
33+
const fromConditions = (value: unknown): string | undefined => {
34+
if (typeof value === "string") {
35+
return value;
36+
}
37+
if (value && typeof value === "object" && !Array.isArray(value)) {
38+
const conditions = value as Record<string, unknown>;
39+
for (const key of ["import", "module", "node", "default"]) {
40+
if (key in conditions) {
41+
const resolved = fromConditions(conditions[key]);
42+
if (resolved) {
43+
return resolved;
44+
}
45+
}
46+
}
47+
}
48+
return undefined;
49+
};
50+
51+
let exp = manifest.exports;
52+
if (exp && typeof exp === "object" && !Array.isArray(exp) && "." in (exp as object)) {
53+
exp = (exp as Record<string, unknown>)["."];
54+
}
55+
56+
return (
57+
fromConditions(exp) ||
58+
(typeof manifest.module === "string" ? manifest.module : undefined) ||
59+
(typeof manifest.main === "string" ? manifest.main : undefined) ||
60+
"index.js"
61+
);
62+
};
63+
64+
/**
65+
* Pure-ESM presets such as conventional-changelog-angular@>=9 and
66+
* conventional-changelog-conventionalcommits@>=10 declare only the `import`
67+
* condition in their `exports` map (no `require`/`default`/`main`), so the
68+
* CommonJS resolvers above fail with ERR_PACKAGE_PATH_NOT_EXPORTED. Walk
69+
* node_modules from the requesting location and read the manifest ourselves.
70+
*/
71+
const resolveEsmOnly = (lookup: string, fromDir: string): string | undefined => {
72+
if (path.isAbsolute(lookup) || lookup.startsWith(".")) {
73+
return undefined;
74+
}
75+
76+
const segments = lookup.split("/");
77+
const pkgName = lookup.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
78+
const subpath = lookup.slice(pkgName.length).replace(/^\//, "");
79+
80+
let dir = fromDir;
81+
for (;;) {
82+
const pkgDir = path.join(dir, "node_modules", pkgName);
83+
const manifestPath = path.join(pkgDir, "package.json");
84+
85+
if (fs.existsSync(manifestPath)) {
86+
if (subpath) {
87+
for (const suffix of pathSuffixes) {
88+
const filename = path.join(pkgDir, subpath) + suffix;
89+
if (fs.existsSync(filename)) {
90+
return filename;
91+
}
92+
}
93+
return undefined;
94+
}
95+
96+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
97+
const resolved = path.join(pkgDir, resolveExportsEntry(manifest));
98+
if (fs.existsSync(resolved)) {
99+
return resolved;
100+
}
101+
return undefined;
102+
}
103+
104+
const parentDir = path.dirname(dir);
105+
if (parentDir === dir) {
106+
return undefined;
107+
}
108+
dir = parentDir;
109+
}
110+
};
111+
28112
export const resolveFrom = (lookup: string, parent?: string): string => {
29113
if (path.isAbsolute(lookup)) {
30114
for (const suffix of pathSuffixes) {
@@ -62,6 +146,10 @@ export const resolveFrom = (lookup: string, parent?: string): string => {
62146
*/
63147
return resolveFrom_(path.dirname(parentPath), lookup);
64148
} catch {
149+
const esmResolved = resolveEsmOnly(lookup, path.dirname(parentPath));
150+
if (esmResolved) {
151+
return esmResolved;
152+
}
65153
throw resolveError;
66154
}
67155
};

0 commit comments

Comments
 (0)