Skip to content

Commit 9765070

Browse files
committed
fix: harden target resolution and zh-CN bundle selection
1 parent 4e00c11 commit 9765070

2 files changed

Lines changed: 268 additions & 0 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import path from "node:path";
2+
3+
export function parseSemverParts(version) {
4+
const m = String(version || "").trim().match(/^(\d+)\.(\d+)\.(\d+)$/);
5+
if (!m) return null;
6+
return [Number(m[1]), Number(m[2]), Number(m[3])];
7+
}
8+
9+
export function compareSemver(a, b) {
10+
const pa = parseSemverParts(a);
11+
const pb = parseSemverParts(b);
12+
if (!pa || !pb) return null;
13+
for (let i = 0; i < 3; i += 1) {
14+
if (pa[i] > pb[i]) return 1;
15+
if (pa[i] < pb[i]) return -1;
16+
}
17+
return 0;
18+
}
19+
20+
export function readVersionFromExtensionDirName(extDirOrName) {
21+
const base = path.basename(String(extDirOrName || ""));
22+
const m = base.match(/^openai\.chatgpt-(\d+\.\d+\.\d+)/);
23+
return m ? m[1] : null;
24+
}
25+
26+
function pickByMtime(candidates) {
27+
const sorted = [...candidates].sort((a, b) => {
28+
const mtimeDiff = (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0);
29+
if (mtimeDiff !== 0) return mtimeDiff;
30+
return String(a.dir).localeCompare(String(b.dir));
31+
});
32+
return sorted[0];
33+
}
34+
35+
function formatCandidateLines(candidates) {
36+
return candidates
37+
.map((c) => `- ${c.dir}${c.version ? ` (version: ${c.version})` : ""}`)
38+
.join("\n");
39+
}
40+
41+
export function chooseBestExtensionCandidate(candidates, { strictTarget = true } = {}) {
42+
if (!Array.isArray(candidates) || candidates.length === 0) {
43+
throw new Error("No openai.chatgpt extension candidates found.");
44+
}
45+
46+
const normalized = candidates.map((c) => ({
47+
...c,
48+
version: c.version || readVersionFromExtensionDirName(c.dir),
49+
}));
50+
51+
const parsed = normalized.filter((c) => parseSemverParts(c.version));
52+
53+
if (parsed.length === 0) {
54+
if (strictTarget) {
55+
throw new Error(
56+
[
57+
"Cannot resolve extension target: no semver-parsable candidates.",
58+
"Candidates:",
59+
formatCandidateLines(normalized),
60+
"Please rerun with --extDir <path>.",
61+
].join("\n")
62+
);
63+
}
64+
return pickByMtime(normalized);
65+
}
66+
67+
let maxVersion = parsed[0].version;
68+
for (let i = 1; i < parsed.length; i += 1) {
69+
const cmp = compareSemver(parsed[i].version, maxVersion);
70+
if (cmp != null && cmp > 0) {
71+
maxVersion = parsed[i].version;
72+
}
73+
}
74+
75+
const highest = parsed.filter((c) => compareSemver(c.version, maxVersion) === 0);
76+
if (highest.length === 1) {
77+
return highest[0];
78+
}
79+
80+
if (strictTarget) {
81+
throw new Error(
82+
[
83+
`Ambiguous extension target: multiple candidates share highest version ${maxVersion}.`,
84+
"Candidates:",
85+
formatCandidateLines(highest),
86+
"Please rerun with --extDir <path>.",
87+
].join("\n")
88+
);
89+
}
90+
91+
return pickByMtime(highest);
92+
}
93+
94+
export function parseEntryBundleFromIndexHtml(html) {
95+
const m = String(html || "").match(/src="\.\/assets\/(index-[^"]+\.js)"/);
96+
return m ? m[1] : null;
97+
}
98+
99+
function extractZhCnBundleRefs(entryJs) {
100+
if (typeof entryJs !== "string") return [];
101+
const matches = entryJs.match(/zh-CN-[A-Za-z0-9_-]+\.js/g) || [];
102+
return [...new Set(matches)];
103+
}
104+
105+
export function chooseZhCnBundleName({ assetNames, entryJs, strictTarget = true }) {
106+
const assets = Array.isArray(assetNames) ? assetNames : [];
107+
const zhCandidates = assets.filter((n) => /^zh-CN-.*\.js$/.test(n));
108+
109+
if (zhCandidates.length === 0) return null;
110+
111+
const entryRefs = extractZhCnBundleRefs(entryJs).filter((n) => zhCandidates.includes(n));
112+
113+
if (entryRefs.length === 1) {
114+
return entryRefs[0];
115+
}
116+
117+
if (entryRefs.length > 1) {
118+
if (strictTarget) {
119+
throw new Error(
120+
[
121+
"Ambiguous zh-CN bundle: active entry references multiple zh-CN bundles.",
122+
...entryRefs.map((n) => `- ${n}`),
123+
"Please resolve manually or rerun with strict targeting disabled.",
124+
].join("\n")
125+
);
126+
}
127+
return [...entryRefs].sort((a, b) => a.localeCompare(b))[0];
128+
}
129+
130+
if (zhCandidates.length === 1) {
131+
return zhCandidates[0];
132+
}
133+
134+
if (strictTarget) {
135+
throw new Error(
136+
[
137+
"Ambiguous zh-CN bundle: multiple bundles found, but active entry did not reference one uniquely.",
138+
...zhCandidates.map((n) => `- ${n}`),
139+
"Please resolve manually or rerun with strict targeting disabled.",
140+
].join("\n")
141+
);
142+
}
143+
144+
return [...zhCandidates].sort((a, b) => a.localeCompare(b))[0];
145+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import {
5+
chooseBestExtensionCandidate,
6+
chooseZhCnBundleName,
7+
parseEntryBundleFromIndexHtml,
8+
readVersionFromExtensionDirName,
9+
} from "./target-resolution.mjs";
10+
11+
test("readVersionFromExtensionDirName extracts version", () => {
12+
assert.equal(
13+
readVersionFromExtensionDirName("openai.chatgpt-0.4.71-win32-x64"),
14+
"0.4.71"
15+
);
16+
});
17+
18+
test("chooseBestExtensionCandidate picks unique highest semver", () => {
19+
const picked = chooseBestExtensionCandidate([
20+
{ dir: "A", version: "0.4.70", mtimeMs: 10 },
21+
{ dir: "B", version: "0.4.71", mtimeMs: 1 },
22+
]);
23+
assert.equal(picked.dir, "B");
24+
});
25+
26+
test("chooseBestExtensionCandidate fails on highest-version tie in strict mode", () => {
27+
assert.throws(
28+
() =>
29+
chooseBestExtensionCandidate([
30+
{ dir: "A", version: "0.4.71", mtimeMs: 5 },
31+
{ dir: "B", version: "0.4.71", mtimeMs: 10 },
32+
]),
33+
/Ambiguous extension target/
34+
);
35+
});
36+
37+
test("chooseBestExtensionCandidate allows mtime fallback when strictTarget=false", () => {
38+
const picked = chooseBestExtensionCandidate(
39+
[
40+
{ dir: "A", version: "0.4.71", mtimeMs: 5 },
41+
{ dir: "B", version: "0.4.71", mtimeMs: 10 },
42+
],
43+
{ strictTarget: false }
44+
);
45+
assert.equal(picked.dir, "B");
46+
});
47+
48+
test("chooseBestExtensionCandidate falls back by mtime when versions are unparsable and strictTarget=false", () => {
49+
const picked = chooseBestExtensionCandidate(
50+
[
51+
{ dir: "A", version: "bad", mtimeMs: 1 },
52+
{ dir: "B", version: null, mtimeMs: 10 },
53+
],
54+
{ strictTarget: false }
55+
);
56+
assert.equal(picked.dir, "B");
57+
});
58+
59+
test("parseEntryBundleFromIndexHtml returns active entry bundle", () => {
60+
const html = '<script type="module" src="./assets/index-abc123.js"></script>';
61+
assert.equal(parseEntryBundleFromIndexHtml(html), "index-abc123.js");
62+
});
63+
64+
test("parseEntryBundleFromIndexHtml returns null when entry is missing", () => {
65+
assert.equal(parseEntryBundleFromIndexHtml("<html></html>"), null);
66+
});
67+
68+
test("chooseZhCnBundleName prefers bundle referenced by entry js", () => {
69+
const entryJs = 'const x = "./zh-CN-B4K5_9qj.js";';
70+
const picked = chooseZhCnBundleName({
71+
assetNames: ["zh-CN-AAAA.js", "zh-CN-B4K5_9qj.js"],
72+
entryJs,
73+
});
74+
assert.equal(picked, "zh-CN-B4K5_9qj.js");
75+
});
76+
77+
test("chooseZhCnBundleName falls back when only one zh-CN asset exists", () => {
78+
const picked = chooseZhCnBundleName({
79+
assetNames: ["zh-CN-only.js"],
80+
entryJs: "const x = 1;",
81+
});
82+
assert.equal(picked, "zh-CN-only.js");
83+
});
84+
85+
test("chooseZhCnBundleName fails when ambiguous and no entry reference", () => {
86+
assert.throws(
87+
() =>
88+
chooseZhCnBundleName({
89+
assetNames: ["zh-CN-a.js", "zh-CN-b.js"],
90+
entryJs: "const x = 1;",
91+
}),
92+
/Ambiguous zh-CN bundle/
93+
);
94+
});
95+
96+
test("chooseZhCnBundleName fails when entry references multiple zh-CN bundles", () => {
97+
const entryJs = '"./zh-CN-a.js";"./zh-CN-b.js";';
98+
assert.throws(
99+
() =>
100+
chooseZhCnBundleName({
101+
assetNames: ["zh-CN-a.js", "zh-CN-b.js"],
102+
entryJs,
103+
}),
104+
/Ambiguous zh-CN bundle/
105+
);
106+
});
107+
108+
test("chooseZhCnBundleName allows fallback when strictTarget=false", () => {
109+
const picked = chooseZhCnBundleName({
110+
assetNames: ["zh-CN-b.js", "zh-CN-a.js"],
111+
entryJs: "const x = 1;",
112+
strictTarget: false,
113+
});
114+
assert.equal(picked, "zh-CN-a.js");
115+
});
116+
117+
test("chooseZhCnBundleName returns null when no zh-CN assets exist", () => {
118+
const picked = chooseZhCnBundleName({
119+
assetNames: ["en-US.js"],
120+
entryJs: "const x = 1;",
121+
});
122+
assert.equal(picked, null);
123+
});

0 commit comments

Comments
 (0)