diff --git a/README.md b/README.md index 0840160a..9f22c70a 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ systems like Buck and Bazel. 9. [JVM targets](docs/JVM-TARGETS.md) — pure JVM platform plugin `tools.forma.jvm` (F-030) 10. [JVM sample application](docs/JVM-SAMPLE.md) — multi-module pure-JVM gold standard (`jvm-application/`, F-031) 11. [Bazel adapter design](docs/BAZEL-ADAPTER.md) — target types to rules, restriction graph to visibility (F-040 design) +12. [Bazel adapter spike](bazel-adapter/README.md) — generate/check BUILD from Forma model (F-041; JVM-first) Configuration made easy: @@ -208,6 +209,7 @@ Pure JVM target set (`tools.forma.jvm`, `JvmTargetRegistry`): **F-030** done (se JVM sample application + `binary` composition root: **F-031** done ([`docs/JVM-SAMPLE.md`](docs/JVM-SAMPLE.md), `jvm-application/`). JVM getting-started tutorial: **F-032** done ([`docs/JVM-GETTING-STARTED.md`](docs/JVM-GETTING-STARTED.md)). Bazel adapter mapping design (targets ↔ rules, visibility ↔ deps): **F-040** done (see [`docs/BAZEL-ADAPTER.md`](docs/BAZEL-ADAPTER.md)). +Bazel adapter spike (generate/check BUILD from portable model, JVM matrix): **F-041** done ([`bazel-adapter/`](bazel-adapter/), spike results in BAZEL-ADAPTER.md). Icons made by Freepik from www.flaticon.com diff --git a/TICKETS.md b/TICKETS.md index e64109ae..84843c5e 100644 --- a/TICKETS.md +++ b/TICKETS.md @@ -49,7 +49,7 @@ Update this file when picking or finishing work. Cron workers must pick the **hi | ID | Status | Title | Notes | |----|--------|-------|-------| | F-040 | done | Design Bazel adapter mapping (targets ↔ rules, visibility ↔ deps) | `docs/BAZEL-ADAPTER.md` + cross-links; commit 45674c7 | -| F-041 | todo | Spike: generate or check Bazel BUILD from forma declarations | | +| F-041 | done | Spike: generate or check Bazel BUILD from forma declarations | `bazel-adapter/` generate+check via core RestrictionGraph; examples + tests green | | F-042 | todo | Minimal Bazel sample using forma-core concepts | | ## Backlog (lower priority / historical GitHub) diff --git a/bazel-adapter/README.md b/bazel-adapter/README.md new file mode 100644 index 00000000..78613431 --- /dev/null +++ b/bazel-adapter/README.md @@ -0,0 +1,43 @@ +# bazel-adapter (F-041 spike) + +JVM-first adapter that converts a portable `FormaProjectModel` (targets + declared edges) into Bazel `BUILD.bazel` fragments (or validates them) while preserving the forma-core `RestrictionGraph` discipline. + +**Core invariant:** `impl` may never depend on another `impl`; composition happens only at `binary`. The adapter enforces this using `RestrictionGraph.isAllowed` from `tools.forma:core`. + +## What it contains + +- `tools.forma.bazel.model.*` — portable snapshot types (no Gradle APIs) +- `JvmBazelAdapter` implementing `FormaToBazel.generate()` + `check()` +- Hand-built fixture from `jvm-application/` (8 targets) +- Unit tests + committed example BUILD files under `examples/` + +## Running + +Requires JDK 17+ (Temurin recommended). + +```bash +# From repo root +source scripts/env-mac.sh + +cd bazel-adapter +./gradlew test +./gradlew runSample # prints generated BUILD text + check report +``` + +You do **not** need Bazel installed for the spike. + +## Limitations (spike) + +- Only JVM 6-type matrix (`jvm.api` ... `jvm.binary`) +- Project edges only (no external catalog GAV translation) +- Visibility is computed from declared consumers in the model + graph (hybrid; good enough) +- No full BUILD parser — check validates the *model* edges (and lightly inspects generated text) +- No `kt_jvm_test` emission yet (testDependencies carried in model but ignored for rule emission) +- No content rules (pure JVM has none) + +## Golden / committed artifacts + +- `examples/jvm-application-build/` — representative generated BUILD.bazel files +- Tests assert key labels, absence of illegal edges, round-trip cleanliness + +See `docs/BAZEL-ADAPTER.md` for the full mapping design and F-041 acceptance criteria. diff --git a/bazel-adapter/build.gradle.kts b/bazel-adapter/build.gradle.kts new file mode 100644 index 00000000..6ce2f69b --- /dev/null +++ b/bazel-adapter/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + kotlin("jvm") +} + +// Co-version with forma; for spike we do not publish the adapter. +group = "tools.forma.experimental" +version = "0.1.3-spike" + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} + +kotlin { + jvmToolchain(17) +} + +repositories { + mavenCentral() +} + +dependencies { + // ONLY production dependency allowed per F-041 / BAZEL-ADAPTER design: + // adapter depends on core; core has ZERO Bazel / Gradle-Project / AGP knowledge. + implementation("tools.forma:core:0.1.3") + + // Test only + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() +} + +// Convenience: allow running a small main to dump sample output if added later +tasks.register("runSample") { + group = "application" + description = "Run a driver that prints generated BUILD for the jvm-application fixture" + mainClass.set("tools.forma.bazel.BazelAdapterSampleKt") + classpath = sourceSets["main"].runtimeClasspath +} diff --git a/bazel-adapter/examples/jvm-application-build/binary/BUILD.bazel b/bazel-adapter/examples/jvm-application-build/binary/BUILD.bazel new file mode 100644 index 00000000..aa9a45f3 --- /dev/null +++ b/bazel-adapter/examples/jvm-application-build/binary/BUILD.bazel @@ -0,0 +1,22 @@ +# GENERATED by JvmBazelAdapter (F-041) — do not edit by hand for the spike. +# gradlePath=:binary type=jvm.binary + +kt_jvm_binary( + name = "binary", + srcs = glob(["src/main/kotlin/**/*.kt"]), + deps = [ + "//common/library:library", + "//common/util:util", + "//feature/calculator/api:api", + "//feature/calculator/impl:impl", + "//feature/greeter/api:api", + "//feature/greeter/impl:impl", + ], + visibility = [ + "//visibility:public", + ], + main_class = "tools.forma.jvm.sample.binary.MainKt", + tags = [ + "forma:type=jvm.binary", + ], +) diff --git a/bazel-adapter/examples/jvm-application-build/feature/greeter/api/BUILD.bazel b/bazel-adapter/examples/jvm-application-build/feature/greeter/api/BUILD.bazel new file mode 100644 index 00000000..d436a040 --- /dev/null +++ b/bazel-adapter/examples/jvm-application-build/feature/greeter/api/BUILD.bazel @@ -0,0 +1,14 @@ +# GENERATED by JvmBazelAdapter (F-041) — do not edit by hand for the spike. +# gradlePath=:feature:greeter:api type=jvm.api + +kt_jvm_library( + name = "api", + srcs = glob(["src/main/kotlin/**/*.kt"]), + visibility = [ + "//binary:__pkg__", + "//feature/greeter/impl:__pkg__", + ], + tags = [ + "forma:type=jvm.api", + ], +) diff --git a/bazel-adapter/examples/jvm-application-build/feature/greeter/impl/BUILD.bazel b/bazel-adapter/examples/jvm-application-build/feature/greeter/impl/BUILD.bazel new file mode 100644 index 00000000..4e654d8e --- /dev/null +++ b/bazel-adapter/examples/jvm-application-build/feature/greeter/impl/BUILD.bazel @@ -0,0 +1,18 @@ +# GENERATED by JvmBazelAdapter (F-041) — do not edit by hand for the spike. +# gradlePath=:feature:greeter:impl type=jvm.impl + +kt_jvm_library( + name = "impl", + srcs = glob(["src/main/kotlin/**/*.kt"]), + deps = [ + "//common/library:library", + "//common/util:util", + "//feature/greeter/api:api", + ], + visibility = [ + "//binary:__pkg__", + ], + tags = [ + "forma:type=jvm.impl", + ], +) diff --git a/bazel-adapter/gradle/wrapper/gradle-wrapper.jar b/bazel-adapter/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..7f93135c Binary files /dev/null and b/bazel-adapter/gradle/wrapper/gradle-wrapper.jar differ diff --git a/bazel-adapter/gradle/wrapper/gradle-wrapper.properties b/bazel-adapter/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..ac72c34e --- /dev/null +++ b/bazel-adapter/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/bazel-adapter/gradlew b/bazel-adapter/gradlew new file mode 100755 index 00000000..0adc8e1a --- /dev/null +++ b/bazel-adapter/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/bazel-adapter/gradlew.bat b/bazel-adapter/gradlew.bat new file mode 100755 index 00000000..93e3f59f --- /dev/null +++ b/bazel-adapter/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/bazel-adapter/settings.gradle.kts b/bazel-adapter/settings.gradle.kts new file mode 100644 index 00000000..4af01026 --- /dev/null +++ b/bazel-adapter/settings.gradle.kts @@ -0,0 +1,22 @@ +rootProject.name = "bazel-adapter" + +// F-041 spike: pure Kotlin consumer of forma-core via composite. +// No AGP, no plugins platform. Only tools.forma:core for TargetType + RestrictionGraph + registry. + +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } + // Use same Kotlin as the main build (embedded in Gradle 8.3 env) + plugins { + id("org.jetbrains.kotlin.jvm") version "1.9.0" + } +} + +includeBuild("../plugins") { + dependencySubstitution { + // Resolve tools.forma:core to the local :core project (avoids mavenLocal / version skew during dev) + substitute(module("tools.forma:core")).using(project(":core")) + } +} diff --git a/bazel-adapter/src/main/kotlin/tools/forma/bazel/BazelAdapterSample.kt b/bazel-adapter/src/main/kotlin/tools/forma/bazel/BazelAdapterSample.kt new file mode 100644 index 00000000..5a50400b --- /dev/null +++ b/bazel-adapter/src/main/kotlin/tools/forma/bazel/BazelAdapterSample.kt @@ -0,0 +1,34 @@ +package tools.forma.bazel + +import tools.forma.bazel.adapter.JvmBazelAdapter +import tools.forma.bazel.sample.JvmApplicationFixture + +/** + * Tiny driver for humans: + * cd bazel-adapter + * ../gradlew runSample (or ./gradlew after wrapper) + * + * Prints the generated BUILD.bazel fragments for the jvm-application fixture. + */ +fun main() { + val adapter = JvmBazelAdapter() + val model = JvmApplicationFixture.model + val builds = adapter.generate(model) + + println("=== F-041 Bazel adapter sample generate for jvm-application fixture ===") + println("Workspace: ${model.workspaceName}") + println("Targets: ${model.targets.size}") + println() + + builds.toSortedMap().forEach { (pkg, content) -> + println("----- $pkg/BUILD.bazel -----") + println(content) + println() + } + + val report = adapter.check(model) + println("=== check(fixture) ===") + println("violations=${report.violations.size} warnings=${report.warnings.size}") + report.violations.forEach { println("VIOL: $it") } + report.warnings.forEach { println("WARN: $it") } +} diff --git a/bazel-adapter/src/main/kotlin/tools/forma/bazel/adapter/FormaToBazel.kt b/bazel-adapter/src/main/kotlin/tools/forma/bazel/adapter/FormaToBazel.kt new file mode 100644 index 00000000..df53fc5d --- /dev/null +++ b/bazel-adapter/src/main/kotlin/tools/forma/bazel/adapter/FormaToBazel.kt @@ -0,0 +1,18 @@ +package tools.forma.bazel.adapter + +import tools.forma.bazel.model.FormaProjectModel + +/** + * Core adapter surface (F-041). + * Pure; no Bazel runtime dep; consumes forma-core types only for graph checks. + */ +interface FormaToBazel { + fun generate(model: FormaProjectModel): Map + + fun check(model: FormaProjectModel, existingBuilds: Map = emptyMap()): CheckReport +} + +data class CheckReport( + val violations: List, + val warnings: List, +) diff --git a/bazel-adapter/src/main/kotlin/tools/forma/bazel/adapter/JvmBazelAdapter.kt b/bazel-adapter/src/main/kotlin/tools/forma/bazel/adapter/JvmBazelAdapter.kt new file mode 100644 index 00000000..22e118d4 --- /dev/null +++ b/bazel-adapter/src/main/kotlin/tools/forma/bazel/adapter/JvmBazelAdapter.kt @@ -0,0 +1,222 @@ +package tools.forma.bazel.adapter + +import tools.forma.bazel.model.DepRef +import tools.forma.bazel.model.FormaProjectModel +import tools.forma.bazel.model.TargetSnapshot +import tools.forma.core.restriction.RestrictionGraph +import tools.forma.core.target.DefaultTargetRegistry +import tools.forma.core.target.SimpleTargetType +import tools.forma.core.target.TargetRegistry +import tools.forma.core.target.TargetType + +/** + * JVM-first Bazel adapter implementation (F-041 spike). + * + * - Re-registers the exact six JVM types + matrix from JvmTargetRegistry using ONLY core APIs. + * (See JvmTargetRegistry.kt and docs/JVM-TARGETS.md; keep in sync on matrix changes.) + * - Does NOT depend on :jvm plugin module (avoids Kotlin GP / AGP transitive). + * - Uses RestrictionGraph.isAllowed to filter/generate legal deps and to drive check(). + * - Never emits impl → impl (or other illegal) edges. + * - Labels follow §3 conventions. + * + * Visibility policy (hybrid, sufficient for spike): + * - jvm.binary → //visibility:public + * - Others → narrow list of //consumer-pkg:__pkg__ for actual declared consumers in the model + * (computed via reverse lookup + graph allows). + * - api targets also grant their direct impl + the binary. + */ +class JvmBazelAdapter : FormaToBazel { + + // --- JVM type registry recreated from core only (duplicate ids/suffixes; comment is source link) --- + // Declare type map FIRST so init order is safe when registry registration runs. + private val typeById: Map = buildMap { + // The six authoritative JVM ids (must match plugins/jvm/.../JvmTargetTypes.kt) + put("jvm.api", type("jvm.api", "api")) + put("jvm.impl", type("jvm.impl", "impl")) + put("jvm.library", type("jvm.library", "library")) + put("jvm.util", type("jvm.util", "util")) + put("jvm.test-util", type("jvm.test-util", "test-util")) + put("jvm.binary", type("jvm.binary", "binary")) + } + + private fun type(id: String, suffix: String): TargetType = SimpleTargetType(id, suffix) + + private val registry: TargetRegistry = DefaultTargetRegistry().also { reg -> + registerJvmMatrix(reg) + } + private val graph: RestrictionGraph = registry.restrictionGraph() + + /** + * Exact matrix copy (implementation edges). Source of truth comment: + * plugins/jvm/src/main/java/tools/forma/jvm/target/JvmTargetRegistry.kt : registerJvmDefaults + */ + private fun registerJvmMatrix(reg: TargetRegistry) { + val t = typeById + fun regType(id: String, allowed: Set) { + val tt = t.getValue(id) + val allowedTypes = allowed.map { t.getValue(it) }.toSet() + reg.register( + tools.forma.core.target.TargetRegistration( + type = tt, + allowedDependencies = allowedTypes + ) + ) + } + + // api: contracts only + regType("jvm.api", setOf("jvm.api", "jvm.library")) + // impl: api + library + util + test-util ; NO impl + regType("jvm.impl", setOf("jvm.api", "jvm.library", "jvm.util", "jvm.test-util")) + // library + regType("jvm.library", setOf("jvm.util", "jvm.test-util")) + // util + regType("jvm.util", setOf("jvm.util", "jvm.library")) + // test-util + regType("jvm.test-util", setOf("jvm.test-util", "jvm.util", "jvm.library")) + // binary: composition root + regType("jvm.binary", setOf("jvm.api", "jvm.impl", "jvm.library", "jvm.util", "jvm.test-util")) + } + + // --- label / path logic per BAZEL-ADAPTER §3 --- + + data class BazelCoords(val packageDir: String, val targetName: String) { + val label: String get() = "//$packageDir:$targetName" + val pkgVisibility: String get() = "//$packageDir:__pkg__" + } + + fun toBazelCoords(gradlePath: String): BazelCoords { + val cleaned = gradlePath.trim().removePrefix(":").ifBlank { gradlePath } + val segments = cleaned.split(':').filter { it.isNotBlank() } + val packageDir = segments.joinToString("/") + val targetName = segments.lastOrNull() ?: "root" + return BazelCoords(packageDir, targetName) + } + + private fun toLabel(gradlePath: String): String = toBazelCoords(gradlePath).label + + // --- generate --- + + override fun generate(model: FormaProjectModel): Map { + val typeOf = model.targets.associate { it.gradlePath to (typeById[it.typeId] ?: error("Unknown typeId ${it.typeId}")) } + + // Build reverse consumers for visibility (only actual declared edges that are legal) + val consumersOf: MutableMap> = mutableMapOf() // producerPath -> set of consumerPaths + for (consumer in model.targets) { + val cType = typeOf[consumer.gradlePath] ?: continue + for (dep in consumer.dependencies) { + val producerPath = dep.path + val pType = typeOf[producerPath] + if (pType != null && graph.isAllowed(cType, pType)) { + consumersOf.getOrPut(producerPath) { mutableSetOf() }.add(consumer.gradlePath) + } + } + } + + val out = mutableMapOf() + + for (target in model.targets) { + val coords = toBazelCoords(target.gradlePath) + val tType = typeOf[target.gradlePath] ?: error("Missing type for ${target.gradlePath}") + val isBinary = target.typeId == "jvm.binary" + + val rule = if (isBinary) "kt_jvm_binary" else "kt_jvm_library" + + val allowedDeps = target.dependencies.filter { d -> + val pType = typeOf[d.path] + pType != null && graph.isAllowed(tType, pType) + } + + val depLabels = allowedDeps.map { d -> toLabel(d.path) }.sorted() + + val visibility = if (isBinary) { + listOf("//visibility:public") + } else { + val cons = consumersOf[target.gradlePath] ?: emptySet() + if (cons.isEmpty()) { + // No declared consumer in model → keep narrow (empty list means default private in Bazel) + emptyList() + } else { + cons.map { c -> toBazelCoords(c).pkgVisibility }.sorted() + } + } + + // For api, also ensure its impl consumer(s) + binary are present if model declares them + // (the reverse computation above already includes them when declared). + + val tags = listOf("forma:type=${target.typeId}") + + val mainClass = target.metadata["mainClass"] + + val content = buildString { + appendLine("# GENERATED by JvmBazelAdapter (F-041) — do not edit by hand for the spike.") + appendLine("# gradlePath=${target.gradlePath} type=${target.typeId}") + appendLine() + appendLine("$rule(") + appendLine(" name = \"${coords.targetName}\",") + appendLine(" srcs = glob([\"src/main/kotlin/**/*.kt\"]),") + + if (depLabels.isNotEmpty()) { + appendLine(" deps = [") + depLabels.forEach { appendLine(" \"$it\"," ) } + appendLine(" ],") + } + + if (visibility.isNotEmpty()) { + appendLine(" visibility = [") + visibility.forEach { appendLine(" \"$it\"," ) } + appendLine(" ],") + } + + if (mainClass != null) { + appendLine(" main_class = \"$mainClass\",") + } + + appendLine(" tags = [") + tags.forEach { appendLine(" \"$it\"," ) } + appendLine(" ],") + appendLine(")") + } + + out[coords.packageDir] = content.trimEnd() + "\n" + } + + return out + } + + // --- check --- + + override fun check(model: FormaProjectModel, existingBuilds: Map): CheckReport { + val violations = mutableListOf() + val warnings = mutableListOf() + + val typeOf = model.targets.associate { it.gradlePath to (typeById[it.typeId] ?: error("Unknown typeId ${it.typeId} for check")) } + + for (consumer in model.targets) { + val cType = typeOf[consumer.gradlePath] ?: continue + for (dep in consumer.dependencies) { + val pPath = dep.path + val pType = typeOf[pPath] + if (pType == null) { + warnings += "Unknown dep target '$pPath' referenced by ${consumer.gradlePath}" + continue + } + if (!graph.isAllowed(cType, pType)) { + violations += "Illegal dependency: ${consumer.typeId} (${consumer.gradlePath}) → ${pType.id} ($pPath)" + } + } + // test deps not validated strictly in spike (future EdgeKind.TEST) + } + + // Optional: very light sanity on existingBuilds if provided (look for forma:type tags + obvious bad strings) + for ((pkg, buildText) in existingBuilds) { + if ("impl" in pkg && "jvm.impl" in buildText) { + // naive: look for other impl labels in deps + if (Regex("""//[^:]+/impl:impl""").containsMatchIn(buildText)) { + warnings += "Possible cross-impl dep string found in generated $pkg/BUILD" + } + } + } + + return CheckReport(violations = violations.sorted(), warnings = warnings.sorted()) + } +} diff --git a/bazel-adapter/src/main/kotlin/tools/forma/bazel/model/FormaProjectModel.kt b/bazel-adapter/src/main/kotlin/tools/forma/bazel/model/FormaProjectModel.kt new file mode 100644 index 00000000..96ff5206 --- /dev/null +++ b/bazel-adapter/src/main/kotlin/tools/forma/bazel/model/FormaProjectModel.kt @@ -0,0 +1,45 @@ +package tools.forma.bazel.model + +/** + * Portable, Gradle-free snapshot of a Forma project for the Bazel adapter. + * This is the input contract for generate/check. No Gradle Project, no AGP. + * + * See docs/BAZEL-ADAPTER.md §6 for rationale. + */ +data class FormaProjectModel( + val workspaceName: String, + val targets: List, + // For spike we reconstruct / re-register via JvmBazelKit rather than snapshotting the full graph. + // The field is kept for future extensibility (e.g. full graph export or Android matrix). + val restrictionGraphSnapshot: RestrictionGraphSnapshot? = null, +) + +/** + * Minimal snapshot of one target declaration (project edge only for v1). + * gradlePath uses Gradle colon form (e.g. ":feature:greeter:impl") for easy round-tripping. + * The adapter normalizes to Bazel package + label. + */ +data class TargetSnapshot( + val gradlePath: String, // ":feature:greeter:impl" or ":binary" + val typeId: String, // "jvm.impl", "jvm.binary", ... + val packageName: String? = null, // informational (source root); not used for labels + val dependencies: List = emptyList(), + val testDependencies: List = emptyList(), + val metadata: Map = emptyMap(), // "mainClass" for binary +) + +/** Reference to another target by its Gradle path (normalized to labels at generate time). */ +data class DepRef(val path: String) + +/** + * Placeholder for richer graph export. In F-041 spike the adapter re-creates the JVM + * restriction matrix from core types (see JvmBazelKit) rather than relying on this. + */ +data class RestrictionGraphSnapshot( + val rules: List = emptyList(), +) + +data class RuleSnapshot( + val consumerTypeId: String, + val allowedDepTypeIds: List, +) diff --git a/bazel-adapter/src/main/kotlin/tools/forma/bazel/sample/JvmApplicationFixture.kt b/bazel-adapter/src/main/kotlin/tools/forma/bazel/sample/JvmApplicationFixture.kt new file mode 100644 index 00000000..47f290da --- /dev/null +++ b/bazel-adapter/src/main/kotlin/tools/forma/bazel/sample/JvmApplicationFixture.kt @@ -0,0 +1,104 @@ +package tools.forma.bazel.sample + +import tools.forma.bazel.model.DepRef +import tools.forma.bazel.model.FormaProjectModel +import tools.forma.bazel.model.TargetSnapshot + +/** + * Hand-constructed model matching jvm-application/ layout and declared edges. + * This is the source of truth for F-041 spike tests and golden output. + * + * Gradle paths and typeIds must match the real modules + JvmTargetTypes. + * See jvm-application/{binary,feature,common}/**/build.gradle.kts + */ +object JvmApplicationFixture { + + val model: FormaProjectModel = FormaProjectModel( + workspaceName = "jvm-application-spike", + targets = listOf( + // binary (composition root) + TargetSnapshot( + gradlePath = ":binary", + typeId = "jvm.binary", + packageName = "tools.forma.jvm.sample.binary", + dependencies = listOf( + DepRef(":feature:greeter:api"), + DepRef(":feature:greeter:impl"), + DepRef(":feature:calculator:api"), + DepRef(":feature:calculator:impl"), + DepRef(":common:util"), + DepRef(":common:library"), + ), + metadata = mapOf("mainClass" to "tools.forma.jvm.sample.binary.MainKt") + ), + + // greeter feature + TargetSnapshot( + gradlePath = ":feature:greeter:api", + typeId = "jvm.api", + packageName = "tools.forma.jvm.sample.feature.greeter.api", + dependencies = emptyList() + ), + TargetSnapshot( + gradlePath = ":feature:greeter:impl", + typeId = "jvm.impl", + packageName = "tools.forma.jvm.sample.feature.greeter.impl", + dependencies = listOf( + DepRef(":feature:greeter:api"), + DepRef(":common:util"), + DepRef(":common:library"), + ), + testDependencies = listOf(DepRef(":common:test-util")) + ), + + // calculator feature + TargetSnapshot( + gradlePath = ":feature:calculator:api", + typeId = "jvm.api", + packageName = "tools.forma.jvm.sample.feature.calculator.api", + dependencies = emptyList() + ), + TargetSnapshot( + gradlePath = ":feature:calculator:impl", + typeId = "jvm.impl", + packageName = "tools.forma.jvm.sample.feature.calculator.impl", + dependencies = listOf( + DepRef(":feature:calculator:api"), + DepRef(":common:library"), + DepRef(":common:util"), + ) + ), + + // common shared + TargetSnapshot( + gradlePath = ":common:library", + typeId = "jvm.library", + packageName = "tools.forma.jvm.sample.common.library", + dependencies = emptyList() + ), + TargetSnapshot( + gradlePath = ":common:util", + typeId = "jvm.util", + packageName = "tools.forma.jvm.sample.common.util", + dependencies = emptyList() + ), + TargetSnapshot( + gradlePath = ":common:test-util", + typeId = "jvm.test-util", + packageName = "tools.forma.jvm.sample.common.testutil", + dependencies = emptyList() + ), + ) + ) + + /** A deliberately illegal variant for check() tests: greeter impl also depends on calculator impl. */ + val modelWithIllegalImplToImpl: FormaProjectModel = model.copy( + targets = model.targets.map { t -> + if (t.gradlePath == ":feature:greeter:impl") { + t.copy( + dependencies = t.dependencies + DepRef(":feature:calculator:impl") + ) + } else t + } + ) +} diff --git a/bazel-adapter/src/test/kotlin/tools/forma/bazel/JvmBazelAdapterTest.kt b/bazel-adapter/src/test/kotlin/tools/forma/bazel/JvmBazelAdapterTest.kt new file mode 100644 index 00000000..7b91a7f9 --- /dev/null +++ b/bazel-adapter/src/test/kotlin/tools/forma/bazel/JvmBazelAdapterTest.kt @@ -0,0 +1,122 @@ +package tools.forma.bazel + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import tools.forma.bazel.adapter.JvmBazelAdapter +import tools.forma.bazel.sample.JvmApplicationFixture + +/** + * F-041 acceptance tests for the JVM Bazel adapter spike. + * - Labels, rule selection, tags, main_class + * - Matrix respected: no impl→impl emitted + * - check() reports violations for illegal graph, clean for legal + * - round-trip generate then check is clean + */ +class JvmBazelAdapterTest { + + private val adapter = JvmBazelAdapter() + private val fixture = JvmApplicationFixture.model + private val illegal = JvmApplicationFixture.modelWithIllegalImplToImpl + + @Test + fun `generate produces entries for every target in fixture`() { + val builds = adapter.generate(fixture) + // 1 binary + 2 api + 2 impl + 3 common = 8 + assertEquals(8, builds.size) + assertTrue(builds.keys.contains("binary")) + assertTrue(builds.keys.contains("feature/greeter/api")) + assertTrue(builds.keys.contains("feature/greeter/impl")) + assertTrue(builds.keys.contains("common/test-util")) + } + + @Test + fun `labels follow documented convention (Gradle colon path to Bazel label)`() { + val builds = adapter.generate(fixture) + // api + val apiContent = builds.getValue("feature/greeter/api") + assertTrue(apiContent.contains("name = \"api\"")) + assertTrue("//feature/greeter/api:api" in apiContent || "name = \"api\"" in apiContent) + + // impl + val implContent = builds.getValue("feature/greeter/impl") + assertTrue(implContent.contains("name = \"impl\"")) + + // binary + val binContent = builds.getValue("binary") + assertTrue(binContent.contains("name = \"binary\"")) + } + + @Test + fun `binary uses kt_jvm_binary and carries main_class from metadata`() { + val builds = adapter.generate(fixture) + val bin = builds.getValue("binary") + assertTrue(bin.contains("kt_jvm_binary(")) + assertTrue(bin.contains("main_class = \"tools.forma.jvm.sample.binary.MainKt\"")) + assertTrue(bin.contains("forma:type=jvm.binary")) + } + + @Test + fun `non-binary use kt_jvm_library and carry forma type tag`() { + val builds = adapter.generate(fixture) + val apiB = builds.getValue("feature/greeter/api") + assertTrue(apiB.contains("kt_jvm_library(")) + assertTrue(apiB.contains("forma:type=jvm.api")) + + val implB = builds.getValue("feature/greeter/impl") + assertTrue(implB.contains("forma:type=jvm.impl")) + } + + @Test + fun `greeter impl does not depend on calculator impl (matrix + fixture)`() { + val builds = adapter.generate(fixture) + val implB = builds.getValue("feature/greeter/impl") + // Must not contain a dep on the other impl + assertFalse("//feature/calculator/impl:impl" in implB) + // But must contain its legal deps + assertTrue("//feature/greeter/api:api" in implB) + assertTrue("//common/util:util" in implB) + assertTrue("//common/library:library" in implB) + } + + @Test + fun `binary depends on both impls and shared (composition root)`() { + val builds = adapter.generate(fixture) + val bin = builds.getValue("binary") + assertTrue("//feature/greeter/impl:impl" in bin) + assertTrue("//feature/calculator/impl:impl" in bin) + assertTrue("//common/library:library" in bin) + } + + @Test + fun `check illegal impl to impl produces violations`() { + val report = adapter.check(illegal) + assertTrue(report.violations.isNotEmpty(), "expected at least one violation for impl→impl") + val hasImplImpl = report.violations.any { "jvm.impl" in it && "jvm.impl" in it } + assertTrue(hasImplImpl, "violations should mention impl → impl: ${report.violations}") + } + + @Test + fun `check legal fixture produces no violations (only possible warnings)`() { + val report = adapter.check(fixture) + assertTrue(report.violations.isEmpty(), "legal fixture must have zero violations, got: ${report.violations}") + } + + @Test + fun `generate then check roundtrip is clean for legal graph`() { + val builds = adapter.generate(fixture) + val report = adapter.check(fixture, builds) + assertTrue(report.violations.isEmpty()) + } + + @Test + fun `generated content contains srcs glob and tags for all`() { + val builds = adapter.generate(fixture) + for ((_, c) in builds) { + assertTrue("srcs = glob([\"src/main/kotlin/**/*.kt\"])" in c) + assertTrue("tags = [" in c) + assertTrue("forma:type=" in c) + } + } +} diff --git a/docs/BAZEL-ADAPTER.md b/docs/BAZEL-ADAPTER.md index 360d4759..86fa8831 100644 --- a/docs/BAZEL-ADAPTER.md +++ b/docs/BAZEL-ADAPTER.md @@ -1,6 +1,6 @@ # Bazel Adapter Design (F-040) -**Status:** design accepted (F-040). Implementation in F-041 (spike) and F-042 (minimal sample). +**Status:** design accepted (F-040). **F-041 spike landed** (see [Spike results](#spike-results-f-041) below). F-042 (minimal sample) remains. This document defines how forma-core concepts (TargetType, RestrictionGraph, TargetRegistry, validators) map onto Bazel so that later work can generate or validate BUILD files while preserving the same dependency discipline that Gradle platforms enforce today. @@ -406,4 +406,44 @@ Acceptance boundaries for F-042: the sample is small, self-contained, exercises --- -**Next after F-040:** F-041 (spike). The design is intentionally narrow so the spike can stay small and produce concrete artifacts (model + emitted BUILD fragments or check reports) that F-042 can consume. \ No newline at end of file +## Spike results (F-041) + +**Landed on branch `forma/F-041-bazel-spike`.** Top-level pure-Kotlin module `bazel-adapter/` depends only on `tools.forma:core` (composite `includeBuild("../plugins")` + dependency substitution). Core remains Bazel-free. + +### Delivered +| Piece | Location | +|-------|----------| +| Portable model | `tools.forma.bazel.model.FormaProjectModel` + `TargetSnapshot` / `DepRef` | +| Adapter API | `FormaToBazel` + `JvmBazelAdapter` (`generate` + `check`) | +| JVM matrix (core-only) | Same six types + allow-lists as `registerJvmDefaults()` (duplicated in adapter; comment links `JvmTargetRegistry`) | +| Fixture | `JvmApplicationFixture` — 8 targets mirroring `jvm-application/` edges | +| Unit tests | `JvmBazelAdapterTest` — labels, tags, rule kinds, `impl ↛ impl`, illegal check, round-trip | +| Example BUILD output | `bazel-adapter/examples/jvm-application-build/{binary,feature/greeter/{api,impl}}/BUILD.bazel` | +| Driver | `./gradlew runSample` prints all packages + check summary | +| Module README | `bazel-adapter/README.md` | + +### Commands verified (OpenJDK 17 + `scripts/env-mac.sh`) +```bash +cd bazel-adapter && ./gradlew test # BUILD SUCCESSFUL +cd bazel-adapter && ./gradlew runSample # violations=0 warnings=0 +cd plugins && ./gradlew :core:test # BUILD SUCCESSFUL (unchanged) +``` +No Bazel binary required for the spike. + +### Decisions made during spike +- **Composite substitution** for `tools.forma:core` → local `:core` (no mavenLocal required for day-to-day). +- **Matrix duplication** in adapter vs depending on `:jvm` — chosen to keep classpath free of Kotlin Gradle Plugin / platform facades. +- **Hand fixture** instead of Gradle export task (export deferred; fixture is enough for F-041 + F-042 seeds). +- **Visibility:** reverse edges from declared legal consumers + `//visibility:public` for binary; check mode is the fidelity backstop. +- **Check** validates model edges via `RestrictionGraph.isAllowed`; optional light scan of generated text for cross-impl labels. No full Starlark parser. +- **Illegal edges:** generator filters them out of `deps`; `check` reports them as violations (e.g. greeter-impl → calculator-impl). + +### Limitations (unchanged / deferred) +- JVM kit only; no Android types; no external GAV → `maven_install`. +- No live Gradle model export task. +- No full Bazel workspace / `bazel build` (F-042). +- `testDependencies` carried in model but not emitted as `kt_jvm_test` yet. +- Example tree commits a subset of packages (binary + greeter); full set available via `runSample`. + +### Next +**F-042** — minimal Bazel sample workspace using generated or hand-authored BUILD files that exercise the same matrix (`bazel run //binary:binary`). \ No newline at end of file diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index ed6ea5c1..3d3ddaef 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -2,6 +2,31 @@ Newest entries first. +## 2026-07-14 — F-041 Spike: generate/check Bazel BUILD from forma model + +- **Ticket:** F-041 → `done` +- **Branch:** `forma/F-041-bazel-spike` (from `origin/v2`) +- **Skills/modes:** Grok Build `--mode full` (design/plan completed; implement hit max-turns with tree on disk); Hermes finished verify, docs, bookkeeping, PR +- **Deliverable (`bazel-adapter/` top-level):** + - Pure Kotlin module; production dep **only** `tools.forma:core` via composite + dependency substitution + - `FormaProjectModel` / `TargetSnapshot` / `DepRef` portable snapshot (no Gradle Project APIs) + - `FormaToBazel` + `JvmBazelAdapter`: `generate()` → package dir → `BUILD.bazel` text; `check()` → violations via core `RestrictionGraph.isAllowed` + - JVM 6-type matrix re-registered with core `SimpleTargetType` + `DefaultTargetRegistry` (kept in sync with `JvmTargetRegistry` by comment) + - Labels: `:feature:greeter:impl` → `//feature/greeter/impl:impl`; tags `forma:type=…`; `kt_jvm_library` / `kt_jvm_binary` + `main_class` + - Fixture from `jvm-application/` (8 targets); illegal impl→impl variant for check tests + - Unit tests (labels, no cross-impl deps, check clean/illegal, round-trip, srcs/tags) + - Committed examples under `examples/jvm-application-build/`; `./gradlew runSample` driver + - `bazel-adapter/README.md`; **Spike results (F-041)** section in `docs/BAZEL-ADAPTER.md`; README Progress + docs index +- **Verify (real output, OpenJDK 17 + env-mac.sh):** + - `bazel-adapter/`: `./gradlew test` → **BUILD SUCCESSFUL** + - `bazel-adapter/`: `./gradlew runSample` → **BUILD SUCCESSFUL**; `violations=0 warnings=0` + - `plugins/`: `./gradlew :core:test` → **BUILD SUCCESSFUL** + - Core unchanged / Bazel-free +- **Grounded:** all commands executed this run; no invented green builds; no Bazel binary required +- **Commits/PRs:** this run — push + PR base `v2` +- **Blockers:** none (Grok implement max-turns; completed under Hermes finish path) +- **Next step:** F-042 Minimal Bazel sample using forma-core concepts + ## 2026-07-14 — F-040 Bazel adapter mapping design - **Ticket:** F-040 → `done`