diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 8b4e1a8..36aab08 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -67,7 +67,7 @@
},
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
"workspaceFolder": "/workspace",
- "postCreateCommand": "yarn install",
+ "postCreateCommand": "yarn install && mkdir -p /home/node/.m2 && cp /workspace/.devcontainer/maven-settings.xml /home/node/.m2/settings.xml",
"postStartCommand": "mkdir -p /home/node/.m2/repository",
"features": {
"ghcr.io/devcontainers/features/git:1": {
diff --git a/.devcontainer/maven-settings.xml b/.devcontainer/maven-settings.xml
new file mode 100644
index 0000000..c76561a
--- /dev/null
+++ b/.devcontainer/maven-settings.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+ maven-default-http-blocker
+ external:http:*,!folio-nexus,!folio-snapshots,!apache.snapshots,!index-data-nexus
+ Pseudo repository to mirror external repositories initially using HTTP.
+ http://0.0.0.0/
+ true
+
+
+
+
+
+
+ folio
+
+
+ folio-nexus
+ FOLIO Maven Repository
+ https://repository.folio.org/repository/maven-folio/
+
+ true
+
+
+ true
+
+
+
+ folio-snapshots
+ FOLIO Snapshot Repository
+ https://repository.folio.org/repository/snapshots/
+
+ false
+
+
+ true
+ always
+
+
+
+
+
+ folio-nexus
+ FOLIO Maven Repository
+ https://repository.folio.org/repository/maven-folio/
+
+ true
+
+
+ true
+
+
+
+
+
+
+
+
+ folio
+
+
+
diff --git a/package.json b/package.json
index cc94571..db40945 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,8 @@
"start": "node dist/cli.js",
"dev": "ts-node src/cli.ts",
"test": "jest",
+ "test:unit": "jest --testPathIgnorePatterns='integration'",
+ "test:integration": "jest --testMatch='**/*.integration.test.ts' --runInBand",
"clean": "rm -rf dist"
},
"keywords": [
diff --git a/src/__tests__/integration/cli.integration.test.ts b/src/__tests__/integration/cli.integration.test.ts
new file mode 100644
index 0000000..148e40c
--- /dev/null
+++ b/src/__tests__/integration/cli.integration.test.ts
@@ -0,0 +1,296 @@
+import * as path from 'path';
+import * as fs from 'fs-extra';
+import * as os from 'os';
+import { ModuleEvaluator } from '../../module-evaluator';
+import { ReportGenerator } from '../../utils/report-generator';
+import { EvaluationConfig, ReportOptions } from '../../types';
+
+describe('CLI Integration Tests', () => {
+ const testOutputDir = path.join(os.tmpdir(), 'folio-eval-integration-tests', Date.now().toString());
+
+ beforeAll(async () => {
+ // Create test output directory
+ await fs.ensureDir(testOutputDir);
+ });
+
+ afterAll(async () => {
+ // Clean up test output directory
+ try {
+ await fs.remove(testOutputDir);
+ } catch (error) {
+ console.warn('Failed to clean up test output directory:', error);
+ }
+ });
+
+ // Extended timeout for integration tests (5 minutes per test)
+ jest.setTimeout(300000);
+
+ describe('Repository Evaluation', () => {
+ test('should evaluate mod-search (Java backend module)', async () => {
+ const repoUrl = 'https://github.com/folio-org/mod-search';
+ const config: EvaluationConfig = {
+ outputDir: testOutputDir,
+ skipCleanup: false,
+ };
+
+ // Create evaluator with config
+ const evaluator = new ModuleEvaluator(config);
+
+ // Run evaluation
+ const result = await evaluator.evaluateModule(repoUrl);
+
+ // Verify evaluation result structure
+ expect(result).toBeDefined();
+ expect(result.moduleName).toBe('mod-search');
+ expect(result.repositoryUrl).toBe(repoUrl);
+ expect(result.evaluatedAt).toBeDefined();
+ expect(result.language).toBe('Java');
+ expect(result.criteria).toBeInstanceOf(Array);
+ expect(result.criteria.length).toBeGreaterThan(0);
+
+ // Verify each result has required fields
+ result.criteria.forEach((criterionResult) => {
+ expect(criterionResult.criterionId).toBeDefined();
+ expect(criterionResult.status).toMatch(/pass|fail|manual|not_applicable/);
+ expect(criterionResult.evidence).toBeDefined();
+ });
+
+ // Generate reports
+ const reportGenerator = new ReportGenerator(testOutputDir);
+ const reportPaths = await reportGenerator.generateReports(result);
+
+ // Verify reports were generated
+ expect(reportPaths.htmlPath).toBeDefined();
+ expect(reportPaths.jsonPath).toBeDefined();
+
+ // Verify HTML report exists and has content
+ const htmlContent = await fs.readFile(reportPaths.htmlPath!, 'utf-8');
+ expect(htmlContent).toContain('mod-search');
+ expect(htmlContent).toContain('Evaluation Report');
+
+ // Verify JSON report is valid JSON
+ const jsonContent = await fs.readFile(reportPaths.jsonPath!, 'utf-8');
+ const jsonData = JSON.parse(jsonContent);
+ expect(jsonData.moduleName).toBe('mod-search');
+ expect(jsonData.criteria).toBeInstanceOf(Array);
+ });
+
+ test('should evaluate mod-source-record-storage (Java backend module)', async () => {
+ const repoUrl = 'https://github.com/folio-org/mod-source-record-storage';
+ const config: EvaluationConfig = {
+ outputDir: testOutputDir,
+ skipCleanup: false,
+ };
+
+ // Create evaluator with config
+ const evaluator = new ModuleEvaluator(config);
+
+ // Run evaluation
+ const result = await evaluator.evaluateModule(repoUrl);
+
+ // Verify evaluation result structure
+ expect(result).toBeDefined();
+ expect(result.moduleName).toBe('mod-source-record-storage');
+ expect(result.repositoryUrl).toBe(repoUrl);
+ expect(result.evaluatedAt).toBeDefined();
+ expect(result.language).toBe('Java');
+ expect(result.criteria).toBeInstanceOf(Array);
+ expect(result.criteria.length).toBeGreaterThan(0);
+
+ // Generate reports
+ const reportGenerator = new ReportGenerator(testOutputDir);
+ const reportPaths = await reportGenerator.generateReports(result);
+
+ // Verify reports were generated
+ expect(reportPaths.htmlPath).toBeDefined();
+ expect(reportPaths.jsonPath).toBeDefined();
+
+ // Verify JSON structure
+ const jsonContent = await fs.readFile(reportPaths.jsonPath!, 'utf-8');
+ const jsonData = JSON.parse(jsonContent);
+ expect(jsonData.moduleName).toBe('mod-source-record-storage');
+ expect(jsonData.language).toBe('Java');
+ expect(jsonData.criteria).toBeInstanceOf(Array);
+ });
+
+ test('should evaluate ui-data-import (JavaScript/UI module)', async () => {
+ const repoUrl = 'https://github.com/folio-org/ui-data-import';
+ const config: EvaluationConfig = {
+ outputDir: testOutputDir,
+ skipCleanup: false,
+ };
+
+ // Create evaluator with config
+ const evaluator = new ModuleEvaluator(config);
+
+ // Run evaluation
+ const result = await evaluator.evaluateModule(repoUrl);
+
+ // Verify evaluation result structure
+ expect(result).toBeDefined();
+ expect(result.moduleName).toBe('ui-data-import');
+ expect(result.repositoryUrl).toBe(repoUrl);
+ expect(result.evaluatedAt).toBeDefined();
+ expect(result.language).toBe('JavaScript');
+ expect(result.criteria).toBeInstanceOf(Array);
+ expect(result.criteria.length).toBeGreaterThan(0);
+
+ // Generate reports
+ const reportGenerator = new ReportGenerator(testOutputDir);
+ const reportPaths = await reportGenerator.generateReports(result);
+
+ // Verify reports were generated
+ expect(reportPaths.htmlPath).toBeDefined();
+ expect(reportPaths.jsonPath).toBeDefined();
+
+ // Verify JSON structure
+ const jsonContent = await fs.readFile(reportPaths.jsonPath!, 'utf-8');
+ const jsonData = JSON.parse(jsonContent);
+ expect(jsonData.moduleName).toBe('ui-data-import');
+ expect(jsonData.language).toBe('JavaScript');
+ expect(jsonData.criteria).toBeInstanceOf(Array);
+ });
+ });
+
+ describe('Report Generation Options', () => {
+ test('should generate only JSON report when outputHtml is false', async () => {
+ const repoUrl = 'https://github.com/folio-org/mod-search';
+ const config: EvaluationConfig = {
+ outputDir: testOutputDir,
+ skipCleanup: false,
+ };
+
+ // Create evaluator with config
+ const evaluator = new ModuleEvaluator(config);
+
+ // Run evaluation
+ const result = await evaluator.evaluateModule(repoUrl);
+ expect(result).toBeDefined();
+
+ // Generate only JSON report
+ const reportGenerator = new ReportGenerator(testOutputDir);
+ const reportOptions: ReportOptions = {
+ outputHtml: false,
+ outputJson: true,
+ outputDir: testOutputDir
+ };
+ const reportPaths = await reportGenerator.generateReports(result, reportOptions);
+
+ // Verify only JSON report was generated
+ expect(reportPaths.htmlPath).toBeUndefined();
+ expect(reportPaths.jsonPath).toBeDefined();
+
+ // Verify JSON file exists
+ const jsonExists = await fs.pathExists(reportPaths.jsonPath!);
+ expect(jsonExists).toBe(true);
+ });
+
+ test('should generate only HTML report when outputJson is false', async () => {
+ const repoUrl = 'https://github.com/folio-org/mod-search';
+ const config: EvaluationConfig = {
+ outputDir: testOutputDir,
+ skipCleanup: false,
+ };
+
+ // Create evaluator with config
+ const evaluator = new ModuleEvaluator(config);
+
+ // Run evaluation
+ const result = await evaluator.evaluateModule(repoUrl);
+ expect(result).toBeDefined();
+
+ // Generate only HTML report
+ const reportGenerator = new ReportGenerator(testOutputDir);
+ const reportOptions: ReportOptions = {
+ outputHtml: true,
+ outputJson: false,
+ outputDir: testOutputDir
+ };
+ const reportPaths = await reportGenerator.generateReports(result, reportOptions);
+
+ // Verify only HTML report was generated
+ expect(reportPaths.htmlPath).toBeDefined();
+ expect(reportPaths.jsonPath).toBeUndefined();
+
+ // Verify HTML file exists
+ const htmlExists = await fs.pathExists(reportPaths.htmlPath!);
+ expect(htmlExists).toBe(true);
+ });
+ });
+
+ describe('Cleanup Functionality', () => {
+ test('should clean up temporary clone directory by default', async () => {
+ const repoUrl = 'https://github.com/folio-org/mod-search';
+ const config: EvaluationConfig = {
+ outputDir: testOutputDir,
+ skipCleanup: false,
+ };
+
+ // Create evaluator with config
+ const evaluator = new ModuleEvaluator(config);
+
+ // Run evaluation
+ await evaluator.evaluateModule(repoUrl);
+
+ // Check that no temporary directories remain in system temp
+ const tempDir = path.join(os.tmpdir(), 'folio-eval');
+ if (await fs.pathExists(tempDir)) {
+ const dirs = await fs.readdir(tempDir);
+ // There should be very few or no mod-search directories
+ const modSearchDirs = dirs.filter(d => d.startsWith('mod-search'));
+ expect(modSearchDirs.length).toBeLessThanOrEqual(1);
+ }
+ });
+
+ test('should not clean up when skipCleanup is true', async () => {
+ const repoUrl = 'https://github.com/folio-org/mod-search';
+ const tempDir = path.join(os.tmpdir(), 'folio-eval-test-nocleanup');
+
+ const config: EvaluationConfig = {
+ outputDir: testOutputDir,
+ skipCleanup: true,
+ tempDir: tempDir,
+ };
+
+ // Create evaluator with config
+ const evaluator = new ModuleEvaluator(config);
+
+ // Run evaluation
+ await evaluator.evaluateModule(repoUrl);
+
+ // Check that temporary directory still exists
+ expect(await fs.pathExists(tempDir)).toBe(true);
+ const dirs = await fs.readdir(tempDir);
+ const modSearchDirs = dirs.filter(d => d.startsWith('mod-search'));
+ expect(modSearchDirs.length).toBeGreaterThan(0);
+
+ // Clean up manually
+ for (const dir of modSearchDirs) {
+ await fs.remove(path.join(tempDir, dir));
+ }
+ });
+ });
+
+ describe('Criteria Filtering', () => {
+ test('should filter results when criteria filter is provided', async () => {
+ const repoUrl = 'https://github.com/folio-org/mod-search';
+ const config: EvaluationConfig = {
+ outputDir: testOutputDir,
+ skipCleanup: false,
+ criteriaFilter: ['A001', 'S001'],
+ };
+
+ // Create evaluator with config
+ const evaluator = new ModuleEvaluator(config);
+
+ // Run evaluation
+ const result = await evaluator.evaluateModule(repoUrl);
+
+ // Verify only specified criteria are in results
+ expect(result.criteria.length).toBeLessThanOrEqual(2);
+ result.criteria.forEach((r) => {
+ expect(['A001', 'S001']).toContain(r.criterionId);
+ });
+ });
+ });
+});
diff --git a/src/utils/parsers/maven-dependency-parser.ts b/src/utils/parsers/maven-dependency-parser.ts
index c270c76..3ec185e 100644
--- a/src/utils/parsers/maven-dependency-parser.ts
+++ b/src/utils/parsers/maven-dependency-parser.ts
@@ -35,6 +35,7 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import * as path from 'path';
+import { parseStringPromise } from 'xml2js';
import { Dependency, DependencyExtractionResult, DependencyExtractionError } from '../../types';
import { normalizeLicenseName } from '../license-policy';
import { isNonEmptyString, isValidDependency } from '../type-guards';
@@ -54,6 +55,9 @@ const MAVEN_COORDINATE_PATTERN = /^(.+?):(.+?):(.+?)$/;
// Maven build file patterns
const MAVEN_BUILD_FILES = ['pom.xml'];
+// Valid Maven packaging types
+const VALID_PACKAGING_TYPES = ['pom', 'jar', 'war', 'ear', 'maven-plugin', 'ejb', 'rar', 'bundle'];
+
/**
* Safely read file contents
* @param filePath - Path to file
@@ -108,6 +112,54 @@ function validateRepoPath(repoPath: string): string | null {
}
}
+/**
+ * Determine the Maven packaging type from pom.xml using proper XML parsing
+ * @param repoPath - Path to Maven project
+ * @returns Promise resolving to packaging type ('pom', 'jar', 'war', etc.) or 'jar' as default
+ */
+async function getMavenPackaging(repoPath: string): Promise {
+ try {
+ const pomPath = path.join(repoPath, 'pom.xml');
+ if (!fs.existsSync(pomPath)) {
+ console.warn('pom.xml not found, defaulting to jar packaging');
+ return 'jar';
+ }
+
+ const pomContent = fs.readFileSync(pomPath, 'utf-8');
+
+ // Parse XML using xml2js for robust handling of XML variations
+ const parsed = await parseStringPromise(pomContent, {
+ trim: true,
+ explicitArray: true,
+ mergeAttrs: false
+ });
+
+ // Extract packaging from parsed XML
+ // Structure: parsed.project.packaging[0]
+ const packaging = parsed?.project?.packaging?.[0];
+
+ if (packaging && isNonEmptyString(packaging)) {
+ const trimmedPackaging = packaging.trim();
+
+ // Validate against known packaging types
+ if (!VALID_PACKAGING_TYPES.includes(trimmedPackaging)) {
+ console.warn(`Unknown Maven packaging type: ${trimmedPackaging}, defaulting to jar`);
+ return 'jar';
+ }
+
+ console.log(`Detected Maven packaging type: ${trimmedPackaging}`);
+ return trimmedPackaging;
+ }
+
+ // Maven defaults to 'jar' when packaging is not specified
+ console.log('No packaging specified in pom.xml, defaulting to jar');
+ return 'jar';
+ } catch (error) {
+ console.warn('Failed to parse pom.xml packaging, defaulting to jar:', error);
+ return 'jar';
+ }
+}
+
/**
* Split a Maven license string that may contain multiple licenses separated by pipe character
* This is Maven-specific format for dual licensing: "Apache-2.0|MIT"
@@ -343,9 +395,21 @@ export async function getMavenDependencies(repoPath: string): Promise