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`