From efafe44db1e790f2a14827d8150f58bd8a1aaa07 Mon Sep 17 00:00:00 2001 From: dave Date: Sat, 16 May 2026 23:15:02 +0000 Subject: [PATCH] huskies: merge 1110 story Chat bootstrap Phase 2b: additional stack overlays (Go, Python, Ruby, JVM) --- docker/stacks/go/Dockerfile.fragment | 28 +++ docker/stacks/go/markers | 4 + docker/stacks/jvm/Dockerfile.fragment | 50 +++++ docker/stacks/jvm/markers | 6 + docker/stacks/python/Dockerfile.fragment | 27 +++ docker/stacks/python/markers | 6 + docker/stacks/ruby/Dockerfile.fragment | 28 +++ docker/stacks/ruby/markers | 4 + .../src/chat/transport/matrix/new_project.rs | 202 ++++++++++++++++-- 9 files changed, 341 insertions(+), 14 deletions(-) create mode 100644 docker/stacks/go/Dockerfile.fragment create mode 100644 docker/stacks/go/markers create mode 100644 docker/stacks/jvm/Dockerfile.fragment create mode 100644 docker/stacks/jvm/markers create mode 100644 docker/stacks/python/Dockerfile.fragment create mode 100644 docker/stacks/python/markers create mode 100644 docker/stacks/ruby/Dockerfile.fragment create mode 100644 docker/stacks/ruby/markers diff --git a/docker/stacks/go/Dockerfile.fragment b/docker/stacks/go/Dockerfile.fragment new file mode 100644 index 00000000..6b636dae --- /dev/null +++ b/docker/stacks/go/Dockerfile.fragment @@ -0,0 +1,28 @@ +# Go stack overlay fragment. +# +# Layer this on top of huskies-project-base to produce a project container +# with Go 1.22, gopls (official Go language server), and standard tooling. +# +# Build the combined image: +# (echo "FROM huskies-project-base"; \ +# cat docker/stacks/go/Dockerfile.fragment) | \ +# docker build -t huskies-project-go - +# +# Adding a new stack: create docker/stacks//Dockerfile.fragment and +# docker/stacks//markers — no changes to orchestration code required. + +USER root + +# Official Go binary distribution — Debian's golang-go package is too old for gopls. +# Update GOVERSION to pick up a newer release. +ENV GOVERSION="1.22.3" +RUN curl -fsSL "https://go.dev/dl/go${GOVERSION}.linux-amd64.tar.gz" \ + | tar -C /usr/local -xzf - + +ENV PATH="/usr/local/go/bin:${PATH}" + +# gopls: the official Go language server. +# GOBIN=/usr/local/bin puts the binary on the system PATH for all users. +RUN GOBIN=/usr/local/bin go install golang.org/x/tools/gopls@latest + +USER huskies diff --git a/docker/stacks/go/markers b/docker/stacks/go/markers new file mode 100644 index 00000000..af09db41 --- /dev/null +++ b/docker/stacks/go/markers @@ -0,0 +1,4 @@ +# Stack detection markers for the go stack. +# Each non-blank, non-comment line names a file relative to the project root. +# If any listed file exists in the project directory, this stack is matched. +go.mod diff --git a/docker/stacks/jvm/Dockerfile.fragment b/docker/stacks/jvm/Dockerfile.fragment new file mode 100644 index 00000000..c9984c47 --- /dev/null +++ b/docker/stacks/jvm/Dockerfile.fragment @@ -0,0 +1,50 @@ +# JVM stack overlay fragment. +# +# Layer this on top of huskies-project-base to produce a project container +# with OpenJDK 21, Maven, and eclipse.jdt.ls (the canonical Java/JVM LSP). +# +# Build the combined image: +# (echo "FROM huskies-project-base"; \ +# cat docker/stacks/jvm/Dockerfile.fragment) | \ +# docker build -t huskies-project-jvm - +# +# Adding a new stack: create docker/stacks//Dockerfile.fragment and +# docker/stacks//markers — no changes to orchestration code required. + +USER root + +# OpenJDK 21 (current LTS) and Maven for build support. +RUN apt-get update && apt-get install -y --no-install-recommends \ + openjdk-21-jdk-headless \ + maven \ + && rm -rf /var/lib/apt/lists/* + +ENV JAVA_HOME="/usr/lib/jvm/java-21-openjdk-amd64" + +# Eclipse JDT Language Server — canonical LSP for Java/JVM (Java, Kotlin, Groovy). +# Pin to a specific release; update JDTLS_VERSION + JDTLS_BUILD for upgrades. +# All releases: https://github.com/eclipse-jdtls/eclipse.jdt.ls/releases +ENV JDTLS_VERSION="1.38.0" \ + JDTLS_BUILD="202503271418" +RUN mkdir -p /opt/jdtls \ + && curl -fsSL \ + "https://download.eclipse.org/jdtls/milestones/${JDTLS_VERSION}/jdt-language-server-${JDTLS_VERSION}-${JDTLS_BUILD}.tar.gz" \ + | tar -xzf - -C /opt/jdtls + +# Wrapper script so `jdtls` is available as a PATH command. +RUN { \ + echo '#!/bin/sh'; \ + echo 'JAR=$(ls /opt/jdtls/plugins/org.eclipse.equinox.launcher_*.jar 2>/dev/null | head -1)'; \ + echo 'exec java \'; \ + echo ' -Declipse.application=org.eclipse.jdt.ls.core.id1 \'; \ + echo ' -Dosgi.bundles.defaultStartLevel=4 \'; \ + echo ' -Declipse.product=org.eclipse.jdt.ls.core.product \'; \ + echo ' -Dlog.protocol=true \'; \ + echo ' -Dlog.level=ALL \'; \ + echo ' -jar "$JAR" \'; \ + echo ' -configuration /opt/jdtls/config_linux \'; \ + echo ' "$@"'; \ + } > /usr/local/bin/jdtls \ + && chmod +x /usr/local/bin/jdtls + +USER huskies diff --git a/docker/stacks/jvm/markers b/docker/stacks/jvm/markers new file mode 100644 index 00000000..dea81c74 --- /dev/null +++ b/docker/stacks/jvm/markers @@ -0,0 +1,6 @@ +# Stack detection markers for the jvm stack. +# Each non-blank, non-comment line names a file relative to the project root. +# If any listed file exists in the project directory, this stack is matched. +pom.xml +build.gradle +build.gradle.kts diff --git a/docker/stacks/python/Dockerfile.fragment b/docker/stacks/python/Dockerfile.fragment new file mode 100644 index 00000000..43992823 --- /dev/null +++ b/docker/stacks/python/Dockerfile.fragment @@ -0,0 +1,27 @@ +# Python stack overlay fragment. +# +# Layer this on top of huskies-project-base to produce a project container +# with Python 3, pip, and pyright (the Microsoft Python LSP / type checker). +# +# Build the combined image: +# (echo "FROM huskies-project-base"; \ +# cat docker/stacks/python/Dockerfile.fragment) | \ +# docker build -t huskies-project-python - +# +# Adding a new stack: create docker/stacks//Dockerfile.fragment and +# docker/stacks//markers — no changes to orchestration code required. + +USER root + +# Python 3 runtime and pip. +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + python3-pip \ + && rm -rf /var/lib/apt/lists/* + +# pyright: Microsoft's Python language server / static type checker. +# --break-system-packages is required on Debian 12+ where pip is externally +# managed; the flag is safe inside a Docker container. +RUN pip install --no-cache-dir --break-system-packages pyright + +USER huskies diff --git a/docker/stacks/python/markers b/docker/stacks/python/markers new file mode 100644 index 00000000..08217c14 --- /dev/null +++ b/docker/stacks/python/markers @@ -0,0 +1,6 @@ +# Stack detection markers for the python stack. +# Each non-blank, non-comment line names a file relative to the project root. +# If any listed file exists in the project directory, this stack is matched. +pyproject.toml +requirements.txt +setup.py diff --git a/docker/stacks/ruby/Dockerfile.fragment b/docker/stacks/ruby/Dockerfile.fragment new file mode 100644 index 00000000..eb80fa01 --- /dev/null +++ b/docker/stacks/ruby/Dockerfile.fragment @@ -0,0 +1,28 @@ +# Ruby stack overlay fragment. +# +# Layer this on top of huskies-project-base to produce a project container +# with Ruby, Bundler, and ruby-lsp (the Shopify Ruby language server). +# +# Build the combined image: +# (echo "FROM huskies-project-base"; \ +# cat docker/stacks/ruby/Dockerfile.fragment) | \ +# docker build -t huskies-project-ruby - +# +# Adding a new stack: create docker/stacks//Dockerfile.fragment and +# docker/stacks//markers — no changes to orchestration code required. + +USER root + +# Ruby runtime, development headers (needed by native gem extensions), and Bundler. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ruby \ + ruby-dev \ + bundler \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# ruby-lsp: Shopify's Ruby language server (LSP-compliant, actively maintained). +# Installed globally so the `ruby-lsp` binary is available on PATH. +RUN gem install ruby-lsp + +USER huskies diff --git a/docker/stacks/ruby/markers b/docker/stacks/ruby/markers new file mode 100644 index 00000000..5a50f815 --- /dev/null +++ b/docker/stacks/ruby/markers @@ -0,0 +1,4 @@ +# Stack detection markers for the ruby stack. +# Each non-blank, non-comment line names a file relative to the project root. +# If any listed file exists in the project directory, this stack is matched. +Gemfile diff --git a/server/src/chat/transport/matrix/new_project.rs b/server/src/chat/transport/matrix/new_project.rs index ed58628d..75903572 100644 --- a/server/src/chat/transport/matrix/new_project.rs +++ b/server/src/chat/transport/matrix/new_project.rs @@ -97,7 +97,8 @@ pub fn detect_stack(project_path: &Path, stacks_dir: &Path) -> (Option, Err(_) => return (None, vec![]), }; - let mut matched: Vec = Vec::new(); + // (stack_name, number_of_matched_marker_files) + let mut matched: Vec<(String, usize)> = Vec::new(); let mut stack_dirs: Vec<_> = entries .filter_map(|e| e.ok()) @@ -112,26 +113,32 @@ pub fn detect_stack(project_path: &Path, stacks_dir: &Path) -> (Option, let Ok(content) = std::fs::read_to_string(&markers_path) else { continue; }; - let hit = content.lines().any(|line| { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - return false; - } - project_path.join(trimmed).exists() - }); - if hit { - matched.push(stack_name); + let count = content + .lines() + .filter(|line| { + let trimmed = line.trim(); + !trimmed.is_empty() + && !trimmed.starts_with('#') + && project_path.join(trimmed).exists() + }) + .count(); + if count > 0 { + matched.push((stack_name, count)); } } match matched.len() { 0 => (None, vec![]), - 1 => (Some(matched.remove(0)), vec![]), + 1 => (Some(matched.remove(0).0), vec![]), _ => { - let names = matched.join(", "); - let chosen = matched.remove(0); + // Dominant stack: most marker files matched; alphabetical tiebreak for stability. + matched.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + let names: Vec = matched.iter().map(|(n, _)| n.clone()).collect(); + let names_str = names.join(", "); + let chosen = matched.swap_remove(0).0; let warning = format!( - "Multiple stacks detected ({names}); using **{chosen}**. \ + "Multiple stacks detected ({names_str}); using **{chosen}** \ + (most marker files matched). \ Pass `--stack ` to override." ); (Some(chosen), vec![warning]) @@ -527,4 +534,171 @@ mod tests { assert_eq!(stack, None); assert!(warnings.is_empty()); } + + #[test] + fn detect_stack_go_mod() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("go.mod"), "module example").unwrap(); + + let stacks = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(stacks.path().join("go")).unwrap(); + std::fs::write(stacks.path().join("go/markers"), "go.mod\n").unwrap(); + + let (stack, warnings) = detect_stack(dir.path(), stacks.path()); + assert_eq!(stack, Some("go".to_string())); + assert!(warnings.is_empty()); + } + + #[test] + fn detect_stack_python_pyproject() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("pyproject.toml"), "[tool.poetry]").unwrap(); + + let stacks = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(stacks.path().join("python")).unwrap(); + std::fs::write( + stacks.path().join("python/markers"), + "pyproject.toml\nrequirements.txt\nsetup.py\n", + ) + .unwrap(); + + let (stack, warnings) = detect_stack(dir.path(), stacks.path()); + assert_eq!(stack, Some("python".to_string())); + assert!(warnings.is_empty()); + } + + #[test] + fn detect_stack_python_requirements_txt() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap(); + + let stacks = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(stacks.path().join("python")).unwrap(); + std::fs::write( + stacks.path().join("python/markers"), + "pyproject.toml\nrequirements.txt\nsetup.py\n", + ) + .unwrap(); + + let (stack, warnings) = detect_stack(dir.path(), stacks.path()); + assert_eq!(stack, Some("python".to_string())); + assert!(warnings.is_empty()); + } + + #[test] + fn detect_stack_python_setup_py() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("setup.py"), "from setuptools import setup").unwrap(); + + let stacks = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(stacks.path().join("python")).unwrap(); + std::fs::write( + stacks.path().join("python/markers"), + "pyproject.toml\nrequirements.txt\nsetup.py\n", + ) + .unwrap(); + + let (stack, warnings) = detect_stack(dir.path(), stacks.path()); + assert_eq!(stack, Some("python".to_string())); + assert!(warnings.is_empty()); + } + + #[test] + fn detect_stack_ruby_gemfile() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("Gemfile"), "source 'https://rubygems.org'").unwrap(); + + let stacks = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(stacks.path().join("ruby")).unwrap(); + std::fs::write(stacks.path().join("ruby/markers"), "Gemfile\n").unwrap(); + + let (stack, warnings) = detect_stack(dir.path(), stacks.path()); + assert_eq!(stack, Some("ruby".to_string())); + assert!(warnings.is_empty()); + } + + #[test] + fn detect_stack_jvm_pom_xml() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("pom.xml"), "").unwrap(); + + let stacks = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(stacks.path().join("jvm")).unwrap(); + std::fs::write( + stacks.path().join("jvm/markers"), + "pom.xml\nbuild.gradle\nbuild.gradle.kts\n", + ) + .unwrap(); + + let (stack, warnings) = detect_stack(dir.path(), stacks.path()); + assert_eq!(stack, Some("jvm".to_string())); + assert!(warnings.is_empty()); + } + + #[test] + fn detect_stack_jvm_build_gradle() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("build.gradle"), "plugins { }").unwrap(); + + let stacks = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(stacks.path().join("jvm")).unwrap(); + std::fs::write( + stacks.path().join("jvm/markers"), + "pom.xml\nbuild.gradle\nbuild.gradle.kts\n", + ) + .unwrap(); + + let (stack, warnings) = detect_stack(dir.path(), stacks.path()); + assert_eq!(stack, Some("jvm".to_string())); + assert!(warnings.is_empty()); + } + + #[test] + fn detect_stack_jvm_build_gradle_kts() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("build.gradle.kts"), "plugins { }").unwrap(); + + let stacks = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(stacks.path().join("jvm")).unwrap(); + std::fs::write( + stacks.path().join("jvm/markers"), + "pom.xml\nbuild.gradle\nbuild.gradle.kts\n", + ) + .unwrap(); + + let (stack, warnings) = detect_stack(dir.path(), stacks.path()); + assert_eq!(stack, Some("jvm".to_string())); + assert!(warnings.is_empty()); + } + + /// A polyglot repo with more Python markers than Node markers should prefer python. + #[test] + fn detect_stack_multiple_dominant_wins() { + let dir = tempfile::tempdir().unwrap(); + // Two Python markers: pyproject.toml + requirements.txt + std::fs::write(dir.path().join("pyproject.toml"), "").unwrap(); + std::fs::write(dir.path().join("requirements.txt"), "").unwrap(); + // One Node marker: package.json (e.g. for a build tool) + std::fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let stacks = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(stacks.path().join("node")).unwrap(); + std::fs::write( + stacks.path().join("node/markers"), + "package.json\ntsconfig.json\n", + ) + .unwrap(); + std::fs::create_dir_all(stacks.path().join("python")).unwrap(); + std::fs::write( + stacks.path().join("python/markers"), + "pyproject.toml\nrequirements.txt\nsetup.py\n", + ) + .unwrap(); + + let (stack, warnings) = detect_stack(dir.path(), stacks.path()); + // python matches 2 markers, node matches 1 — python should win. + assert_eq!(stack, Some("python".to_string())); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("Multiple stacks")); + } }