@groma/scanner-java 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Gavrilescu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
Binary file
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@groma/scanner-java",
3
+ "version": "0.1.0",
4
+ "description": "Java compiler and project evidence; framework runtime use is not verified.",
5
+ "private": false,
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "groma": {
9
+ "scanner": {
10
+ "id": "java",
11
+ "entry": "./src/index.js",
12
+ "discovery": {
13
+ "technologies": [
14
+ "java"
15
+ ],
16
+ "rules": [
17
+ {
18
+ "type": "xml",
19
+ "files": [
20
+ "**/pom.xml"
21
+ ],
22
+ "technology": "java",
23
+ "kind": "language",
24
+ "versionTags": [
25
+ "java.version",
26
+ "maven.compiler.release",
27
+ "maven.compiler.source"
28
+ ],
29
+ "declaration": "Maven project; Java version unresolved"
30
+ },
31
+ {
32
+ "type": "xml",
33
+ "files": [
34
+ "**/pom.xml"
35
+ ],
36
+ "technology": "spring-boot",
37
+ "kind": "framework",
38
+ "versionTags": [],
39
+ "when": {
40
+ "tag": "groupId",
41
+ "equals": "org.springframework.boot"
42
+ },
43
+ "declaration": "org.springframework.boot declaration; runtime use unverified"
44
+ }
45
+ ],
46
+ "compatibility": {
47
+ "groma": "^0.3.0",
48
+ "technologyVersions": {
49
+ "java": "25.0.0"
50
+ }
51
+ }
52
+ }
53
+ }
54
+ },
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "https://github.com/MrLesk/Groma.md.git"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public"
61
+ }
62
+ }
package/src/index.js ADDED
@@ -0,0 +1,428 @@
1
+ // @bun
2
+ // plugins/scanners/java/src/adapter.ts
3
+ import { fileURLToPath } from "url";
4
+ import path3 from "path";
5
+
6
+ // packages/scanner/src/index.ts
7
+ function compare(...values) {
8
+ return values.join("\x00");
9
+ }
10
+ function uniqueBy(values, key, label) {
11
+ const seen = new Set;
12
+ for (const value of values) {
13
+ const id = key(value);
14
+ if (seen.has(id))
15
+ throw new Error(`duplicate ${label}: ${id}`);
16
+ seen.add(id);
17
+ }
18
+ return [...values].sort((left, right) => key(left).localeCompare(key(right)));
19
+ }
20
+ function createScanObservation(input) {
21
+ const roots = validateRoots(input.roots);
22
+ const rootIds = new Set(roots.map((root) => root.id));
23
+ const files = uniqueBy(input.files.map((file) => ({
24
+ ...file,
25
+ roots: [...new Set(file.roots)].sort(),
26
+ symbols: [...new Map(file.symbols.map((symbol) => [
27
+ compare(symbol.id, symbol.kind),
28
+ symbol
29
+ ])).values()].sort((left, right) => {
30
+ return compare(left.id, left.kind).localeCompare(compare(right.id, right.kind));
31
+ })
32
+ })), (file) => file.file, "file path");
33
+ const filePaths = new Set(files.map((file) => file.file));
34
+ for (const file of files) {
35
+ if (file.roots.length === 0)
36
+ throw new Error(`file has no root: ${file.file}`);
37
+ for (const root of file.roots) {
38
+ if (!rootIds.has(root))
39
+ throw new Error(`file references unknown root: ${root}`);
40
+ }
41
+ }
42
+ for (const diagnostic of input.diagnostics)
43
+ diagnosticLocation(diagnostic);
44
+ return {
45
+ schemaVersion: 1,
46
+ scanner: input.scanner,
47
+ roots,
48
+ files,
49
+ ...operationEvidence(input, filePaths),
50
+ diagnostics: [...new Map(input.diagnostics.map((diagnostic) => [
51
+ diagnosticKey(diagnostic),
52
+ diagnostic
53
+ ])).values()].sort((left, right) => {
54
+ return diagnosticKey(left).localeCompare(diagnosticKey(right));
55
+ })
56
+ };
57
+ }
58
+ function validateRoots(input) {
59
+ const roots = uniqueBy(input, (root) => root.id, "root id");
60
+ const byId = new Map(roots.map((root) => [root.id, root]));
61
+ for (const root of roots) {
62
+ const visited = new Set([root.id]);
63
+ let parent = root.parent;
64
+ while (parent !== undefined) {
65
+ if (visited.has(parent))
66
+ throw new Error(`root hierarchy contains a cycle: ${parent}`);
67
+ visited.add(parent);
68
+ const ancestor = byId.get(parent);
69
+ if (ancestor === undefined)
70
+ throw new Error(`root references unknown parent: ${parent}`);
71
+ parent = ancestor.parent;
72
+ }
73
+ }
74
+ return roots;
75
+ }
76
+ function diagnosticKey(diagnostic) {
77
+ return compare(diagnostic.severity, diagnostic.code, diagnostic.message, diagnostic.file ?? "", String(diagnostic.line ?? ""));
78
+ }
79
+ function diagnosticLocation(value) {
80
+ if (value.line !== undefined && (!Number.isInteger(value.line) || Number(value.line) < 1)) {
81
+ throw new Error("diagnostic.line must be a positive integer");
82
+ }
83
+ return {
84
+ ...value.file === undefined ? {} : { file: string(value.file, "diagnostic.file") },
85
+ ...value.line === undefined ? {} : { line: Number(value.line) }
86
+ };
87
+ }
88
+ function object(value, label) {
89
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
90
+ throw new Error(`${label} must be an object`);
91
+ }
92
+ return value;
93
+ }
94
+ function string(value, label) {
95
+ if (typeof value !== "string")
96
+ throw new Error(`${label} must be a string`);
97
+ return value;
98
+ }
99
+ function array(value, label) {
100
+ if (!Array.isArray(value))
101
+ throw new Error(`${label} must be an array`);
102
+ return value;
103
+ }
104
+ function parseScanObservation(source) {
105
+ const value = object(JSON.parse(source), "observation");
106
+ if (value.schemaVersion !== 1)
107
+ throw new Error("unsupported scanner schema");
108
+ const scanner = object(value.scanner, "scanner");
109
+ return createScanObservation({
110
+ scanner: {
111
+ id: string(scanner.id, "scanner.id"),
112
+ technology: string(scanner.technology, "scanner.technology"),
113
+ engine: string(scanner.engine, "scanner.engine"),
114
+ engineVersion: string(scanner.engineVersion, "scanner.engineVersion")
115
+ },
116
+ ...parseOperations(value),
117
+ roots: array(value.roots, "roots").map((entry) => {
118
+ const root = object(entry, "root");
119
+ return {
120
+ id: string(root.id, "root.id"),
121
+ kind: string(root.kind, "root.kind"),
122
+ name: string(root.name, "root.name"),
123
+ ...root.file === undefined ? {} : { file: string(root.file, "root.file") },
124
+ ...root.parent === undefined ? {} : { parent: string(root.parent, "root.parent") }
125
+ };
126
+ }),
127
+ files: array(value.files, "files").map((entry, index) => {
128
+ const file = object(entry, `files[${index}]`);
129
+ return {
130
+ file: string(file.file, `files[${index}].file`),
131
+ roots: array(file.roots, "file.roots").map((root) => string(root, "file.root")),
132
+ symbols: array(file.symbols, `files[${index}].symbols`).map((entry, symbolIndex) => {
133
+ const symbol = object(entry, `files[${index}].symbols[${symbolIndex}]`);
134
+ return {
135
+ id: string(symbol.id, "symbol.id"),
136
+ name: string(symbol.name, "symbol.name"),
137
+ kind: string(symbol.kind, "symbol.kind")
138
+ };
139
+ })
140
+ };
141
+ }),
142
+ diagnostics: array(value.diagnostics, "diagnostics").map((entry, index) => {
143
+ const diagnostic = object(entry, `diagnostics[${index}]`);
144
+ return {
145
+ severity: string(diagnostic.severity, `diagnostics[${index}].severity`),
146
+ code: string(diagnostic.code, `diagnostics[${index}].code`),
147
+ message: string(diagnostic.message, `diagnostics[${index}].message`),
148
+ ...diagnosticLocation(diagnostic)
149
+ };
150
+ })
151
+ });
152
+ }
153
+ function parseOperations(value) {
154
+ if (value.operations === undefined) {
155
+ if (value.invocations !== undefined)
156
+ throw new Error("invocations require operation declarations");
157
+ return {};
158
+ }
159
+ const operations = array(value.operations, "operations").map((entry) => {
160
+ const operation = object(entry, "operation");
161
+ return {
162
+ id: string(operation.id, "operation.id"),
163
+ file: string(operation.file, "operation.file"),
164
+ name: string(operation.name, "operation.name"),
165
+ ...sourcePosition(operation.position),
166
+ ...operationTokens(operation)
167
+ };
168
+ });
169
+ const invocations = array(value.invocations, "invocations").map((entry) => {
170
+ const invocation = object(entry, "invocation");
171
+ if (typeof invocation.unresolved !== "boolean")
172
+ throw new Error("invocation.unresolved must be a boolean");
173
+ if (!Number.isInteger(invocation.line) || Number(invocation.line) < 1)
174
+ throw new Error("invocation.line must be a positive integer");
175
+ const binding = invocation.binding === undefined ? undefined : object(invocation.binding, "invocation.binding");
176
+ if (binding && (!Number.isInteger(binding.line) || Number(binding.line) < 1))
177
+ throw new Error("binding.line must be a positive integer");
178
+ return {
179
+ source: string(invocation.source, "invocation.source"),
180
+ targets: array(invocation.targets, "invocation.targets").map((target) => string(target, "invocation.target")),
181
+ unresolved: invocation.unresolved,
182
+ line: Number(invocation.line),
183
+ ...sourcePosition(invocation.position),
184
+ ...invocation.member === undefined ? {} : { member: string(invocation.member, "invocation.member") },
185
+ ...binding === undefined ? {} : { binding: { file: string(binding.file, "binding.file"), line: Number(binding.line), ...sourcePosition(binding.position) } }
186
+ };
187
+ });
188
+ return { operations, invocations };
189
+ }
190
+ function operationEvidence(input, files) {
191
+ if (input.operations === undefined && input.invocations !== undefined)
192
+ throw new Error("invocations require operation declarations");
193
+ if (input.operations === undefined)
194
+ return {};
195
+ const operations = uniqueBy(input.operations, (operation) => operation.id, "operation id");
196
+ const ids = new Set(operations.map((operation) => operation.id));
197
+ for (const operation of operations) {
198
+ if (!files.has(operation.file))
199
+ throw new Error(`operation references unknown file: ${operation.file}`);
200
+ sourcePosition(operation.position);
201
+ validateOperationTokens(operation);
202
+ }
203
+ const invocations = input.invocations ?? [];
204
+ for (const invocation of invocations)
205
+ validateInvocation(invocation, ids, files);
206
+ return { operations, invocations };
207
+ }
208
+ function operationTokens(operation) {
209
+ if (operation.tokens === undefined) {
210
+ if (operation.startLine !== undefined || operation.endLine !== undefined) {
211
+ throw new Error("operation range requires tokens");
212
+ }
213
+ return {};
214
+ }
215
+ if (operation.startLine === undefined || operation.endLine === undefined) {
216
+ throw new Error("operation tokens require a source range");
217
+ }
218
+ if (!Number.isInteger(operation.startLine) || Number(operation.startLine) < 1) {
219
+ throw new Error("operation.startLine must be a positive integer");
220
+ }
221
+ if (!Number.isInteger(operation.endLine) || Number(operation.endLine) < 1) {
222
+ throw new Error("operation.endLine must be a positive integer");
223
+ }
224
+ const startLine = Number(operation.startLine);
225
+ const endLine = Number(operation.endLine);
226
+ if (endLine < startLine)
227
+ throw new Error("operation.endLine must be at or after startLine");
228
+ return {
229
+ startLine,
230
+ endLine,
231
+ tokens: array(operation.tokens, "operation.tokens").map((token) => string(token, "operation.token"))
232
+ };
233
+ }
234
+ function validateOperationTokens(operation) {
235
+ operationTokens({
236
+ tokens: operation.tokens,
237
+ startLine: operation.startLine,
238
+ endLine: operation.endLine
239
+ });
240
+ }
241
+ function validateInvocation(invocation, ids, files) {
242
+ sourcePosition(invocation.position);
243
+ sourcePosition(invocation.binding?.position);
244
+ if (!invocation.targets.length && !invocation.unresolved)
245
+ throw new Error("an empty target set must be unresolved");
246
+ if (!ids.has(invocation.source) || invocation.targets.some((target) => !ids.has(target))) {
247
+ throw new Error("invocation references unknown operation");
248
+ }
249
+ if (invocation.binding && !files.has(invocation.binding.file))
250
+ throw new Error("invocation binding references unknown file");
251
+ }
252
+ function sourcePosition(position) {
253
+ if (position === undefined)
254
+ return {};
255
+ if (!Number.isInteger(position) || Number(position) < 0)
256
+ throw new Error("source position must be a non-negative integer");
257
+ return { position: Number(position) };
258
+ }
259
+
260
+ // plugins/scanners/java/src/maven.ts
261
+ import { access, mkdtemp, readFile, readdir, realpath, rm } from "fs/promises";
262
+ import os from "os";
263
+ import path2 from "path";
264
+
265
+ // plugins/scanners/java/src/process.ts
266
+ import { execFile } from "child_process";
267
+ import path from "path";
268
+ function javaCommand() {
269
+ const executable = process.platform === "win32" ? "java.exe" : "java";
270
+ return process.env.JAVA_HOME ? path.join(process.env.JAVA_HOME, "bin", executable) : executable;
271
+ }
272
+ function run(command, args, root, input = "", timeout = 120000) {
273
+ return new Promise((resolve, reject) => {
274
+ const child = execFile(command, args, {
275
+ cwd: root,
276
+ encoding: "utf8",
277
+ timeout,
278
+ killSignal: "SIGKILL",
279
+ maxBuffer: 64 * 1024 * 1024,
280
+ windowsVerbatimArguments: process.platform === "win32" && path.basename(command).toLowerCase() === "cmd.exe"
281
+ }, (error, stdout, stderr) => {
282
+ if (error)
283
+ reject(new Error(stderr.trim() || stdout.trim() || error.message));
284
+ else
285
+ resolve(stdout);
286
+ });
287
+ child.stdin?.on("error", () => {});
288
+ child.stdin?.end(input);
289
+ });
290
+ }
291
+ function mavenInvocation(command, args, platform = process.platform) {
292
+ if (platform !== "win32" || !/\.(cmd|bat)$/i.test(command))
293
+ return [command, args];
294
+ return [process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `"${[command, ...args].map((value) => `"${value}"`).join(" ")}"`]];
295
+ }
296
+
297
+ // plugins/scanners/java/src/maven.ts
298
+ async function exists(file) {
299
+ try {
300
+ await access(file);
301
+ return true;
302
+ } catch (error) {
303
+ if (error.code === "ENOENT")
304
+ return false;
305
+ throw error;
306
+ }
307
+ }
308
+ async function collect(root, directory) {
309
+ const files = [];
310
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
311
+ const file = path2.join(directory, entry.name);
312
+ if (entry.isDirectory())
313
+ files.push(...await collect(root, file));
314
+ else if (entry.isFile() && file.endsWith(".java"))
315
+ files.push(path2.relative(root, file).split(path2.sep).join("/"));
316
+ }
317
+ return files.sort();
318
+ }
319
+ async function mavenCommand(root) {
320
+ const wrapper = path2.join(root, process.platform === "win32" ? "mvnw.cmd" : "mvnw");
321
+ return await exists(wrapper) ? wrapper : process.platform === "win32" ? "mvn.cmd" : "mvn";
322
+ }
323
+ var mavenGoals = [
324
+ "org.apache.maven.plugins:maven-help-plugin:3.5.1:effective-pom",
325
+ "org.apache.maven.plugins:maven-dependency-plugin:3.9.0:build-classpath"
326
+ ];
327
+ async function readJavaInput(repositoryRoot, java, worker, maven) {
328
+ const root = await realpath(repositoryRoot);
329
+ if (!await exists(path2.join(root, "pom.xml"))) {
330
+ throw new Error("JAVA_UNSUPPORTED_BUILD: The Java scanner supports a root single-module Maven pom.xml. Gradle and other build arrangements are not supported.");
331
+ }
332
+ const temporary = await mkdtemp(path2.join(os.tmpdir(), "groma-java-model-"));
333
+ try {
334
+ const model = path2.join(temporary, "pom.xml");
335
+ const classpath = path2.join(temporary, "classpath.txt");
336
+ const command = maven ?? await mavenCommand(root);
337
+ const args = [
338
+ "--offline",
339
+ "--batch-mode",
340
+ "--no-transfer-progress",
341
+ "--non-recursive",
342
+ ...mavenGoals,
343
+ `-Doutput=${model}`,
344
+ `-Dmdep.outputFile=${classpath}`,
345
+ "-DincludeScope=compile"
346
+ ];
347
+ try {
348
+ await run(...mavenInvocation(command, args), root);
349
+ } catch (error) {
350
+ throw new Error(`JAVA_MAVEN_PREPARATION: Install the project JDK and Maven (or use its wrapper), then run ${command} ${mavenGoals.join(" ")} -DincludeScope=compile once with dependency access. Scans use Maven offline. ${error instanceof Error ? error.message : error}`);
351
+ }
352
+ const exported = JSON.parse(await run(java, ["-jar", worker, "model", model], root));
353
+ const files = await collect(root, exported.sourceRoot);
354
+ if (!files.length)
355
+ throw new Error("JAVA_EMPTY_SOURCE_SET: Maven main source directory contains no Java sources.");
356
+ if (files.some((file) => file.endsWith("module-info.java")))
357
+ throw new Error("JAVA_UNSUPPORTED_BUILD: JPMS module paths are not supported.");
358
+ const dependencies = (await readFile(classpath, "utf8")).trim().split(path2.delimiter).filter(Boolean);
359
+ if (await exists(exported.output))
360
+ dependencies.push(exported.output);
361
+ return {
362
+ root,
363
+ release: exported.release,
364
+ encoding: exported.encoding,
365
+ name: exported.name,
366
+ files,
367
+ classpath: dependencies,
368
+ generatedRoot: await exists(exported.generatedRoot) ? exported.generatedRoot : ""
369
+ };
370
+ } finally {
371
+ await rm(temporary, { recursive: true, force: true });
372
+ }
373
+ }
374
+
375
+ // plugins/scanners/java/src/adapter.ts
376
+ var worker = fileURLToPath(new URL("../dist/worker.jar", import.meta.url));
377
+ async function checkJavaReadiness(repositoryRoot, options = {}) {
378
+ const jar = options.worker ?? worker;
379
+ if (!await exists(jar))
380
+ throw new Error("JAVA_WORKER_MISSING: Install the packaged Java scanner, or build it with bun plugins/scanners/java/build.ts.");
381
+ const command = options.java ?? javaCommand();
382
+ try {
383
+ const modules = await run(command, ["--list-modules"], repositoryRoot);
384
+ if (!modules.includes("jdk.compiler@"))
385
+ throw new Error("The selected runtime has no Java compiler module.");
386
+ } catch (error) {
387
+ throw new Error(`JAVA_JDK_MISSING: Install the project JDK (Java 25 for the supported example) and select it with JAVA_HOME. ${error}`);
388
+ }
389
+ const input = await readJavaInput(repositoryRoot, command, jar, options.maven);
390
+ return { input, command, jar };
391
+ }
392
+ async function scanJavaSource(repositoryRoot, options = {}) {
393
+ const { input, command, jar } = await checkJavaReadiness(repositoryRoot, options);
394
+ let stdout;
395
+ try {
396
+ stdout = await run(command, [
397
+ "-Xmx1024m",
398
+ "-jar",
399
+ jar,
400
+ input.root,
401
+ input.release,
402
+ input.encoding,
403
+ input.generatedRoot
404
+ ], input.root, `${input.classpath.join(path3.delimiter)}
405
+ ${input.files.join(`
406
+ `)}
407
+ `, options.timeout);
408
+ } catch (error) {
409
+ throw new Error(`JAVA_COMPILATION_FAILED: No observation was produced. Use the project's declared JDK ${input.release}, resolve Maven dependencies, and prepare required generated sources with the project's documented build command. ${error}`);
410
+ }
411
+ const observation = parseScanObservation(stdout);
412
+ observation.roots[0].name = input.name;
413
+ return observation;
414
+ }
415
+
416
+ // plugins/scanners/java/src/index.ts
417
+ var scanner = {
418
+ id: "java",
419
+ watch: { include: ["**/*.java", "pom.xml", ".mvn/**"], exclude: [] },
420
+ checkReadiness: async (root) => {
421
+ await checkJavaReadiness(root);
422
+ },
423
+ scan: scanJavaSource
424
+ };
425
+ var src_default = scanner;
426
+ export {
427
+ src_default as default
428
+ };