
Quality Stack
- 1 installs
- 21 repo stars
- Updated August 5, 2026
- joaquimscosta/arkhe-claude-plugins
Scans a project to detect configured quality and testing tools across JVM, Android, Node.js, and Python, then recommends and helps set up missing ones.
About
Detects configured quality and testing tools across JVM, Android, Node, and Python ecosystems and cross-references them against research-backed recommendations. A developer uses it to audit tooling and fill gaps like coverage, linting, or CI quality checks.
- Auto-detects mixed-ecosystem monorepos
- Recommend-then-setup two-phase workflow
Quality Stack by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,750 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/joaquimscosta/arkhe-claude-plugins --skill quality-stackAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 5, 2026 |
| Repository | joaquimscosta/arkhe-claude-plugins ↗ |
What it does
Scans a project to detect configured quality and testing tools across JVM, Android, Node.js, and Python, then recommends and helps set up missing ones.
Files
Quality Stack
Scan a project's build configuration across JVM, Android, Node.js, and Python ecosystems, cross-reference against curated research documents, and assist with tool setup.
Pre-flight
1. Run the orchestrator — it auto-detects ecosystems:
python3 <skill-path>/scripts/scan_project.py <project-root>2. If "error": "no_ecosystem_detected", check nearby_project_files for subproject paths. 3. Monorepo? — use --recursive or --ecosystem to force a specific scanner:
python3 <skill-path>/scripts/scan_project.py --recursive <project-root>
python3 <skill-path>/scripts/scan_project.py --ecosystem node <project-root>4. Legacy (JVM only) — scan_tooling.py still works as a backwards-compatible wrapper.
Two-Phase Workflow
Phase 1: Recommend
1. Run the scanner on the project root (see Pre-flight above).
2. Fetch research documents via WebFetch — only for detected ecosystems:
Android (when ecosystems contains "android"):
https://raw.githubusercontent.com/joaquimscosta/arkhe-claude-plugins/main/docs/research/android-ecosystem-tooling.md
https://raw.githubusercontent.com/joaquimscosta/arkhe-claude-plugins/main/docs/research/android-testing-ecosystem.mdJVM (when ecosystems contains "jvm"):
https://raw.githubusercontent.com/joaquimscosta/arkhe-claude-plugins/main/docs/research/jvm-quality-tools-evaluation.md
https://raw.githubusercontent.com/joaquimscosta/arkhe-claude-plugins/main/docs/research/kotlin-spring-boot-testing-ecosystem.mdNode.js (when ecosystems contains "node"):
https://raw.githubusercontent.com/joaquimscosta/arkhe-claude-plugins/main/docs/research/node-quality-tools-evaluation.mdPython (when ecosystems contains "python"):
https://raw.githubusercontent.com/joaquimscosta/arkhe-claude-plugins/main/docs/research/python-quality-tools-evaluation.mdCross-cutting (always):
https://raw.githubusercontent.com/joaquimscosta/arkhe-claude-plugins/main/docs/research/cross-cutting-devtools-evaluation.mdIf WebFetch fails, warn the user and proceed using scanner results + LLM knowledge only.
3. Cross-reference scanner output against research recommendations per ecosystem:
- Identify tools recommended but missing from the project
- Check
statusfield:disabledorconfig-onlytools need attention - Flag outdated or superseded tools
- Apply ecosystem-specific SKIP rules (see WORKFLOW.md)
- Review
tool_configfor threshold values and settings
4. Generate the recommendation report using the format in WORKFLOW.md.
Phase 2: Setup
After presenting the report, present tools for selection using the multi-round protocol in WORKFLOW.md. Group by ecosystem and priority tier. Include effort estimates. After user completes selection across all rounds:
1. Read the relevant research doc section for setup instructions 2. Apply Setup Guards — resolve versions, check compatibility 3. For each selected tool, apply changes per ecosystem:
- JVM: Add Gradle/Maven plugin, test deps, config files
- Node.js:
pnpm add -D, tsconfig edits, config file creation - Python:
uv add --dev, pyproject.toml edits, config file creation - Cross-cutting: CI/CD workflow steps, Lefthook config, EditorConfig
4. Verify each tool after configuration — run the tool's check command, verify filter patterns against actual codebase paths, and check for config inheritance conflicts. See WORKFLOW.md Post-Setup Verification. 5. Re-run the scanner to confirm all tools detected
Priority Classification
| Priority | Criteria |
|---|---|
| NOW | Essential missing tools, zero-dependency additions |
| SOON | High-value additions requiring minor setup |
| LATER | Nice-to-have with prerequisites |
| SKIP | Not applicable (wrong ecosystem, incompatible version, deprecated) |
Ecosystem-aware rules — see WORKFLOW.md for full classification tables per ecosystem.
Key rules:
- Android Compose project: NOW Compose UI testing, SOON Roborazzi; SKIP Espresso
- Android KMP project: NOW commonTest setup, NOW Turbine; SOON Ktor MockEngine
- Android no lint config: NOW Android Lint baseline; SOON custom lint rules
- JVM Pure Kotlin: SKIP Error Prone, SpotBugs; JVM Pure Java: SKIP Detekt, ktlint, MockK
- JVM Spring Boot 4+: SKIP REST Assured, NOW MockMvcTester
- Node.js no linter: NOW ESLint; no formatter + no Biome: NOW Prettier
- Node.js TypeScript not strict: NOW enable strict
- Python no linter: NOW Ruff; no type checker + has type annotations: NOW mypy
Cross-Cutting Tools
| Tool | When to Recommend | Priority |
|---|---|---|
| Lefthook | No git hook manager + has linters | SOON |
| commitlint | No commit conventions + has team | LATER |
| EditorConfig | Missing .editorconfig | NOW |
| Renovate/Dependabot | No dependency automation | SOON |
| Trivy/gitleaks | No security scanning | SOON |
Research Documents
Fetch via WebFetch at runtime — only for detected ecosystems:
- Android Ecosystem Tooling:
android-ecosystem-tooling.md - Android Testing Ecosystem:
android-testing-ecosystem.md - JVM Quality Tools:
jvm-quality-tools-evaluation.md - JVM Testing Ecosystem:
kotlin-spring-boot-testing-ecosystem.md - Node.js Quality Tools:
node-quality-tools-evaluation.md - Python Quality Tools:
python-quality-tools-evaluation.md - Cross-Cutting Tools:
cross-cutting-devtools-evaluation.md
Scanner Architecture
scripts/
scan_project.py # Orchestrator — auto-detects + merges
scan_jvm.py # JVM scanner (Gradle/Maven)
scan_android.py # Android scanner (AGP/Compose/KMP)
scan_node.py # Node.js/TypeScript scanner
scan_python.py # Python scanner
scan_cross_cutting.py # Cross-cutting tools (CI, hooks, security)
shared.py # Shared utilities
scan_tooling.py # Legacy wrapper → scan_jvm.pyReferences
- Workflow: See WORKFLOW.md for classification rules and report format
- Examples: See EXAMPLES.md for realistic audit scenarios
- Troubleshooting: See TROUBLESHOOTING.md for scanner issues
Examples: Quality Stack Tooling Recommender
Example 1: Kotlin Project with Minimal Tooling
Scanner Output
{
"project": {
"build_tool": "gradle-kotlin",
"spring_boot_version": "4.0.1",
"language": "kotlin",
"java_version": "21",
"kotlin_version": "2.2.0",
"has_version_catalog": true,
"test_file_count": 8,
"main_file_count": 45
},
"detected_tools": {
"static_analysis": [],
"testing_libraries": [
{"name": "assertj", "status": "active", "source": "build-file"},
{"name": "mockk", "status": "active", "source": "build-file"},
{"name": "testcontainers", "status": "active", "source": "build-file"}
],
"coverage": [
{"name": "jacoco", "status": "active", "source": "build-file"}
],
"mutation_testing": [],
"architecture": [],
"contract_testing": [],
"property_testing": [],
"api_testing": [],
"benchmarking": [],
"ci_cd": [
{"name": "github-actions", "status": "active", "source": "ci-file"}
],
"dependency_management": [],
"security": [],
"migrations": [],
"git_hooks": []
},
"config_files": {
"detekt.yml": false,
"detekt-config.yml": false,
".editorconfig": true,
".trivyignore": false,
"renovate.json": false,
".github/dependabot.yml": false,
"sonar-project.properties": false,
"lefthook.yml": false,
".husky/_/husky.sh": false,
".pre-commit-config.yaml": false
},
"versions": {
"spring-boot": "4.0.1",
"kotlin": "2.2.0",
"java": "21",
"jacoco": "0.8.14"
},
"tool_config": {
"jacoco_threshold": "0.05",
"ktlint_sarif_enabled": false
}
}Generated Recommendation Report
Tooling Audit Report
Project Profile
- Build: Gradle (Kotlin DSL) | Language: Kotlin | Spring Boot: 4.0.1 | Java: 21
- Test files: 8 | Main files: 45
Current Stack
| Category | Tools | Status |
|---|---|---|
| Testing | AssertJ, MockK, Testcontainers | active |
| Coverage | JaCoCo | active |
| CI/CD | GitHub Actions | active |
Tool Configuration
| Setting | Value | Assessment |
|---|---|---|
| JaCoCo threshold | 5% | Too low — recommend 70%+ for meaningful coverage gates |
Recommendations
| Priority | Tool | Category | Why |
|---|---|---|---|
| NOW | Detekt 1.23.8 | Static Analysis | Native Kotlin AST analysis; catches coroutine misuse, complexity, code smells |
| NOW | ktlint 1.8.0 | Static Analysis | Zero-config Kotlin formatter with auto-fix; pre-commit hooks |
| NOW | Kover 0.9.7 | Coverage | Kotlin-native coverage; handles inline functions, data classes correctly (JaCoCo doesn't) |
| NOW | Hamcrest | Testing | EXCLUDE from spring-boot-starter-test — superseded by AssertJ |
| NOW | JaCoCo threshold | Coverage | Raise threshold from 5% to at least 70% |
| SOON | Instancio 5.4.1 | Test Data | Auto-generate complex object graphs; complement manual fixtures |
| SOON | Kotest assertions 6.1.4 | Testing | Kotlin DSL assertions (shouldBe, shouldContain); no runner needed |
| SOON | Trivy | Security | Free, comprehensive vulnerability + secret scanning |
| SOON | Renovate | Dependencies | Auto-update dependencies; supports Gradle version catalogs |
| SOON | jqwik 1.9.3 | Property Testing | JUnit 5 native; test domain invariants (Money, TransactionNumber) |
| LATER | PIT 1.19.1 | Mutation Testing | Wait until test coverage >70%; currently only 8 test files |
| LATER | Pact 4.6.x | Contract Testing | Valuable when mobile KMP client consumes the API |
| LATER | JMH 1.37 | Benchmarking | Add when performance-sensitive code paths exist |
| SKIP | Error Prone | Static Analysis | Java-only; not applicable for pure Kotlin project |
| SKIP | SpotBugs | Static Analysis | Bytecode analysis; low value for pure Kotlin with Detekt |
| SKIP | REST Assured | API Testing | spring-mock-mvc broken with jakarta (Spring Boot 4) |
Ready to set up?
[AskUserQuestion — multiSelect: true] Question: "Which tools would you like me to configure?" Options: 1. "Detekt 1.23.8 — native Kotlin static analysis" 2. "ktlint 1.8.0 — zero-config Kotlin formatter" 3. "Kover 0.9.7 — Kotlin-native coverage (replaces JaCoCo)" 4. "Skip setup — I'll configure manually"
---
Example 2: Well-Equipped Java Project
Scanner Output
{
"project": {
"build_tool": "maven",
"spring_boot_version": "3.4.2",
"language": "java",
"java_version": "21",
"kotlin_version": null,
"has_version_catalog": false,
"test_file_count": 120,
"main_file_count": 200
},
"detected_tools": {
"static_analysis": [
{"name": "error-prone", "status": "active", "source": "build-file"},
{"name": "spotbugs", "status": "active", "source": "build-file"},
{"name": "sonarqube", "status": "active", "source": "build-file"}
],
"testing_libraries": [
{"name": "assertj", "status": "active", "source": "build-file"},
{"name": "mockito", "status": "active", "source": "build-file"},
{"name": "testcontainers", "status": "active", "source": "build-file"}
],
"coverage": [
{"name": "jacoco", "status": "active", "source": "build-file"}
],
"mutation_testing": [
{"name": "pitest", "status": "active", "source": "build-file"}
],
"architecture": [
{"name": "archunit", "status": "active", "source": "build-file"}
],
"contract_testing": [
{"name": "spring-cloud-contract", "status": "active", "source": "build-file"}
],
"property_testing": [],
"api_testing": [],
"benchmarking": [],
"ci_cd": [
{"name": "github-actions", "status": "active", "source": "ci-file"}
],
"dependency_management": [
{"name": "dependabot", "status": "active", "source": "config-file"}
],
"security": [
{"name": "owasp-dependency-check", "status": "active", "source": "build-file"}
],
"migrations": [],
"git_hooks": []
},
"config_files": {
"spotbugs-exclude.xml": true,
"sonar-project.properties": true,
".github/dependabot.yml": true,
"lefthook.yml": false,
".husky/_/husky.sh": false,
".pre-commit-config.yaml": false
},
"versions": {
"spring-boot": "3.4.2",
"java": "21"
},
"tool_config": {
"jacoco_threshold": "0.80"
}
}Generated Recommendation Report
Tooling Audit Report
Project Profile
- Build: Maven | Language: Java | Spring Boot: 3.4.2 | Java: 21
- Test files: 120 | Main files: 200
Current Stack
| Category | Tools | Status |
|---|---|---|
| Static Analysis | Error Prone, SpotBugs, SonarQube | active |
| Testing | AssertJ, Mockito, Testcontainers | active |
| Coverage | JaCoCo (threshold: 80%) | active |
| Mutation Testing | PIT | active |
| Architecture | ArchUnit | active |
| Contract Testing | Spring Cloud Contract | active |
| CI/CD | GitHub Actions | active |
| Dependencies | Dependabot | active |
| Security | OWASP Dependency-Check | active |
Recommendations
| Priority | Tool | Category | Why |
|---|---|---|---|
| NOW | MockMvcTester | API Testing | Built into Spring Boot 3.4+; full AssertJ integration for controller tests |
| SOON | Trivy | Security | Complement OWASP DC with container + secret scanning; lower false positives |
| SOON | OpenRewrite | Migrations | Prepare for Spring Boot 4.0 migration (javax->jakarta automated) |
| SOON | Renovate | Dependencies | More capable than Dependabot for Maven (auto-merge, stability days) |
| LATER | JMH 1.37 | Benchmarking | Performance regression detection for critical paths |
| SKIP | Detekt, ktlint, Kover, MockK | Various | Java-only project; Kotlin tools not applicable |
Ready to set up?
[AskUserQuestion — multiSelect: true] Question: "Which tools would you like me to configure?" Options: 1. "MockMvcTester — built-in AssertJ API testing for Spring Boot 3.4+" 2. "Trivy — comprehensive vulnerability + secret scanning" 3. "OpenRewrite — automated Spring Boot 4.0 migration" 4. "Skip setup — I'll configure manually"
---
Example 3: Disabled Tools Detected
Scanner Output (partial)
{
"detected_tools": {
"static_analysis": [
{"name": "detekt", "status": "disabled", "source": "build-file"},
{"name": "ktlint", "status": "active", "source": "build-file"}
],
"coverage": [
{"name": "jacoco", "status": "config-only", "source": "config-file"}
]
},
"tool_config": {
"ktlint_sarif_enabled": false
}
}Interpretation
- Detekt:
disabled— plugin is commented out in the build file. Recommendation: re-enable or remove the commented-out block. - JaCoCo:
config-only— a JaCoCo config exists but the plugin isn't declared in the build file. Likely configured viabuildSrc/or a convention plugin. Verify manually. - ktlint SARIF: not enabled. Recommendation: enable SARIF output for GitHub Security tab integration.
---
Example 4: Phase 2 Setup Walkthrough
User selects Detekt and Trivy via AskUserQuestion from Example 1 recommendations.
Detekt Setup
1. Add Gradle plugin:
// build.gradle.kts
plugins {
id("io.gitlab.arturbosch.detekt") version "1.23.8"
}
detekt {
config.setFrom(file("config/detekt.yml"))
buildUponDefaultConfig = true
}
dependencies {
detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.8")
}2. Generate default config:
./gradlew detektGenerateConfigThis creates config/detekt.yml with all default rules that you can customize.
3. Add CI step (if GitHub Actions detected):
# In .github/workflows/build.yml, add step:
- name: Detekt
run: ./gradlew detektTrivy Setup
1. Create `.trivyignore` (empty initially):
# Add CVE IDs to suppress false positives
# Example: CVE-2023-12345
# Example with expiry: CVE-2024-56789 exp:2026-06-012. Add CI step:
# In .github/workflows/build.yml, add job:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'HIGH,CRITICAL'
- name: Upload to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: 'trivy-results.sarif'3. Local usage:
brew install trivy # macOS
trivy fs --severity HIGH,CRITICAL .Verification
Re-run the scanner to confirm:
python3 scan_tooling.py /path/to/projectExpected changes in output:
detected_tools.static_analysisincludes{"name": "detekt", "status": "active", "source": "build-file"}config_files[".trivyignore"]istruedetected_tools.securityincludes{"name": "trivy", "status": "active", "source": "config-file"}
---
Example 5: Kotlin Project with Linters but No Git Hooks
Scanner Output (partial)
{
"project": {
"build_tool": "gradle-kotlin",
"spring_boot_version": "4.0.1",
"language": "kotlin",
"java_version": "21",
"kotlin_version": "2.2.0"
},
"detected_tools": {
"static_analysis": [
{"name": "detekt", "status": "active", "source": "build-file"},
{"name": "ktlint", "status": "active", "source": "build-file"}
],
"git_hooks": [],
"ci_cd": [
{"name": "github-actions", "status": "active", "source": "ci-file"}
]
},
"config_files": {
"lefthook.yml": false,
".husky/_/husky.sh": false,
".pre-commit-config.yaml": false
}
}Interpretation
The project has Detekt and ktlint configured in the build, but no git hook manager enforces them locally. Developers can commit without running linters — CI catches violations late.
Recommendation
| Priority | Tool | Category | Why |
|---|---|---|---|
| SOON | Lefthook | Git Hooks | Enforce ktlint + Detekt on every commit; catch violations before CI |
Phase 2: Lefthook Setup
1. Install lefthook:
pnpm add -D lefthook # if package.json exists
# OR
brew install lefthook # system binary2. Generate `lefthook.yml`:
# Lefthook — Git hooks for the project
# Install: pnpm add -D lefthook && npx lefthook install
pre-commit:
parallel: true
commands:
gitleaks:
run: gitleaks protect --staged --verbose
skip:
- merge
- rebase
ktlint:
glob: "**/*.{kt,kts}"
run: ./gradlew ktlintCheck
detekt:
glob: "**/*.{kt,kts}"
run: ./gradlew detekt3. Install and verify:
npx lefthook install
npx lefthook run pre-commit
# All hooks should show "(skip) no files for inspection"4. Re-run scanner:
python3 scan_tooling.py /path/to/projectExpected: detected_tools.git_hooks now includes {"name": "lefthook", "status": "active", "source": "config-file"}
---
Example 6: Project with Existing Husky (Conflict)
Scanner Output (partial)
{
"detected_tools": {
"static_analysis": [
{"name": "ktlint", "status": "active", "source": "build-file"}
],
"git_hooks": [
{"name": "husky", "status": "active", "source": "config-file"}
]
},
"config_files": {
"lefthook.yml": false,
".husky/_/husky.sh": true
}
}Interpretation
Husky is already managing git hooks. Adding lefthook would conflict. Recommendation: SKIP lefthook or migrate from Husky first.
Recommendation
| Priority | Tool | Category | Why |
|---|---|---|---|
| SKIP | Lefthook | Git Hooks | Husky already present — migration needed first (remove .husky/, reset git config core.hooksPath) |
---
Example 7: Monorepo with Frontend + Backend (Lefthook Wiring)
Scanner Output (partial — showing frontend_tools)
{
"project": {
"build_tool": "gradle-kotlin",
"spring_boot_version": "4.0.1",
"language": "kotlin"
},
"detected_tools": {
"static_analysis": [
{"name": "detekt", "status": "active", "source": "build-file"},
{"name": "ktlint", "status": "active", "source": "build-file"}
],
"git_hooks": []
},
"frontend_tools": [
{
"path": "apps/web",
"tools": {
"eslint": {"detected": true, "version": "^9.17.0", "source": "package-json", "config_file": "eslint.config.mjs"},
"prettier": {"detected": true, "version": "^3.4.2", "source": "package-json", "config_file": ".prettierrc"},
"tailwindcss": {"detected": true, "version": "^4.0.0", "source": "package-json", "config_file": null},
"prettier-plugin-tailwindcss": {"detected": true, "version": "^0.6.11", "source": "package-json", "config_file": null}
}
}
]
}Interpretation
The monorepo has a Kotlin Spring Boot backend (apps/api/) with Detekt and ktlint, plus a frontend app (apps/web/) with ESLint, Prettier, and Tailwind CSS. No git hook manager is configured.
Phase 2: Lefthook Setup with Frontend Hooks
Generated `lefthook.yml`:
# Lefthook — Git hooks for the monorepo
# Install: pnpm add -D lefthook && npx lefthook install
pre-commit:
parallel: true
commands:
gitleaks:
run: gitleaks protect --staged --verbose
skip:
- merge
- rebase
# JVM hooks (from detected_tools.static_analysis)
ktlint:
glob: "apps/api/**/*.{kt,kts}"
root: "apps/api/"
run: ./gradlew ktlintCheck
detekt:
glob: "apps/api/**/*.{kt,kts}"
root: "apps/api/"
run: ./gradlew detekt
# Frontend hooks (from frontend_tools, path: apps/web)
eslint:
glob: "apps/web/**/*.{ts,tsx,js,jsx}"
root: "apps/web/"
run: npx eslint --fix {staged_files}
stage_fixed: true
prettier-code:
glob: "apps/web/**/*.{ts,tsx,js,jsx}"
root: "apps/web/"
run: npx prettier --write {staged_files}
stage_fixed: true
prettier-assets:
glob: "apps/web/**/*.{json,css,md,yml,yaml}"
root: "apps/web/"
run: npx prettier --write {staged_files}
stage_fixed: trueKey decisions:
- Globs use full paths from repo root (
apps/web/**/*) even thoughroot:is set - Frontend hooks use
stage_fixed: true(lefthook v2 best practice) so formatted files are auto-restaged - Tailwind CSS class sorting happens automatically through
prettier-plugin-tailwindcss— no separate hook needed - Since Tailwind CSS v4 is detected, remind user to verify
tailwindStylesheetin.prettierrcpoints to the correct CSS entry file
---
Example 8: Next.js Project with Minimal Tooling
Scanner Output
{
"ecosystem": "node",
"project": {
"package_manager": "pnpm",
"framework": "next",
"typescript": true,
"typescript_strict": false,
"monorepo_tool": null,
"node_version": "22",
"test_file_count": 0,
"src_file_count": 45
},
"detected_tools": {
"static_analysis": [
{"name": "eslint", "status": "active", "source": "package-json", "version": "^9.0.0"}
],
"formatting": [],
"testing": [],
"type_checking": [
{"name": "typescript", "status": "active", "source": "package-json", "version": "^5.7.0"}
],
"coverage": [],
"bundle_analysis": [],
"security": [],
"build_tools": [],
"framework": [
{"name": "next", "status": "active", "source": "package-json", "version": "^15.0.0"}
],
"api_testing": []
},
"tool_config": {
"eslint_config_type": "flat",
"has_test_script": false,
"has_lint_script": true
}
}Generated Recommendation Report
Tooling Audit Report — Node.js
Project Profile
- Package Manager: pnpm | Framework: Next.js 15 | TypeScript: yes (strict: NO)
- Test files: 0 | Source files: 45
Current Stack
| Category | Tools | Status |
|---|---|---|
| Static Analysis | ESLint (flat config) | active |
| Type Checking | TypeScript | active |
| Framework | Next.js 15 | active |
Recommendations
| Priority | Tool | Category | Why |
|---|---|---|---|
| NOW | TypeScript strict: true | Type Checking | Catches null/undefined bugs, prevents any proliferation |
| NOW | Prettier | Formatting | No formatter; inconsistent code style across team |
| NOW | Vitest | Testing | No test runner; 0 test files for 45 source files |
| SOON | @vitest/coverage-v8 | Coverage | Add coverage once tests exist |
| SOON | Playwright Test | Testing | No E2E testing for Next.js app |
| SOON | @testing-library/react | Testing | React component testing library |
| LATER | @next/bundle-analyzer | Bundle Analysis | Monitor bundle size growth |
---
Example 9: Python FastAPI Project with Ruff but No Type Checking
Scanner Output
{
"ecosystem": "python",
"project": {
"dependency_manager": "uv",
"build_system": "hatch",
"python_version": ">=3.12",
"framework": "fastapi",
"has_pyproject_toml": true,
"has_src_layout": true,
"test_file_count": 15,
"src_file_count": 40
},
"detected_tools": {
"linting": [
{"name": "ruff", "status": "active", "source": "pyproject-toml"}
],
"formatting": [
{"name": "ruff", "status": "active", "source": "pyproject-toml"}
],
"type_checking": [],
"testing": [
{"name": "pytest", "status": "active", "source": "dependency"}
],
"coverage": [],
"security": [],
"task_runner": [],
"documentation": [],
"framework": [
{"name": "fastapi", "status": "active", "source": "dependency"}
]
},
"tool_config": {
"ruff_target_version": "py312",
"ruff_line_length": "88",
"ruff_formatter_enabled": true,
"ruff_select_rules": ["E", "F", "I", "UP"]
}
}Generated Recommendation Report
Tooling Audit Report — Python
Project Profile
- Dep Manager: uv | Build: hatch | Python: >=3.12 | Framework: FastAPI
- Test files: 15 | Source files: 40
Current Stack
| Category | Tools | Status |
|---|---|---|
| Linting | Ruff | active |
| Formatting | Ruff (formatter) | active |
| Testing | pytest | active |
| Framework | FastAPI | active |
Tool Configuration
| Setting | Value | Assessment |
|---|---|---|
| Ruff target | py312 | Good — matches requires-python |
| Ruff line-length | 88 | Standard (Black default) |
| Ruff rules | E, F, I, UP | Good start — consider adding B, SIM, PTH |
| Ruff formatter | enabled | Good — no need for Black |
Recommendations
| Priority | Tool | Category | Why |
|---|---|---|---|
| NOW | mypy (strict) | Type Checking | No type checker; FastAPI uses Pydantic models with type hints |
| SOON | pytest-cov | Coverage | Has pytest but no coverage measurement |
| SOON | bandit | Security | No security scanning for Python code |
| SOON | pip-audit | Security | No dependency vulnerability scanning |
| SOON | Ruff rules expansion | Linting | Add B (bugbear), SIM (simplify), PTH (pathlib) rule sets |
| LATER | hypothesis | Testing | Property-based testing for API input validation |
---
Example 10: Multi-Ecosystem Monorepo (JVM + Node.js + Python)
Orchestrator Output (summary)
{
"ecosystems": [
{
"ecosystem": "jvm",
"root": "services/api",
"project": {
"build_tool": "gradle-kotlin",
"spring_boot_version": "4.0.1",
"language": "kotlin"
},
"detected_tools": {
"static_analysis": [
{"name": "detekt", "status": "active"},
{"name": "ktlint", "status": "active"}
],
"coverage": [
{"name": "kover", "status": "active"}
]
}
},
{
"ecosystem": "node",
"root": "apps/web",
"project": {
"package_manager": "pnpm",
"framework": "next",
"typescript": true,
"typescript_strict": true
},
"detected_tools": {
"static_analysis": [
{"name": "eslint", "status": "active"}
],
"formatting": [
{"name": "prettier", "status": "active"}
],
"testing": [
{"name": "vitest", "status": "active"}
]
}
},
{
"ecosystem": "python",
"root": "ml/pipeline",
"project": {
"dependency_manager": "uv",
"framework": null,
"python_version": ">=3.11"
},
"detected_tools": {
"linting": [
{"name": "ruff", "status": "active"}
],
"testing": [
{"name": "pytest", "status": "active"}
]
}
}
],
"cross_cutting": {
"ci_cd": [{"name": "github-actions", "status": "active"}],
"git_hooks": [],
"dependency_automation": [],
"security_scanning": []
}
}Generated Recommendation Report
Tooling Audit Report — Multi-Ecosystem
Detected Ecosystems
- JVM (Gradle Kotlin) —
services/api(Spring Boot 4.0.1, Kotlin) - Node.js (pnpm) —
apps/web(Next.js, TypeScript strict) - Python (uv) —
ml/pipeline(Python >=3.11)
JVM Ecosystem (services/api)
| Priority | Tool | Category | Why |
|---|---|---|---|
| SOON | Instancio | Test Data | Auto-generate complex domain objects |
| SOON | MockMvcTester | API Testing | Built-in for Spring Boot 4+ |
Node.js Ecosystem (apps/web)
| Priority | Tool | Category | Why |
|---|---|---|---|
| SOON | Playwright Test | Testing | No E2E testing |
| SOON | @vitest/coverage-v8 | Coverage | Has Vitest but no coverage |
Python Ecosystem (ml/pipeline)
| Priority | Tool | Category | Why |
|---|---|---|---|
| NOW | mypy | Type Checking | No type checker |
| SOON | pytest-cov | Coverage | Has pytest but no coverage |
Cross-Cutting
| Priority | Tool | Category | Why |
|---|---|---|---|
| NOW | EditorConfig | Editor | Missing entirely across all ecosystems |
| SOON | Lefthook | Git Hooks | Enforce detekt+ktlint+eslint+ruff on commit |
| SOON | Renovate | Dependencies | Auto-update across Gradle, npm, and pip |
| SOON | Trivy | Security | Vulnerability scanning in CI |
Phase 2: Lefthook for Multi-Ecosystem
pre-commit:
parallel: true
commands:
gitleaks:
run: gitleaks protect --staged --verbose
skip: [merge, rebase]
# JVM (services/api)
ktlint:
glob: "services/api/**/*.{kt,kts}"
root: "services/api/"
run: ./gradlew ktlintCheck
detekt:
glob: "services/api/**/*.{kt,kts}"
root: "services/api/"
run: ./gradlew detekt
# Node.js (apps/web)
eslint:
glob: "apps/web/**/*.{ts,tsx,js,jsx}"
root: "apps/web/"
run: npx eslint --fix {staged_files}
stage_fixed: true
prettier:
glob: "apps/web/**/*.{ts,tsx,js,jsx,json,css,md}"
root: "apps/web/"
run: npx prettier --write {staged_files}
stage_fixed: true
# Python (ml/pipeline)
ruff-check:
glob: "ml/pipeline/**/*.py"
root: "ml/pipeline/"
run: ruff check --fix {staged_files}
stage_fixed: true
ruff-format:
glob: "ml/pipeline/**/*.py"
root: "ml/pipeline/"
run: ruff format {staged_files}
stage_fixed: trueExample: Android KMP Project with Compose
Scanner Output (Android ecosystem)
{
"ecosystem": "android",
"project": {
"agp_version": "9.0.0",
"compose_bom_version": "2024.12.01",
"android_project_type": "kmp-library",
"ui_toolkit": "compose",
"is_kmp": true,
"kotlin_version": "2.2.21",
"compile_sdk": "36",
"min_sdk": "26",
"target_sdk": "35",
"has_version_catalog": true,
"has_convention_plugins": true,
"main_file_count": 27,
"test_file_count": 1,
"android_test_file_count": 0,
"common_main_file_count": 11,
"common_test_file_count": 2
},
"detected_tools": {
"build_config": [
{"name": "agp-application", "status": "active", "source": "version-catalog"},
{"name": "agp-kmp-library", "status": "active", "source": "version-catalog"},
{"name": "kotlin-multiplatform", "status": "active", "source": "version-catalog"}
],
"compose": [
{"name": "compose-compiler", "status": "active", "source": "version-catalog"}
],
"static_analysis": [],
"testing": [
{"name": "turbine", "status": "active", "source": "version-catalog"}
],
"screenshot_testing": [],
"database": [
{"name": "sqldelight", "status": "active", "source": "version-catalog"}
],
"database_testing": [
{"name": "sqldelight-jvm-driver", "status": "active", "source": "build-file"}
],
"network_testing": [
{"name": "ktor-mock-engine", "status": "active", "source": "build-file"}
],
"dependency_injection": [],
"performance": []
},
"config_files": {
"lint.xml": false,
"baseline-prof.txt": false,
"build-logic/": true
},
"tool_config": {
"compose_stability_config": false,
"has_lint_config": false,
"has_baseline_profile": false,
"has_include_android_resources": false
}
}Recommendation Report
## Tooling Audit Report
### Android Ecosystem
#### Project Profile
- **AGP**: 9.0.0 | **Compose BOM**: 2024.12.01 | **KMP**: Yes | **UI**: Compose
- **Kotlin**: 2.2.21 | **compileSdk**: 36 | **minSdk**: 26
#### Current Stack
| Category | Tools | Status |
|----------|-------|--------|
| Build Config | AGP 9.0, KMP, Compose Compiler | active |
| Testing | Turbine | active |
| Screenshot Testing | (none) | — |
| Database | SQLDelight, JVM driver | active |
| Network Testing | Ktor MockEngine | active |
| Static Analysis | (none — check JVM ecosystem) | — |
| Performance | (none) | — |
#### Recommendations
| Priority | Tool | Category | Why |
|----------|------|----------|-----|
| NOW | Compose UI testing | UI Testing | Has Compose deps but no `ui-test-junit4` |
| NOW | Android Lint baseline | Static Analysis | No `lint.xml` configured |
| NOW | Robolectric | Testing | Enable JVM-based Android tests |
| SOON | Roborazzi | Screenshot Testing | Compose project needs visual regression |
| SOON | Baseline Profiles | Performance | No `baseline-prof.txt` in app module |
| LATER | Compose stability config | Compose | Optimize recomposition skipping |#!/usr/bin/env python3
"""
Android Project Tooling Scanner
Scans Android Gradle build files to detect configured quality, testing, and
build tools specific to Android/KMP projects. Returns JSON for cross-referencing
against research recommendations.
Complements the JVM scanner — detects Android-specific tools (AGP, Compose,
screenshot testing, etc.) while the JVM scanner covers shared Kotlin/Java tools
(detekt, ktlint, kover, mockk, etc.).
Uses only standard library (no external dependencies). Python 3.8+.
Usage (standalone):
python3 scan_android.py <project_root>
python3 scan_android.py --recursive <project_root>
Usage (as module):
from scan_android import scan
result = scan(Path("/path/to/android/project"), recursive=False)
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# Sibling import
sys.path.insert(0, str(Path(__file__).resolve().parent))
from shared import SKIP_DIRS, make_detection, read_file_safe, check_files_exist, count_files_by_glob
from scan_jvm import (
find_gradle_build_files,
find_build_files_recursive,
is_commented_out_gradle,
_detect_tools_with_status,
scan_version_catalog,
)
# ---------------------------------------------------------------------------
# Detection patterns: Android-specific build plugins
# ---------------------------------------------------------------------------
ANDROID_PLUGIN_PATTERNS: Dict[str, List[str]] = {
# AGP plugins
"agp-application": [r"com\.android\.application"],
"agp-library": [r"com\.android\.library"],
"agp-kmp-library": [r"com\.android\.kotlin\.multiplatform\.library"],
# Compose
"compose-compiler": [
r"org\.jetbrains\.kotlin\.plugin\.compose",
r'kotlin\s*\(\s*"compose"\s*\)',
],
# KMP
"kotlin-multiplatform": [
r"org\.jetbrains\.kotlin\.multiplatform",
r'kotlin\s*\(\s*"multiplatform"\s*\)',
],
# Kotlin serialization
"kotlin-serialization": [
r"org\.jetbrains\.kotlin\.plugin\.serialization",
r'kotlin\s*\(\s*"plugin\.serialization"\s*\)',
],
# Android testing
"android-junit5": [r"de\.mannodermaus\.android-junit5"],
# DI
"hilt": [r"com\.google\.dagger\.hilt\.android", r"dagger\.hilt"],
"ksp": [r"com\.google\.devtools\.ksp"],
# Database
"room": [r"androidx\.room"],
"sqldelight": [r"app\.cash\.sqldelight"],
# Screenshot testing
"roborazzi-plugin": [r"io\.github\.takahirom\.roborazzi"],
"paparazzi-plugin": [r"app\.cash\.paparazzi"],
# Lint
"android-lint-custom": [r"lintChecks|lintPublish"],
# Performance
"baseline-profile-plugin": [
r"androidx\.baselineprofile",
r"com\.android\.test",
],
}
ANDROID_TEST_DEPENDENCY_PATTERNS: Dict[str, List[str]] = {
# Compose UI testing
"compose-ui-test": [r"ui-test-junit4", r"compose\.ui.*ui-test"],
"compose-ui-test-manifest": [r"ui-test-manifest"],
# Screenshot testing
"roborazzi": [r"roborazzi"],
"paparazzi": [r"paparazzi"],
"compose-preview-screenshot": [r"compose-preview-screenshot", r"screenshotTest"],
# JVM-based Android testing
"robolectric": [r"robolectric"],
# Flow/coroutine testing
"turbine": [r"app\.cash\.turbine", r"\bturbine\b"],
# Google assertions
"truth": [r"com\.google\.common\.truth", r"com\.google\.truth"],
# AndroidX test
"androidx-test-core": [r"androidx\.test:core", r"androidx\.test\.core"],
"androidx-test-runner": [r"androidx\.test:runner", r"test-runner"],
"androidx-test-rules": [r"androidx\.test:rules", r"test-rules"],
# Espresso
"espresso": [r"espresso-core", r"espresso-contrib", r"espresso-intents"],
# Network testing
"ktor-mock-engine": [r"ktor-client-mock"],
"mockwebserver": [r"mockwebserver", r"MockWebServer"],
# Database testing
"sqldelight-jvm-driver": [r"sqlite-driver", r"sqldelight.*jvm.*driver", r"jdbc-driver"],
# Macrobenchmark
"macrobenchmark": [r"benchmark-macro", r"macrobenchmark"],
}
# Android-specific config files
ANDROID_CONFIG_FILE_INDICATORS: Dict[str, str] = {
"lint.xml": "android-lint",
"proguard-rules.pro": "r8-proguard",
"consumer-proguard-rules.pro": "r8-proguard-consumer",
"compose-stability-config.conf": "compose-stability",
}
# Version catalog keys specific to Android
ANDROID_VERSION_CATALOG_KEY_MAP = {
"agp": "agp",
"android-gradle-plugin": "agp",
"kotlin": "kotlin",
"compose-bom": "compose-bom",
"compose": "compose-bom",
"hilt": "hilt",
"room": "room",
"navigation": "navigation",
"lifecycle": "lifecycle",
"datastore": "datastore",
"work": "workmanager",
"ksp": "ksp",
"roborazzi": "roborazzi",
"turbine": "turbine",
"sqldelight": "sqldelight",
"ktor": "ktor",
"koin": "koin",
}
# ---------------------------------------------------------------------------
# Android project detection
# ---------------------------------------------------------------------------
def is_android_project(root: Path) -> bool:
"""Check if directory contains an Android project.
Looks for Android Gradle plugins in build files or AndroidManifest.xml.
"""
for name in ["build.gradle.kts", "build.gradle"]:
build_file = root / name
if build_file.exists():
content = read_file_safe(build_file)
if content and re.search(
r"com\.android\.(application|library|kotlin\.multiplatform\.library)",
content,
):
return True
# Check settings file for included builds with Android plugins
for name in ["settings.gradle.kts", "settings.gradle"]:
settings_file = root / name
if settings_file.exists():
content = read_file_safe(settings_file)
if content and re.search(r"com\.android\.", content):
return True
# Check for AndroidManifest.xml in standard locations
manifest_patterns = [
"src/main/AndroidManifest.xml",
"*/src/main/AndroidManifest.xml",
"app/src/main/AndroidManifest.xml",
"androidApp/src/main/AndroidManifest.xml",
]
for pattern in manifest_patterns:
if list(root.glob(pattern)):
return True
# Check version catalog for AGP
catalog = root / "gradle" / "libs.versions.toml"
if catalog.exists():
content = read_file_safe(catalog)
if content and re.search(r"com\.android\.(application|library)", content):
return True
return False
# ---------------------------------------------------------------------------
# Android-specific version extraction
# ---------------------------------------------------------------------------
def extract_android_versions(content: str) -> Dict[str, str]:
"""Extract Android-specific versions from build file content."""
versions: Dict[str, str] = {}
# compileSdk
match = re.search(r"compileSdk\s*[=:]\s*(\d+)", content)
if not match:
match = re.search(r"compileSdkVersion\s*[=(]\s*(\d+)", content)
if match:
versions["compile_sdk"] = match.group(1)
# minSdk
match = re.search(r"minSdk\s*[=:]\s*(\d+)", content)
if not match:
match = re.search(r"minSdkVersion\s*[=(]\s*(\d+)", content)
if match:
versions["min_sdk"] = match.group(1)
# targetSdk
match = re.search(r"targetSdk\s*[=:]\s*(\d+)", content)
if not match:
match = re.search(r"targetSdkVersion\s*[=(]\s*(\d+)", content)
if match:
versions["target_sdk"] = match.group(1)
# AGP version from plugin declaration
agp_match = re.search(
r'com\.android\.\w+["\')]\s*version\s*["\']([^"\']+)', content
)
if agp_match:
versions["agp"] = agp_match.group(1)
# Compose BOM version
bom_match = re.search(r'compose-bom:([^"\']+)', content)
if not bom_match:
bom_match = re.search(r'composeBom\s*=\s*"([^"]+)"', content)
if bom_match:
versions["compose-bom"] = bom_match.group(1)
# Kotlin version
kt_match = re.search(
r'kotlin\s*\(\s*"[^"]+"\s*\)\s*version\s*"([^"]+)"', content
)
if not kt_match:
kt_match = re.search(
r'org\.jetbrains\.kotlin\.\w+["\')]\s*version\s*["\']([^"\']+)',
content,
)
if kt_match:
versions["kotlin"] = kt_match.group(1)
return versions
def extract_android_version_catalog(root: Path) -> Dict[str, str]:
"""Extract Android-specific versions from version catalog."""
versions: Dict[str, str] = {}
catalog_path = root / "gradle" / "libs.versions.toml"
if not catalog_path.exists():
return versions
content = read_file_safe(catalog_path)
if not content:
return versions
in_versions = False
for line in content.splitlines():
stripped = line.strip()
if stripped == "[versions]":
in_versions = True
continue
if stripped.startswith("[") and stripped.endswith("]"):
in_versions = False
continue
if in_versions and "=" in stripped:
key, _, val = stripped.partition("=")
key = key.strip().strip('"').strip("'")
val = val.strip().strip('"').strip("'")
tool_name = ANDROID_VERSION_CATALOG_KEY_MAP.get(key.lower())
if tool_name:
versions[tool_name] = val
# Also check for android SDK versions in catalog
android_sdk_patterns = {
"androidcompilesdk": "compile_sdk",
"androidminsdk": "min_sdk",
"androidtargetsdk": "target_sdk",
"compilesdk": "compile_sdk",
"minsdk": "min_sdk",
"targetsdk": "target_sdk",
}
in_versions = False
for line in content.splitlines():
stripped = line.strip()
if stripped == "[versions]":
in_versions = True
continue
if stripped.startswith("[") and stripped.endswith("]"):
in_versions = False
continue
if in_versions and "=" in stripped:
key, _, val = stripped.partition("=")
key = key.strip().strip('"').strip("'").lower().replace("-", "").replace("_", "")
sdk_name = android_sdk_patterns.get(key)
if sdk_name:
versions[sdk_name] = val.strip().strip('"').strip("'")
return versions
# ---------------------------------------------------------------------------
# UI toolkit detection
# ---------------------------------------------------------------------------
def detect_ui_toolkit(root: Path, combined_content: str) -> str:
"""Detect whether project uses Compose, Views, or both.
Returns: 'compose', 'views', 'mixed', or 'unknown'
"""
has_compose = bool(
re.search(r"compose|@Composable", combined_content)
or list(root.glob("**/src/**/composable/**"))
)
# Check for Compose dependencies
if not has_compose:
has_compose = bool(
re.search(r"compose-bom|compose\.ui|compose\.material", combined_content)
)
has_views = bool(list(root.glob("**/src/main/res/layout/*.xml")))
if not has_views:
has_views = bool(list(root.glob("**/src/main/res/layout/**/*.xml")))
if has_compose and has_views:
return "mixed"
elif has_compose:
return "compose"
elif has_views:
return "views"
return "unknown"
# ---------------------------------------------------------------------------
# Project structure analysis
# ---------------------------------------------------------------------------
def _exclude_build_dirs(paths: List[Path]) -> List[Path]:
"""Filter out paths that pass through build output directories."""
return [p for p in paths if not (SKIP_DIRS & set(p.parts))]
def analyze_android_structure(root: Path) -> Dict:
"""Analyze Android/KMP project structure."""
result = {
"main_file_count": 0,
"test_file_count": 0,
"android_test_file_count": 0,
"common_main_file_count": 0,
"common_test_file_count": 0,
}
# Standard Android source sets
main_kt = _exclude_build_dirs(list(root.glob("**/src/main/**/*.kt")))
main_java = _exclude_build_dirs(list(root.glob("**/src/main/**/*.java")))
test_kt = _exclude_build_dirs(list(root.glob("**/src/test/**/*.kt")))
test_java = _exclude_build_dirs(list(root.glob("**/src/test/**/*.java")))
android_test_kt = _exclude_build_dirs(
list(root.glob("**/src/androidTest/**/*.kt"))
)
android_test_java = _exclude_build_dirs(
list(root.glob("**/src/androidTest/**/*.java"))
)
# KMP source sets
android_main = _exclude_build_dirs(list(root.glob("**/src/androidMain/**/*.kt")))
common_main = _exclude_build_dirs(list(root.glob("**/src/commonMain/**/*.kt")))
common_test = _exclude_build_dirs(list(root.glob("**/src/commonTest/**/*.kt")))
jvm_test = _exclude_build_dirs(list(root.glob("**/src/jvmTest/**/*.kt")))
result["main_file_count"] = (
len(main_kt) + len(main_java) + len(android_main) + len(common_main)
)
result["test_file_count"] = len(test_kt) + len(test_java) + len(jvm_test)
result["android_test_file_count"] = len(android_test_kt) + len(android_test_java)
result["common_main_file_count"] = len(common_main)
result["common_test_file_count"] = len(common_test)
return result
# ---------------------------------------------------------------------------
# Android project type detection
# ---------------------------------------------------------------------------
def detect_android_project_type(combined_content: str) -> str:
"""Determine Android project type from build content.
Returns: 'application', 'library', 'kmp-library', or 'unknown'
"""
if re.search(r"com\.android\.kotlin\.multiplatform\.library", combined_content):
return "kmp-library"
if re.search(r"com\.android\.application", combined_content):
return "application"
if re.search(r"com\.android\.library", combined_content):
return "library"
return "unknown"
def detect_is_kmp(combined_content: str) -> bool:
"""Check if the project uses Kotlin Multiplatform."""
return bool(
re.search(
r"org\.jetbrains\.kotlin\.multiplatform|"
r'kotlin\s*\(\s*"multiplatform"\s*\)|'
r"com\.android\.kotlin\.multiplatform\.library",
combined_content,
)
)
# ---------------------------------------------------------------------------
# Tool classification into Android categories
# ---------------------------------------------------------------------------
ANDROID_PLUGIN_CATEGORY_MAP = {
"agp-application": "build_config",
"agp-library": "build_config",
"agp-kmp-library": "build_config",
"compose-compiler": "compose",
"kotlin-multiplatform": "build_config",
"kotlin-serialization": "build_config",
"android-junit5": "testing",
"hilt": "dependency_injection",
"ksp": "build_config",
"room": "database",
"sqldelight": "database",
"roborazzi-plugin": "screenshot_testing",
"paparazzi-plugin": "screenshot_testing",
"android-lint-custom": "static_analysis",
"baseline-profile-plugin": "performance",
}
ANDROID_DEP_CATEGORY_MAP = {
"compose-ui-test": "testing",
"compose-ui-test-manifest": "testing",
"roborazzi": "screenshot_testing",
"paparazzi": "screenshot_testing",
"compose-preview-screenshot": "screenshot_testing",
"robolectric": "testing",
"turbine": "testing",
"truth": "testing",
"androidx-test-core": "testing",
"androidx-test-runner": "testing",
"androidx-test-rules": "testing",
"espresso": "testing",
"ktor-mock-engine": "network_testing",
"mockwebserver": "network_testing",
"sqldelight-jvm-driver": "database_testing",
"macrobenchmark": "performance",
}
def classify_android_tools(
plugins: List[dict],
deps: List[dict],
config_files: Dict[str, bool],
) -> Dict[str, List[dict]]:
"""Organize detected Android tools into categories."""
categories: Dict[str, List[dict]] = {
"build_config": [],
"compose": [],
"static_analysis": [],
"testing": [],
"screenshot_testing": [],
"database": [],
"database_testing": [],
"network_testing": [],
"dependency_injection": [],
"performance": [],
}
classified_names = set()
for detection in plugins:
cat = ANDROID_PLUGIN_CATEGORY_MAP.get(detection["name"])
if cat:
categories[cat].append(detection)
classified_names.add(detection["name"])
for detection in deps:
cat = ANDROID_DEP_CATEGORY_MAP.get(detection["name"])
if cat:
categories[cat].append(detection)
classified_names.add(detection["name"])
# Config-only detection
config_only_map = {
"lint.xml": ("android-lint", "static_analysis"),
"compose-stability-config.conf": ("compose-stability", "compose"),
}
for config_file, (tool, cat) in config_only_map.items():
if config_files.get(config_file, False) and tool not in classified_names:
categories[cat].append(make_detection(tool, "config-only", "config-file"))
classified_names.add(tool)
return categories
# ---------------------------------------------------------------------------
# Tool config extraction
# ---------------------------------------------------------------------------
def extract_tool_config(combined_content: str, root: Path) -> Dict[str, object]:
"""Extract Android-specific tool configuration details."""
config: Dict[str, object] = {}
# Compose stability config
stability_path = root / "compose-stability-config.conf"
config["compose_stability_config"] = stability_path.exists()
# Android Lint config
lint_path = root / "lint.xml"
config["has_lint_config"] = lint_path.exists()
# Baseline profiles
baseline_prof = root / "src" / "main" / "baseline-prof.txt"
if not baseline_prof.exists():
baseline_prof = root / "app" / "src" / "main" / "baseline-prof.txt"
config["has_baseline_profile"] = baseline_prof.exists()
# R8/ProGuard
config["has_proguard_rules"] = (root / "proguard-rules.pro").exists()
# Convention plugins
config["has_convention_plugins"] = (root / "build-logic").is_dir()
config["has_buildsrc"] = (root / "buildSrc").is_dir()
# Check for compose features enabled
config["compose_build_features"] = bool(
re.search(r"buildFeatures\s*\{[^}]*compose\s*=\s*true", combined_content, re.DOTALL)
)
# Check for testOptions configuration
config["has_include_android_resources"] = bool(
re.search(
r"isIncludeAndroidResources\s*=\s*true|includeAndroidResources\s*true",
combined_content,
)
)
return config
# ---------------------------------------------------------------------------
# Version catalog scanning (Android-specific keys)
# ---------------------------------------------------------------------------
def scan_android_version_catalog(root: Path) -> Tuple[List[dict], List[dict], Dict[str, str]]:
"""Scan version catalog for Android-specific tool references."""
detected_plugins: List[dict] = []
detected_deps: List[dict] = []
versions: Dict[str, str] = {}
catalog_path = root / "gradle" / "libs.versions.toml"
if not catalog_path.exists():
return detected_plugins, detected_deps, versions
content = read_file_safe(catalog_path)
if not content:
return detected_plugins, detected_deps, versions
seen_plugins = set()
seen_deps = set()
for tool, patterns in ANDROID_PLUGIN_PATTERNS.items():
for pattern in patterns:
if re.search(pattern, content) and tool not in seen_plugins:
seen_plugins.add(tool)
detected_plugins.append(make_detection(tool, "active", "version-catalog"))
break
for tool, patterns in ANDROID_TEST_DEPENDENCY_PATTERNS.items():
for pattern in patterns:
if re.search(pattern, content) and tool not in seen_deps:
seen_deps.add(tool)
detected_deps.append(make_detection(tool, "active", "version-catalog"))
break
# Extract versions
versions = extract_android_version_catalog(root)
return detected_plugins, detected_deps, versions
# ---------------------------------------------------------------------------
# Config file scanning
# ---------------------------------------------------------------------------
def scan_android_config_files(root: Path) -> Dict[str, bool]:
"""Check for existence of Android-specific config files."""
result = {}
for file_path in ANDROID_CONFIG_FILE_INDICATORS:
result[file_path] = (root / file_path).exists()
# Also check common alternate locations
result["baseline-prof.txt"] = (
(root / "src" / "main" / "baseline-prof.txt").exists()
or (root / "app" / "src" / "main" / "baseline-prof.txt").exists()
)
result["build-logic/"] = (root / "build-logic").is_dir()
result["buildSrc/"] = (root / "buildSrc").is_dir()
return result
# ---------------------------------------------------------------------------
# Module discovery (for --recursive)
# ---------------------------------------------------------------------------
def discover_android_modules(root: Path) -> List[dict]:
"""Discover Android modules and report tools per module."""
modules = []
build_files = find_build_files_recursive(root)
for bf in build_files:
if not bf.name.startswith("build.gradle"):
continue
content = read_file_safe(bf)
if not content:
continue
# Only include modules with Android plugins
if not re.search(r"com\.android\.", content):
continue
module_dir = bf.parent
rel_path = str(module_dir.relative_to(root))
if rel_path == ".":
rel_path = "(root)"
tool_names = []
all_patterns = {}
all_patterns.update(ANDROID_PLUGIN_PATTERNS)
all_patterns.update(ANDROID_TEST_DEPENDENCY_PATTERNS)
for tool, patterns in all_patterns.items():
for pattern in patterns:
if re.search(pattern, content):
tool_names.append(tool)
break
project_type = detect_android_project_type(content)
modules.append(
{
"path": rel_path,
"project_type": project_type,
"tools": tool_names,
}
)
return modules
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def scan(root: Path, recursive: bool = False) -> Optional[dict]:
"""Scan an Android project and return structured results.
This is the main entry point when used as a module by the orchestrator.
Returns a dict with ecosystem, project, detected_tools, etc.
Returns None if not an Android project.
"""
if not is_android_project(root):
return None
# Collect all build file content
if recursive:
build_files = [
f
for f in find_build_files_recursive(root)
if f.name.startswith("build.gradle")
]
else:
build_files = find_gradle_build_files(root)
combined_content = ""
for bf in build_files:
content = read_file_safe(bf)
if content:
combined_content += content + "\n"
if not combined_content:
return None
# Detect tools from build files
plugins = _detect_tools_with_status(
combined_content, ANDROID_PLUGIN_PATTERNS, is_commented_out_gradle
)
deps = _detect_tools_with_status(
combined_content, ANDROID_TEST_DEPENDENCY_PATTERNS, is_commented_out_gradle
)
# Extract versions from build files
versions = extract_android_versions(combined_content)
# Scan version catalog for additional tools and versions
cat_plugins, cat_deps, cat_versions = scan_android_version_catalog(root)
existing_plugin_names = {d["name"] for d in plugins}
existing_dep_names = {d["name"] for d in deps}
for p in cat_plugins:
if p["name"] not in existing_plugin_names:
plugins.append(p)
for d in cat_deps:
if d["name"] not in existing_dep_names:
deps.append(d)
for k, v in cat_versions.items():
if k not in versions:
versions[k] = v
# Scan config files
config_files = scan_android_config_files(root)
# Classify tools into Android categories
categories = classify_android_tools(plugins, deps, config_files)
# Analyze project structure
structure = analyze_android_structure(root)
# Include version catalog content for project type detection
catalog_path = root / "gradle" / "libs.versions.toml"
detection_content = combined_content
catalog_content = read_file_safe(catalog_path)
if catalog_content:
detection_content += "\n" + catalog_content
# Detect project characteristics
project_type = detect_android_project_type(detection_content)
is_kmp = detect_is_kmp(detection_content)
ui_toolkit = detect_ui_toolkit(root, combined_content)
# Extract tool config
tool_config = extract_tool_config(combined_content, root)
has_version_catalog = (root / "gradle" / "libs.versions.toml").exists()
result = {
"ecosystem": "android",
"project": {
"agp_version": versions.get("agp"),
"compose_bom_version": versions.get("compose-bom"),
"android_project_type": project_type,
"ui_toolkit": ui_toolkit,
"is_kmp": is_kmp,
"kotlin_version": versions.get("kotlin"),
"compile_sdk": versions.get("compile_sdk"),
"min_sdk": versions.get("min_sdk"),
"target_sdk": versions.get("target_sdk"),
"has_version_catalog": has_version_catalog,
"has_convention_plugins": tool_config.get("has_convention_plugins", False),
"main_file_count": structure["main_file_count"],
"test_file_count": structure["test_file_count"],
"android_test_file_count": structure["android_test_file_count"],
"common_main_file_count": structure.get("common_main_file_count", 0),
"common_test_file_count": structure.get("common_test_file_count", 0),
},
"detected_tools": categories,
"config_files": config_files,
"versions": versions,
"tool_config": tool_config,
}
if recursive:
result["modules"] = discover_android_modules(root)
return result
# ---------------------------------------------------------------------------
# Standalone CLI
# ---------------------------------------------------------------------------
def main():
"""Main entry point for standalone usage."""
parser = argparse.ArgumentParser(
description="Android Project Tooling Scanner — detects quality and testing tools"
)
parser.add_argument("project_root", help="Path to the project root directory")
parser.add_argument(
"--recursive",
action="store_true",
help="Recursively discover Android modules",
)
args = parser.parse_args()
root = Path(args.project_root).resolve()
if not root.is_dir():
print(json.dumps({"error": f"Not a directory: {root}"}, indent=2))
sys.exit(1)
result = scan(root, recursive=args.recursive)
if result is None:
print(
json.dumps(
{
"error": "not_android_project",
"message": f"No Android project detected at: {root}",
"hint": "Ensure the project has Android Gradle plugins or AndroidManifest.xml",
},
indent=2,
)
)
sys.exit(1)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Cross-Cutting Tools Scanner
Detects ecosystem-agnostic quality tools: git hooks, CI/CD systems,
commit conventions, EditorConfig, dependency automation, security scanning,
and frontend tools (for Lefthook wiring).
Uses only standard library (no external dependencies). Python 3.8+.
Usage (standalone):
python3 scan_cross_cutting.py <project_root>
Usage (as module):
from scan_cross_cutting import scan
result = scan(Path("/path/to/project"))
"""
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional
sys.path.insert(0, str(Path(__file__).resolve().parent))
from shared import SKIP_DIRS, make_detection, read_file_safe, read_json_safe
# ---------------------------------------------------------------------------
# CI/CD detection
# ---------------------------------------------------------------------------
CI_FILE_INDICATORS: Dict[str, str] = {
".github/workflows": "github-actions",
"Jenkinsfile": "jenkins",
".gitlab-ci.yml": "gitlab-ci",
".circleci/config.yml": "circleci",
"bitbucket-pipelines.yml": "bitbucket-pipelines",
"azure-pipelines.yml": "azure-devops",
}
def scan_ci_files(root: Path) -> List[dict]:
"""Detect CI/CD systems in use."""
detected = []
for path, ci_system in CI_FILE_INDICATORS.items():
full_path = root / path
if full_path.is_dir():
has_workflows = list(full_path.glob("*.yml")) or list(full_path.glob("*.yaml"))
if has_workflows:
detected.append(make_detection(ci_system, "active", "ci-file"))
elif full_path.exists():
detected.append(make_detection(ci_system, "active", "ci-file"))
return detected
# ---------------------------------------------------------------------------
# Git hooks detection
# ---------------------------------------------------------------------------
GIT_HOOK_CONFIG_FILES = {
"lefthook.yml": "lefthook",
"lefthook-local.yml": "lefthook",
".husky/_/husky.sh": "husky",
".pre-commit-config.yaml": "pre-commit",
}
def scan_git_hooks(root: Path) -> List[dict]:
"""Detect git hook managers from config files and package.json."""
detected = []
seen = set()
# Config file detection
for config_file, tool in GIT_HOOK_CONFIG_FILES.items():
if (root / config_file).exists() and tool not in seen:
seen.add(tool)
detected.append(make_detection(tool, "active", "config-file"))
# Package.json detection
pkg = read_json_safe(root / "package.json")
if pkg:
all_deps = {}
for key in ("devDependencies", "dependencies"):
if key in pkg and isinstance(pkg[key], dict):
all_deps.update(pkg[key])
for tool in ["lefthook", "husky"]:
if tool in all_deps and tool not in seen:
seen.add(tool)
detected.append(make_detection(tool, "active", "package-json"))
return detected
def extract_lefthook_config(root: Path) -> Dict[str, object]:
"""Extract lefthook configuration details from lefthook.yml."""
content = read_file_safe(root / "lefthook.yml")
if content is None:
return {}
config: Dict[str, object] = {}
# Extract configured hook command names from all hook sections
hooks: List[str] = []
in_commands = False
commands_indent = -1
child_indent = -1
for line in content.splitlines():
stripped = line.strip()
if stripped == "commands:":
in_commands = True
commands_indent = len(line) - len(line.lstrip())
child_indent = -1
continue
if in_commands:
if not stripped:
continue
line_indent = len(line) - len(line.lstrip())
if line_indent <= commands_indent:
in_commands = False
# Don't continue — this line may start a new section or commands block
if stripped == "commands:":
in_commands = True
commands_indent = len(line) - len(line.lstrip())
child_indent = -1
continue
match = re.match(r"^\s+(\w[\w-]*):", line)
if match:
if child_indent == -1:
child_indent = line_indent
if line_indent == child_indent:
hooks.append(match.group(1))
config["lefthook_hooks"] = hooks
# Check for stage_fixed usage (lefthook v2 best practice)
config["lefthook_has_stage_fixed"] = bool(
re.search(r"stage_fixed:\s*true", content)
)
return config
# ---------------------------------------------------------------------------
# Commit conventions detection
# ---------------------------------------------------------------------------
COMMITLINT_CONFIG_FILES = [
"commitlint.config.js",
"commitlint.config.cjs",
"commitlint.config.mjs",
"commitlint.config.ts",
".commitlintrc",
".commitlintrc.json",
".commitlintrc.yml",
".commitlintrc.yaml",
".commitlintrc.js",
".commitlintrc.cjs",
".commitlintrc.ts",
]
def scan_commit_conventions(root: Path) -> List[dict]:
"""Detect commit convention tools."""
detected = []
seen = set()
# commitlint config files
for config_file in COMMITLINT_CONFIG_FILES:
if (root / config_file).exists() and "commitlint" not in seen:
seen.add("commitlint")
detected.append(make_detection("commitlint", "active", "config-file"))
break
# commitlint in package.json
pkg = read_json_safe(root / "package.json")
if pkg:
all_deps = {}
for key in ("devDependencies", "dependencies"):
if key in pkg and isinstance(pkg[key], dict):
all_deps.update(pkg[key])
if "@commitlint/cli" in all_deps and "commitlint" not in seen:
seen.add("commitlint")
detected.append(make_detection("commitlint", "active", "package-json"))
# commitizen
if "commitizen" in all_deps and "commitizen" not in seen:
seen.add("commitizen")
detected.append(make_detection("commitizen", "active", "package-json"))
elif "cz-conventional-changelog" in all_deps and "commitizen" not in seen:
seen.add("commitizen")
detected.append(make_detection("commitizen", "active", "package-json"))
return detected
# ---------------------------------------------------------------------------
# EditorConfig detection
# ---------------------------------------------------------------------------
def _scan_subdir_editorconfigs(root: Path) -> List[dict]:
"""Find .editorconfig files in subdirectories and check for root=true.
Scans two levels deep (e.g., apps/api/) to catch monorepo patterns.
"""
configs: List[dict] = []
try:
for entry in root.iterdir():
if not entry.is_dir() or entry.name in SKIP_DIRS:
continue
ec_path = entry / ".editorconfig"
if ec_path.exists():
content = read_file_safe(ec_path)
is_root = bool(
re.search(r"root\s*=\s*true", content or "", re.IGNORECASE)
)
configs.append({
"path": str(entry.relative_to(root)),
"is_root": is_root,
})
# Check one level deeper (apps/api/, packages/web/)
try:
for subentry in entry.iterdir():
if not subentry.is_dir() or subentry.name in SKIP_DIRS:
continue
ec_path = subentry / ".editorconfig"
if ec_path.exists():
content = read_file_safe(ec_path)
is_root = bool(
re.search(
r"root\s*=\s*true", content or "", re.IGNORECASE
)
)
configs.append({
"path": str(subentry.relative_to(root)),
"is_root": is_root,
})
except Exception:
pass
except Exception:
pass
return configs
def scan_editor_config(root: Path) -> dict:
"""Detect and audit EditorConfig setup, including subdirectory conflicts."""
result = {
"detected": False,
"settings": {},
"subdirectory_configs": [],
}
content = read_file_safe(root / ".editorconfig")
if content is None:
# Still scan subdirectories — they may have configs even without root
result["subdirectory_configs"] = _scan_subdir_editorconfigs(root)
return result
result["detected"] = True
# Extract key settings
settings = {}
if re.search(r"indent_style\s*=\s*(\w+)", content):
match = re.search(r"indent_style\s*=\s*(\w+)", content)
settings["indent_style"] = match.group(1)
if re.search(r"indent_size\s*=\s*(\w+)", content):
match = re.search(r"indent_size\s*=\s*(\w+)", content)
settings["indent_size"] = match.group(1)
if re.search(r"end_of_line\s*=\s*(\w+)", content):
match = re.search(r"end_of_line\s*=\s*(\w+)", content)
settings["end_of_line"] = match.group(1)
settings["trim_trailing_whitespace"] = bool(
re.search(r"trim_trailing_whitespace\s*=\s*true", content, re.IGNORECASE)
)
settings["insert_final_newline"] = bool(
re.search(r"insert_final_newline\s*=\s*true", content, re.IGNORECASE)
)
settings["is_root"] = bool(
re.search(r"root\s*=\s*true", content, re.IGNORECASE)
)
result["settings"] = settings
# Scan subdirectories for .editorconfig files that may block inheritance
result["subdirectory_configs"] = _scan_subdir_editorconfigs(root)
return result
# ---------------------------------------------------------------------------
# Dependency automation detection
# ---------------------------------------------------------------------------
def scan_dependency_automation(root: Path) -> List[dict]:
"""Detect dependency automation tools (Renovate, Dependabot)."""
detected = []
seen = set()
# Renovate
renovate_files = [
"renovate.json", "renovate.json5", ".renovaterc", ".renovaterc.json",
]
for rf in renovate_files:
if (root / rf).exists() and "renovate" not in seen:
seen.add("renovate")
detected.append(make_detection("renovate", "active", "config-file"))
break
# Dependabot
if (root / ".github" / "dependabot.yml").exists() and "dependabot" not in seen:
seen.add("dependabot")
detected.append(make_detection("dependabot", "active", "config-file"))
return detected
# ---------------------------------------------------------------------------
# Security scanning detection
# ---------------------------------------------------------------------------
def scan_security_tools(root: Path) -> List[dict]:
"""Detect security scanning tools (Trivy, gitleaks, Snyk)."""
detected = []
# Trivy
if (root / ".trivyignore").exists():
detected.append(make_detection("trivy", "active", "config-file"))
# gitleaks
if (root / ".gitleaks.toml").exists():
detected.append(make_detection("gitleaks", "active", "config-file"))
# Snyk
if (root / ".snyk").exists():
detected.append(make_detection("snyk", "active", "config-file"))
# Also check CI workflows for security tool mentions
workflows_dir = root / ".github" / "workflows"
if workflows_dir.is_dir():
for wf in list(workflows_dir.glob("*.yml")) + list(workflows_dir.glob("*.yaml")):
content = read_file_safe(wf)
if content is None:
continue
tool_seen = {d["name"] for d in detected}
if "trivy" not in tool_seen and re.search(r"trivy", content, re.IGNORECASE):
detected.append(make_detection("trivy", "active", "ci-workflow"))
if "gitleaks" not in tool_seen and re.search(r"gitleaks", content, re.IGNORECASE):
detected.append(make_detection("gitleaks", "active", "ci-workflow"))
if "snyk" not in tool_seen and re.search(r"snyk", content, re.IGNORECASE):
detected.append(make_detection("snyk", "active", "ci-workflow"))
return detected
# ---------------------------------------------------------------------------
# Frontend tool detection (for Lefthook wiring)
# ---------------------------------------------------------------------------
FRONTEND_CONFIG_FILES: Dict[str, str] = {
"eslint.config.mjs": "eslint",
"eslint.config.js": "eslint",
"eslint.config.cjs": "eslint",
".eslintrc.js": "eslint",
".eslintrc.cjs": "eslint",
".eslintrc.json": "eslint",
".eslintrc.yml": "eslint",
".eslintrc.yaml": "eslint",
".prettierrc": "prettier",
".prettierrc.json": "prettier",
".prettierrc.yml": "prettier",
".prettierrc.yaml": "prettier",
".prettierrc.js": "prettier",
".prettierrc.cjs": "prettier",
"prettier.config.js": "prettier",
"prettier.config.cjs": "prettier",
"prettier.config.mjs": "prettier",
"tailwind.config.js": "tailwindcss",
"tailwind.config.ts": "tailwindcss",
"tailwind.config.cjs": "tailwindcss",
"tailwind.config.mjs": "tailwindcss",
"biome.json": "biome",
"biome.jsonc": "biome",
}
FRONTEND_PACKAGE_NAMES: Dict[str, str] = {
"eslint": "eslint",
"prettier": "prettier",
"tailwindcss": "tailwindcss",
"prettier-plugin-tailwindcss": "prettier-plugin-tailwindcss",
"@biomejs/biome": "biome",
}
def scan_frontend_tools(directory: Path, root: Path) -> Optional[dict]:
"""Scan a directory for frontend tools (ESLint, Prettier, Tailwind CSS, Biome).
Used for Lefthook wiring and Node.js scanner integration.
Returns None if no frontend tools are detected.
"""
tools: Dict[str, dict] = {}
for pkg_name in FRONTEND_PACKAGE_NAMES:
tool_name = FRONTEND_PACKAGE_NAMES[pkg_name]
if tool_name not in tools:
tools[tool_name] = {
"detected": False,
"version": None,
"source": None,
"config_file": None,
}
# Scan package.json for dependencies
pkg = read_json_safe(directory / "package.json")
if pkg:
all_deps: Dict[str, str] = {}
for key in ("devDependencies", "dependencies"):
if key in pkg and isinstance(pkg[key], dict):
all_deps.update(pkg[key])
for pkg_name, tool_name in FRONTEND_PACKAGE_NAMES.items():
if pkg_name in all_deps:
tools[tool_name]["detected"] = True
tools[tool_name]["version"] = all_deps[pkg_name]
tools[tool_name]["source"] = "package-json"
# Scan for config files
for config_file, tool_name in FRONTEND_CONFIG_FILES.items():
if (directory / config_file).exists():
if tool_name in tools:
tools[tool_name]["config_file"] = config_file
if not tools[tool_name]["detected"]:
tools[tool_name]["detected"] = True
tools[tool_name]["source"] = "config-file"
if not any(t["detected"] for t in tools.values()):
return None
rel_path = str(directory.relative_to(root)) if directory != root else "(root)"
return {"path": rel_path, "tools": tools}
def discover_frontend_directories(root: Path) -> List[Path]:
"""Find directories containing package.json for frontend tool scanning."""
dirs: List[Path] = []
seen: set = set()
def _add(d: Path) -> None:
resolved = d.resolve()
if resolved not in seen and (d / "package.json").exists():
seen.add(resolved)
dirs.append(d)
_add(root)
try:
for entry in root.iterdir():
if entry.is_dir() and entry.name not in SKIP_DIRS:
_add(entry)
except Exception:
pass
monorepo_prefixes = ["apps", "packages", "frontend", "web", "client"]
for prefix in monorepo_prefixes:
prefix_dir = root / prefix
if prefix_dir.is_dir():
try:
for entry in prefix_dir.iterdir():
if entry.is_dir() and entry.name not in SKIP_DIRS:
_add(entry)
except Exception:
pass
return dirs
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def scan(root: Path) -> dict:
"""Scan for cross-cutting tools and return structured results.
This is the main entry point when used as a module by the orchestrator.
"""
ci_cd = scan_ci_files(root)
git_hooks = scan_git_hooks(root)
lefthook_config = extract_lefthook_config(root)
commit_conventions = scan_commit_conventions(root)
editor_config = scan_editor_config(root)
dependency_automation = scan_dependency_automation(root)
security_scanning = scan_security_tools(root)
# Frontend tools for Lefthook wiring
frontend_dirs = discover_frontend_directories(root)
frontend_tools = []
for fdir in frontend_dirs:
result = scan_frontend_tools(fdir, root)
if result is not None:
frontend_tools.append(result)
output = {
"ci_cd": ci_cd,
"git_hooks": git_hooks,
"commit_conventions": commit_conventions,
"editor_config": editor_config,
"dependency_automation": dependency_automation,
"security_scanning": security_scanning,
}
if lefthook_config:
output["lefthook_config"] = lefthook_config
if frontend_tools:
output["frontend_tools"] = frontend_tools
return output
# ---------------------------------------------------------------------------
# Standalone CLI
# ---------------------------------------------------------------------------
def main():
"""Main entry point for standalone usage."""
parser = argparse.ArgumentParser(
description="Cross-Cutting Tools Scanner — detects CI/CD, git hooks, security tools"
)
parser.add_argument("project_root", help="Path to the project root directory")
args = parser.parse_args()
root = Path(args.project_root).resolve()
if not root.is_dir():
print(json.dumps({"error": f"Not a directory: {root}"}, indent=2))
sys.exit(1)
result = scan(root)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
JVM Project Tooling Scanner
Scans Gradle and Maven build files to detect configured quality and testing
tools. Returns JSON for cross-referencing against research recommendations.
Uses only standard library (no external dependencies). Python 3.8+.
Usage (standalone):
python3 scan_jvm.py <project_root>
python3 scan_jvm.py --recursive <project_root>
Usage (as module):
from scan_jvm import scan
result = scan(Path("/path/to/project"), recursive=False)
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# Sibling import
sys.path.insert(0, str(Path(__file__).resolve().parent))
from shared import SKIP_DIRS, make_detection
# ---------------------------------------------------------------------------
# Detection patterns: tool name -> list of regex patterns for build files
# ---------------------------------------------------------------------------
GRADLE_PLUGIN_PATTERNS: Dict[str, List[str]] = {
# Static analysis
"error-prone": [r"net\.ltgt\.errorprone", r"error_prone_core"],
"spotbugs": [r"com\.github\.spotbugs", r"spotbugs"],
"detekt": [r"io\.gitlab\.arturbosch\.detekt", r"detekt"],
"ktlint": [r"org\.jlleitschuh\.gradle\.ktlint", r"ktlint"],
"sonarqube": [r"org\.sonarqube", r"sonarqube"],
# Coverage
"jacoco": [r"\bjacoco\b"],
"kover": [r"org\.jetbrains\.kotlinx\.kover", r"kotlinx\.kover"],
# Mutation testing
"pitest": [r"info\.solidsoft\.pitest", r"pitest"],
# Security
"owasp-dependency-check": [r"org\.owasp\.dependencycheck", r"owasp"],
# Migrations
"openrewrite": [r"org\.openrewrite\.rewrite", r"openrewrite"],
}
MAVEN_PLUGIN_PATTERNS: Dict[str, List[str]] = {
"error-prone": [r"error_prone_core", r"error-prone"],
"spotbugs": [r"spotbugs-maven-plugin", r"spotbugs"],
"detekt": [r"detekt-maven-plugin"],
"ktlint": [r"ktlint-maven-plugin"],
"sonarqube": [r"sonar-maven-plugin", r"sonarqube"],
"jacoco": [r"jacoco-maven-plugin", r"org\.jacoco"],
"pitest": [r"pitest-maven", r"pitest"],
"owasp-dependency-check": [r"dependency-check-maven"],
"openrewrite": [r"rewrite-maven-plugin"],
}
TEST_DEPENDENCY_PATTERNS: Dict[str, List[str]] = {
# Assertions
"assertj": [r"assertj-core", r"assertj"],
"kotest-assertions": [r"kotest-assertions-core"],
"kotest-runner": [r"kotest-runner-junit5"],
"strikt": [r"strikt-core"],
"hamcrest": [r"org\.hamcrest", r"hamcrest"],
# Mocking
"mockk": [r"io\.mockk", r"\bmockk\b"],
"mockito": [r"mockito-core", r"mockito-kotlin"],
# Test data
"instancio": [r"instancio-junit", r"instancio-core", r"instancio"],
"kotlin-faker": [r"kotlin-faker"],
"fixture-monkey": [r"fixture-monkey"],
# Integration testing
"testcontainers": [r"testcontainers"],
# Architecture
"archunit": [r"archunit"],
"konsist": [r"com\.lemonappdev.*konsist", r"konsist"],
# Contract testing
"pact": [r"pact-jvm", r"au\.com\.dius\.pact", r"pact"],
"spring-cloud-contract": [r"spring-cloud-contract", r"spring-cloud-starter-contract"],
# Property-based testing
"jqwik": [r"jqwik"],
# API testing
"rest-assured": [r"rest-assured"],
# Benchmarking
"jmh": [r"jmh-core", r"jmh-generator", r"me\.champeau\.jmh"],
# Spring Modulith testing
"spring-modulith-test": [r"spring-modulith-starter-test", r"spring-modulith-test"],
# Async testing
"awaitility": [r"awaitility"],
# HTTP mocking
"wiremock": [r"wiremock", r"org\.wiremock"],
}
# JVM-specific config files (for config-only detection)
JVM_CONFIG_FILE_INDICATORS: Dict[str, str] = {
"detekt.yml": "detekt",
"detekt-config.yml": "detekt",
"config/detekt.yml": "detekt",
".editorconfig": "editorconfig",
"spotbugs-exclude.xml": "spotbugs",
"sonar-project.properties": "sonarqube",
"dependency-suppression.xml": "owasp-dependency-check",
"config/dependency-suppression.xml": "owasp-dependency-check",
}
# Maps version catalog keys to normalized tool names
VERSION_CATALOG_KEY_MAP = {
"spring-boot": "spring-boot",
"springboot": "spring-boot",
"kotlin": "kotlin",
"detekt": "detekt",
"ktlint": "ktlint",
"kover": "kover",
"jacoco": "jacoco",
"pitest": "pitest",
"kotest": "kotest",
"mockk": "mockk",
"assertj": "assertj",
"testcontainers": "testcontainers",
"archunit": "archunit",
"jqwik": "jqwik",
"instancio": "instancio",
"java": "java",
}
# ---------------------------------------------------------------------------
# Comment detection
# ---------------------------------------------------------------------------
def is_commented_out_gradle(content: str, match_pos: int) -> bool:
"""Check if a match position in Gradle content is inside a comment."""
line_start = content.rfind("\n", 0, match_pos) + 1
line_prefix = content[line_start:match_pos].lstrip()
if line_prefix.startswith("//"):
return True
opens = len(re.findall(r"/\*", content[:match_pos]))
closes = len(re.findall(r"\*/", content[:match_pos]))
return opens > closes
def is_commented_out_maven(content: str, match_pos: int) -> bool:
"""Check if a match position in Maven POM is inside an XML comment."""
last_open = content.rfind("<!--", 0, match_pos)
if last_open == -1:
return False
last_close = content.rfind("-->", 0, match_pos)
return last_close < last_open
# ---------------------------------------------------------------------------
# Tool config extraction
# ---------------------------------------------------------------------------
def extract_jacoco_threshold(content: str) -> Optional[str]:
"""Extract JaCoCo minimum coverage threshold from violationRules block."""
match = re.search(
r"violationRules\s*\{[^}]*minimum\.set\s*\(\s*([0-9.]+)\s*\)",
content,
re.DOTALL,
)
if not match:
match = re.search(
r"violationRules\s*\{[^}]*minimum\s*=\s*([0-9.]+)",
content,
re.DOTALL,
)
if not match:
match = re.search(r"<minimum>([0-9.]+)</minimum>", content)
return match.group(1) if match else None
def detect_ktlint_sarif(content: str) -> bool:
"""Check if ktlint is configured with SARIF reporter."""
return bool(
re.search(r"reporter.*sarif|sarif.*reporter|outputToSarif", content, re.IGNORECASE)
)
# ---------------------------------------------------------------------------
# Build tool detection
# ---------------------------------------------------------------------------
def detect_build_tool(root: Path) -> str:
"""Determine the JVM build tool used by the project."""
if (root / "build.gradle.kts").exists():
return "gradle-kotlin"
if (root / "build.gradle").exists():
return "gradle-groovy"
if (root / "pom.xml").exists():
return "maven"
return "unknown"
def is_jvm_project(root: Path) -> bool:
"""Check if directory contains a JVM project."""
return detect_build_tool(root) != "unknown"
# ---------------------------------------------------------------------------
# Build file discovery
# ---------------------------------------------------------------------------
def find_gradle_build_files(root: Path) -> List[Path]:
"""Find all Gradle build files (root + submodules)."""
files = []
for name in ["build.gradle.kts", "build.gradle"]:
root_file = root / name
if root_file.exists():
files.append(root_file)
settings = root / "settings.gradle.kts"
if not settings.exists():
settings = root / "settings.gradle"
if settings.exists():
try:
content = settings.read_text(encoding="utf-8")
includes = re.findall(r'include\s*\(\s*"([^"]+)"\s*\)', content)
includes += re.findall(r"include\s*\(\s*'([^']+)'\s*\)", content)
includes += re.findall(r'include\s+"([^"]+)"', content)
includes += re.findall(r"include\s+'([^']+)'", content)
for module in includes:
module_dir = root / module.replace(":", "/").lstrip("/")
for name in ["build.gradle.kts", "build.gradle"]:
module_file = module_dir / name
if module_file.exists():
files.append(module_file)
except Exception:
pass
return files
def find_build_files_recursive(root: Path) -> List[Path]:
"""Walk directories to find all JVM build files, skipping output dirs."""
build_files = []
for dirpath, dirs, files in os.walk(str(root), topdown=True):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for name in ["build.gradle.kts", "build.gradle", "pom.xml"]:
if name in files:
build_files.append(Path(dirpath) / name)
return build_files
# ---------------------------------------------------------------------------
# Gradle scanning
# ---------------------------------------------------------------------------
def _detect_tools_with_status(
content: str,
patterns: Dict[str, List[str]],
comment_checker,
) -> List[dict]:
"""Detect tools in content with comment-aware status."""
detected = []
seen = set()
for tool, tool_patterns in patterns.items():
for pattern in tool_patterns:
match = re.search(pattern, content)
if match and tool not in seen:
seen.add(tool)
commented = comment_checker(content, match.start())
status = "disabled" if commented else "active"
detected.append(make_detection(tool, status, "build-file"))
break
return detected
def scan_gradle_builds(
root: Path, recursive: bool = False
) -> Tuple[List[dict], List[dict], Dict[str, str], Dict[str, object]]:
"""Scan Gradle build files for plugins, dependencies, versions, and config."""
detected_plugins: List[dict] = []
detected_deps: List[dict] = []
versions: Dict[str, str] = {}
tool_config: Dict[str, object] = {}
if recursive:
build_files = [
f
for f in find_build_files_recursive(root)
if f.name.startswith("build.gradle")
]
else:
build_files = find_gradle_build_files(root)
combined_content = ""
for bf in build_files:
try:
combined_content += bf.read_text(encoding="utf-8") + "\n"
except Exception:
pass
if not combined_content:
return detected_plugins, detected_deps, versions, tool_config
detected_plugins = _detect_tools_with_status(
combined_content, GRADLE_PLUGIN_PATTERNS, is_commented_out_gradle
)
detected_deps = _detect_tools_with_status(
combined_content, TEST_DEPENDENCY_PATTERNS, is_commented_out_gradle
)
# Extract Spring Boot version
sb_match = re.search(
r'org\.springframework\.boot["\')]\s*version\s*["\']([^"\']+)',
combined_content,
)
if not sb_match:
sb_match = re.search(
r'id\s*\(\s*"org\.springframework\.boot"\s*\)\s*version\s*"([^"]+)"',
combined_content,
)
if sb_match:
versions["spring-boot"] = sb_match.group(1)
# Extract Kotlin version
kt_match = re.search(
r'kotlin\s*\(\s*"jvm"\s*\)\s*version\s*"([^"]+)"', combined_content
)
if not kt_match:
kt_match = re.search(
r'org\.jetbrains\.kotlin\.jvm["\')]\s*version\s*["\']([^"\']+)',
combined_content,
)
if kt_match:
versions["kotlin"] = kt_match.group(1)
# Extract Java version
java_match = re.search(r"jvmToolchain\s*\(\s*(\d+)\s*\)", combined_content)
if not java_match:
java_match = re.search(
r"sourceCompatibility\s*=\s*JavaVersion\.VERSION_(\d+)",
combined_content,
)
if not java_match:
java_match = re.search(
r"java\.sourceCompatibility\s*=\s*.*?(\d+)", combined_content
)
if java_match:
versions["java"] = java_match.group(1)
# Extract tool versions where visible
plugin_names = {d["name"] for d in detected_plugins}
version_extractions = {
"detekt": [r'detekt["\')]\s*version\s*["\']([^"\']+)'],
"ktlint": [
r'ktlint["\')]\s*version\s*["\']([^"\']+)',
r'version\.set\s*\(\s*"([^"]+)"',
],
"kover": [r'kover["\')]\s*version\s*["\']([^"\']+)'],
"pitest": [
r'pitest["\')]\s*version\s*["\']([^"\']+)',
r'pitestVersion\.set\s*\(\s*"([^"]+)"',
],
"jacoco": [r'toolVersion\s*=\s*"([^"]+)"'],
}
for tool, regexes in version_extractions.items():
if tool in plugin_names:
for regex in regexes:
match = re.search(regex, combined_content)
if match:
versions[tool] = match.group(1)
break
# Extract tool config details
threshold = extract_jacoco_threshold(combined_content)
if threshold is not None:
tool_config["jacoco_threshold"] = threshold
if "ktlint" in plugin_names:
tool_config["ktlint_sarif_enabled"] = detect_ktlint_sarif(combined_content)
return detected_plugins, detected_deps, versions, tool_config
# ---------------------------------------------------------------------------
# Maven scanning
# ---------------------------------------------------------------------------
def scan_maven_build(
root: Path, recursive: bool = False
) -> Tuple[List[dict], List[dict], Dict[str, str], Dict[str, object]]:
"""Scan pom.xml for plugins, dependencies, versions, and config."""
detected_plugins: List[dict] = []
detected_deps: List[dict] = []
versions: Dict[str, str] = {}
tool_config: Dict[str, object] = {}
if recursive:
pom_files = [
f for f in find_build_files_recursive(root) if f.name == "pom.xml"
]
else:
pom_file = root / "pom.xml"
pom_files = [pom_file] if pom_file.exists() else []
combined_content = ""
for pf in pom_files:
try:
combined_content += pf.read_text(encoding="utf-8") + "\n"
except Exception:
pass
if not combined_content:
return detected_plugins, detected_deps, versions, tool_config
detected_plugins = _detect_tools_with_status(
combined_content, MAVEN_PLUGIN_PATTERNS, is_commented_out_maven
)
detected_deps = _detect_tools_with_status(
combined_content, TEST_DEPENDENCY_PATTERNS, is_commented_out_maven
)
sb_match = re.search(
r"<artifactId>spring-boot-starter-parent</artifactId>\s*<version>([^<]+)</version>",
combined_content,
)
if sb_match:
versions["spring-boot"] = sb_match.group(1)
java_match = re.search(r"<java\.version>(\d+)</java\.version>", combined_content)
if not java_match:
java_match = re.search(
r"<maven\.compiler\.source>(\d+)</maven\.compiler\.source>",
combined_content,
)
if java_match:
versions["java"] = java_match.group(1)
kt_match = re.search(
r"<kotlin\.version>([^<]+)</kotlin\.version>", combined_content
)
if kt_match:
versions["kotlin"] = kt_match.group(1)
threshold = extract_jacoco_threshold(combined_content)
if threshold is not None:
tool_config["jacoco_threshold"] = threshold
plugin_names = {d["name"] for d in detected_plugins}
if "ktlint" in plugin_names:
tool_config["ktlint_sarif_enabled"] = detect_ktlint_sarif(combined_content)
return detected_plugins, detected_deps, versions, tool_config
# ---------------------------------------------------------------------------
# Version catalog scanning (Gradle)
# ---------------------------------------------------------------------------
def scan_version_catalog(root: Path) -> Tuple[List[dict], List[dict], Dict[str, str]]:
"""Scan gradle/libs.versions.toml for tool references and versions."""
detected_plugins: List[dict] = []
detected_deps: List[dict] = []
versions: Dict[str, str] = {}
catalog_path = root / "gradle" / "libs.versions.toml"
if not catalog_path.exists():
return detected_plugins, detected_deps, versions
try:
content = catalog_path.read_text(encoding="utf-8")
except Exception:
return detected_plugins, detected_deps, versions
seen_plugins = set()
seen_deps = set()
for tool, patterns in GRADLE_PLUGIN_PATTERNS.items():
for pattern in patterns:
if re.search(pattern, content) and tool not in seen_plugins:
seen_plugins.add(tool)
detected_plugins.append(make_detection(tool, "active", "version-catalog"))
break
for tool, patterns in TEST_DEPENDENCY_PATTERNS.items():
for pattern in patterns:
if re.search(pattern, content) and tool not in seen_deps:
seen_deps.add(tool)
detected_deps.append(make_detection(tool, "active", "version-catalog"))
break
# Extract versions from [versions] section
in_versions = False
for line in content.splitlines():
stripped = line.strip()
if stripped == "[versions]":
in_versions = True
continue
if stripped.startswith("[") and stripped.endswith("]"):
in_versions = False
continue
if in_versions and "=" in stripped:
key, _, val = stripped.partition("=")
key = key.strip().strip('"').strip("'")
val = val.strip().strip('"').strip("'")
tool_name = VERSION_CATALOG_KEY_MAP.get(key.lower())
if tool_name:
versions[tool_name] = val
return detected_plugins, detected_deps, versions
# ---------------------------------------------------------------------------
# Config file scanning (JVM-specific)
# ---------------------------------------------------------------------------
def scan_jvm_config_files(root: Path) -> Dict[str, bool]:
"""Check for existence of JVM tool config files."""
result = {}
for file_path in JVM_CONFIG_FILE_INDICATORS:
result[file_path] = (root / file_path).exists()
return result
# ---------------------------------------------------------------------------
# Project structure analysis
# ---------------------------------------------------------------------------
def _exclude_build_dirs(paths: List[Path]) -> List[Path]:
"""Filter out paths that pass through build output directories."""
return [p for p in paths if not (SKIP_DIRS & set(p.parts))]
def analyze_project_structure(root: Path) -> Dict:
"""Analyze JVM project structure for language, test count, etc."""
result = {
"language": "unknown",
"test_file_count": 0,
"main_file_count": 0,
"has_test_dir": False,
"has_jmh_dir": False,
}
java_main = _exclude_build_dirs(list(root.glob("**/src/main/**/*.java")))
kotlin_main = _exclude_build_dirs(list(root.glob("**/src/main/**/*.kt")))
java_test = _exclude_build_dirs(list(root.glob("**/src/test/**/*.java")))
kotlin_test = _exclude_build_dirs(list(root.glob("**/src/test/**/*.kt")))
result["main_file_count"] = len(java_main) + len(kotlin_main)
result["test_file_count"] = len(java_test) + len(kotlin_test)
result["has_test_dir"] = (root / "src" / "test").exists()
result["has_jmh_dir"] = (root / "src" / "jmh").exists()
java_count = len(java_main) + len(java_test)
kotlin_count = len(kotlin_main) + len(kotlin_test)
if kotlin_count > 0 and java_count > 0:
result["language"] = "mixed"
elif kotlin_count > 0:
result["language"] = "kotlin"
elif java_count > 0:
result["language"] = "java"
return result
# ---------------------------------------------------------------------------
# Tool classification into categories
# ---------------------------------------------------------------------------
PLUGIN_CATEGORY_MAP = {
"error-prone": "static_analysis",
"spotbugs": "static_analysis",
"detekt": "static_analysis",
"ktlint": "static_analysis",
"sonarqube": "static_analysis",
"jacoco": "coverage",
"kover": "coverage",
"pitest": "mutation_testing",
"owasp-dependency-check": "security",
"openrewrite": "migrations",
}
DEP_CATEGORY_MAP = {
"assertj": "testing_libraries",
"kotest-assertions": "testing_libraries",
"kotest-runner": "testing_libraries",
"strikt": "testing_libraries",
"hamcrest": "testing_libraries",
"mockk": "testing_libraries",
"mockito": "testing_libraries",
"instancio": "testing_libraries",
"kotlin-faker": "testing_libraries",
"fixture-monkey": "testing_libraries",
"testcontainers": "testing_libraries",
"spring-modulith-test": "testing_libraries",
"archunit": "architecture",
"konsist": "architecture",
"pact": "contract_testing",
"spring-cloud-contract": "contract_testing",
"jqwik": "property_testing",
"rest-assured": "api_testing",
"jmh": "benchmarking",
"awaitility": "testing_libraries",
"wiremock": "testing_libraries",
}
# Config files that indicate a JVM tool when only config exists (no build reference)
CONFIG_ONLY_MAP = {
"detekt.yml": ("detekt", "static_analysis"),
"detekt-config.yml": ("detekt", "static_analysis"),
"config/detekt.yml": ("detekt", "static_analysis"),
"spotbugs-exclude.xml": ("spotbugs", "static_analysis"),
"sonar-project.properties": ("sonarqube", "static_analysis"),
}
def classify_jvm_tools(
plugins: List[dict],
deps: List[dict],
config: Dict[str, bool],
) -> Dict[str, List[dict]]:
"""Organize detected JVM tools into categories with status info."""
categories: Dict[str, List[dict]] = {
"static_analysis": [],
"testing_libraries": [],
"coverage": [],
"mutation_testing": [],
"architecture": [],
"contract_testing": [],
"property_testing": [],
"api_testing": [],
"benchmarking": [],
"security": [],
"migrations": [],
}
classified_names = set()
for detection in plugins:
cat = PLUGIN_CATEGORY_MAP.get(detection["name"])
if cat:
categories[cat].append(detection)
classified_names.add(detection["name"])
for detection in deps:
cat = DEP_CATEGORY_MAP.get(detection["name"])
if cat:
categories[cat].append(detection)
classified_names.add(detection["name"])
# Config-only detection: config file exists but no build-file reference
for config_file, (tool, cat) in CONFIG_ONLY_MAP.items():
if config.get(config_file, False) and tool not in classified_names:
categories[cat].append(make_detection(tool, "config-only", "config-file"))
classified_names.add(tool)
return categories
# ---------------------------------------------------------------------------
# Module discovery (for --recursive)
# ---------------------------------------------------------------------------
def discover_modules(root: Path) -> List[dict]:
"""Discover JVM modules in a monorepo and report tools per module."""
modules = []
build_files = find_build_files_recursive(root)
for bf in build_files:
module_dir = bf.parent
rel_path = str(module_dir.relative_to(root))
if rel_path == ".":
rel_path = "(root)"
build_tool = detect_build_tool(module_dir)
content = ""
try:
content = bf.read_text(encoding="utf-8")
except Exception:
pass
tool_names = []
all_patterns = {}
all_patterns.update(GRADLE_PLUGIN_PATTERNS)
all_patterns.update(MAVEN_PLUGIN_PATTERNS)
all_patterns.update(TEST_DEPENDENCY_PATTERNS)
for tool, patterns in all_patterns.items():
for pattern in patterns:
if re.search(pattern, content):
tool_names.append(tool)
break
modules.append(
{
"path": rel_path,
"build_tool": build_tool,
"tools": tool_names,
}
)
return modules
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def scan(root: Path, recursive: bool = False) -> dict:
"""Scan a JVM project and return structured results.
This is the main entry point when used as a module by the orchestrator.
Returns a dict with ecosystem, project, detected_tools, etc.
"""
build_tool = detect_build_tool(root)
if build_tool == "unknown":
return None # Not a JVM project
# Scan build files
if build_tool.startswith("gradle"):
plugins, deps, versions, tool_config = scan_gradle_builds(root, recursive=recursive)
elif build_tool == "maven":
plugins, deps, versions, tool_config = scan_maven_build(root, recursive=recursive)
else:
plugins, deps, versions, tool_config = [], [], {}, {}
# Scan version catalog (Gradle only)
if build_tool.startswith("gradle"):
cat_plugins, cat_deps, cat_versions = scan_version_catalog(root)
existing_plugin_names = {d["name"] for d in plugins}
existing_dep_names = {d["name"] for d in deps}
for p in cat_plugins:
if p["name"] not in existing_plugin_names:
plugins.append(p)
for d in cat_deps:
if d["name"] not in existing_dep_names:
deps.append(d)
for k, v in cat_versions.items():
if k not in versions:
versions[k] = v
# Scan JVM config files
config_files = scan_jvm_config_files(root)
# Analyze project structure
structure = analyze_project_structure(root)
# Classify tools into JVM categories
categories = classify_jvm_tools(plugins, deps, config_files)
has_version_catalog = (root / "gradle" / "libs.versions.toml").exists()
result = {
"ecosystem": "jvm",
"project": {
"build_tool": build_tool,
"spring_boot_version": versions.get("spring-boot"),
"language": structure["language"],
"java_version": versions.get("java"),
"kotlin_version": versions.get("kotlin"),
"has_version_catalog": has_version_catalog,
"test_file_count": structure["test_file_count"],
"main_file_count": structure["main_file_count"],
},
"detected_tools": categories,
"config_files": config_files,
"versions": versions,
"tool_config": tool_config,
}
if recursive:
result["modules"] = discover_modules(root)
return result
# ---------------------------------------------------------------------------
# Standalone CLI (backwards compatible with scan_tooling.py)
# ---------------------------------------------------------------------------
def main():
"""Main entry point for standalone usage."""
parser = argparse.ArgumentParser(
description="JVM Project Tooling Scanner — detects quality and testing tools"
)
parser.add_argument("project_root", help="Path to the project root directory")
parser.add_argument(
"--recursive",
action="store_true",
help="Recursively discover build files (for monorepos)",
)
args = parser.parse_args()
root = Path(args.project_root).resolve()
if not root.is_dir():
print(json.dumps({"error": f"Not a directory: {root}"}, indent=2))
sys.exit(1)
result = scan(root, recursive=args.recursive)
if result is None:
hint_files = []
for name in ["build.gradle.kts", "build.gradle", "pom.xml"]:
found = list(root.glob(f"*/{name}"))[:5]
hint_files.extend(str(f.relative_to(root)) for f in found)
output = {
"error": "no_build_file",
"message": f"No build file found at project root: {root}",
"hint": "Try --recursive flag or pass a subproject path directly",
"nearby_build_files": hint_files,
}
print(json.dumps(output, indent=2))
sys.exit(1)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Node.js / TypeScript Project Tooling Scanner
Scans package.json, tsconfig.json, and config files to detect configured
quality and testing tools. Returns JSON for cross-referencing against
research recommendations.
Uses only standard library (no external dependencies). Python 3.8+.
Usage (standalone):
python3 scan_node.py <project_root>
Usage (as module):
from scan_node import scan
result = scan(Path("/path/to/project"))
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
sys.path.insert(0, str(Path(__file__).resolve().parent))
from shared import SKIP_DIRS, make_detection, read_file_safe, read_json_safe
# ---------------------------------------------------------------------------
# Package detection patterns: tool name -> list of npm package names
# ---------------------------------------------------------------------------
# Static analysis
LINTER_PACKAGES = {
"eslint": ["eslint"],
"biome": ["@biomejs/biome"],
}
# Formatting
FORMATTER_PACKAGES = {
"prettier": ["prettier"],
"biome": ["@biomejs/biome"], # biome also does formatting
}
# Testing frameworks
TEST_PACKAGES = {
"vitest": ["vitest"],
"jest": ["jest", "ts-jest", "@jest/core"],
"playwright": ["@playwright/test"],
"cypress": ["cypress"],
"testing-library-react": ["@testing-library/react"],
"testing-library-vue": ["@testing-library/vue"],
"testing-library-dom": ["@testing-library/dom"],
}
# Coverage
COVERAGE_PACKAGES = {
"c8": ["c8"],
"istanbul-nyc": ["nyc"],
"vitest-coverage": ["@vitest/coverage-v8", "@vitest/coverage-istanbul"],
}
# Bundle analysis
BUNDLE_PACKAGES = {
"size-limit": ["size-limit", "@size-limit/preset-small-lib", "@size-limit/preset-app"],
"bundlemon": ["bundlemon"],
"next-bundle-analyzer": ["@next/bundle-analyzer"],
"webpack-bundle-analyzer": ["webpack-bundle-analyzer"],
}
# Security
SECURITY_PACKAGES = {
"snyk": ["snyk"],
}
# Build tools
BUILD_PACKAGES = {
"turbo": ["turbo"],
"nx": ["nx", "@nx/workspace"],
"tsup": ["tsup"],
"vite": ["vite"],
"esbuild": ["esbuild"],
"webpack": ["webpack"],
"rollup": ["rollup"],
}
# API testing
API_TEST_PACKAGES = {
"supertest": ["supertest"],
"msw": ["msw"],
"nock": ["nock"],
}
# Framework detection
FRAMEWORK_PACKAGES = {
"next": ["next"],
"react": ["react"],
"vue": ["vue"],
"angular": ["@angular/core"],
"svelte": ["svelte"],
"nuxt": ["nuxt"],
"remix": ["@remix-run/react"],
"astro": ["astro"],
}
# Config files for tool detection
NODE_CONFIG_FILES: Dict[str, str] = {
# ESLint
"eslint.config.mjs": "eslint",
"eslint.config.js": "eslint",
"eslint.config.cjs": "eslint",
"eslint.config.ts": "eslint",
".eslintrc.js": "eslint",
".eslintrc.cjs": "eslint",
".eslintrc.json": "eslint",
".eslintrc.yml": "eslint",
".eslintrc.yaml": "eslint",
# Prettier
".prettierrc": "prettier",
".prettierrc.json": "prettier",
".prettierrc.yml": "prettier",
".prettierrc.yaml": "prettier",
".prettierrc.js": "prettier",
".prettierrc.cjs": "prettier",
"prettier.config.js": "prettier",
"prettier.config.cjs": "prettier",
"prettier.config.mjs": "prettier",
# Biome
"biome.json": "biome",
"biome.jsonc": "biome",
# Tailwind CSS
"tailwind.config.js": "tailwindcss",
"tailwind.config.ts": "tailwindcss",
"tailwind.config.cjs": "tailwindcss",
"tailwind.config.mjs": "tailwindcss",
# Vitest
"vitest.config.ts": "vitest",
"vitest.config.js": "vitest",
"vitest.config.mts": "vitest",
"vitest.workspace.ts": "vitest",
# Jest
"jest.config.js": "jest",
"jest.config.ts": "jest",
"jest.config.cjs": "jest",
"jest.config.mjs": "jest",
# Playwright
"playwright.config.ts": "playwright",
"playwright.config.js": "playwright",
# Cypress
"cypress.config.ts": "cypress",
"cypress.config.js": "cypress",
"cypress.config.cjs": "cypress",
"cypress.config.mjs": "cypress",
}
# ---------------------------------------------------------------------------
# Package.json parsing
# ---------------------------------------------------------------------------
def get_all_deps(pkg: dict) -> Dict[str, str]:
"""Extract all dependencies from package.json."""
all_deps: Dict[str, str] = {}
for key in ("dependencies", "devDependencies", "peerDependencies"):
if key in pkg and isinstance(pkg[key], dict):
all_deps.update(pkg[key])
return all_deps
def detect_packages(
all_deps: Dict[str, str],
package_map: Dict[str, List[str]],
) -> List[Tuple[str, str]]:
"""Detect tools by checking for their npm packages.
Returns list of (tool_name, matched_package) tuples.
"""
found = []
seen = set()
for tool, packages in package_map.items():
for pkg in packages:
if pkg in all_deps and tool not in seen:
seen.add(tool)
found.append((tool, all_deps[pkg]))
break
return found
# ---------------------------------------------------------------------------
# TypeScript analysis
# ---------------------------------------------------------------------------
def analyze_tsconfig(root: Path) -> Dict[str, object]:
"""Analyze tsconfig.json for strict mode and other settings."""
result = {
"detected": False,
"strict": False,
"strict_flags": {},
"target": None,
"module": None,
}
tsconfig = read_json_safe(root / "tsconfig.json")
if tsconfig is None:
return result
result["detected"] = True
compiler_opts = tsconfig.get("compilerOptions", {})
result["strict"] = compiler_opts.get("strict", False)
result["target"] = compiler_opts.get("target")
result["module"] = compiler_opts.get("module")
# Track individual strict flags
strict_flags = {}
flag_names = [
"noImplicitAny", "strictNullChecks", "strictFunctionTypes",
"strictBindCallApply", "strictPropertyInitialization",
"noImplicitThis", "alwaysStrict",
"noUncheckedIndexedAccess", "exactOptionalPropertyTypes",
"noImplicitReturns", "noFallthroughCasesInSwitch",
"noUnusedLocals", "noUnusedParameters",
]
for flag in flag_names:
if flag in compiler_opts:
strict_flags[flag] = compiler_opts[flag]
result["strict_flags"] = strict_flags
return result
# ---------------------------------------------------------------------------
# ESLint config analysis
# ---------------------------------------------------------------------------
def detect_eslint_config_type(root: Path) -> Optional[str]:
"""Detect ESLint config type: 'flat' or 'legacy'."""
flat_configs = [
"eslint.config.mjs", "eslint.config.js", "eslint.config.cjs", "eslint.config.ts",
]
legacy_configs = [
".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yml", ".eslintrc.yaml",
]
for config in flat_configs:
if (root / config).exists():
return "flat"
for config in legacy_configs:
if (root / config).exists():
return "legacy"
return None
# ---------------------------------------------------------------------------
# Package manager detection
# ---------------------------------------------------------------------------
def detect_package_manager(root: Path) -> str:
"""Detect which package manager is used."""
if (root / "pnpm-lock.yaml").exists():
return "pnpm"
if (root / "yarn.lock").exists():
return "yarn"
if (root / "bun.lockb").exists() or (root / "bun.lock").exists():
return "bun"
if (root / "package-lock.json").exists():
return "npm"
# Check packageManager field in package.json
pkg = read_json_safe(root / "package.json")
if pkg and "packageManager" in pkg:
pm = pkg["packageManager"]
if pm.startswith("pnpm"):
return "pnpm"
if pm.startswith("yarn"):
return "yarn"
if pm.startswith("bun"):
return "bun"
return "npm" # default
# ---------------------------------------------------------------------------
# Monorepo tool detection
# ---------------------------------------------------------------------------
def detect_monorepo_tool(root: Path, all_deps: Dict[str, str]) -> Optional[str]:
"""Detect monorepo management tool."""
if (root / "turbo.json").exists() or "turbo" in all_deps:
return "turbo"
if (root / "nx.json").exists() or "nx" in all_deps or "@nx/workspace" in all_deps:
return "nx"
if (root / "lerna.json").exists():
return "lerna"
if (root / "pnpm-workspace.yaml").exists():
return "pnpm-workspaces"
# Check package.json workspaces
pkg = read_json_safe(root / "package.json")
if pkg and "workspaces" in pkg:
return "workspaces"
return None
# ---------------------------------------------------------------------------
# Framework detection
# ---------------------------------------------------------------------------
def detect_framework(all_deps: Dict[str, str]) -> Optional[str]:
"""Detect the primary framework."""
# Order matters: check more specific frameworks first
if "next" in all_deps:
return "next"
if "nuxt" in all_deps:
return "nuxt"
if "@remix-run/react" in all_deps:
return "remix"
if "astro" in all_deps:
return "astro"
if "@angular/core" in all_deps:
return "angular"
if "svelte" in all_deps:
return "svelte"
if "vue" in all_deps:
return "vue"
if "react" in all_deps:
return "react"
return None
# ---------------------------------------------------------------------------
# Node version detection
# ---------------------------------------------------------------------------
def detect_node_version(root: Path, pkg: dict) -> Optional[str]:
"""Detect the Node.js version requirement."""
# Check .nvmrc
nvmrc = read_file_safe(root / ".nvmrc")
if nvmrc:
return nvmrc.strip()
# Check .node-version
node_version = read_file_safe(root / ".node-version")
if node_version:
return node_version.strip()
# Check engines in package.json
engines = pkg.get("engines", {})
if "node" in engines:
return engines["node"]
return None
# ---------------------------------------------------------------------------
# File counting
# ---------------------------------------------------------------------------
def count_source_files(root: Path) -> Tuple[int, int]:
"""Count source and test files in a Node.js project."""
src_count = 0
test_count = 0
test_patterns = {"test", "tests", "__tests__", "spec", "specs", "__mocks__"}
test_suffixes = {".test.", ".spec.", ".e2e."}
for dirpath, dirs, files in os.walk(str(root), topdown=True):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for f in files:
if not any(f.endswith(ext) for ext in (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")):
continue
# Determine if test file
is_test = False
dir_parts = set(Path(dirpath).relative_to(root).parts)
if dir_parts & test_patterns:
is_test = True
elif any(suffix in f for suffix in test_suffixes):
is_test = True
if is_test:
test_count += 1
else:
src_count += 1
return src_count, test_count
# ---------------------------------------------------------------------------
# Tool classification
# ---------------------------------------------------------------------------
def classify_node_tools(
all_deps: Dict[str, str],
root: Path,
) -> Dict[str, List[dict]]:
"""Detect and classify all Node.js tools into categories."""
categories: Dict[str, List[dict]] = {
"static_analysis": [],
"formatting": [],
"testing": [],
"type_checking": [],
"coverage": [],
"bundle_analysis": [],
"security": [],
"build_tools": [],
"framework": [],
"api_testing": [],
}
seen_tools: Set[str] = set()
def _add(cat: str, name: str, version: Optional[str], source: str) -> None:
if name not in seen_tools:
seen_tools.add(name)
det = make_detection(name, "active", source)
if version:
det["version"] = version
categories[cat].append(det)
# Package-based detection
for tool, version in detect_packages(all_deps, LINTER_PACKAGES):
_add("static_analysis", tool, version, "package-json")
for tool, version in detect_packages(all_deps, FORMATTER_PACKAGES):
if tool not in seen_tools: # biome may already be in static_analysis
_add("formatting", tool, version, "package-json")
for tool, version in detect_packages(all_deps, TEST_PACKAGES):
_add("testing", tool, version, "package-json")
for tool, version in detect_packages(all_deps, COVERAGE_PACKAGES):
_add("coverage", tool, version, "package-json")
for tool, version in detect_packages(all_deps, BUNDLE_PACKAGES):
_add("bundle_analysis", tool, version, "package-json")
for tool, version in detect_packages(all_deps, SECURITY_PACKAGES):
_add("security", tool, version, "package-json")
for tool, version in detect_packages(all_deps, BUILD_PACKAGES):
_add("build_tools", tool, version, "package-json")
for tool, version in detect_packages(all_deps, API_TEST_PACKAGES):
_add("api_testing", tool, version, "package-json")
# Framework detection (informational)
for tool, version in detect_packages(all_deps, FRAMEWORK_PACKAGES):
_add("framework", tool, version, "package-json")
# Config-file-only detection (tool not in deps but config exists)
config_to_category = {
"eslint": "static_analysis",
"biome": "static_analysis",
"prettier": "formatting",
"vitest": "testing",
"jest": "testing",
"playwright": "testing",
"cypress": "testing",
}
for config_file, tool in NODE_CONFIG_FILES.items():
if (root / config_file).exists() and tool not in seen_tools:
cat = config_to_category.get(tool)
if cat:
_add(cat, tool, None, "config-file")
# TypeScript as type checking tool
if "typescript" in all_deps:
_add("type_checking", "typescript", all_deps["typescript"], "package-json")
return categories
# ---------------------------------------------------------------------------
# Tool config extraction
# ---------------------------------------------------------------------------
def extract_tool_config(root: Path, all_deps: Dict[str, str]) -> Dict[str, object]:
"""Extract specific tool configuration details."""
config: Dict[str, object] = {}
# ESLint config type
eslint_type = detect_eslint_config_type(root)
if eslint_type:
config["eslint_config_type"] = eslint_type
# Check for npm audit in scripts
pkg = read_json_safe(root / "package.json")
if pkg:
scripts = pkg.get("scripts", {})
has_audit = any("audit" in v for v in scripts.values()) if scripts else False
config["has_audit_script"] = has_audit
# Check for test script
config["has_test_script"] = "test" in scripts if scripts else False
# Check for lint script
config["has_lint_script"] = any(
k in scripts for k in ["lint", "lint:fix", "eslint", "check"]
) if scripts else False
# Tailwind CSS version (v4 needs special attention)
if "tailwindcss" in all_deps:
version = all_deps["tailwindcss"]
config["tailwindcss_version"] = version
if version.startswith("^4") or version.startswith("~4") or version.startswith("4"):
config["tailwindcss_v4"] = True
# Next.js specific
if "next" in all_deps:
config["next_version"] = all_deps["next"]
return config
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def is_node_project(root: Path) -> bool:
"""Check if directory contains a Node.js project."""
return (root / "package.json").exists()
def scan(root: Path) -> Optional[dict]:
"""Scan a Node.js/TypeScript project and return structured results.
Returns None if not a Node.js project.
"""
pkg = read_json_safe(root / "package.json")
if pkg is None:
return None
all_deps = get_all_deps(pkg)
# Detect project metadata
package_manager = detect_package_manager(root)
framework = detect_framework(all_deps)
monorepo_tool = detect_monorepo_tool(root, all_deps)
typescript_config = analyze_tsconfig(root)
node_version = detect_node_version(root, pkg)
src_count, test_count = count_source_files(root)
# Classify tools
categories = classify_node_tools(all_deps, root)
# Extract tool config
tool_config = extract_tool_config(root, all_deps)
# Config file existence map
config_files = {}
for file_path in NODE_CONFIG_FILES:
config_files[file_path] = (root / file_path).exists()
result = {
"ecosystem": "node",
"project": {
"package_manager": package_manager,
"framework": framework,
"typescript": typescript_config["detected"],
"typescript_strict": typescript_config["strict"],
"monorepo_tool": monorepo_tool,
"node_version": node_version,
"test_file_count": test_count,
"src_file_count": src_count,
},
"detected_tools": categories,
"config_files": config_files,
"versions": {
k: all_deps[k] for k in ["typescript", "next", "react", "vue"]
if k in all_deps
},
"tool_config": tool_config,
}
if typescript_config["detected"]:
result["typescript_analysis"] = typescript_config
return result
# ---------------------------------------------------------------------------
# Standalone CLI
# ---------------------------------------------------------------------------
def main():
"""Main entry point for standalone usage."""
parser = argparse.ArgumentParser(
description="Node.js/TypeScript Project Tooling Scanner"
)
parser.add_argument("project_root", help="Path to the project root directory")
args = parser.parse_args()
root = Path(args.project_root).resolve()
if not root.is_dir():
print(json.dumps({"error": f"Not a directory: {root}"}, indent=2))
sys.exit(1)
result = scan(root)
if result is None:
print(json.dumps({
"error": "no_package_json",
"message": f"No package.json found at: {root}",
}, indent=2))
sys.exit(1)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()