diff --git a/lib/cso/preparation.ts b/lib/cso/preparation.ts index dff72d712..2ea5c6445 100644 --- a/lib/cso/preparation.ts +++ b/lib/cso/preparation.ts @@ -5,7 +5,8 @@ import { createHash } from 'node:crypto'; import { lstatSync, readFileSync, realpathSync } from 'node:fs'; import { isAbsolute, relative, resolve, sep } from 'node:path'; -export type CsoStack = 'node' | 'bun' | 'python' | 'rails'; +/** 'java' (Maven or Gradle) is detected so it is reported as itself; no runtime prepares it. */ +export type CsoStack = 'node' | 'bun' | 'python' | 'rails' | 'java'; export interface PreparationPrerequisite { code: string; message: string; @@ -1196,7 +1197,13 @@ export function inspectPreparation(snapshotPath: string, stack?: CsoStack): Prep detectedStacks.push('node'); if (read(root, 'Gemfile.lock', true) !== undefined || read(root, 'Gemfile', true) !== undefined) detectedStacks.push('rails'); - if ( + // A root Maven or Gradle build names the repository's language; Python + // metadata beside it is helper tooling, not a second application stack. + const jvmBuild = ['pom.xml', 'build.gradle', 'build.gradle.kts'].find( + (file) => read(root, file, true) !== undefined, + ); + if (jvmBuild) detectedStacks.push('java'); + else if ( read(root, 'uv.lock', true) !== undefined || read(root, 'requirements.txt', true) !== undefined || read(root, 'pyproject.toml', true) !== undefined @@ -1210,12 +1217,17 @@ export function inspectPreparation(snapshotPath: string, stack?: CsoStack): Prep const detected = stack ?? detectedStacks[0] ?? 'python'; plan.stack = detected; plan.runtimeProfile = detected; + if (detected === 'java') + fail( + 'UNSUPPORTED_STACK', + `Java (${jvmBuild ?? 'Maven or Gradle'}) is not a supported runtime stack yet; supported runtime stacks are Node, Bun, Python, and Rails.`, + jvmBuild, + ); if (!['node', 'bun', 'python', 'rails'].includes(detected)) fail('UNSUPPORTED_STACK', 'Supported runtime stacks are Node, Bun, Python, and Rails.'); - ({ node: inspectNode, bun: inspectBun, python: inspectPython, rails: inspectRails })[detected]( - root, - plan, - ); + ({ node: inspectNode, bun: inspectBun, python: inspectPython, rails: inspectRails })[ + detected as Exclude + ](root, plan); } catch (error) { plan.status = 'prerequisites'; plan.prerequisites.push( diff --git a/test/cso-preparation-executor.test.ts b/test/cso-preparation-executor.test.ts index f6d63cb7b..39bcaa65b 100644 --- a/test/cso-preparation-executor.test.ts +++ b/test/cso-preparation-executor.test.ts @@ -50,7 +50,7 @@ function snapshot(stack: CsoStack): string { return base; } -function runtime(stack: CsoStack | 'postgresql') { return qualifiedRuntimeFixture(stack); } +function runtime(stack: Parameters[0]) { return qualifiedRuntimeFixture(stack); } function catalog(..._stacks: Array) { return completeRuntimeCatalogFixture('executor-test-v1'); } function cacheFixture() { const base = root('cso-executor-cache-'), staging = path.join(base, 'staging'); fs.mkdirSync(staging, { mode: 0o700 }); diff --git a/test/cso-preparation-java.test.ts b/test/cso-preparation-java.test.ts new file mode 100644 index 000000000..cb8c32001 --- /dev/null +++ b/test/cso-preparation-java.test.ts @@ -0,0 +1,62 @@ +/** + * A Java/Maven repository was reported as `stack: python` and asked for a + * requirements.txt, because no supported stack matched and preparation + * defaulted to Python. A root Maven or Gradle build now names the stack as + * Java (unsupported for runtime preparation, so static assessment only), and + * Python helper scripts or metadata beside it do not turn it into Python. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { inspectPreparation } from '../lib/cso/preparation'; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + +function fixture(files: Record): string { + const root = mkdtempSync(join(tmpdir(), 'cso-preparation-java-')); + roots.push(root); + for (const [path, contents] of Object.entries(files)) { + mkdirSync(join(root, path, '..'), { recursive: true }); + writeFileSync(join(root, path), contents); + } + return root; +} + +const maven = { + 'pom.xml': '4.0.0aapp1\n', + 'src/main/java/app/App.java': 'package app;\npublic class App { public static void main(String[] a) {} }\n', + 'scripts/release.py': 'print("helper")\n', + 'tools/lint_check.py': 'import sys\n', +}; + +describe('/cso preparation: a Maven repository with helper Python scripts is Java', () => { + test('helper .py scripts only', () => { + const plan = inspectPreparation(fixture(maven)); + expect(plan.stack).toBe('java'); + expect(plan.status).toBe('prerequisites'); + expect(plan.prerequisites).toEqual([{ + code: 'UNSUPPORTED_STACK', + message: 'Java (pom.xml) is not a supported runtime stack yet; supported runtime stacks are Node, Bun, Python, and Rails.', + path: 'pom.xml', + }]); + expect(JSON.stringify(plan)).not.toContain('requirements.txt'); + }); + + test('helper scripts with a root requirements.txt stay Java', () => { + const plan = inspectPreparation(fixture({ ...maven, 'requirements.txt': 'requests==2.32.3\n' })); + expect(plan.stack).toBe('java'); + expect(plan.prerequisites[0]?.code).toBe('UNSUPPORTED_STACK'); + }); + + test('Gradle builds are Java too', () => { + const plan = inspectPreparation(fixture({ 'build.gradle.kts': 'plugins { java }\n', 'scripts/x.py': '' })); + expect(plan.stack).toBe('java'); + expect(plan.prerequisites[0]?.message).toContain('Java (build.gradle.kts)'); + }); + + test('a Python project without a JVM build is still Python (control)', () => { + expect(inspectPreparation(fixture({ 'requirements.txt': 'requests==2.32.3\n', 'app.py': '' })).stack).toBe('python'); + }); +}); diff --git a/test/helpers/cso-runtime-catalog.ts b/test/helpers/cso-runtime-catalog.ts index a480399f9..f004896b7 100644 --- a/test/helpers/cso-runtime-catalog.ts +++ b/test/helpers/cso-runtime-catalog.ts @@ -1,4 +1,6 @@ import type { CsoStack } from '../../lib/cso/preparation'; + +type RuntimeStack = Exclude; import { canonical, sha256 } from '../../lib/cso/contracts'; import { ISOLATION_POLICY_HASH } from '../../lib/cso/docker'; import { @@ -14,7 +16,7 @@ const SOURCE_COMMIT = 'b'.repeat(40); const WORKFLOW = 'https://github.com/garrytan/gstack/actions/runs/1'; const DIGEST = `sha256:${'a'.repeat(64)}`; -const VERSIONS: Record> = { +const VERSIONS: Record> = { node: { node: '24.1.0', npm: '11.3.0', 'cso-preparation': '1.0.0' }, bun: { bun: '1.3.10', 'cso-preparation': '1.0.0' }, python: { python: '3.12.9', uv: '0.8.0', 'cso-preparation': '1.0.0' }, @@ -22,12 +24,12 @@ const VERSIONS: Record> = { postgresql: { postgresql: '17.2' }, }; -function runtimeId(stack: CsoStack | 'postgresql', platform: RuntimePlatform): string { +function runtimeId(stack: RuntimeStack | 'postgresql', platform: RuntimePlatform): string { return `${stack}-qualified-test-${platform === 'linux/amd64' ? 'amd64' : 'arm64'}`; } export function qualifiedRuntimeFixture( - stack: CsoStack | 'postgresql', + stack: RuntimeStack | 'postgresql', platform: RuntimePlatform = 'linux/amd64', ): QualifiedRuntime { const arch = platform === 'linux/amd64' ? 'amd64' : 'arm64';