jvm-upgrade 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -35,7 +35,7 @@ Run jvm-upgrade -u to apply the underlined versions (target: major), or jvm-upgr
35
35
  npm install -g jvm-upgrade
36
36
  ```
37
37
 
38
- Or run it once with `npx jvm-upgrade`. Needs Node.js 20.12 or newer.
38
+ Or run it once with `npx jvm-upgrade`. Needs Node.js 22.13 or newer.
39
39
 
40
40
  ## Usage
41
41
 
@@ -124,7 +124,15 @@ Dynamic versions (`1.+`, `[1.0,2.0)`, `latest.release`) are shown but never rewr
124
124
 
125
125
  ## Changelogs
126
126
 
127
- For each upgrade, jvm-upgrade looks for the source repository in the POM (`<scm>`, `<url>`, parent POMs), or uses the GitHub repo directly for JitPack. It then reads the GitHub releases between your version and the new one. If there are none, it reads `CHANGELOG.md` (and similar files) and cuts out the right sections. If it finds nothing, it gives you the link.
127
+ For each upgrade, jvm-upgrade finds the source repository:
128
+
129
+ - the POM's `<scm>` or `<url>`, following parent POMs and Gradle plugin markers,
130
+ - the GitHub repo itself for JitPack (`com.github.user:repo`),
131
+ - `gradle/gradle` for the Gradle wrapper.
132
+
133
+ It then reads the GitHub releases between your version and the new one. Prereleases are skipped unless you upgrade to one. If there are no releases, it reads `CHANGELOG.md` (and similar files) and cuts out the right sections. Tags without the major version (protobuf-java `4.36.2` is tag `v36.2`) are matched too.
134
+
135
+ If it finds nothing, it gives you a link: the release notes page for androidx, Google Play services, Firebase and Gradle, or the project's releases page.
128
136
 
129
137
  Set `GITHUB_TOKEN` (or `GH_TOKEN`) to avoid GitHub rate limits.
130
138
 
package/dist/apply.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
3
3
  export class EditConflictError extends Error {
4
4
  }
5
5
  /** Rewrites only the version literals. Every file is checked first, then written atomically. */
@@ -41,7 +41,8 @@ function rewrite(file, content, edits) {
41
41
  }
42
42
  return out;
43
43
  }
44
- async function writeAtomic(file, content) {
44
+ async function writeAtomic(path, content) {
45
+ const file = await realpath(path);
45
46
  const tmp = `${file}.${randomBytes(4).toString('hex')}.tmp`;
46
47
  const { mode } = await stat(file);
47
48
  try {
package/dist/changelog.js CHANGED
@@ -1,7 +1,13 @@
1
1
  import { fetchPom } from './repos.js';
2
+ import { isPrerelease } from './version.js';
2
3
  const CHANGELOG_FILES = ['CHANGELOG.md', 'CHANGES.md', 'HISTORY.md', 'RELEASE_NOTES.md', 'docs/CHANGELOG.md'];
3
4
  const MAX_RELEASE_PAGES = 5;
4
5
  const MAX_PARENT_DEPTH = 3;
6
+ const GOOGLE_NOTES = [
7
+ ['com.google.android.gms', 'https://developers.google.com/android/guides/releases'],
8
+ ['com.google.gms', 'https://developers.google.com/android/guides/releases'],
9
+ ['com.google.firebase', 'https://firebase.google.com/support/release-notes/android'],
10
+ ];
5
11
  const POM_NOISE = /<(parent|scm|licenses|developers|contributors|organization|distributionManagement|issueManagement|ciManagement|mailingLists|repositories|pluginRepositories|build|reporting|profiles|dependencies|dependencyManagement)\b[\s\S]*?<\/\1>/g;
6
12
  export function normalizeScmUrl(raw) {
7
13
  let s = raw.trim();
@@ -56,9 +62,21 @@ export function androidxReleasesUrl(group) {
56
62
  const name = first === 'compose' && second ? `compose-${second}` : first;
57
63
  return `https://developer.android.com/jetpack/androidx/releases/${name}`;
58
64
  }
65
+ function googleNotesUrl(group) {
66
+ return GOOGLE_NOTES.find(([prefix]) => group === prefix || group.startsWith(`${prefix}.`))?.[1];
67
+ }
59
68
  export async function findSourceRepo(http, dep, version, repo) {
60
- if (dep.kind === 'gradle')
61
- return { url: `https://docs.gradle.org/${version}/release-notes.html` };
69
+ if (dep.kind === 'gradle') {
70
+ const notesUrl = `https://docs.gradle.org/${version}/release-notes.html`;
71
+ return { github: { owner: 'gradle', repo: 'gradle' }, url: notesUrl, notesUrl };
72
+ }
73
+ const fromPom = await sourceFromPom(http, dep, version, repo);
74
+ const notesUrl = googleNotesUrl(dep.group);
75
+ if (notesUrl)
76
+ return { ...fromPom, url: fromPom.url ?? notesUrl, notesUrl };
77
+ return fromPom;
78
+ }
79
+ async function sourceFromPom(http, dep, version, repo) {
62
80
  const [root, , owner] = dep.group.split('.');
63
81
  if (dep.group.startsWith('com.github.') && owner) {
64
82
  return { github: { owner, repo: dep.artifact }, url: `https://github.com/${owner}/${dep.artifact}` };
@@ -114,11 +132,19 @@ export function normalizeTag(tag, artifact) {
114
132
  return v;
115
133
  }
116
134
  const isVersionLike = (v) => /^\d+(\.\d+)*([-.+_][0-9A-Za-z.+_-]*)?$/.test(v);
135
+ const trimZeros = (v) => v.replace(/(\.0)+(?=$|-)/, '');
136
+ /** Trailing ".0" parts are ignored so a "v9.8.0" tag matches version "9.8". */
117
137
  function inRange(v, from, to, compare) {
118
- return compare(v, from) > 0 && compare(v, to) <= 0;
138
+ const x = trimZeros(v);
139
+ return compare(x, trimZeros(from)) > 0 && compare(x, trimZeros(to)) <= 0;
140
+ }
141
+ /** Some projects tag without the artifact's major, e.g. protobuf-java 4.36.2 is tag v36.2. */
142
+ function withoutMajor(v) {
143
+ const parts = v.split('.');
144
+ return parts.length >= 3 && /^\d+$/.test(parts[0]) ? parts.slice(1).join('.') : undefined;
119
145
  }
120
146
  const sortDesc = (entries, compare) => entries.sort((a, b) => compare(b.version, a.version));
121
- async function releaseEntries(http, gh, artifact, from, to, compare) {
147
+ async function releaseEntries(http, gh, artifact, from, to, compare, includePre) {
122
148
  const entries = [];
123
149
  for (let page = 1; page <= MAX_RELEASE_PAGES; page++) {
124
150
  const res = await http
@@ -135,7 +161,7 @@ async function releaseEntries(http, gh, artifact, from, to, compare) {
135
161
  if (r.draft || !isVersionLike(version))
136
162
  continue;
137
163
  versions.push(version);
138
- if (!inRange(version, from, to, compare))
164
+ if (!inRange(version, from, to, compare) || (r.prerelease && !includePre))
139
165
  continue;
140
166
  entries.push({
141
167
  version,
@@ -196,12 +222,23 @@ export async function getChangelog(http, dep, from, to, repo, compare) {
196
222
  const src = await findSourceRepo(http, dep, to, repo);
197
223
  const gh = src.github;
198
224
  if (!gh)
199
- return { entries: [], source: src.url };
225
+ return { entries: [], source: src.notesUrl ?? src.url };
200
226
  const releasesUrl = `https://github.com/${gh.owner}/${gh.repo}/releases`;
201
- const releases = await releaseEntries(http, gh, dep.artifact, from, to, compare);
202
- if (releases.entries.length)
203
- return { entries: sortDesc(releases.entries, compare), source: releasesUrl };
204
- if (releases.rateLimited)
205
- return { entries: [], source: releasesUrl };
206
- return (await changelogFileEntries(http, gh, from, to, compare)) ?? { entries: [], source: releasesUrl };
227
+ const fallback = { entries: [], source: src.notesUrl ?? releasesUrl };
228
+ const includePre = isPrerelease(to);
229
+ const ranges = [[from, to]];
230
+ const [shortFrom, shortTo] = [withoutMajor(from), withoutMajor(to)];
231
+ if (shortFrom && shortTo && compare(shortFrom, shortTo) < 0)
232
+ ranges.push([shortFrom, shortTo]);
233
+ for (const [a, b] of ranges) {
234
+ const releases = await releaseEntries(http, gh, dep.artifact, a, b, compare, includePre);
235
+ if (releases.entries.length)
236
+ return { entries: sortDesc(releases.entries, compare), source: releasesUrl };
237
+ if (releases.rateLimited)
238
+ return fallback;
239
+ const files = await changelogFileEntries(http, gh, a, b, compare);
240
+ if (files)
241
+ return files;
242
+ }
243
+ return fallback;
207
244
  }
package/dist/http.js CHANGED
@@ -21,7 +21,7 @@ export async function clearCache(dir) {
21
21
  }
22
22
  export function authHeader(url, credentials) {
23
23
  const match = credentials
24
- .filter((c) => url.startsWith(c.url) && sameOrigin(url, c.url))
24
+ .filter((c) => underPrefix(url, c.url) && sameOrigin(url, c.url))
25
25
  .sort((a, b) => b.url.length - a.url.length)[0];
26
26
  if (match?.token)
27
27
  return `Bearer ${match.token}`;
@@ -33,6 +33,10 @@ export function authHeader(url, credentials) {
33
33
  return `Bearer ${gh}`;
34
34
  return undefined;
35
35
  }
36
+ function underPrefix(url, prefix) {
37
+ const base = prefix.endsWith('/') ? prefix : `${prefix}/`;
38
+ return url === prefix || url.startsWith(base);
39
+ }
36
40
  function hostOf(url) {
37
41
  try {
38
42
  return new URL(url).host;
@@ -69,10 +73,12 @@ function limiter(max) {
69
73
  }
70
74
  };
71
75
  }
72
- async function readLocal(url) {
76
+ async function readLocal(url, method) {
73
77
  try {
74
- const body = await readFile(fileURLToPath(url), 'utf8');
75
- return { status: 200, headers: {}, body };
78
+ const path = fileURLToPath(url);
79
+ const { mtime } = await stat(path);
80
+ const body = method === 'HEAD' ? '' : await readFile(path, 'utf8');
81
+ return { status: 200, headers: { 'last-modified': mtime.toUTCString() }, body };
76
82
  }
77
83
  catch {
78
84
  return { status: 404, headers: {}, body: '' };
@@ -89,6 +95,7 @@ async function atomicWrite(file, data) {
89
95
  throw err;
90
96
  }
91
97
  }
98
+ const cacheable = (res) => res.status === 200 || res.status === 404;
92
99
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
93
100
  export function createHttp(opts) {
94
101
  const ttl = opts.ttlMs ?? DEFAULT_TTL;
@@ -104,10 +111,11 @@ export function createHttp(opts) {
104
111
  const file = cacheFile(url);
105
112
  try {
106
113
  const entry = JSON.parse(await readFile(file, 'utf8'));
107
- if (Date.now() - entry.time <= ttl) {
108
- return { status: entry.status, headers: entry.headers, body: entry.body };
114
+ const age = Date.now() - entry.time;
115
+ // Stale entries are not deleted here: that races fresh writes from other processes.
116
+ if (age >= 0 && age <= ttl && typeof entry.status === 'number' && typeof entry.body === 'string') {
117
+ return { status: entry.status, headers: entry.headers ?? {}, body: entry.body };
109
118
  }
110
- await rm(file, { force: true });
111
119
  }
112
120
  catch {
113
121
  // missing or corrupt entry
@@ -115,7 +123,7 @@ export function createHttp(opts) {
115
123
  return undefined;
116
124
  }
117
125
  async function writeCache(url, res) {
118
- if (!cacheDir || (res.status !== 200 && res.status !== 404))
126
+ if (!cacheDir || !cacheable(res))
119
127
  return;
120
128
  try {
121
129
  dirReady ??= mkdir(cacheDir, { recursive: true });
@@ -145,6 +153,8 @@ export function createHttp(opts) {
145
153
  current = new URL(location, current).toString();
146
154
  continue;
147
155
  }
156
+ if (method === 'HEAD')
157
+ await res.body?.cancel();
148
158
  return {
149
159
  status: res.status,
150
160
  headers: Object.fromEntries(res.headers),
@@ -175,7 +185,7 @@ export function createHttp(opts) {
175
185
  }
176
186
  async function get(url) {
177
187
  if (url.startsWith('file:'))
178
- return readLocal(url);
188
+ return readLocal(url, 'GET');
179
189
  const authed = authHeader(url, opts.credentials) !== undefined;
180
190
  const cached = authed ? undefined : await readCache(url);
181
191
  if (cached)
@@ -191,13 +201,14 @@ export function createHttp(opts) {
191
201
  if (!p) {
192
202
  p = get(url);
193
203
  inflight.set(url, p);
194
- p.catch(() => inflight.delete(url));
204
+ // Keep good answers for the whole run; let errors and 5xx/429 be retried later.
205
+ p.then((res) => cacheable(res) || inflight.delete(url), () => inflight.delete(url));
195
206
  }
196
207
  return p;
197
208
  },
198
209
  async head(url) {
199
210
  if (url.startsWith('file:'))
200
- return readLocal(url);
211
+ return readLocal(url, 'HEAD');
201
212
  return network('HEAD', url);
202
213
  },
203
214
  async close() {
package/dist/main.js CHANGED
@@ -150,6 +150,7 @@ export async function main(argv = process.argv.slice(2)) {
150
150
  await clearCache(cacheDir);
151
151
  const showProgress = flags.progress && process.stderr.isTTY;
152
152
  const log = (text) => process.stderr.write(`${text}\n`);
153
+ const say = (text) => (json ? process.stderr : process.stdout).write(`${text}\n`);
153
154
  let scan;
154
155
  try {
155
156
  scan = await scanProject(root);
@@ -160,7 +161,7 @@ export async function main(argv = process.argv.slice(2)) {
160
161
  for (const warning of scan.warnings)
161
162
  log(styleText('yellow', `warning: ${warning}`));
162
163
  if (!scan.dependencies.length) {
163
- log('No Gradle or Maven dependencies found.');
164
+ say('No Gradle or Maven dependencies found.');
164
165
  if (json)
165
166
  process.stdout.write('[]\n');
166
167
  return EXIT.ok;
@@ -194,6 +195,7 @@ export async function main(argv = process.argv.slice(2)) {
194
195
  if (nameCount.get(row.name) > 1)
195
196
  row.name += ` (${relative(root, row.deps[0].file)})`;
196
197
  const outdated = rows.filter((r) => r.choices.length);
198
+ const selectable = outdated.filter((r) => r.selected !== undefined);
197
199
  const failed = rows.filter((r) => !r.choices.length && r.errors.length);
198
200
  const failedAll = rows.length > 0 && failed.length === rows.length;
199
201
  const visible = flags.verbose ? rows : [...outdated, ...failed];
@@ -202,9 +204,9 @@ export async function main(argv = process.argv.slice(2)) {
202
204
  }
203
205
  else if (!flags.interactive) {
204
206
  if (visible.length)
205
- log(renderTable(visible, root));
207
+ say(renderTable(visible, root));
206
208
  if (!outdated.length)
207
- log(styleText('green', `All ${rows.length - failed.length} dependencies are up to date.`));
209
+ say(styleText('green', `All ${rows.length - failed.length} dependencies are up to date.`));
208
210
  }
209
211
  if (failedAll)
210
212
  return EXIT.network;
@@ -214,7 +216,7 @@ export async function main(argv = process.argv.slice(2)) {
214
216
  if (flags.interactive) {
215
217
  const picked = await (await import('./tui.js')).pick(outdated, (row, choice) => loadChangelog(http, row, choice));
216
218
  if (!picked) {
217
- log('Cancelled. Nothing was changed.');
219
+ say('Cancelled. Nothing was changed.');
218
220
  return EXIT.ok;
219
221
  }
220
222
  chosen = picked;
@@ -232,7 +234,7 @@ export async function main(argv = process.argv.slice(2)) {
232
234
  const writable = [...chosen].filter(([row]) => row.location);
233
235
  if (flags.upgrade || flags.interactive) {
234
236
  if (!writable.length) {
235
- log('Nothing to upgrade.');
237
+ say('Nothing to upgrade.');
236
238
  return EXIT.ok;
237
239
  }
238
240
  const edits = writable.map(([row, choice]) => ({ location: row.location, from: row.current, to: choice.version }));
@@ -245,15 +247,15 @@ export async function main(argv = process.argv.slice(2)) {
245
247
  throw e;
246
248
  }
247
249
  for (const [row, choice] of writable)
248
- log(`${styleText('green', '✔')} ${row.name} ${row.current} → ${choice.version}`);
249
- log(styleText('gray', 'Build files updated. Run your build and tests to check the upgrades.'));
250
+ say(`${styleText('green', '✔')} ${row.name} ${row.current} → ${choice.version}`);
251
+ say(styleText('gray', 'Build files updated. Run your build and tests to check the upgrades.'));
250
252
  return EXIT.ok;
251
253
  }
252
254
  if (!json) {
253
255
  const target = rootOptions.target;
254
- log(styleText('gray', `\nRun ${styleText('cyan', 'jvm-upgrade -u')} to apply the underlined versions (target: ${target}), or ${styleText('cyan', 'jvm-upgrade -i')} to pick.`));
256
+ say(styleText('gray', `\nRun ${styleText('cyan', 'jvm-upgrade -u')} to apply the underlined versions (target: ${target}), or ${styleText('cyan', 'jvm-upgrade -i')} to pick.`));
255
257
  }
256
- return flags['error-on-outdated'] ? EXIT.outdated : EXIT.ok;
258
+ return flags['error-on-outdated'] && selectable.length ? EXIT.outdated : EXIT.ok;
257
259
  }
258
260
  finally {
259
261
  await http.close();
package/dist/repos.js CHANGED
@@ -41,21 +41,29 @@ function loadGradle(http) {
41
41
  return p;
42
42
  }
43
43
  async function fetchGithubTags(http, owner, repo) {
44
- const res = await http.get(`https://api.github.com/repos/${owner}/${repo}/tags?per_page=100`);
44
+ const url = `https://api.github.com/repos/${owner}/${repo}/tags?per_page=100`;
45
+ const res = await http.get(url);
46
+ if (res.status === 404)
47
+ return [];
45
48
  if (res.status !== 200)
46
- return undefined;
49
+ throw new Error(`${url}: HTTP ${res.status}`);
47
50
  const tags = JSON.parse(res.body);
48
51
  return tags.map((t) => t.name.replace(/^v(?=\d)/i, ''));
49
52
  }
50
53
  async function versionsFromRepo(http, dep, repo) {
51
54
  const url = `${repo}${artifactPath(dep.group, dep.artifact)}/maven-metadata.xml`;
52
55
  const res = await http.get(url);
53
- if (res.status === 200)
56
+ if (res.status === 200) {
57
+ // Some proxies answer 200 with an HTML page instead of 404 or 401.
58
+ if (!res.body.includes('<metadata'))
59
+ throw new Error(`${url}: not a maven-metadata.xml response`);
54
60
  return parseMetadata(res.body);
61
+ }
55
62
  if (res.status === 404) {
56
- const owner = dep.group.startsWith('com.github.') ? dep.group.split('.')[2] : undefined;
57
- if (repo.includes('jitpack.io') && owner) {
58
- return (await fetchGithubTags(http, owner, dep.artifact)) ?? [];
63
+ // JitPack: com.github.Owner:Repo, or com.github.Owner.Repo:Module for multi-module builds.
64
+ const [, , owner, ghRepo = dep.artifact] = dep.group.split('.');
65
+ if (repo.includes('jitpack.io') && dep.group.startsWith('com.github.') && owner) {
66
+ return fetchGithubTags(http, owner, ghRepo);
59
67
  }
60
68
  return [];
61
69
  }
package/dist/rows.js CHANGED
@@ -42,6 +42,10 @@ export async function buildRows(deps, opts) {
42
42
  try {
43
43
  return await buildRow(group, optionsOf.get(group[0]), opts);
44
44
  }
45
+ catch (e) {
46
+ const first = group[0];
47
+ return { name: displayName(first), deps: group, current: first.version, location: first.location, choices: [], repoOf: new Map(), errors: [e.message] };
48
+ }
45
49
  finally {
46
50
  opts.progress.tick();
47
51
  }
@@ -1,9 +1,11 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { dedupeRepos, GOOGLE, GRADLE_PLUGIN_PORTAL, MAVEN_CENTRAL, NAMED_REPOS } from './repos.js';
4
- const COORD_RE = /(["'])([\w.\-]+):([\w.\-]+):([^"'\s@:]+)(?::[\w.\-]+)?(?:@\w+)?\1/dg;
4
+ const COORD_RE = /(["'])([A-Za-z][\w.\-]*):([\w.\-]+):((?:\$\{[^}\n]*\}|[\w.\-+!$\[\](),])+)(?::[\w.\-]+)?(?:@\w+)?\1/dg;
5
+ // Rejects look-alikes such as "jdbc:h2:mem:test".
6
+ const VERSIONISH_RE = /\d|\$|^\+$|^latest\.|SNAPSHOT/;
5
7
  const MAP_RE = /\bgroup\s*[:=]\s*(["'])([^"']+)\1\s*,\s*name\s*[:=]\s*(["'])([^"']+)\3\s*,\s*version\s*[:=]\s*(?:(["'])([^"']*)\5|([A-Za-z_][\w.]*))/dg;
6
- const KOTLIN_DEP_RE = /\bkotlin\s*\(\s*"([\w\-]+)"\s*,\s*(?:version\s*=\s*)?"([^"]*)"\s*\)/dg;
8
+ const KOTLIN_DEP_RE = /\bkotlin\s*\(\s*"([\w\-]+)"\s*,\s*(?:version\s*=\s*)?(?:"([^"]*)"|([A-Za-z_][\w.]*))\s*\)/dg;
7
9
  const PLUGIN_RE = /\b(id|kotlin)\s*\(?\s*(["'])([\w.\-]+)\2\s*\)?\s*\.?\s*version\s*\(?\s*(?:(["'])([^"']*)\4|([A-Za-z_][\w.]*(?:\(\))?))/dg;
8
10
  const REPO_RE = /\b(mavenCentral|google|gradlePluginPortal|jcenter|mavenLocal)\s*[({]|\bmaven\s*\(\s*(?:url\s*=\s*)?(?:uri\s*\(\s*)?["']([^"'$]+)["']|\b(?:url|setUrl)\s*(?:=\s*)?\(?\s*(?:uri\s*\(\s*)?["']([^"'$]+)["']/g;
9
11
  const CATALOG_RE = /(?:create\s*\(\s*["'](\w+)["']\s*\)|(\w+))\s*\{[^{}]*?\bfrom\s*\(\s*files\s*\(\s*["']([^"']+)["']/g;
@@ -17,6 +19,8 @@ const EXT_BLOCK_RES = [
17
19
  /(?<![\w.])(\w+)\s*=\s*(["'])([^"'$\n]*)\2/dg,
18
20
  /\bset\s*\(\s*["'](\w+)["']\s*,\s*(["'])([^"'$\n]*)\2/dg,
19
21
  ];
22
+ const MAP_VAR_RE = /(?<![\w.])(?:ext\.)?(\w+)\s*=\s*\[/g;
23
+ const MAP_ENTRY_RE = /(?<![\w.])(\w+)\s*:\s*(["'])([^"'$\n]*)\2/dg;
20
24
  const PROPERTY_RE = /^[ \t]*([\w.\-]+)[ \t]*[=:][ \t]*(.*?)[ \t]*\r?$/dgm;
21
25
  const WRAPPER_RE = /distributionUrl\s*[=:].*?gradle-([\w.\-]+?)-(?:bin|all)\.zip/d;
22
26
  export function isDynamic(version) {
@@ -90,8 +94,7 @@ export function stripProperties(src) {
90
94
  }
91
95
  async function readSource(file, strip) {
92
96
  try {
93
- const content = await readFile(file, 'utf8');
94
- return { file, content, code: strip(content) };
97
+ return { file, code: strip(await readFile(file, 'utf8')) };
95
98
  }
96
99
  catch {
97
100
  return undefined;
@@ -101,15 +104,17 @@ function findBlocks(code, name) {
101
104
  const re = new RegExp(`\\b${name}\\s*(?:\\([^()]*\\))?\\s*\\{`, 'g');
102
105
  return [...code.matchAll(re)].map((m) => {
103
106
  const open = m.index + m[0].length - 1;
104
- return { start: open + 1, end: matchBrace(code, open) };
107
+ return { start: open + 1, end: matchClose(code, open) };
105
108
  });
106
109
  }
107
- function matchBrace(code, open) {
110
+ function matchClose(code, open) {
111
+ const opener = code[open];
112
+ const closer = opener === '[' ? ']' : '}';
108
113
  let depth = 0;
109
114
  for (let i = open; i < code.length; i++) {
110
- if (code[i] === '{')
115
+ if (code[i] === opener)
111
116
  depth++;
112
- else if (code[i] === '}' && --depth === 0)
117
+ else if (code[i] === closer && --depth === 0)
113
118
  return i;
114
119
  }
115
120
  return code.length;
@@ -150,6 +155,13 @@ function collectVars(src) {
150
155
  ].sort((a, b) => a.index - b.index);
151
156
  for (const m of matches)
152
157
  vars.set(m[1], { value: m[3], loc: locOf(src, m, 3) });
158
+ for (const m of src.code.matchAll(MAP_VAR_RE)) {
159
+ const open = m.index + m[0].length - 1;
160
+ for (const e of src.code.slice(open, matchClose(src.code, open)).matchAll(MAP_ENTRY_RE)) {
161
+ const [start, end] = e.indices[3];
162
+ vars.set(`${m[1]}.${e[1]}`, { value: e[3], loc: { file: src.file, start: open + start, end: open + end } });
163
+ }
164
+ }
153
165
  return vars;
154
166
  }
155
167
  function propertyVars(src) {
@@ -168,8 +180,8 @@ function lookup(scope, expr) {
168
180
  const catalog = /^(\w+)\.versions\.([\w.]+)$/.exec(e);
169
181
  if (catalog)
170
182
  return scope.catalogs.get(catalog[1])?.get(normKey(catalog[2]));
171
- const key = /(?:extra|properties)\s*\[\s*["']([\w.\-]+)["']\s*\]$/.exec(e)?.[1] ??
172
- /property\s*\(\s*["']([\w.\-]+)["']\s*\)$/.exec(e)?.[1] ??
183
+ const key = /(?:ext|extra|properties)\s*\[\s*["']([\w.\-]+)["']\s*\]$/.exec(e)?.[1] ??
184
+ /(?:findProperty|property)\s*\(\s*["']([\w.\-]+)["']\s*\)$/.exec(e)?.[1] ??
173
185
  e.replace(/^(?:(?:rootProject|project|ext|extra|properties)\.)+/, '');
174
186
  for (const vars of scope.vars) {
175
187
  const value = vars.get(key);
@@ -211,30 +223,30 @@ function addResolved(ctx, src, kind, group, artifact, raw, loc) {
211
223
  }
212
224
  ctx.out.push(makeDep(kind, group, artifact, version, src.file, ctx.repositories));
213
225
  }
226
+ /** Adds a match whose version is either a quoted literal in group `literal` or a bare reference in `literal + 1`. */
227
+ function addLiteralOrRef(ctx, src, kind, group, artifact, m, literal) {
228
+ if (m[literal] !== undefined)
229
+ addResolved(ctx, src, kind, group, artifact, m[literal], locOf(src, m, literal));
230
+ else
231
+ addResolved(ctx, src, kind, group, artifact, `\${${m[literal + 1]}}`);
232
+ }
214
233
  function libraryDeps(src, ctx) {
215
234
  for (const m of src.code.matchAll(COORD_RE)) {
216
- addResolved(ctx, src, 'library', m[2], m[3], m[4], locOf(src, m, 4));
217
- }
218
- for (const m of src.code.matchAll(MAP_RE)) {
219
- if (m[6] !== undefined)
220
- addResolved(ctx, src, 'library', m[2], m[4], m[6], locOf(src, m, 6));
221
- else
222
- addResolved(ctx, src, 'library', m[2], m[4], `\${${m[7]}}`);
235
+ if (VERSIONISH_RE.test(m[4]))
236
+ addResolved(ctx, src, 'library', m[2], m[3], m[4], locOf(src, m, 4));
223
237
  }
238
+ for (const m of src.code.matchAll(MAP_RE))
239
+ addLiteralOrRef(ctx, src, 'library', m[2], m[4], m, 6);
224
240
  const plugins = findBlocks(src.code, 'plugins');
225
241
  for (const m of src.code.matchAll(KOTLIN_DEP_RE)) {
226
- if (!within(m.index, plugins)) {
227
- addResolved(ctx, src, 'library', 'org.jetbrains.kotlin', `kotlin-${m[1]}`, m[2], locOf(src, m, 2));
228
- }
242
+ if (!within(m.index, plugins))
243
+ addLiteralOrRef(ctx, src, 'library', 'org.jetbrains.kotlin', `kotlin-${m[1]}`, m, 2);
229
244
  }
230
245
  }
231
246
  function pluginDeps(src, ctx) {
232
247
  for (const m of matchesIn(src.code, PLUGIN_RE, findBlocks(src.code, 'plugins'))) {
233
248
  const id = m[1] === 'kotlin' ? `org.jetbrains.kotlin.${m[3]}` : m[3];
234
- if (m[5] !== undefined)
235
- addResolved(ctx, src, 'plugin', id, `${id}.gradle.plugin`, m[5], locOf(src, m, 5));
236
- else
237
- addResolved(ctx, src, 'plugin', id, `${id}.gradle.plugin`, `\${${m[6]}}`);
249
+ addLiteralOrRef(ctx, src, 'plugin', id, `${id}.gradle.plugin`, m, 5);
238
250
  }
239
251
  }
240
252
  export function parseToml(code) {
@@ -305,6 +317,8 @@ export function parseToml(code) {
305
317
  break;
306
318
  if (code[i] === '[') {
307
319
  const end = code.indexOf(']', i);
320
+ if (end < 0)
321
+ break;
308
322
  table = code.slice(i + 1, end).trim();
309
323
  i = end + 1;
310
324
  skipLine();
@@ -354,8 +368,7 @@ function pickVersion(src, fields, path) {
354
368
  function splitNotation(src, s) {
355
369
  const parts = s.value.split(':');
356
370
  const last = parts[parts.length - 1];
357
- const start = s.end - last.length;
358
- return { parts, version: tomlValue(src, { value: last, start, end: s.end }) };
371
+ return { parts, version: tomlValue(src, { value: last, start: s.end - last.length, end: s.end }) };
359
372
  }
360
373
  function parseCatalog(src, libraryRepos, pluginRepos, warnings) {
361
374
  const entries = parseToml(src.code);
@@ -379,7 +392,7 @@ function parseCatalog(src, libraryRepos, pluginRepos, warnings) {
379
392
  const str = e.fields.get('');
380
393
  if (str) {
381
394
  const { parts, version } = splitNotation(src, str);
382
- if (parts.length >= 3 && version)
395
+ if (parts.length >= 3)
383
396
  deps.push(makeDep('library', parts[0], parts[1], version, src.file, libraryRepos));
384
397
  continue;
385
398
  }
@@ -437,12 +450,17 @@ export async function scanGradle(build) {
437
450
  const scope = { vars: [collectVars(settings), rootProps], catalogs };
438
451
  pluginDeps(settings, { scope, repositories: pluginRepos, warnings, out });
439
452
  }
440
- for (const file of build.buildFiles) {
441
- const src = file === rootBuildFile ? rootBuild : await readSource(file, stripCode);
453
+ const modules = await Promise.all(build.buildFiles.map(async (file) => {
454
+ const dir = dirname(file);
455
+ const [src, props] = await Promise.all([
456
+ file === rootBuildFile ? rootBuild : readSource(file, stripCode),
457
+ dir === build.root ? undefined : readSource(join(dir, 'gradle.properties'), stripProperties),
458
+ ]);
459
+ return { src, moduleProps: propertyVars(props) };
460
+ }));
461
+ for (const { src, moduleProps } of modules) {
442
462
  if (!src)
443
463
  continue;
444
- const dir = dirname(file);
445
- const moduleProps = dir === build.root ? new Map() : propertyVars(await readSource(join(dir, 'gradle.properties'), stripProperties));
446
464
  const scope = { vars: [collectVars(src), rootVars, moduleProps, rootProps], catalogs };
447
465
  libraryDeps(src, { scope, repositories: libraryRepos(buildFileRepos(src)), warnings, out });
448
466
  pluginDeps(src, { scope, repositories: pluginRepos, warnings, out });
@@ -5,21 +5,31 @@ import { scanMaven } from './maven.js';
5
5
  const SKIP_DIRS = new Set(['build', 'node_modules', 'target', 'out', 'bin', 'intermediates']);
6
6
  const SETTINGS = new Set(['settings.gradle', 'settings.gradle.kts']);
7
7
  const BUILD_FILES = new Set(['build.gradle', 'build.gradle.kts']);
8
+ const isBuildFile = (name) => SETTINGS.has(name) || BUILD_FILES.has(name) || name === 'pom.xml';
9
+ // Symlinked directories are not followed (Dirent.isDirectory() is false for them), so link loops cannot recurse.
8
10
  async function walk(dir, files) {
9
11
  const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
12
+ // A module's src/ holds sources and test fixtures (src/it, src/test/resources), never real modules.
13
+ const isModule = entries.some((e) => !e.isDirectory() && isBuildFile(e.name));
10
14
  await Promise.all(entries.map(async (entry) => {
11
15
  const path = join(dir, entry.name);
12
16
  if (entry.isDirectory()) {
13
- if (!entry.name.startsWith('.') && !SKIP_DIRS.has(entry.name))
17
+ const skip = entry.name.startsWith('.') || SKIP_DIRS.has(entry.name) || (isModule && entry.name === 'src');
18
+ if (!skip)
14
19
  await walk(path, files);
15
20
  }
16
- else if (SETTINGS.has(entry.name) || BUILD_FILES.has(entry.name) || entry.name === 'pom.xml') {
21
+ else if (isBuildFile(entry.name)) {
17
22
  files.push(path);
18
23
  }
19
24
  }));
20
25
  }
21
- function isUnder(dir, root) {
22
- return dir === root || dir.startsWith(root + sep);
26
+ function nearestBuild(dir, builds) {
27
+ for (let d = dir;; d = dirname(d)) {
28
+ if (builds.has(d))
29
+ return d;
30
+ if (dirname(d) === d)
31
+ return undefined;
32
+ }
23
33
  }
24
34
  function depth(path) {
25
35
  return path.split(sep).length;
@@ -32,7 +42,7 @@ export function groupGradleBuilds(files) {
32
42
  const buildFiles = files.filter((f) => BUILD_FILES.has(basename(f))).sort((a, b) => depth(a) - depth(b));
33
43
  for (const file of buildFiles) {
34
44
  const dir = dirname(file);
35
- const owner = [...builds.keys()].filter((root) => isUnder(dir, root)).sort((a, b) => b.length - a.length)[0];
45
+ const owner = nearestBuild(dir, builds);
36
46
  const root = owner && basename(dir) !== 'buildSrc' ? owner : dir;
37
47
  if (!builds.has(root))
38
48
  builds.set(root, { root, buildFiles: [] });
@@ -72,7 +72,6 @@ async function loadPom(file) {
72
72
  const pom = { file, code, project, props: new Map(), parent: child(project, 'parent') };
73
73
  pom.artifactId = childText(pom, project, 'artifactId');
74
74
  pom.groupId = childText(pom, project, 'groupId') ?? childText(pom, pom.parent, 'groupId');
75
- pom.version = childText(pom, project, 'version') ?? childText(pom, pom.parent, 'version');
76
75
  for (const prop of child(project, 'properties')?.children ?? []) {
77
76
  const value = textOf(pom, prop);
78
77
  if (value)
@@ -192,7 +191,7 @@ export async function scanMaven(pomFiles) {
192
191
  kind,
193
192
  version: version.value,
194
193
  file: pom.file,
195
- repositories: kind === 'plugin' ? pluginRepos : libraryRepos,
194
+ repositories: kind === 'plugin' || node.parent?.parent?.name === 'plugin' ? pluginRepos : libraryRepos,
196
195
  ...(location && { location }),
197
196
  });
198
197
  }
package/dist/version.js CHANGED
@@ -44,10 +44,6 @@ export function isSnapshot(v) {
44
44
  export function isPrerelease(v) {
45
45
  return tokenize(v).some((t) => PRERELEASE.has(t));
46
46
  }
47
- export function isDynamic(v) {
48
- const s = v.trim();
49
- return /^[[(]/.test(s) || /[\])]$/.test(s) || s.endsWith('+') || /^latest\./i.test(s) || /^(LATEST|RELEASE)$/.test(s);
50
- }
51
47
  export function upgradeLevel(from, to) {
52
48
  if (compareVersions(to, from) <= 0)
53
49
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jvm-upgrade",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Find and apply library upgrades in Java, Kotlin and Android projects (Gradle and Maven), with changelogs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  "LICENSE"
13
13
  ],
14
14
  "engines": {
15
- "node": ">=20.12"
15
+ "node": ">=22.13"
16
16
  },
17
17
  "scripts": {
18
18
  "build": "rm -rf dist && tsc -p tsconfig.build.json",