osv-scanner-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { compareMavenVersions, mavenVersionSeries } from "../../utils/mavenVersion.js";
3
+ function expectEqual(a, b) {
4
+ expect(compareMavenVersions(a, b), `${a} == ${b}`).toBe(0);
5
+ expect(compareMavenVersions(b, a), `${b} == ${a}`).toBe(0);
6
+ }
7
+ function expectLess(a, b) {
8
+ expect(compareMavenVersions(a, b), `${a} < ${b}`).toBeLessThan(0);
9
+ expect(compareMavenVersions(b, a), `${b} > ${a}`).toBeGreaterThan(0);
10
+ }
11
+ /** 配列内のすべてのペア(i < j)について versions[i] < versions[j] を検証する。 */
12
+ function expectStrictOrder(versions) {
13
+ for (let i = 0; i < versions.length; i++) {
14
+ expectEqual(versions[i], versions[i]);
15
+ for (let j = i + 1; j < versions.length; j++) {
16
+ expectLess(versions[i], versions[j]);
17
+ }
18
+ }
19
+ }
20
+ // Maven本家 ComparableVersionTest のテストコーパス
21
+ describe("compareMavenVersions (Maven本家テストコーパス)", () => {
22
+ it("修飾子付きバージョンの全順序", () => {
23
+ expectStrictOrder([
24
+ "1-alpha2snapshot",
25
+ "1-alpha2",
26
+ "1-alpha-123",
27
+ "1-beta-2",
28
+ "1-beta123",
29
+ "1-m2",
30
+ "1-m11",
31
+ "1-rc",
32
+ "1-cr2",
33
+ "1-rc123",
34
+ "1-SNAPSHOT",
35
+ "1",
36
+ "1-sp",
37
+ "1-sp2",
38
+ "1-sp123",
39
+ "1-abc",
40
+ "1-def",
41
+ "1-pom-1",
42
+ "1-1-snapshot",
43
+ "1-1",
44
+ "1-2",
45
+ "1-123",
46
+ ]);
47
+ });
48
+ it("数値バージョンの全順序", () => {
49
+ expectStrictOrder([
50
+ "2.0",
51
+ "2-1",
52
+ "2.0.a",
53
+ "2.0.0.a",
54
+ "2.0.2",
55
+ "2.0.123",
56
+ "2.1.0",
57
+ "2.1-a",
58
+ "2.1b",
59
+ "2.1-c",
60
+ "2.1-1",
61
+ "2.1.0.1",
62
+ "2.2",
63
+ "2.123",
64
+ "11.a2",
65
+ "11.a11",
66
+ "11.b2",
67
+ "11.b11",
68
+ "11.m2",
69
+ "11.m11",
70
+ "11",
71
+ "11.a",
72
+ "11b",
73
+ "11c",
74
+ "11m",
75
+ ]);
76
+ });
77
+ it("末尾ゼロ・区切り表記ゆれの同値", () => {
78
+ expectEqual("1", "1");
79
+ expectEqual("1", "1.0");
80
+ expectEqual("1", "1.0.0");
81
+ expectEqual("1.0", "1.0.0");
82
+ expectEqual("1", "1-0");
83
+ expectEqual("1", "1.0-0");
84
+ expectEqual("1.0", "1.0-0");
85
+ // 数字と文字の境界は区切り扱い
86
+ expectEqual("1a", "1-a");
87
+ expectEqual("1a", "1.0-a");
88
+ expectEqual("1a", "1.0.0-a");
89
+ expectEqual("1.0a", "1.0-a");
90
+ expectEqual("1.0.0a", "1.0.0-a");
91
+ expectEqual("1x", "1-x");
92
+ expectEqual("1x", "1.0-x");
93
+ expectEqual("1x", "1.0.0-x");
94
+ expectEqual("1.0x", "1.0-x");
95
+ expectEqual("1.0.0x", "1.0.0-x");
96
+ // 先頭・連続セパレータの空トークンはゼロとして補完される
97
+ expectEqual(".1", "0.1");
98
+ expectEqual("1..2", "1.0.2");
99
+ expectEqual("-1", "0-1");
100
+ });
101
+ it("修飾子のエイリアスと略記の同値", () => {
102
+ expectEqual("1ga", "1");
103
+ expectEqual("1release", "1");
104
+ expectEqual("1final", "1");
105
+ expectEqual("1cr", "1rc");
106
+ // 大文字小文字は区別しない
107
+ expectEqual("1a1", "1-alpha-1");
108
+ expectEqual("1b2", "1-beta-2");
109
+ expectEqual("1m3", "1-milestone-3");
110
+ expectEqual("1X", "1x");
111
+ expectEqual("1A", "1a");
112
+ expectEqual("1B", "1b");
113
+ expectEqual("1M", "1m");
114
+ expectEqual("1Ga", "1");
115
+ expectEqual("1GA", "1");
116
+ expectEqual("1RELEASE", "1");
117
+ expectEqual("1release", "1");
118
+ expectEqual("1RELeaSE", "1");
119
+ expectEqual("1Final", "1");
120
+ expectEqual("1FinaL", "1");
121
+ expectEqual("1FINAL", "1");
122
+ expectEqual("1Cr", "1Rc");
123
+ expectEqual("1cR", "1rC");
124
+ expectEqual("1m3", "1Milestone3");
125
+ expectEqual("1m3", "1MileStone3");
126
+ expectEqual("1m3", "1MILESTONE3");
127
+ });
128
+ it("基本的な大小関係", () => {
129
+ expectLess("1", "2");
130
+ expectLess("1.5", "2");
131
+ expectLess("1", "2.5");
132
+ expectLess("1.0", "1.1");
133
+ expectLess("1.1", "1.2");
134
+ expectLess("1.0.0", "1.1");
135
+ expectLess("1.0.1", "1.1");
136
+ expectLess("1.1", "1.2.0");
137
+ expectLess("1.0-alpha-1", "1.0");
138
+ expectLess("1.0-alpha-1", "1.0-alpha-2");
139
+ expectLess("1.0-alpha-1", "1.0-beta-1");
140
+ expectLess("1.0-beta-1", "1.0-SNAPSHOT");
141
+ expectLess("1.0-SNAPSHOT", "1.0");
142
+ expectLess("1.0-alpha-1-SNAPSHOT", "1.0-alpha-1");
143
+ expectLess("1.0", "1.0-1");
144
+ expectLess("1.0-1", "1.0-2");
145
+ expectLess("1.0.0", "1.0-1");
146
+ expectLess("2.0-1", "2.0.1");
147
+ expectLess("2.0.1-klm", "2.0.1-lmn");
148
+ expectLess("2.0.1", "2.0.1-xyz");
149
+ expectLess("2.0.1", "2.0.1-123");
150
+ expectLess("2.0.1-xyz", "2.0.1-123");
151
+ });
152
+ });
153
+ // このプロジェクト固有の要件(docs/DESIGN_TODO.md 実機確認の実例)
154
+ describe("compareMavenVersions (OSV-Scanner-MCP要件)", () => {
155
+ it("semverが扱えないMaven特有の接尾辞", () => {
156
+ expectEqual("2.17.1-RELEASE", "2.17.1");
157
+ expectEqual("2.17.1.RELEASE", "2.17.1");
158
+ expectLess("2.17.0", "2.17.1-RELEASE");
159
+ expectLess("2.17.1-RELEASE", "2.17.2");
160
+ });
161
+ it("log4j-coreの複数リリース系統(実スキャンで確認した実例)", () => {
162
+ // 2.12系バックポート < 2.1x系メインライン < 2.25系
163
+ expectStrictOrder(["2.12.2", "2.12.4", "2.14.1", "2.15.0", "2.16.0", "2.17.1", "2.25.3", "2.25.4"]);
164
+ });
165
+ it("タイムスタンプ形式の大きな数値", () => {
166
+ expectLess("20040616", "20211215");
167
+ expectLess("2.4.0", "20040616");
168
+ });
169
+ });
170
+ describe("mavenVersionSeries", () => {
171
+ it("major.minorを系統として抽出する", () => {
172
+ expect(mavenVersionSeries("2.14.1")).toEqual({ major: 2, minor: 14 });
173
+ expect(mavenVersionSeries("2.25.4")).toEqual({ major: 2, minor: 25 });
174
+ expect(mavenVersionSeries("1.0-alpha-1")).toEqual({ major: 1, minor: 0 });
175
+ expect(mavenVersionSeries("2.17.1-RELEASE")).toEqual({ major: 2, minor: 17 });
176
+ });
177
+ it("minor省略時は0とみなす", () => {
178
+ expect(mavenVersionSeries("3")).toEqual({ major: 3, minor: 0 });
179
+ expect(mavenVersionSeries("3-beta")).toEqual({ major: 3, minor: 0 });
180
+ });
181
+ it("数字で始まらないバージョンはnull(系統判定不能)", () => {
182
+ expect(mavenVersionSeries("alpha")).toBeNull();
183
+ expect(mavenVersionSeries("")).toBeNull();
184
+ });
185
+ });
@@ -0,0 +1,154 @@
1
+ import { chmod, mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterAll, describe, expect, it } from "vitest";
5
+ import { ScanToolError } from "../../errors.js";
6
+ import { detectJavaProject } from "../../utils/projectDetector.js";
7
+ const POM = "<project/>";
8
+ const tempDirs = [];
9
+ async function makeTempDir() {
10
+ const dir = await mkdtemp(path.join(os.tmpdir(), "osv-mcp-detector-"));
11
+ tempDirs.push(dir);
12
+ return dir;
13
+ }
14
+ afterAll(async () => {
15
+ await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })));
16
+ });
17
+ async function expectScanError(promise, kind) {
18
+ const error = await promise.then(() => null, (e) => e);
19
+ expect(error).toBeInstanceOf(ScanToolError);
20
+ expect(error.kind).toBe(kind);
21
+ }
22
+ describe("detectJavaProject", () => {
23
+ it("ルート直下のpom.xmlを検出する", async () => {
24
+ const dir = await makeTempDir();
25
+ await writeFile(path.join(dir, "pom.xml"), POM);
26
+ const project = await detectJavaProject(dir);
27
+ expect(project.manifests).toEqual(["pom.xml"]);
28
+ });
29
+ it("サブモジュールのpom.xmlも深さ上限内で検出する", async () => {
30
+ const dir = await makeTempDir();
31
+ await writeFile(path.join(dir, "pom.xml"), POM);
32
+ await mkdir(path.join(dir, "module-a"), { recursive: true });
33
+ await writeFile(path.join(dir, "module-a", "pom.xml"), POM);
34
+ await mkdir(path.join(dir, "a", "b", "c", "d"), { recursive: true });
35
+ await writeFile(path.join(dir, "a", "b", "c", "d", "pom.xml"), POM); // 深さ5: 対象外
36
+ const project = await detectJavaProject(dir);
37
+ expect(project.manifests.sort()).toEqual(["module-a/pom.xml", "pom.xml"]);
38
+ });
39
+ it("target等のビルド成果物ディレクトリは探索しない", async () => {
40
+ const dir = await makeTempDir();
41
+ await mkdir(path.join(dir, "target"), { recursive: true });
42
+ await writeFile(path.join(dir, "target", "pom.xml"), POM);
43
+ await expectScanError(detectJavaProject(dir), "no_manifest_found");
44
+ });
45
+ it("pom.xmlファイルのパスを直接受け付ける", async () => {
46
+ const dir = await makeTempDir();
47
+ const pomPath = path.join(dir, "pom.xml");
48
+ await writeFile(pomPath, POM);
49
+ const project = await detectJavaProject(pomPath);
50
+ expect(project.manifests).toEqual(["pom.xml"]);
51
+ });
52
+ it("対応外のファイル指定はエラー", async () => {
53
+ const dir = await makeTempDir();
54
+ const filePath = path.join(dir, "readme.txt");
55
+ await writeFile(filePath, "");
56
+ await expectScanError(detectJavaProject(filePath), "project_not_found");
57
+ });
58
+ it("存在しないパス・空文字はエラー", async () => {
59
+ await expectScanError(detectJavaProject("/no/such/path/xyz"), "project_not_found");
60
+ await expectScanError(detectJavaProject(" "), "project_not_found");
61
+ });
62
+ it("対応マニフェストが無いディレクトリはno_manifest_found", async () => {
63
+ const dir = await makeTempDir();
64
+ await expectScanError(detectJavaProject(dir), "no_manifest_found");
65
+ });
66
+ it("gradle.lockfileを検出する(buildscript-gradle.lockfileも)", async () => {
67
+ const dir = await makeTempDir();
68
+ await writeFile(path.join(dir, "gradle.lockfile"), "a:b:1.0=runtimeClasspath\n");
69
+ await writeFile(path.join(dir, "buildscript-gradle.lockfile"), "empty=classpath\n");
70
+ const project = await detectJavaProject(dir);
71
+ expect(project.manifests.sort()).toEqual(["buildscript-gradle.lockfile", "gradle.lockfile"]);
72
+ });
73
+ it("MavenとGradleの混在プロジェクトは両方のマニフェストを返す", async () => {
74
+ const dir = await makeTempDir();
75
+ await writeFile(path.join(dir, "pom.xml"), POM);
76
+ await mkdir(path.join(dir, "gradle-module"));
77
+ await writeFile(path.join(dir, "gradle-module", "gradle.lockfile"), "a:b:1.0=runtimeClasspath\n");
78
+ const project = await detectJavaProject(dir);
79
+ expect(project.manifests.sort()).toEqual(["gradle-module/gradle.lockfile", "pom.xml"]);
80
+ });
81
+ it("build.gradleはあるがlockfileが無い場合は生成手順を案内する", async () => {
82
+ const dir = await makeTempDir();
83
+ await writeFile(path.join(dir, "build.gradle"), "plugins { id 'java' }\n");
84
+ const error = await detectJavaProject(dir).then(() => null, (e) => e);
85
+ expect(error).toBeInstanceOf(ScanToolError);
86
+ expect(error.kind).toBe("gradle_lockfile_missing");
87
+ expect(error.message).toContain("--write-locks");
88
+ });
89
+ it("build.gradle.kts / settings.gradle でもGradleプロジェクトとして認識する", async () => {
90
+ for (const name of ["build.gradle.kts", "settings.gradle"]) {
91
+ const dir = await makeTempDir();
92
+ await writeFile(path.join(dir, name), "");
93
+ await expectScanError(detectJavaProject(dir), "gradle_lockfile_missing");
94
+ }
95
+ });
96
+ it("gradle.lockfileのパスを直接受け付ける", async () => {
97
+ const dir = await makeTempDir();
98
+ const lockPath = path.join(dir, "gradle.lockfile");
99
+ await writeFile(lockPath, "a:b:1.0=runtimeClasspath\n");
100
+ const project = await detectJavaProject(lockPath);
101
+ expect(project.manifests).toEqual(["gradle.lockfile"]);
102
+ });
103
+ it("build.gradleのパス直接指定はディレクトリとして解決される(lockfile無しなら案内)", async () => {
104
+ const dir = await makeTempDir();
105
+ const buildPath = path.join(dir, "build.gradle");
106
+ await writeFile(buildPath, "");
107
+ await expectScanError(detectJavaProject(buildPath), "gradle_lockfile_missing");
108
+ // lockfileがあれば正常に解決される
109
+ await writeFile(path.join(dir, "gradle.lockfile"), "a:b:1.0=runtimeClasspath\n");
110
+ const project = await detectJavaProject(buildPath);
111
+ expect(project.manifests).toEqual(["gradle.lockfile"]);
112
+ });
113
+ it("allowedRoot配下なら許可、外ならエラー", async () => {
114
+ const root = await makeTempDir();
115
+ const inside = path.join(root, "sub");
116
+ await mkdir(inside);
117
+ await writeFile(path.join(inside, "pom.xml"), POM);
118
+ const project = await detectJavaProject(inside, { allowedRoot: root });
119
+ expect(project.manifests).toEqual(["pom.xml"]);
120
+ const outside = await makeTempDir();
121
+ await writeFile(path.join(outside, "pom.xml"), POM);
122
+ await expectScanError(detectJavaProject(outside, { allowedRoot: root }), "path_outside_allowed_root");
123
+ });
124
+ it("シンボリックリンクは実体パスに解決してから境界チェックする", async () => {
125
+ const root = await makeTempDir();
126
+ const outside = await makeTempDir();
127
+ await writeFile(path.join(outside, "pom.xml"), POM);
128
+ // root配下のリンクがroot外を指すケース: ../../etc型の抜け道を塞ぐ
129
+ const link = path.join(root, "sneaky-link");
130
+ await symlink(outside, link);
131
+ await expectScanError(detectJavaProject(link, { allowedRoot: root }), "path_outside_allowed_root");
132
+ });
133
+ it("権限不足で読めないディレクトリはスキップして探索を続ける", async () => {
134
+ const dir = await makeTempDir();
135
+ await writeFile(path.join(dir, "pom.xml"), POM);
136
+ const locked = path.join(dir, "locked");
137
+ await mkdir(locked);
138
+ await chmod(locked, 0o000);
139
+ try {
140
+ const project = await detectJavaProject(dir);
141
+ expect(project.manifests).toEqual(["pom.xml"]);
142
+ }
143
+ finally {
144
+ await chmod(locked, 0o755); // クリーンアップできるよう権限を戻す
145
+ }
146
+ });
147
+ it("pom.xml探索でシンボリックリンクのディレクトリは辿らない", async () => {
148
+ const dir = await makeTempDir();
149
+ const elsewhere = await makeTempDir();
150
+ await writeFile(path.join(elsewhere, "pom.xml"), POM);
151
+ await symlink(elsewhere, path.join(dir, "linked"));
152
+ await expectScanError(detectJavaProject(dir), "no_manifest_found");
153
+ });
154
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `explain_vulnerability`ツールのハンドラ。
3
+ * OSVデータベースAPIから脆弱性レコードを取得し、構造化した詳細を返す。
4
+ * (スキャナ実行は不要。scan_java_project / suggest_fix の結果に含まれる
5
+ * GHSA/CVE IDをそのまま渡せる)
6
+ */
7
+ import { fetchOsvRecord } from "../osv/osvApi.js";
8
+ import { buildVulnerabilityExplanation } from "../osv/vulnerabilityExplanation.js";
9
+ import { errorResult, jsonResult } from "./toolResult.js";
10
+ export async function handleExplainVulnerability(args, options = {}) {
11
+ try {
12
+ const raw = await fetchOsvRecord(args.vulnerability_id, options);
13
+ return jsonResult(buildVulnerabilityExplanation(raw, args.vulnerability_id.trim()));
14
+ }
15
+ catch (error) {
16
+ return errorResult(error);
17
+ }
18
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `scan_java_project`ツールのハンドラ。
3
+ * レスポンス形式(成功/エラー)は`toolResult.ts`参照。
4
+ */
5
+ import { runOsvScan } from "../osv/runner.js";
6
+ import { detectJavaProject } from "../utils/projectDetector.js";
7
+ import { errorResult, jsonResult } from "./toolResult.js";
8
+ export async function handleScanJavaProject(args, options = {}) {
9
+ try {
10
+ const project = await detectJavaProject(args.project_path, {
11
+ allowedRoot: options.allowedRoot,
12
+ });
13
+ const report = await runOsvScan(project.projectDir, options);
14
+ return jsonResult({
15
+ project_dir: project.projectDir,
16
+ manifests: project.manifests,
17
+ ...report,
18
+ });
19
+ }
20
+ catch (error) {
21
+ return errorResult(error);
22
+ }
23
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `suggest_fix`ツールのハンドラ。
3
+ * scan_java_projectと同じスキャンを実行し、脆弱なパッケージごとの
4
+ * 推奨アップグレードバージョン(3段階Tier)を返す。
5
+ */
6
+ import { runOsvScan } from "../osv/runner.js";
7
+ import { suggestUpgrades } from "../osv/suggestFix.js";
8
+ import { detectJavaProject } from "../utils/projectDetector.js";
9
+ import { errorResult, jsonResult } from "./toolResult.js";
10
+ export async function handleSuggestFix(args, options = {}) {
11
+ try {
12
+ const project = await detectJavaProject(args.project_path, {
13
+ allowedRoot: options.allowedRoot,
14
+ });
15
+ const report = await runOsvScan(project.projectDir, options);
16
+ const suggestions = suggestUpgrades(report.packages);
17
+ const unfixedVulnerabilities = suggestions.reduce((sum, s) => sum + s.per_cve_detail.filter((d) => d.tier === "unfixed").length, 0);
18
+ return jsonResult({
19
+ project_dir: project.projectDir,
20
+ manifests: project.manifests,
21
+ vulnerable_package_count: suggestions.length,
22
+ unfixed_vulnerability_count: unfixedVulnerabilities,
23
+ suggestions,
24
+ });
25
+ }
26
+ catch (error) {
27
+ return errorResult(error);
28
+ }
29
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * MCPツール共通のレスポンス整形。
3
+ *
4
+ * エラーレスポンス形式(確定済み):
5
+ * - 失敗時は`isError: true`+`{"error": {"kind", "message", "detail?"}}`のJSONテキスト
6
+ * - `kind`は`ScanToolError`のkind。LLMクライアントが機械的に分岐できるよう必ず含める
7
+ * - 予期しない例外は内部情報を漏らさず`internal_error`に丸める
8
+ */
9
+ import { ScanToolError } from "../errors.js";
10
+ export function jsonResult(payload, isError = false) {
11
+ return {
12
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
13
+ ...(isError ? { isError: true } : {}),
14
+ };
15
+ }
16
+ /** 捕捉した例外をエラーレスポンスに変換する。 */
17
+ export function errorResult(error) {
18
+ if (error instanceof ScanToolError) {
19
+ return jsonResult({
20
+ error: {
21
+ kind: error.kind,
22
+ message: error.message,
23
+ ...(error.detail !== undefined && error.detail !== "" ? { detail: error.detail } : {}),
24
+ },
25
+ }, true);
26
+ }
27
+ // 想定外の例外はスタックトレース等の内部情報をクライアントへ返さない
28
+ return jsonResult({ error: { kind: "internal_error", message: "予期しないエラーが発生しました" } }, true);
29
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Maven形式バージョンのコンパレータ。
3
+ *
4
+ * npmの`semver`は`2.17.1-RELEASE`のようなMaven特有の接尾辞を正しく扱えないため、
5
+ * Maven本家の`org.apache.maven.artifact.versioning.ComparableVersion`の
6
+ * アルゴリズムをTypeScriptに移植したもの。suggest_fixの3段階Tier
7
+ * フォールバック(docs/DESIGN_TODO.md参照)の前提部品。
8
+ *
9
+ * 仕様の要点:
10
+ * - `.`と`-`でトークン分割。数字⇔文字の境界でも暗黙に分割される
11
+ * - 修飾子の順序: alpha < beta < milestone < rc < snapshot < ""(通常リリース) < sp
12
+ * 未知の修飾子はそれらの後に辞書順
13
+ * - エイリアス: ga / final / release → ""(通常リリース)、cr → rc
14
+ * - 数字直前の1文字略記: a → alpha, b → beta, m → milestone(例: `1a1` = `1-alpha-1`)
15
+ * - 末尾のゼロ・空修飾子は正規化で除去(`1.0.0` = `1`、`1-ga` = `1`)
16
+ */
17
+ const QUALIFIERS = ["alpha", "beta", "milestone", "rc", "snapshot", "", "sp"];
18
+ const ALIASES = new Map([
19
+ ["ga", ""],
20
+ ["final", ""],
21
+ ["release", ""],
22
+ ["cr", "rc"],
23
+ ]);
24
+ /** 修飾子を比較可能なキーに変換する。既知の修飾子は順序インデックス、未知はその後に辞書順で並ぶ。 */
25
+ function comparableQualifier(qualifier) {
26
+ const index = QUALIFIERS.indexOf(qualifier);
27
+ return index === -1 ? `${QUALIFIERS.length}-${qualifier}` : String(index);
28
+ }
29
+ function cmp(a, b) {
30
+ return a < b ? -1 : a > b ? 1 : 0;
31
+ }
32
+ class IntItem {
33
+ value;
34
+ // タイムスタンプ形式(例: 20211215)や桁あふれに備えbigintで保持
35
+ constructor(value) {
36
+ this.value = value;
37
+ }
38
+ isNull() {
39
+ return this.value === 0n;
40
+ }
41
+ compareTo(other) {
42
+ if (other === null) {
43
+ return this.isNull() ? 0 : 1;
44
+ }
45
+ if (other instanceof IntItem) {
46
+ return cmp(this.value, other.value);
47
+ }
48
+ // 数値トークンは修飾子(StrItem)やサブリスト(ListItem)より常に新しい
49
+ return 1;
50
+ }
51
+ }
52
+ class StrItem {
53
+ value;
54
+ constructor(raw, followedByDigit) {
55
+ let v = raw;
56
+ if (followedByDigit && v.length === 1) {
57
+ if (v === "a")
58
+ v = "alpha";
59
+ else if (v === "b")
60
+ v = "beta";
61
+ else if (v === "m")
62
+ v = "milestone";
63
+ }
64
+ this.value = ALIASES.get(v) ?? v;
65
+ }
66
+ isNull() {
67
+ return this.value === "";
68
+ }
69
+ compareTo(other) {
70
+ if (other === null) {
71
+ // トークン無し = 通常リリース("")との比較
72
+ return cmp(comparableQualifier(this.value), comparableQualifier(""));
73
+ }
74
+ if (other instanceof StrItem) {
75
+ return cmp(comparableQualifier(this.value), comparableQualifier(other.value));
76
+ }
77
+ // IntItemにもListItemにも負ける
78
+ return -1;
79
+ }
80
+ }
81
+ class ListItem {
82
+ items = [];
83
+ isNull() {
84
+ return this.items.length === 0;
85
+ }
86
+ /** 末尾のnull相当トークン(0、空修飾子、空リスト)を取り除く。 */
87
+ normalize() {
88
+ for (let i = this.items.length - 1; i >= 0; i--) {
89
+ const last = this.items[i];
90
+ if (last.isNull()) {
91
+ this.items.splice(i, 1);
92
+ }
93
+ else if (!(last instanceof ListItem)) {
94
+ break;
95
+ }
96
+ }
97
+ }
98
+ compareTo(other) {
99
+ if (other === null) {
100
+ for (const item of this.items) {
101
+ const result = item.compareTo(null);
102
+ if (result !== 0)
103
+ return result;
104
+ }
105
+ return 0;
106
+ }
107
+ if (other instanceof IntItem) {
108
+ return -1;
109
+ }
110
+ if (other instanceof StrItem) {
111
+ return 1;
112
+ }
113
+ const otherItems = other.items;
114
+ const len = Math.max(this.items.length, otherItems.length);
115
+ for (let i = 0; i < len; i++) {
116
+ const left = i < this.items.length ? this.items[i] : null;
117
+ const right = i < otherItems.length ? otherItems[i] : null;
118
+ // 片側にトークンが無い場合はnull埋めで比較する
119
+ const result = left === null ? (right === null ? 0 : -right.compareTo(null)) : left.compareTo(right);
120
+ if (result !== 0)
121
+ return result;
122
+ }
123
+ return 0;
124
+ }
125
+ }
126
+ function isDigitChar(c) {
127
+ return c >= "0" && c <= "9";
128
+ }
129
+ function parseItem(digit, buf) {
130
+ return digit ? new IntItem(BigInt(buf)) : new StrItem(buf, false);
131
+ }
132
+ function parseVersion(version) {
133
+ const v = version.toLowerCase();
134
+ const root = new ListItem();
135
+ let list = root;
136
+ const stack = [root];
137
+ let digit = false;
138
+ let startIndex = 0;
139
+ const pushSublist = () => {
140
+ const sub = new ListItem();
141
+ list.items.push(sub);
142
+ list = sub;
143
+ stack.push(sub);
144
+ };
145
+ for (let i = 0; i < v.length; i++) {
146
+ const c = v[i];
147
+ if (c === ".") {
148
+ if (i === startIndex) {
149
+ list.items.push(new IntItem(0n));
150
+ }
151
+ else {
152
+ list.items.push(parseItem(digit, v.slice(startIndex, i)));
153
+ }
154
+ startIndex = i + 1;
155
+ }
156
+ else if (c === "-") {
157
+ if (i === startIndex) {
158
+ list.items.push(new IntItem(0n));
159
+ }
160
+ else {
161
+ list.items.push(parseItem(digit, v.slice(startIndex, i)));
162
+ }
163
+ startIndex = i + 1;
164
+ pushSublist();
165
+ }
166
+ else if (isDigitChar(c)) {
167
+ if (!digit && i > startIndex) {
168
+ // 文字→数字の境界: 直前の文字列は「数字が続く修飾子」として解釈(a→alpha等)
169
+ list.items.push(new StrItem(v.slice(startIndex, i), true));
170
+ startIndex = i;
171
+ pushSublist();
172
+ }
173
+ digit = true;
174
+ }
175
+ else {
176
+ if (digit && i > startIndex) {
177
+ list.items.push(parseItem(true, v.slice(startIndex, i)));
178
+ startIndex = i;
179
+ pushSublist();
180
+ }
181
+ digit = false;
182
+ }
183
+ }
184
+ if (v.length > startIndex) {
185
+ list.items.push(parseItem(digit, v.slice(startIndex)));
186
+ }
187
+ while (stack.length > 0) {
188
+ stack.pop().normalize();
189
+ }
190
+ return root;
191
+ }
192
+ /**
193
+ * Mavenのバージョン優先順位規則で2つのバージョン文字列を比較する。
194
+ *
195
+ * @returns a < b なら負、a > b なら正、優先順位が等しければ 0
196
+ * (例: `compareMavenVersions("2.17.1-RELEASE", "2.17.1") === 0`)
197
+ */
198
+ export function compareMavenVersions(a, b) {
199
+ return parseVersion(a).compareTo(parseVersion(b));
200
+ }
201
+ /**
202
+ * バージョンが属する「系統」(major.minor)を返す。
203
+ * 3段階Tierフォールバックの系統判定に使う(例: `2.14.1` → major 2, minor 14)。
204
+ * 先頭が数字で始まらないバージョンはnull(系統判定不能としてTier 3扱いを想定)。
205
+ */
206
+ export function mavenVersionSeries(version) {
207
+ const m = /^(\d+)(?:\.(\d+))?/.exec(version.trim());
208
+ if (!m)
209
+ return null;
210
+ return { major: Number(m[1]), minor: m[2] === undefined ? 0 : Number(m[2]) };
211
+ }