From 35622066bf96308ffd2b2fc044423ce53974554a Mon Sep 17 00:00:00 2001 From: Lance Paine Date: Thu, 12 Mar 2026 19:36:54 +0000 Subject: [PATCH 1/2] feat: add scala, ruby, kotlin language support + baci CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add full language support (patterns, detection, fn/type extraction, imports, deps, project root, module resolution, classification) for scala, ruby, and kotlin โ€” bringing spai to 11 languages. Also migrates CI from hand-written YAML to the baci pattern (ci.clj โ†’ emit-yaml), adds multilang smoke tests, and fixes pre-existing :reflect contract drift. Co-Authored-By: Claude Opus 4.6 --- .github/ci.clj | 177 +++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 180 ++++++++---------------------------- src/spai/code.clj | 156 +++++++++++++++++++++++++++++-- src/spai/core.clj | 44 ++++++++- test/spai/contract_test.clj | 2 +- test/spai/core_test.clj | 51 +++++++++- 6 files changed, 453 insertions(+), 157 deletions(-) create mode 100644 .github/ci.clj diff --git a/.github/ci.clj b/.github/ci.clj new file mode 100644 index 0000000..0d142e2 --- /dev/null +++ b/.github/ci.clj @@ -0,0 +1,177 @@ +#!/usr/bin/env bb +;; spai CI/CD Pipeline โ€” baci pattern (Clojure data โ†’ YAML) + +(require '[clojure.java.shell :as shell] + '[clojure.string :as str] + '[clj-yaml.core :as yaml]) + +;; GitHub Actions workflow command emitters +(defn gh-group [name] (println (str "::group::" name))) +(defn gh-endgroup [] (println "::endgroup::")) +(defn gh-error [msg] (println (str "::error::" msg))) +(defn gh-warning [msg] (println (str "::warning::" msg))) + +(defmacro with-group [name & body] + `(do (gh-group ~name) + (try ~@body + (finally (gh-endgroup))))) + +(defn run-bash [cmd & [{:keys [continue-on-error] :or {continue-on-error false}}]] + (println (str "$ " cmd)) + (let [result (shell/sh "bash" "-c" cmd)] + (print (:out result)) + (print (:err result)) + (when-not (zero? (:exit result)) + (if continue-on-error + (gh-warning (str "Command failed but continuing: " cmd)) + (do + (gh-error (str "Command failed: " cmd)) + (System/exit (:exit result))))) + result)) + +;; Pipeline stages +(defn test-stage [] + (with-group "๐Ÿงช Unit + contract tests" + (run-bash "bb test"))) + +(defn smoke-stage [] + (with-group "๐Ÿ’จ Smoke test โ€” all commands run" + ;; Code analysis + (run-bash "bb spai.clj shape src/") + (run-bash "bb spai.clj usages grepf src/") + (run-bash "bb spai.clj def shape-raw src/") + (run-bash "bb spai.clj sig src/spai/core.clj") + (run-bash "bb spai.clj who src/spai/core.clj src/") + (run-bash "bb spai.clj context grepf src/") + (run-bash "bb spai.clj grep 'defn-?\\s' src/") + (run-bash "bb spai.clj patterns src/") + ;; Project analysis + (run-bash "bb spai.clj overview .") + (run-bash "bb spai.clj layout src/") + (run-bash "bb spai.clj hotspots src/") + (run-bash "bb spai.clj todos src/") + ;; Git commands + (run-bash "bb spai.clj changes src/ 3") + (run-bash "bb spai.clj diff-shape src/ HEAD~1") + (run-bash "bb spai.clj diff src/spai/core.clj 2") + ;; Structural editing + (run-bash "bb spai-edit.clj forms src/spai/core.clj") + (run-bash "bb spai-edit.clj validate src/spai/core.clj") + (run-bash "bb spai-edit.clj validate spai.clj"))) + +(defn multilang-stage [] + (with-group "๐ŸŒ Multi-language detection smoke test" + ;; Create temp files for each supported language, verify detection + (run-bash (str "cd /tmp && " + "echo 'fn main() {}' > test_spai.rs && " + "echo 'function f() {}' > test_spai.ts && " + "echo '(defn f [])' > test_spai.clj && " + "echo 'def f(): pass' > test_spai.py && " + "echo 'func main() {}' > test_spai.go && " + "echo ' test_spai.php && " + "echo 'class F {}' > test_spai.java && " + "echo 'func f() {}' > test_spai.swift && " + "echo 'def f(): Unit = ()' > test_spai.scala && " + "echo 'def f; end' > test_spai.rb && " + "echo 'fun f() {}' > test_spai.kt && " + "cd -")) + (doseq [ext ["rs" "ts" "clj" "py" "go" "php" "java" "swift" "scala" "rb" "kt"]] + (run-bash (str "bb spai.clj shape /tmp/test_spai." ext))) + ;; Cleanup + (run-bash "rm -f /tmp/test_spai.*"))) + +(defn install-stage [] + (with-group "๐Ÿ“ฆ Install simulation" + (run-bash (str "SHARE_DIR=\"$HOME/.local/share/spai\" && " + "BIN_DIR=\"$HOME/.local/bin\" && " + "mkdir -p \"$SHARE_DIR\" \"$BIN_DIR\" && " + "cp -r ./* \"$SHARE_DIR/\" && " + "chmod +x \"$SHARE_DIR\"/*.clj 2>/dev/null || true")) + (run-bash (str "cat > $HOME/.local/bin/spai << 'WRAPPER'\n" + "#!/usr/bin/env bash\n" + "export PATH=\"$HOME/.local/share/spai/plugins:$PATH\"\n" + "_d=\"$PWD\"\n" + "while [ \"$_d\" != \"/\" ]; do\n" + " [ -d \"$_d/.spai/plugins\" ] && export PATH=\"$_d/.spai/plugins:$PATH\" && break\n" + " _d=\"$(dirname \"$_d\")\"\n" + "done\n" + "unset _d\n" + "bb \"$HOME/.local/share/spai/spai.clj\" \"$@\"\n" + "WRAPPER\n" + "chmod +x $HOME/.local/bin/spai")) + (run-bash (str "cat > $HOME/.local/bin/spai-edit << 'WRAPPER'\n" + "#!/usr/bin/env bash\n" + "bb \"$HOME/.local/share/spai/spai-edit.clj\" \"$@\"\n" + "WRAPPER\n" + "chmod +x $HOME/.local/bin/spai-edit")) + (run-bash "export PATH=\"$HOME/.local/bin:$PATH\" && spai help") + (run-bash "export PATH=\"$HOME/.local/bin:$PATH\" && spai-edit help"))) + +(defn run-pipeline [] + (test-stage) + (smoke-stage) + (multilang-stage) + (install-stage)) + +;; โ”€โ”€โ”€ Pipeline as data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +(def pipeline + {:name "CI" + :on {:push {:branches ["main"]} + :pull_request {:branches ["main"]}} + :jobs + {:test + {:runs-on "ubuntu-latest" + :steps [{:uses "actions/checkout@v4" + :with {:fetch-depth 20}} + {:name "Install babashka" + :uses "turtlequeue/setup-babashka@v1.5.0" + :with {:babashka-version "1.3.186"}} + {:name "Install ripgrep" + :run "sudo apt-get install -y ripgrep"} + {:name "Verify deps" + :run "bb --version && rg --version"} + {:name "Test" + :run "bb .github/ci.clj test"} + {:name "Smoke" + :run "bb .github/ci.clj smoke"} + {:name "Multi-language" + :run "bb .github/ci.clj multilang"}]} + :install + {:runs-on "ubuntu-latest" + :steps [{:uses "actions/checkout@v4"} + {:name "Install babashka" + :uses "turtlequeue/setup-babashka@v1.5.0" + :with {:babashka-version "1.3.186"}} + {:name "Install ripgrep" + :run "sudo apt-get install -y ripgrep"} + {:name "Install + verify" + :run "bb .github/ci.clj install"}]}}}) + +;; โ”€โ”€โ”€ YAML emitter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +(defn emit-yaml-cmd [] + (let [header (str "# GENERATED by ci.clj โ€” do not edit by hand\n" + "# Source of truth: .github/ci.clj\n" + "# Regenerate: bb .github/ci.clj emit-yaml\n\n") + content (yaml/generate-string pipeline + :dumper-options {:flow-style :block}) + yaml (str header content) + path ".github/workflows/ci.yml"] + (spit path yaml) + (println (str "Generated " path " (" (count (str/split-lines yaml)) " lines)")))) + +;; CLI +(defn -main [& args] + (case (first args) + "run" (run-pipeline) + "test" (test-stage) + "smoke" (smoke-stage) + "multilang" (multilang-stage) + "install" (install-stage) + "emit-yaml" (emit-yaml-cmd) + (do (println "Usage: bb ci.clj ") + (System/exit 1)))) + +(when (= *file* (System/getProperty "babashka.file")) + (apply -main *command-line-args*)) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c38d71f..6e43d03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,151 +1,45 @@ -name: CI +# GENERATED by ci.clj โ€” do not edit by hand +# Source of truth: .github/ci.clj +# Regenerate: bb .github/ci.clj emit-yaml -on: +name: CI +'on': push: - branches: [main] + branches: + - main pull_request: - branches: [main] - + branches: + - main jobs: - smoke-test: + test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 20 # Need git history for changes/diff-shape - - - name: Install babashka - run: | - curl -sLO https://raw.githubusercontent.com/babashka/babashka/master/install - chmod +x install - sudo ./install - - - name: Install ripgrep - run: sudo apt-get install -y ripgrep - - - name: Verify deps - run: | - bb --version - rg --version - - - name: Load test โ€” all modules parse - run: bb spai.clj help - - # --- Code analysis commands --- - - - name: shape - run: bb spai.clj shape src/ - - - name: usages - run: bb spai.clj usages grepf src/ - - - name: def - run: bb spai.clj def shape-raw src/ - - - name: sig - run: bb spai.clj sig src/core.clj - - - name: who - run: bb spai.clj who src/core.clj src/ - - - name: context - run: bb spai.clj context grepf src/ - - - name: grep - run: bb spai.clj grep 'defn-?\s' src/ - - - name: patterns - run: bb spai.clj patterns src/ - - # --- Project analysis commands --- - - - name: overview - run: bb spai.clj overview . - - - name: layout - run: bb spai.clj layout src/ - - - name: hotspots - run: bb spai.clj hotspots src/ - - - name: todos - run: bb spai.clj todos src/ - - # --- Git commands --- - - - name: changes - run: bb spai.clj changes src/ 3 - - - name: diff-shape - run: bb spai.clj diff-shape src/ HEAD~1 - - - name: diff - run: bb spai.clj diff src/core.clj 2 - - # --- Structural editing --- - - - name: spai-edit forms - run: bb spai-edit.clj forms src/core.clj - - - name: spai-edit validate - run: bb spai-edit.clj validate src/core.clj - - - name: spai-edit validate spai.clj - run: bb spai-edit.clj validate spai.clj - - install-test: + - uses: actions/checkout@v4 + with: + fetch-depth: 20 + - name: Install babashka + uses: turtlequeue/setup-babashka@v1.5.0 + with: + babashka-version: 1.3.186 + - name: Install ripgrep + run: sudo apt-get install -y ripgrep + - name: Verify deps + run: bb --version && rg --version + - name: Test + run: bb .github/ci.clj test + - name: Smoke + run: bb .github/ci.clj smoke + - name: Multi-language + run: bb .github/ci.clj multilang + install: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - name: Install babashka - run: | - curl -sLO https://raw.githubusercontent.com/babashka/babashka/master/install - chmod +x install - sudo ./install - - - name: Install ripgrep - run: sudo apt-get install -y ripgrep - - - name: Simulate install from checkout - run: | - # install.sh clones from GitHub which fails for private repos. - # Simulate the install using the checked-out code instead. - SHARE_DIR="$HOME/.local/share/spai" - BIN_DIR="$HOME/.local/bin" - mkdir -p "$SHARE_DIR" "$BIN_DIR" - - # Copy repo files to share dir - cp -r ./* "$SHARE_DIR/" - chmod +x "$SHARE_DIR"/*.clj 2>/dev/null || true - - # Create spai wrapper - cat > "$BIN_DIR/spai" << WRAPPER - #!/usr/bin/env bash - export PATH="$SHARE_DIR/plugins:\$PATH" - _d="\$PWD" - while [ "\$_d" != "/" ]; do - [ -d "\$_d/.spai/plugins" ] && export PATH="\$_d/.spai/plugins:\$PATH" && break - _d="\$(dirname "\$_d")" - done - unset _d - bb "$SHARE_DIR/spai.clj" "\$@" - WRAPPER - chmod +x "$BIN_DIR/spai" - - # Create spai-edit wrapper - cat > "$BIN_DIR/spai-edit" << WRAPPER - #!/usr/bin/env bash - bb "$SHARE_DIR/spai-edit.clj" "\$@" - WRAPPER - chmod +x "$BIN_DIR/spai-edit" - - - name: Verify spai wrapper - run: | - export PATH="$HOME/.local/bin:$PATH" - spai help - - - name: Verify spai-edit wrapper - run: | - export PATH="$HOME/.local/bin:$PATH" - spai-edit help + - uses: actions/checkout@v4 + - name: Install babashka + uses: turtlequeue/setup-babashka@v1.5.0 + with: + babashka-version: 1.3.186 + - name: Install ripgrep + run: sudo apt-get install -y ripgrep + - name: Install + verify + run: bb .github/ci.clj install diff --git a/src/spai/code.clj b/src/spai/code.clj index 096542b..a79a260 100644 --- a/src/spai/code.clj +++ b/src/spai/code.clj @@ -83,11 +83,13 @@ [symbol path] (let [path (or path ".") type-args (if @core/has-rg? - ["-g" "*.{rs,clj,cljs,ts,tsx,py,go,php,java,swift,edn,toml,md}"] - ["--include=*.rs" "--include=*.clj" "--include=*.ts" - "--include=*.tsx" "--include=*.py" "--include=*.go" - "--include=*.php" "--include=*.java" "--include=*.swift" - "--include=*.edn" "--include=*.toml" + ["-g" "*.{rs,clj,cljs,ts,tsx,py,go,php,java,swift,scala,sc,rb,rake,kt,kts,edn,toml,md}"] + ["--include=*.rs" "--include=*.clj" "--include=*.cljs" + "--include=*.ts" "--include=*.tsx" "--include=*.py" + "--include=*.go" "--include=*.php" "--include=*.java" + "--include=*.swift" "--include=*.scala" "--include=*.sc" + "--include=*.rb" "--include=*.rake" "--include=*.kt" + "--include=*.kts" "--include=*.edn" "--include=*.toml" "--include=*.md"]) matches (or (apply core/grepf symbol path "-w" type-args) [])] @@ -128,7 +130,10 @@ :go #"^(func|type|var|const)\s+" :php #"^\s*(public|protected|private)?\s*(static\s+)?(function|class|interface|trait|enum|abstract\s+class|final\s+class)\s+" :java #"^\s*(public|protected|private)?\s*(static\s+)?(abstract\s+)?(class|interface|enum|record|@interface|void|int|long|boolean|String|[A-Z]\w+)\s+" - :swift #"^\s*(@\w+(\([^)]*\))?\s+)*(open\s+|public\s+|internal\s+|fileprivate\s+|private\s+)?(static\s+|class\s+|override\s+|mutating\s+)?(func|struct|class|enum|protocol|actor|extension|typealias|let|var)\s+"}) + :swift #"^\s*(@\w+(\([^)]*\))?\s+)*(open\s+|public\s+|internal\s+|fileprivate\s+|private\s+)?(static\s+|class\s+|override\s+|mutating\s+)?(func|struct|class|enum|protocol|actor|extension|typealias|let|var)\s+" + :scala #"^\s*(override\s+)?(private(\[\w+\])?\s+|protected(\[\w+\])?\s+)?(lazy\s+)?(sealed\s+|abstract\s+|final\s+)?(case\s+)?(def|val|var|class|object|trait|enum)\s+" + :ruby #"^\s*(def\s+self\.\w+|def\s+\w+|class\s+\w+|module\s+\w+)" + :kotlin #"^\s*(override\s+)?(public\s+|private\s+|protected\s+|internal\s+)?(sealed\s+|abstract\s+|open\s+|inner\s+|data\s+|value\s+|enum\s+)*(fun|val|var|class|interface|object)\s+"}) (defn definition "Find where a symbol is defined. Filters usages to definition-site patterns." @@ -176,7 +181,11 @@ :php {:pattern "^\\s*(use|require|require_once|include|include_once)\\s+" :extract #"(?:use|require|require_once|include|include_once)\s+(\S+)"} :java {:pattern "^import\\s+" :extract #"import\s+(?:static\s+)?([^;]+)"} - :swift {:pattern "^(@testable\\s+)?import\\s+" :extract #"import\s+(\w+)"}}) + :swift {:pattern "^(@testable\\s+)?import\\s+" :extract #"import\s+(\w+)"} + :scala {:pattern "^import\\s+" :extract #"import\s+(\S+)"} + :ruby {:pattern "^\\s*(require|require_relative|include|extend|prepend)\\s+" + :extract #"(?:require|require_relative)\s+['\"]([^'\"]+)['\"]"} + :kotlin {:pattern "^import\\s+" :extract #"import\s+(\S+)"}}) (defn who "Reverse file dependencies. Who imports/uses this file? @@ -315,6 +324,38 @@ :line (:line h) :kind (if (re-find #"^import\s+static\s+" (:text h)) :static-import :import)})))))) +(defmethod extract-imports :kotlin [_ file-path] + (let [hits (or (core/grepf "^import\\s+" file-path) [])] + (->> hits + (keep (fn [h] + (when-let [[_ path] (re-find #"import\s+(\S+)" (:text h))] + {:module (str/trim path) + :line (:line h) + :kind :import})))))) + +(defmethod extract-imports :ruby [_ file-path] + (let [hits (or (core/grepf "^\\s*(require|require_relative)\\s+" file-path) [])] + (->> hits + (keep (fn [h] + (let [text (:text h)] + (cond + (re-find #"require_relative" text) + (when-let [[_ path] (re-find #"require_relative\s+['\"]([^'\"]+)['\"]" text)] + {:module path :line (:line h) :kind :require_relative}) + + (re-find #"require\s" text) + (when-let [[_ path] (re-find #"require\s+['\"]([^'\"]+)['\"]" text)] + {:module path :line (:line h) :kind :require})))))))) + +(defmethod extract-imports :scala [_ file-path] + (let [hits (or (core/grepf "^import\\s+" file-path) [])] + (->> hits + (keep (fn [h] + (when-let [[_ path] (re-find #"import\s+(\S+)" (:text h))] + {:module (str/trim path) + :line (:line h) + :kind :import})))))) + (defmethod extract-imports :swift [_ file-path] (let [hits (or (core/grepf "^(@testable\\s+)?import\\s+" file-path) [])] (->> hits @@ -379,6 +420,37 @@ (.getCanonicalPath dir) (recur (.getParentFile dir)))))) +(defmethod find-project-root :kotlin [_ start-path] + (loop [dir (let [f (io/file start-path)] + (if (.isFile f) (.getParentFile f) f))] + (when dir + (if (or (.exists (io/file dir "build.gradle.kts")) + (.exists (io/file dir "build.gradle")) + (.exists (io/file dir "pom.xml"))) + (.getCanonicalPath dir) + (recur (.getParentFile dir)))))) + +(defmethod find-project-root :ruby [_ start-path] + (loop [dir (let [f (io/file start-path)] + (if (.isFile f) (.getParentFile f) f))] + (when dir + (if (or (.exists (io/file dir "Gemfile")) + (.exists (io/file dir "config.ru")) + (and (.exists (io/file dir "lib")) + (.exists (io/file dir "Rakefile")))) + (.getCanonicalPath dir) + (recur (.getParentFile dir)))))) + +(defmethod find-project-root :scala [_ start-path] + (loop [dir (let [f (io/file start-path)] + (if (.isFile f) (.getParentFile f) f))] + (when dir + (if (or (.exists (io/file dir "build.sbt")) + (.exists (io/file dir "build.sc")) + (.exists (io/file dir "pom.xml"))) + (.getCanonicalPath dir) + (recur (.getParentFile dir)))))) + (defmethod find-project-root :swift [_ start-path] (loop [dir (let [f (io/file start-path)] (if (.isFile f) (.getParentFile f) f))] @@ -507,6 +579,63 @@ ;; Android layout (try-file (str project-root "/app/src/main/java")))))) +(defmethod resolve-module :kotlin [_ mod-str _current-file project-root] + (let [as-path (str (str/replace mod-str "." "/") ".kt") + try-file (fn [base] + (when base + (let [f (io/file (str base "/" as-path))] + (when (.exists f) (.getCanonicalPath f)))))] + (when project-root + (or (try-file (str project-root "/src/main/kotlin")) + (try-file (str project-root "/src/main/java")) + (try-file (str project-root "/src")) + (try-file project-root) + ;; Android + (try-file (str project-root "/app/src/main/kotlin")) + (try-file (str project-root "/app/src/main/java")))))) + +(defmethod resolve-module :ruby [_ mod-str current-file project-root] + (let [cur-dir (.getParent (io/file current-file)) + ;; require_relative is always relative to current file + ;; require is from load path (lib/, app/, project root) + try-file (fn [base path] + (when base + (let [f (io/file (str base "/" path ".rb"))] + (when (.exists f) (.getCanonicalPath f)))))] + (cond + ;; require_relative: resolve from current file's directory + (or (str/starts-with? mod-str "./") + (str/starts-with? mod-str "../") + ;; bare name from require_relative also resolves relative + (not (str/includes? mod-str "/"))) + (or (try-file cur-dir mod-str) + (when project-root (try-file (str project-root "/lib") mod-str))) + + :else + (when project-root + (or (try-file (str project-root "/lib") mod-str) + (try-file (str project-root "/app") mod-str) + (try-file (str project-root "/app/models") mod-str) + (try-file project-root mod-str)))))) + +(defmethod resolve-module :scala [_ mod-str _current-file project-root] + (let [;; com.foo.bar.Baz โ†’ com/foo/bar/Baz.scala (or package object) + ;; Strip trailing ._ or .{...} (wildcard/selective imports) + clean (-> mod-str + (str/replace #"\.\{[^}]*\}$" "") + (str/replace #"\._$" "")) + as-path (str (str/replace clean "." "/") ".scala") + try-file (fn [base] + (when base + (let [f (io/file (str base "/" as-path))] + (when (.exists f) (.getCanonicalPath f)))))] + (when project-root + (or (try-file (str project-root "/src/main/scala")) + (try-file (str project-root "/src")) + (try-file project-root) + ;; Mill layout + (try-file (str project-root "/src")))))) + (defmethod resolve-module :default [_ _ _ _] nil) ;; --- @@ -533,6 +662,19 @@ (str/starts-with? mod-str "org.ietf.")) :external :probe) :swift :external ;; Swift imports are always module-level (no relative imports) + :scala (if (or (str/starts-with? mod-str "java.") + (str/starts-with? mod-str "javax.") + (str/starts-with? mod-str "scala.")) + :external :probe) + :ruby (if (or (str/starts-with? mod-str "./") + (str/starts-with? mod-str "../")) + :local :probe) + :kotlin (if (or (str/starts-with? mod-str "java.") + (str/starts-with? mod-str "javax.") + (str/starts-with? mod-str "kotlin.") + (str/starts-with? mod-str "kotlinx.") + (str/starts-with? mod-str "android.")) + :external :probe) :clojure :unknown :go :unknown :unknown)) diff --git a/src/spai/core.clj b/src/spai/core.clj index ef6c529..918c3e9 100644 --- a/src/spai/core.clj +++ b/src/spai/core.clj @@ -113,7 +113,23 @@ {:functions "^\\s*(@\\w+(\\([^)]*\\))?\\s+)*(open\\s+|public\\s+|internal\\s+|fileprivate\\s+|private\\s+)?(static\\s+|class\\s+|override\\s+|mutating\\s+|nonisolated\\s+)*func\\s+\\w+" :types "^\\s*(open\\s+|public\\s+|internal\\s+|fileprivate\\s+|private\\s+)?(final\\s+)?(struct|class|enum|protocol|actor)\\s+\\w+" :impls "^\\s*(open\\s+|public\\s+|internal\\s+|fileprivate\\s+|private\\s+)?extension\\s+\\w+" - :imports "^(@testable\\s+)?import\\s+"}})) + :imports "^(@testable\\s+)?import\\s+"} + + :scala + {:functions "^\\s*(override\\s+)?(private(\\[\\w+\\])?\\s+|protected(\\[\\w+\\])?\\s+)?(lazy\\s+)?(def|val|var)\\s+\\w+" + :types "^\\s*(sealed\\s+|abstract\\s+|final\\s+)?(case\\s+)?(class|object|trait|enum)\\s+\\w+" + :imports "^import\\s+"} + + :ruby + {:functions "^\\s*(def\\s+self\\.\\w+|def\\s+\\w+)" + :types "^\\s*(class|module)\\s+\\w+" + :imports "^\\s*(require|require_relative|include|extend|prepend)\\s+"} + + :kotlin + {:functions "^\\s*(override\\s+)?(public\\s+|private\\s+|protected\\s+|internal\\s+)?(inline\\s+|suspend\\s+|tailrec\\s+)*(fun\\s+\\w+|val\\s+\\w+|var\\s+\\w+)" + :types "^\\s*(public\\s+|private\\s+|protected\\s+|internal\\s+)?(sealed\\s+|abstract\\s+|open\\s+|inner\\s+|data\\s+|value\\s+|enum\\s+)*(class|interface|object)\\s+\\w+" + :imports "^import\\s+"}})) + (defn register-lang! "Register grep patterns for a new language. Extends spai to support any language. @@ -139,6 +155,11 @@ (str/ends-with? name ".php") :php (str/ends-with? name ".java") :java (str/ends-with? name ".swift") :swift + (or (str/ends-with? name ".scala") + (str/ends-with? name ".sc")) :scala + (str/ends-with? name ".rb") :ruby + (or (str/ends-with? name ".kt") + (str/ends-with? name ".kts")) :kotlin :else :unknown)) ;; Directory - sample first 100 files (let [files (->> (file-seq f) @@ -156,6 +177,11 @@ (some #(str/ends-with? % ".php") files) :php (some #(str/ends-with? % ".java") files) :java (some #(str/ends-with? % ".swift") files) :swift + (some #(or (str/ends-with? % ".scala") + (str/ends-with? % ".sc")) files) :scala + (some #(str/ends-with? % ".rb") files) :ruby + (some #(or (str/ends-with? % ".kt") + (str/ends-with? % ".kts")) files) :kotlin :else :unknown))))) (def ^:private core-clj-path @@ -192,15 +218,21 @@ :php (second (re-find #"function\s+(\w+)" text)) :java (second (re-find #"(\w+)\s*\(" text)) :swift (second (re-find #"func\s+(\w+)" text)) + :scala (second (re-find #"(?:def|val|var)\s+(\w+)" text)) + :ruby (or (second (re-find #"def\s+self\.(\w+)" text)) + (second (re-find #"def\s+(\w+)" text))) + :kotlin (or (second (re-find #"fun\s+(\w+)" text)) + (second (re-find #"(?:val|var)\s+(\w+)" text))) nil)) (defn extract-type-name [text] - (second (re-find #"(?:struct|enum|trait|class|interface|type|protocol|record)\s+(\w+)" text))) + (second (re-find #"(?:enum\s+class|struct|enum|trait|class|interface|type|protocol|record|object|module)\s+(\w+)" text))) (defn extract-type-kind [text] (cond - (re-find #"\bstruct\b" text) :struct - (re-find #"\benum\b" text) :enum + (re-find #"\bstruct\b" text) :struct + (re-find #"\benum\s+class\b" text) :enum + (re-find #"\benum\b" text) :enum (re-find #"\btrait\b" text) :trait (re-find #"\binterface\b" text) :interface (re-find #"\bclass\b" text) :class @@ -208,6 +240,8 @@ (re-find #"\bprotocol\b" text) :protocol (re-find #"\brecord\b" text) :record (re-find #"\bactor\b" text) :actor + (re-find #"\bobject\b" text) :object + (re-find #"\bmodule\b" text) :module :else :unknown)) (defn relativize @@ -225,4 +259,4 @@ (def source-exts "Source file extensions we care about." - #"\.(rs|ts|tsx|js|jsx|py|go|clj|cljs|java|rb|php|swift)$") + #"\.(rs|ts|tsx|js|jsx|py|go|clj|cljs|java|rb|php|swift|scala|sc|rake|kt|kts)$") diff --git a/test/spai/contract_test.clj b/test/spai/contract_test.clj index 10cf7b0..ed7c1e9 100644 --- a/test/spai/contract_test.clj +++ b/test/spai/contract_test.clj @@ -65,7 +65,7 @@ :call #(compose/blast "grepf" "src/spai")} :stats {:keys #{:total :by-command :top-paths :recent} :call #(analytics/stats)} - :reflect {:keys #{:total-calls :explored-paths :project-commands :repeated-sequences :plugins} + :reflect {:keys #{:total-calls :explored-paths :spai-commands :repeated-sequences :plugins} :call #(analytics/reflect)}}) ;; ------------------------------------------------------------------- diff --git a/test/spai/core_test.clj b/test/spai/core_test.clj index 14ecf62..500dc74 100644 --- a/test/spai/core_test.clj +++ b/test/spai/core_test.clj @@ -13,6 +13,19 @@ (testing "non-existent file falls through to directory sampling" ;; detect-lang checks .isFile first; non-existent files sample the parent dir (is (keyword? (core/detect-lang "src/spai/core.clj")) "always returns a keyword")) + (testing "new language extensions (file-based detection)" + ;; detect-lang checks .isFile, so we need real files + (let [tmp (System/getProperty "java.io.tmpdir") + files [["test.scala" :scala] ["test.sc" :scala] + ["test.rb" :ruby] + ["test.kt" :kotlin] ["test.kts" :kotlin]]] + (doseq [[fname expected] files] + (let [f (java.io.File. tmp fname)] + (spit f "") + (try + (is (= expected (core/detect-lang (.getPath f))) + (str fname " should detect as " expected)) + (finally (.delete f))))))) (testing "unknown extension returns :unknown" (is (= :unknown (core/detect-lang "foo.xyz"))))) @@ -56,6 +69,25 @@ (is (= "main" (core/extract-fn-name "func main() {" :go))) (is (= "String" (core/extract-fn-name "func (s *Server) String() string {" :go)))) +(deftest extract-fn-name-scala + (is (= "greet" (core/extract-fn-name "def greet(name: String): String" :scala))) + (is (= "version" (core/extract-fn-name "val version = \"1.0\"" :scala))) + (is (= "count" (core/extract-fn-name "var count = 0" :scala)))) + +(deftest extract-fn-name-ruby + (is (= "validate" (core/extract-fn-name "def self.validate(token)" :ruby))) + (is (= "initialize" (core/extract-fn-name "def initialize(secret)" :ruby))) + (is (= "full_name" (core/extract-fn-name "def full_name" :ruby)))) + +(deftest extract-fn-name-kotlin + (is (= "loadUsers" (core/extract-fn-name "suspend fun loadUsers(): Flow>" :kotlin))) + (is (= "isLoading" (core/extract-fn-name "val isLoading: Boolean = false" :kotlin))) + (is (= "validate" (core/extract-fn-name "private fun validate(user: User): Boolean" :kotlin)))) + +(deftest extract-fn-name-swift + (is (= "viewDidLoad" (core/extract-fn-name "override func viewDidLoad()" :swift))) + (is (= "fetch" (core/extract-fn-name "public func fetch() async throws" :swift)))) + (deftest extract-fn-name-unknown-lang (is (nil? (core/extract-fn-name "fn foo()" :unknown-lang)))) @@ -70,6 +102,9 @@ (is (= "App" (core/extract-type-name "class App {"))) (is (= "Renderable" (core/extract-type-name "interface Renderable {"))) (is (= "Name" (core/extract-type-name "type Name = String"))) + (is (= "Main" (core/extract-type-name "object Main {"))) + (is (= "Auth" (core/extract-type-name "module Auth"))) + (is (= "Role" (core/extract-type-name "enum class Role {"))) (is (nil? (core/extract-type-name "fn not_a_type() {}")))) ;; ------------------------------------------------------------------- @@ -79,12 +114,16 @@ (deftest extract-type-kind-test (is (= :struct (core/extract-type-kind "pub struct Query {"))) (is (= :enum (core/extract-type-kind "enum Strategy {"))) + (is (= :enum (core/extract-type-kind "enum class Role {"))) (is (= :trait (core/extract-type-kind "trait Executor {"))) (is (= :interface (core/extract-type-kind "interface Renderable {"))) (is (= :class (core/extract-type-kind "class App {"))) (is (= :type (core/extract-type-kind "type Alias = String"))) (is (= :protocol (core/extract-type-kind "protocol Walkable"))) (is (= :record (core/extract-type-kind "record Point [x y]"))) + (is (= :actor (core/extract-type-kind "actor DataStore {"))) + (is (= :object (core/extract-type-kind "object Main {"))) + (is (= :module (core/extract-type-kind "module Auth"))) (is (= :unknown (core/extract-type-kind "fn something() {}")))) ;; ------------------------------------------------------------------- @@ -116,6 +155,12 @@ (is (re-find core/source-exts "app.tsx")) (is (re-find core/source-exts "core.clj")) (is (re-find core/source-exts "main.py")) + (is (re-find core/source-exts "App.scala")) + (is (re-find core/source-exts "build.sc")) + (is (re-find core/source-exts "app.rb")) + (is (re-find core/source-exts "deploy.rake")) + (is (re-find core/source-exts "Main.kt")) + (is (re-find core/source-exts "build.gradle.kts")) (is (nil? (re-find core/source-exts "data.json"))) (is (nil? (re-find core/source-exts "readme.md")))) @@ -131,7 +176,11 @@ (is (contains? langs :python)) (is (contains? langs :go)) (is (contains? langs :php)) - (is (contains? langs :java)))) + (is (contains? langs :java)) + (is (contains? langs :swift)) + (is (contains? langs :scala)) + (is (contains? langs :ruby)) + (is (contains? langs :kotlin)))) (deftest register-lang-test (core/register-lang! :test-lang {:functions "test_fn" :types "test_type"}) From 928158ba9bc814495b48acea70b91f398aebae79 Mon Sep 17 00:00:00 2001 From: Lance Paine Date: Thu, 12 Mar 2026 19:40:36 +0000 Subject: [PATCH 2/2] fix: contract tests handle empty analytics data in CI stats and reflect return different key sets when no usage history exists (fresh CI checkout). Add keys-or alternative to contracts. Co-Authored-By: Claude Opus 4.6 --- test/spai/contract_test.clj | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/test/spai/contract_test.clj b/test/spai/contract_test.clj index ed7c1e9..7bf2112 100644 --- a/test/spai/contract_test.clj +++ b/test/spai/contract_test.clj @@ -6,7 +6,6 @@ and skills. The contract lives HERE, not in the registry โ€” so editing spai.clj alone won't silently pass." (:require [clojure.test :refer [deftest is testing]] - [clojure.set :as set] [spai.code :as code] [spai.project :as project] [spai.git :as git] @@ -63,9 +62,13 @@ :importers :importing-files :test-files :inline-tests :coverage :risk :summary :authors} :call #(compose/blast "grepf" "src/spai")} + ;; stats and reflect return different shapes when no usage data exists (e.g. fresh CI). + ;; Use :keys-or to accept either the full shape or the empty-data shape. :stats {:keys #{:total :by-command :top-paths :recent} + :keys-or #{:message} :call #(analytics/stats)} :reflect {:keys #{:total-calls :explored-paths :spai-commands :repeated-sequences :plugins} + :keys-or #{:total-calls :plugins} :call #(analytics/reflect)}}) ;; ------------------------------------------------------------------- @@ -73,14 +76,15 @@ ;; ------------------------------------------------------------------- (deftest contract-keys-match - (doseq [[cmd-name {:keys [keys call]}] (sort-by key contracts)] + (doseq [[cmd-name {:keys [keys keys-or call]}] (sort-by key contracts)] (testing (str ":" cmd-name " return keys") (let [result (call) actual-keys (set (clojure.core/keys result)) - missing (set/difference keys actual-keys) - extra (set/difference actual-keys keys)] - (is (= keys actual-keys) + matches? (or (= keys actual-keys) + (and keys-or (= keys-or actual-keys)))] + (is matches? (str "Contract changed for " cmd-name "." - (when (seq missing) (str " Missing: " missing ".")) - (when (seq extra) (str " Added: " extra ".")) - " Update contract_test.clj, then check CLAUDE.md and memory.")))))) + " Got: " actual-keys "." + " Expected: " keys + (when keys-or (str " or " keys-or)) + ". Update contract_test.clj, then check CLAUDE.md and memory."))))))