diff --git a/.dagger/modules/e2e/fixtures/.dagger-java-sdk-skip-generate b/.dagger/modules/e2e/fixtures/.dagger-java-sdk-skip-generate deleted file mode 100644 index e69de29..0000000 diff --git a/.dagger/modules/e2e/fixtures/clients/dep/dagger-module.toml b/.dagger/modules/e2e/fixtures/clients/dep/dagger-module.toml new file mode 100644 index 0000000..5f67e9a --- /dev/null +++ b/.dagger/modules/e2e/fixtures/clients/dep/dagger-module.toml @@ -0,0 +1,5 @@ +name = "client-dep" +engineVersion = "v1.0.0-0" + +[runtime] + source = "dang" diff --git a/.dagger/modules/e2e/fixtures/clients/dep/main.dang b/.dagger/modules/e2e/fixtures/clients/dep/main.dang new file mode 100644 index 0000000..50f1615 --- /dev/null +++ b/.dagger/modules/e2e/fixtures/clients/dep/main.dang @@ -0,0 +1,6 @@ +""" +A module the e2e checks add as a client. +""" +type ClientDep { + pub greeting: String! { "hello from the client dependency" } +} diff --git a/.dagger/modules/e2e/fixtures/dagger.toml b/.dagger/modules/e2e/fixtures/dagger.toml deleted file mode 100644 index bc02768..0000000 --- a/.dagger/modules/e2e/fixtures/dagger.toml +++ /dev/null @@ -1,21 +0,0 @@ -[modules.java-sdk] -source = "../../../.." -check.skip = ["*"] - -[modules.java-sdk.as-sdk] -name = "java" - -[[modules.java-sdk.as-sdk.modules]] -path = "generate/app" - -[[modules.java-sdk.as-sdk.modules]] -path = "lookup/app" - -[[modules.java-sdk.as-sdk.modules]] -path = "deps/app" - -[[modules.java-sdk.as-sdk.modules]] -path = "skip/app" - -[[modules.java-sdk.as-sdk.modules]] -path = "managed-toml/app" diff --git a/.dagger/modules/e2e/fixtures/deps/app/dagger.json b/.dagger/modules/e2e/fixtures/deps/app/dagger.json deleted file mode 100644 index adb4387..0000000 --- a/.dagger/modules/e2e/fixtures/deps/app/dagger.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "deps-app", - "engineVersion": "latest", - "sdk": { - "source": "java" - } -} diff --git a/.dagger/modules/e2e/fixtures/generate/app/dagger.json b/.dagger/modules/e2e/fixtures/generate/app/dagger.json deleted file mode 100644 index 34d9f00..0000000 --- a/.dagger/modules/e2e/fixtures/generate/app/dagger.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "generate-app", - "engineVersion": "latest", - "sdk": { - "source": "java" - } -} diff --git a/.dagger/modules/e2e/fixtures/init-existing/keep.txt b/.dagger/modules/e2e/fixtures/init-existing/keep.txt deleted file mode 100644 index a7107ad..0000000 --- a/.dagger/modules/e2e/fixtures/init-existing/keep.txt +++ /dev/null @@ -1 +0,0 @@ -kept by init diff --git a/.dagger/modules/e2e/fixtures/lookup/app/pom.xml b/.dagger/modules/e2e/fixtures/lookup/app/pom.xml new file mode 100644 index 0000000..c34a1f7 --- /dev/null +++ b/.dagger/modules/e2e/fixtures/lookup/app/pom.xml @@ -0,0 +1,10 @@ + + + + 4.0.0 + io.dagger.modules.lookupapp + lookup-app + 1.0-SNAPSHOT + diff --git a/.dagger/modules/e2e/fixtures/managed-toml/app/dagger-module.toml b/.dagger/modules/e2e/fixtures/managed-toml/app/dagger-module.toml deleted file mode 100644 index d72815b..0000000 --- a/.dagger/modules/e2e/fixtures/managed-toml/app/dagger-module.toml +++ /dev/null @@ -1,5 +0,0 @@ -name = "managed-toml-app" -engineVersion = "v1.0.0-beta.7" - -[runtime] - source = "java" diff --git a/.dagger/modules/e2e/fixtures/skip/.dagger-java-sdk-skip-generate b/.dagger/modules/e2e/fixtures/skip/.dagger-java-sdk-skip-generate deleted file mode 100644 index e69de29..0000000 diff --git a/.dagger/modules/e2e/fixtures/skip/app/dagger.json b/.dagger/modules/e2e/fixtures/skip/app/dagger.json deleted file mode 100644 index e00513e..0000000 --- a/.dagger/modules/e2e/fixtures/skip/app/dagger.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "skip-app", - "engineVersion": "latest", - "sdk": { - "source": "java" - } -} diff --git a/.dagger/modules/e2e/main.dang b/.dagger/modules/e2e/main.dang index 06234e4..4415161 100644 --- a/.dagger/modules/e2e/main.dang +++ b/.dagger/modules/e2e/main.dang @@ -1,19 +1,37 @@ """ End-to-end checks for the Java SDK helper module. + +Every check here calls the java-sdk module, which needs an engine with +dagger/dagger#13992. The module is deliberately not installed in dagger.toml, so +a released-engine `dagger check` never reaches it; `engine-e-2-e:dev-sdk-check` +runs `dagger -m .dagger/modules/e2e check` inside an engine built from that +change. """ type E2e { + let fixtureRoot: String! = ".dagger/modules/e2e/fixtures" + # Scratch scopes, created in memory. generateScope always generates now, so + # every scope a check drives has to be a module Maven can build, not a + # config-only stub. let outputRoot: String! = ".dagger/modules/e2e/out" - let fixtureRoot: String! = ".dagger/modules/e2e/fixtures" - let generateModulePath: String! = fixtureRoot + "/generate/app" + # The Java identifiers in nullableReturnSource — package + # io.dagger.modules.newapp, class NewApp — are derived from this name by hand + # and have to be changed with it. Checks that just need *a* module reuse the + # name so they share one vendored SDK build: the SDK jars are installed under + # a per-module Maven version, so a second name costs a second build. + let newModuleName: String! = "new-app" + let newModulePath: String! = outputRoot + "/" + newModuleName + let clientAppName: String! = "client-app" + let clientAppPath: String! = outputRoot + "/" + clientAppName + let lookupModulePath: String! = fixtureRoot + "/lookup/app" let lookupNestedPath: String! = lookupModulePath + "/nested" - let depsModulePath: String! = fixtureRoot + "/deps/app" - let skipModulePath: String! = fixtureRoot + "/skip/app" - # A CLI 1.0 managed module: configured by dagger-module.toml, not dagger.json. - let managedTomlModulePath: String! = fixtureRoot + "/managed-toml/app" - # A module using a different SDK; this SDK must never manage it. + # A module using a different SDK, with no pom.xml: never a Java client root. let nonJavaModulePath: String! = fixtureRoot + "/lookup/not-java" + let clientDepPath: String! = fixtureRoot + "/clients/dep" + + let vendoredBindings: String! = "sdk/src/main/java/io/dagger/client/Dagger.java" + let entrypointPath: String! = "src/generated/java/io/dagger/gen/entrypoint/Entrypoint.java" """ Fail the current check when a condition is false. @@ -45,229 +63,209 @@ type E2e { } """ - New Java modules should target this repository's build/package-only runtime - rather than a builtin SDK name. + Assert that a string contains every substring. """ - targetRuntimeCheck: Void @check { - assert( - javaSdk.targetRuntime == "github.com/dagger/java-sdk/runtime", - "targetRuntime should be the in-repo build/package-only runtime", - ) + let assertContainsAll(value: String!, wants: [String!]!): Void { + wants.each { want => assertContains(value, want, "expected to contain: " + want) } null } """ - The generate skip marker filename should be stable. + Assert that a string does not contain a substring. """ - skipGenerateFilenameCheck: Void @check { - assert( - javaSdk.skipGenerateFilename == ".dagger-java-sdk-skip-generate", - "skip marker filename changed", - ) - null + let assertNotContains(value: String!, want: String!, message: String!): Void { + assert(value.contains(want) == false, message) } """ - initModule should seed only the SDK-owned template files: a two-pass pom - defaulting dagger.proc=none and the module sources, with the module name - substituted into paths and contents. Engine-owned files (dagger-module.toml, - the legacy dagger.json) are produced by the engine, not by initModule, and no - existing files are touched. + findClientRoot answers with the directory of the nearest pom.xml, as a path + relative to the workspace root: the module itself from inside it, the owning + module from a subdirectory, the owning module from inside its vendored SDK, + and null where no pom.xml is in scope. """ - initCheck(ws: Workspace!): Void @check { - let p = outputRoot + "/init-default" - let changes = javaSdk.initModule(ws, name: "init-default", path: p) - - assertAdded(changes, p + "/pom.xml") - assertAdded(changes, p + "/src/main/java/io/dagger/modules/initdefault/InitDefault.java") - assertAdded(changes, p + "/src/main/java/io/dagger/modules/initdefault/package-info.java") - + findClientRootCheck(ws: Workspace!): Void @check { assert( - contains(changes.addedPaths, p + "/dagger-module.toml") == false, - "initModule should not write the engine-owned dagger-module.toml", + javaSdk.findClientRoot(ws.withWorkdir(lookupModulePath)) == lookupModulePath, + "findClientRoot should answer with the module root itself", ) assert( - contains(changes.addedPaths, p + "/dagger.json") == false, - "initModule should not write the engine-owned dagger.json", + javaSdk.findClientRoot(ws.withWorkdir(lookupNestedPath)) == lookupModulePath, + "findClientRoot should find the module owning a nested path", ) - assert(changes.modifiedPaths.length == 0, "init unexpectedly modified existing files") - assert(changes.removedPaths.length == 0, "init unexpectedly removed files") - let pom = changes.layer.file(p + "/pom.xml").contents - assertContains(pom, "none", "pom should default dagger.proc to none") - assertContains(pom, "init-default", "pom artifactId should be the kebab-case module name") + # A module's vendored SDK has no pom.xml of its own, so the module answers + # for it; nothing has to lift a hit out of sdk/ the way python-sdk does. + let vendored = lookupModulePath + "/sdk/src/main/java" + let withVendoredSdk = ws.withNewFile("/" + vendored + "/.keep", "") + assert( + javaSdk.findClientRoot(withVendoredSdk.withWorkdir(vendored)) == lookupModulePath, + "findClientRoot should answer with the module owning a vendored sdk/", + ) - let module = changes.layer.file(p + "/src/main/java/io/dagger/modules/initdefault/InitDefault.java").contents - assertContains(module, "class InitDefault", "module class should be the camel-case module name") + assert( + javaSdk.findClientRoot(ws.withWorkdir(nonJavaModulePath)) == null, + "a module of another SDK, with no pom.xml, is not a Java client root", + ) + assert( + javaSdk.findClientRoot(ws.withWorkdir(".")) == null, + "the workspace root is not a Java client root", + ) null } """ - initModule onto a directory that already holds files should lay the template - over them and leave them in place: a user running init inside an existing - project must not lose their work. - """ - initExistingCheck(ws: Workspace!): Void @check { - let p = fixtureRoot + "/init-existing" - let changes = javaSdk.initModule(ws, name: "init-existing", path: p) - - assertAdded(changes, p + "/pom.xml") - assert(changes.removedPaths.length == 0, "init removed a file already in the target directory") - - null - } - - let testWS(ws: Workspace!): Workspace! { - ws.directory("/").withoutFile("dagger.toml").asWorkspace(cwd: ".dagger/modules/e2e/fixtures") - } + generateScope initializes a scope that has no config: the template, a + dagger-module.toml from the manifest builder naming this repository's runtime, + and the generated bindings and entrypoint, all under the scope, with the + workspace cwd and the files already there untouched. + Generating the result again changes nothing, so generation is reproducible and + a manifest that already says the right thing is not rewritten. """ - From the workspace root the whole workspace is in scope, so modules() should - return every Java SDK module this workspace manages — whether it is configured - by the legacy dagger.json or the CLI 1.0 dagger-module.toml — and nothing that - isn't managed by this SDK (e.g. a sibling module using another SDK). - """ - modulesCheck(ws: Workspace!): Void @check { - let pathRecords = javaSdk.modules(testWS(ws)).{{rootPath}} + generateScopeInitCheck(ws: Workspace!): Void @check { + let scoped = ws + .withNewFile("/" + newModulePath + "/keep.txt", "keep\n") + .withWorkdir(newModulePath) + let generated = javaSdk.generateScope(scoped, isModule: true, name: newModuleName, clients: []) + let changes = generated.withWorkdir(".").changes(scoped.withWorkdir(".")) + assert(generated.cwd == scoped.cwd, "generateScope must not change the workspace cwd") + + assertAdded(changes, newModulePath + "/dagger-module.toml") assert( - pathRecords.filter { r => r.rootPath == lookupModulePath }.length > 0, - "lookup Java module should be listed", - ) - assert( - pathRecords.filter { r => r.rootPath == skipModulePath }.length > 0, - "skip-marked Java module should still be listed (skip only affects generate)", - ) - assert( - pathRecords.filter { r => r.rootPath == generateModulePath }.length > 0, - "generate Java module should be listed", - ) - assert( - pathRecords.filter { r => r.rootPath == depsModulePath }.length > 0, - "deps Java module should be listed", + contains(changes.addedPaths, newModulePath + "/dagger.json") == false, + "initializing a scope must not write a dagger.json", ) + assertAdded(changes, newModulePath + "/pom.xml") + assertAdded(changes, newModulePath + "/src/main/java/io/dagger/modules/newapp/NewApp.java") + assertAdded(changes, newModulePath + "/" + vendoredBindings) + assertAdded(changes, newModulePath + "/" + entrypointPath) assert( - pathRecords.filter { r => r.rootPath == managedTomlModulePath }.length > 0, - "a dagger-module.toml (CLI 1.0) managed module should be listed", + changes.modifiedPaths.length == 0, + "initializing a scope modified existing files: " + changes.modifiedPaths.join(", "), ) assert( - pathRecords.filter { r => r.rootPath == nonJavaModulePath }.length == 0, - "a module not managed by this SDK should be excluded from modules listing", + changes.removedPaths.length == 0, + "initializing a scope removed existing files: " + changes.removedPaths.join(", "), ) + assertContainsAll(changes.after.file(newModulePath + "/dagger-module.toml").contents, [ + "name = \"" + newModuleName + "\"", + "engineVersion = \"", + "[runtime]", + "source = \"github.com/dagger/java-sdk/runtime\"", + ]) + assertContainsAll(changes.after.file(newModulePath + "/pom.xml").contents, [ + "none", + "" + newModuleName + "", + ]) + + let again = javaSdk + .generateScope(generated, isModule: true, name: newModuleName, clients: []) + .withWorkdir(".") + .changes(generated.withWorkdir(".")) + assert(again.isEmpty, "regenerating an unchanged module should change nothing") + null } """ - Discovery is anchored at the client's cwd, not the workspace root. Re-anchoring - the workspace to a subdirectory scopes modules() to the managed modules in that - cone (walk-down) plus the nearest enclosing one (find-up), and excludes managed - modules that live outside it. + A pre-1.0 module is migrated: the contents of its dagger.json move into a + dagger-module.toml, the runtime it already named is preserved rather than + replaced by this SDK's own, and the dagger.json is removed so two manifest + files cannot disagree. """ - modulesCwdCheck(ws: Workspace!): Void @check { - # A stable snapshot of the workspace, re-anchorable at any cwd. It has to - # carry the fixture dagger.toml, which is where currentModule.asSDK reads the - # managed-module list from, and lookup/app/nested, the config-less - # subdirectory the find-up case is anchored in. - let root = testWS(ws).directory("/") - - # Walk-down: from fixtures/generate only generate/app is in the cone; the - # sibling managed modules live outside it and must be excluded. Its - # cwd-relative path is "app" — a directory beneath the cwd. - let fromGenerate = javaSdk.modules( - root.asWorkspace(cwd: fixtureRoot + "/generate"), - ) - let fromGenerateRoots = fromGenerate.{{rootPath}} - assert( - fromGenerateRoots.filter { r => r.rootPath == generateModulePath }.length > 0, - "cwd=generate: the managed module in the cone should be discovered", - ) - assert( - fromGenerateRoots.filter { r => r.rootPath == lookupModulePath }.length == 0, - "cwd=generate: lookup/app is outside the cone and must be excluded", - ) - assert( - fromGenerateRoots.filter { r => r.rootPath == depsModulePath }.length == 0, - "cwd=generate: deps/app is outside the cone and must be excluded", - ) - assert( - fromGenerateRoots.filter { r => r.rootPath == skipModulePath }.length == 0, - "cwd=generate: skip/app is outside the cone and must be excluded", - ) - assert(fromGenerateRoots.length == 1, "cwd=generate: exactly one managed module is in the cone") - assert(fromGenerate.{{path}}.filter { r => r.path == "app" }.length > 0, "cwd=generate: the discovered module's path should be cwd-relative (app)") - - # Find-up: from inside lookup/app (a nested subdir with no config of its own) - # the enclosing managed module is discovered; siblings are not. Its - # cwd-relative path is ".." — an ancestor of the cwd. - let fromNested = javaSdk.modules(root.asWorkspace(cwd: lookupNestedPath)) - let fromNestedRoots = fromNested.{{rootPath}} - assert( - fromNestedRoots.filter { r => r.rootPath == lookupModulePath }.length > 0, - "cwd=lookup/app/nested: find-up should discover the enclosing lookup/app", + generateScopeMigrateCheck(ws: Workspace!): Void @check { + # Built from a generated module rather than a committed stub, because + # generateScope always generates and so needs a module Maven can build. Same + # module name as generateScopeInitCheck, so the vendored SDK build is shared. + let scaffolded = javaSdk.generateScope( + ws.withWorkdir(newModulePath), + isModule: true, + name: newModuleName, + clients: [], ) - assert( - fromNestedRoots.filter { r => r.rootPath == generateModulePath }.length == 0, - "cwd=lookup/app/nested: generate/app is outside the cone", - ) - assert(fromNestedRoots.length == 1, "cwd=lookup/app/nested: only the enclosing module should be discovered") - assert(fromNested.{{path}}.filter { r => r.path == ".." }.length > 0, "cwd=lookup/app/nested: the enclosing module's path should be cwd-relative (..)") - - # Root cwd: the whole workspace is in scope, so every managed module — and - # only the managed ones — is discovered, whether marked by dagger.json or - # dagger-module.toml. - # Using the fixture root where the test specific workspace is defined - let fromRoot = javaSdk.modules(root.asWorkspace(cwd: fixtureRoot)).{{rootPath}} - assert( - fromRoot.filter { r => r.rootPath == generateModulePath }.length > 0, - "cwd=/: generate/app should be listed", + let legacy = scaffolded + .withoutFile("/" + newModulePath + "/dagger-module.toml") + .withNewFile( + "/" + newModulePath + "/dagger.json", + "{\n \"name\": \"" + newModuleName + "\",\n" + + " \"engineVersion\": \"v1.0.0-0\",\n" + + " \"sdk\": {\n \"source\": \"java\"\n }\n}\n", + ) + let migrated = javaSdk.generateScope( + legacy.withWorkdir(newModulePath), + isModule: true, + name: newModuleName, + clients: [], ) - assert(fromRoot.filter { r => r.rootPath == lookupModulePath }.length > 0, "cwd=/: lookup/app should be listed") - assert(fromRoot.filter { r => r.rootPath == depsModulePath }.length > 0, "cwd=/: deps/app should be listed") - assert(fromRoot.filter { r => r.rootPath == skipModulePath }.length > 0, "cwd=/: skip/app should be listed") + let changes = migrated.withWorkdir(".").changes(legacy.withWorkdir(".")) + + assertAdded(changes, newModulePath + "/dagger-module.toml") assert( - fromRoot.filter { r => r.rootPath == managedTomlModulePath }.length > 0, - "cwd=/: the dagger-module.toml managed module should be listed", + contains(changes.removedPaths, newModulePath + "/dagger.json"), + "migration should remove the pre-1.0 dagger.json", ) - assert(fromRoot.length == 5, "cwd=/: exactly the five managed modules should be listed") + assertContainsAll(migrated.file("/" + newModulePath + "/dagger-module.toml").contents, [ + "name = \"" + newModuleName + "\"", + "source = \"java\"", + ]) null } """ - Generation from a subdirectory must resolve managed module root paths from - the workspace root, not relative to the caller's cwd, and must return a - changeset whose paths are relative to that cwd — the form the engine applies - a generator's result in. + In a module scope the client set becomes the module's dependency set: the + client is recorded in the manifest and its types reach the generated bindings, + and a client that is no longer requested is dropped from both. A scope without + a module is left alone, and standalone clients are refused rather than + silently skipped. """ - generateCwdCheck(ws: Workspace!): Void @check { - # Prepare a valid module in memory and remove the fixture-wide skip marker - # so this check can exercise generation without changing committed fixtures. - let initialized = javaSdk.initModule(ws, name: "generate-app", path: generateModulePath) - let root = testWS(ws) - .directory("/", exclude: [fixtureRoot + "/.dagger-java-sdk-skip-generate"]) - .withDirectory(".", initialized.layer) - - # Module-relative paths of the two artifacts generation stages. - let vendoredClient = "sdk/src/main/java/io/dagger/client/Dagger.java" - let entrypoint = "src/generated/java/io/dagger/gen/entrypoint/Entrypoint.java" - - # From the module's parent the module is one level down, so its changes are - # rooted at "app" — the same cwd-relative path modulesCwdCheck expects. - let fromParent = javaSdk.generateAll( - root.asWorkspace(cwd: fixtureRoot + "/generate"), + generateScopeClientsCheck(ws: Workspace!): Void @check { + let root = ws.withWorkdir(".") + let scoped = ws.withWorkdir(clientAppPath) + let manifestPath = clientAppPath + "/dagger-module.toml" + let bindingsDir = clientAppPath + "/sdk/src/generated/java/io/dagger/client" + let client = ws.moduleSource("/" + clientDepPath) + + let untouched = javaSdk + .generateScope(scoped, isModule: false, name: clientAppName, clients: []) + .withWorkdir(".") + .changes(root) + assert(untouched.isEmpty, "a scope without a module should not be generated") + + let refused = javaSdk + .generateScope(scoped, isModule: false, name: clientAppName, clients: [client]) + .cwd rescue "raised" + assert(refused == "raised", "generateScope should refuse standalone clients") + + let withClient = javaSdk.generateScope(scoped, isModule: true, name: clientAppName, clients: [client]) + assertContainsAll(withClient.file("/" + manifestPath).contents, [ + "[[dependencies]]", + "name = \"client-dep\"", + "client-dep", + ]) + + # The payoff: because the client is recorded as a dependency, the module's + # introspection schema carries its types and so do the generated bindings. + let bindings = withClient.directory("/" + bindingsDir).entries + assert( + bindings.filter { entry => entry.contains("ClientDep") }.length > 0, + "no ClientDep binding under " + bindingsDir + ", only: " + bindings.join(", "), ) - assertAdded(fromParent, "app/" + vendoredClient) - assertAdded(fromParent, "app/" + entrypoint) - # From the module itself the cwd is the module root, so its changes carry no - # prefix at all. - let fromModule = javaSdk.generateAll( - root.asWorkspace(cwd: generateModulePath), + let withoutClient = javaSdk.generateScope(withClient, isModule: true, name: clientAppName, clients: []) + assertNotContains( + withoutClient.file("/" + manifestPath).contents, + "client-dep", + "removing the last client should drop the dependency", + ) + assert( + withoutClient.directory("/" + bindingsDir).entries + .filter { entry => entry.contains("ClientDep") }.length == 0, + "removing the client should drop its types from the bindings", ) - assertAdded(fromModule, vendoredClient) - assertAdded(fromModule, entrypoint) null } @@ -277,34 +275,28 @@ type E2e { return type — and, since generation compiles the entrypoint it produces, generating at all proves the entrypoint the annotation processor writes for an Optional return is valid Java. - - Reuses the generate fixture rather than adding a managed module, so the module - inventory the discovery checks assert stays as it is. """ nullableReturnCheck(ws: Workspace!): Void @check { - let modPath = generateModulePath - let initialized = javaSdk.initModule(ws, name: "generate-app", path: modPath) - let root = testWS(ws) - .directory("/", exclude: [fixtureRoot + "/.dagger-java-sdk-skip-generate"]) - .withDirectory(".", initialized.layer) - .withNewFile( - modPath + "/src/main/java/io/dagger/modules/generateapp/GenerateApp.java", - nullableReturnSource, - ) - - let changes = javaSdk.generateAll(root.asWorkspace(cwd: modPath)) - let entrypoint = changes - .layer - .file("src/generated/java/io/dagger/gen/entrypoint/Entrypoint.java") + # Same scope and module name as generateScopeInitCheck, so both checks share + # the vendored SDK build instead of each paying for one of their own. + let scoped = ws.withWorkdir(newModulePath) + let scaffolded = javaSdk.generateScope(scoped, isModule: true, name: newModuleName, clients: []) + let withSource = scaffolded.withNewFile( + "/" + newModulePath + "/src/main/java/io/dagger/modules/newapp/NewApp.java", + nullableReturnSource, + ) + let generated = javaSdk + .generateScope(withSource, isModule: true, name: newModuleName, clients: []) + .file("/" + newModulePath + "/" + entrypointPath) .contents assertContains( - entrypoint, + generated, "withObject(\"Directory\").withOptional(true)", "an Optional return should register an optional Directory return type", ) assertContains( - entrypoint, + generated, "res.orElse(null)", "the Optional return should be unwrapped before serialization", ) @@ -314,7 +306,7 @@ type E2e { """A module whose only function returns a nullable object.""" let nullableReturnSource: String! { - "package io.dagger.modules.generateapp;\n" + "package io.dagger.modules.newapp;\n" + "\n" + "import static io.dagger.client.Dagger.dag;\n" + "\n" @@ -324,7 +316,7 @@ type E2e { + "import java.util.Optional;\n" + "\n" + "@Object\n" - + "public class GenerateApp {\n" + + "public class NewApp {\n" + " @Function\n" + " public Optional maybeDirectory(boolean found) {\n" + " if (!found) {\n" diff --git a/.dagger/modules/engine-e2e/dagger-module.toml b/.dagger/modules/engine-e2e/dagger-module.toml new file mode 100644 index 0000000..8d12349 --- /dev/null +++ b/.dagger/modules/engine-e2e/dagger-module.toml @@ -0,0 +1,10 @@ +name = "engine-e2e" +engineVersion = "v1.0.0-0" + +[runtime] + source = "dang" + +[[dependencies]] + name = "engine-dev" + source = "github.com/dagger/dagger/.dagger/modules/engine-dev@8fd9b22b5416f8dc7cb420ba37769adef6e874d2" + pin = "8fd9b22b5416f8dc7cb420ba37769adef6e874d2" diff --git a/.dagger/modules/engine-e2e/main.dang b/.dagger/modules/engine-e2e/main.dang new file mode 100644 index 0000000..4813dfe --- /dev/null +++ b/.dagger/modules/engine-e2e/main.dang @@ -0,0 +1,75 @@ +""" +Checks the Java SDK against an engine built from dagger/dagger#13992 (branch +sdk-ux-module-max), at the commit engine-dev is pinned to in dagger-module.toml. + +The released engine has none of that change, so every call into the java-sdk +module fails there. These checks build the engine and run the SDK inside it. +""" +type EngineE2e { + let engineCommit: String! = "8fd9b22b5416f8dc7cb420ba37769adef6e874d2" + let modulePath: String! = ".dagger/modules/sdk-smoke" + + let assert(condition: Boolean!, message: String!): Void { + if (condition == false) { + raise message + } + null + } + + """ + This checkout, mounted in an engine built from the pinned commit, as a + workspace the CLI inside can act on. + """ + let playground(ws: Workspace!): Container! { + let sdkSource = ws.directory("/", exclude: [".git", "**/target/**"]) + let engineSource = git("https://github.com/dagger/dagger") + .ref(engineCommit) + .asWorkspace + engineDev(ws: engineSource) + .playground + .withMountedDirectory("./java-sdk", sdkSource) + .withWorkdir("./java-sdk") + # .git is left out of the mount; workspace detection walks up to a .git to + # find the workspace root, and without one there is no workspace at all. + .withExec(["git", "init", "--quiet"]) + } + + """ + The module-max engine should list this checkout as the java SDK, initialize a + Java module from it, and run the module. + """ + pub devSdkCheck(ws: Workspace!): Void @check { + let listed = playground(ws).withExec(["dagger", "sdk", "list"]) + assert(listed.stdout.contains("java"), "the development Java SDK was not listed") + + let initialized = listed + .withExec(["dagger", "--auto-apply", "module", "init", "java", "--name", "sdk-smoke", "--path", modulePath]) + .withExec(["test", "-f", modulePath + "/dagger-module.toml"]) + .withExec(["test", "!", "-e", modulePath + "/dagger.json"]) + .withExec(["test", "-f", modulePath + "/pom.xml"]) + .withExec(["test", "-f", modulePath + "/src/main/java/io/dagger/modules/sdksmoke/SdkSmoke.java"]) + .withExec(["test", "-f", modulePath + "/sdk/src/main/java/io/dagger/client/Dagger.java"]) + .withExec(["test", "-f", modulePath + "/src/generated/java/io/dagger/gen/entrypoint/Entrypoint.java"]) + .withExec(["grep", "-q", "github.com/dagger/java-sdk/runtime", modulePath + "/dagger-module.toml"]) + + let release = initialized + .withExec(["dagger", "-m", modulePath, "call", "container", "file", "--path", "/etc/alpine-release", "contents"]) + .stdout + assert(release.trimSpace != "", "the generated module did not run") + + null + } + + """ + Run the e2e checks that call the java-sdk module. The module is not installed + in dagger.toml, because on a released engine every one of those calls fails, + so `-m` loads it by path here instead. + """ + pub sdkContractCheck(ws: Workspace!): Void @check { + playground(ws) + .withExec(["dagger", "-m", ".dagger/modules/e2e", "check"]) + .sync + + null + } +} diff --git a/.dagger/modules/packager/main.dang b/.dagger/modules/packager/main.dang index b1f497a..2cfb15c 100644 --- a/.dagger/modules/packager/main.dang +++ b/.dagger/modules/packager/main.dang @@ -10,6 +10,17 @@ into ~/.m2 instead of recompiling the plugin. Run this explicitly produce them change. """ type Packager { + """ + The local Maven repository, shared with the SDK's own codegen containers. + + Mounted LOCKED because both mutate it: this module installs the codegen plugin + into it, and a module generation installs the SDK jars. Two writers at once + produce assets that differ from the committed ones, which surfaces as + `packager:generate` reporting drift after a build that succeeded. The SDK side + is mounted the same way; one volume takes one sharing mode. + """ + let mavenRepo: CacheVolume! { cacheVolume("sdk-java-maven-m2") } + """Maven container used to build the assets (pinned digest, matches the SDK).""" let mvn: Container! { container.from("maven:3.9.9-eclipse-temurin-21-alpine@sha256:4cbb8bf76c46b97e028998f2486ed014759a8e932480431039bdb93dffe6813e") @@ -30,16 +41,23 @@ type Packager { let codegenPluginRepo(ws: Workspace!): Directory! { mvn .withoutEntrypoint - .withMountedCache("/root/.m2", cacheVolume("sdk-java-maven-m2")) + .withMountedCache("/root/.m2", mavenRepo, sharing: CacheSharingMode.LOCKED) .withDirectory("/dagger-io", sdkSource(ws)) .withWorkdir("/dagger-io") .withExec(["mvn", "--projects", "dagger-codegen-maven-plugin", "--also-make", "install", "-T1C", "-Dmaven.test.skip=true", "-Dfmt.skip=true", "-Dproject.build.outputTimestamp=2024-01-01T00:00:00Z", "--no-transfer-progress"]) - # Strip Maven's install-time timestamps so the committed repo is + # Export the two artifacts this module publishes, named one by one rather + # than copying io/dagger wholesale. The local repository is shared with + # module generation, which installs io/dagger/dagger-java-sdk/ and + # io/dagger/dagger-java-annotation-processor/ into it under a + # per-module version; a wholesale copy sweeps those in too, so what landed + # under prebuilt/ depended on whether a generation had run first. + # + # Then strip Maven's install-time timestamps so the committed repo is # byte-reproducible: drop the comment lines from _remote.repositories # (keeping the ">=" local-install markers resolution needs) and pin # . Otherwise every generate re-timestamps these files and # the check reports perpetual drift. - .withExec(["sh", "-c", "rm -rf /out && mkdir -p /out/io && cp -r /root/.m2/repository/io/dagger /out/io/dagger && find /out -name _remote.repositories -exec sed -i '/>=/!d' {} ';' && find /out -name maven-metadata-local.xml -exec sed -i 's|[0-9]*|20240101000000|' {} ';'"]) + .withExec(["sh", "-c", "set -e; rm -rf /out; mkdir -p /out/io/dagger; cd /root/.m2/repository/io/dagger; cp -r dagger-codegen-maven-plugin dagger-sdk-parent maven-metadata-local.xml /out/io/dagger/; find /out -name _remote.repositories -exec sed -i '/>=/!d' {} ';'; find /out -name maven-metadata-local.xml -exec sed -i 's|[0-9]*|20240101000000|' {} ';'"]) .directory("/out") } @@ -67,7 +85,7 @@ type Packager { .introspectionSchemaJSON mvn .withoutEntrypoint - .withMountedCache("/root/.m2", cacheVolume("sdk-java-maven-m2")) + .withMountedCache("/root/.m2", mavenRepo, sharing: CacheSharingMode.LOCKED) .withMountedFile("/schema.json", introspectionJSON) .withDirectory("/dagger-io", sdkSource(ws)) .withWorkdir("/dagger-io") diff --git a/README.md b/README.md index b677c60..1d71876 100644 --- a/README.md +++ b/README.md @@ -8,63 +8,155 @@ are committed to version control, and **no code generation runs at module load time** — the runtime just builds and packages the module. > This repository owns both halves of the Java SDK. The root Dang module -> (`main.dang` / `mod.dang`) owns code generation and scaffolding, and runs at -> `dagger generate` time. The module *runtime* (the SDK contract: building and -> packaging Java modules) is the build/package-only Go module under `runtime/`; -> new modules reference it as `github.com/dagger/java-sdk/runtime`. +> (`main.dang` / `mod.dang`) owns code generation and scaffolding, and runs when +> the engine asks it to generate a scope. The module *runtime* (the SDK +> contract: building and packaging Java modules) is the build/package-only Dang +> module under `runtime/`; new modules reference it as +> `github.com/dagger/java-sdk/runtime`. + +> [!IMPORTANT] +> This SDK implements the module-scope interface from +> [dagger/dagger#13992](https://github.com/dagger/dagger/pull/13992) and needs an +> engine that has it. On the released engine (`v1.0.0-beta.11`) the module loads +> but every call into it fails. +> +> It writes module manifests through +> [`github.com/dagger/sdk-helpers`](https://github.com/dagger/sdk-helpers), the +> manifest builder that #13992 moved out of the engine. ## Install ```sh -dagger workspace install github.com/dagger/java-sdk +dagger module install github.com/dagger/java-sdk +``` + +The engine recognizes the SDK interface and records the module as the `java` SDK +in `dagger.toml`: + +```toml +[modules.java-sdk] +source = "github.com/dagger/java-sdk" + +[sdks.java] +module = "java-sdk" ``` ## Create a module ```sh -dagger call java-sdk init --name=my-module -dagger generate +dagger module init java --name my-module --path .dagger/modules/my-module ``` -`init` scaffolds a new module: +The engine records the module scope in `dagger.toml` and calls this SDK's +`generateScope`, which renders the template, writes `dagger-module.toml`, and +generates the SDK bindings in one step: ``` / - dagger.json # sdk.source=java; codegen.automaticGitignore=false - pom.xml # two-pass build; dagger.proc defaults to "none" + dagger-module.toml # [runtime] source = github.com/dagger/java-sdk/runtime + pom.xml # two-pass build; dagger.proc defaults to "none" src/main/java/io/dagger/modules//.java src/main/java/io/dagger/modules//package-info.java -``` - -`dagger generate` (or `dagger call java-sdk generate --path=`) fills in -the generated, committed sources: - -``` src/generated/java/io/dagger/gen/entrypoint/Entrypoint.java # generated entrypoint sdk/src/main/java/... # vendored SDK library sdk/src/processor/java/... # vendored annotation processor sdk/src/generated/java/... # client bindings (from the engine schema) ``` +The SDK settings become typed flags on `dagger module init java` and are +persisted on the scope: + +```sh +dagger module init java --name my-module --template empty +``` + +`--template` picks a starter under `templates/`: `default` (a small working +module), `empty` (a bare object class), or `legacy`. + Because everything is committed and the pom defaults `dagger.proc=none`, the module builds with a plain `mvn package` (no annotation processor at build time) — in an IDE or CI, without Dagger. -## How generation works +## Generate -`generate` runs Maven in containers it controls: it builds the vendored codegen -plugin, generates the client bindings from the engine's introspection schema, -vendors the SDK library and annotation processor as source, and runs the +```sh +dagger generate +``` + +This regenerates every module scope recorded in `dagger.toml`. The engine +narrows the set to the scopes containing your current directory, so running it +inside a module regenerates that module. + +Generation runs Maven in containers this SDK controls: it builds the vendored +codegen plugin, generates the client bindings from the engine's introspection +schema, vendors the SDK library and annotation processor as source, and runs the processor once to produce the entrypoint. It does not delegate code generation back to the engine. -## The codegen flag +## Module scopes + +A Java scope is a directory with a `pom.xml`. `findClientRoot` answers with the +nearest one at or above your current directory, which is how +`dagger module client add` and friends find the module you are standing in. A +project built with anything but Maven has no `pom.xml`, so this SDK reports no +scope for it. + +## Module clients -`init` sets, in the module's `dagger.json`: +Module dependencies are replaced by generated module clients: -- `codegen.automaticGitignore: false` +```sh +dagger module client add java +``` + +In a module scope the client set becomes the module's dependency set. Each +client is recorded in the manifest the module has — `dagger-module.toml`, or the +`dagger.json` of a pre-1.0 module — and its types are part of the generated +bindings; a client that is removed is dropped from both. + +Standalone clients — in a scope that has no Java module — are not generated yet. +Adding one is refused and the workspace is left unchanged. + +> [!WARNING] +> The client set is the *whole* dependency set. A module that recorded +> dependencies before this interface existed has no clients recorded for them, +> so the first `dagger generate` drops them. Re-register each one first: +> +> ```sh +> dagger module client add java +> ``` +> +> Then check that each one landed in `dagger.toml` before you generate. On the +> `sdk-ux-module-max` engine builds this SDK currently needs, +> `dagger module client add` reports success and writes nothing. + +## Pre-1.0 modules + +A module configured by `dagger.json` is migrated the first time it is generated: +its contents move into a `dagger-module.toml` and the `dagger.json` is removed, +so the two cannot disagree. The runtime it already names is preserved — a module +on the engine's builtin `java` runtime stays there, and does not silently move +onto this repository's. + +## Test + +```sh +dagger check +``` -This single flag carries two meanings: the generated files are committed (not -git-ignored), and — because the committed files can be trusted — the builtin -runtime skips codegen at module load and only builds/packages the committed -sources. +Checks run against two engines: + +- On the released engine, everything that does not call this module: the SDK + library's unit tests (`packager:unit-tests`), the prebuilt assets, and the + templates. +- On an engine built from dagger/dagger#13992, the SDK interface itself. + `engine-e-2-e:dev-sdk-check` builds that engine from the commit pinned in + `.dagger/modules/engine-e2e` (the `engine-dev` dependency and `engineCommit`), + installs this checkout as the `java` SDK, initializes a Java module, and calls + it. `engine-e-2-e:sdk-contract-check` runs the `e-2-e:*` checks inside the same + engine. Bump both pins to follow the branch. + +The `e2e` module is deliberately not installed in `dagger.toml`. Every check in +it calls this module, which a released engine cannot run, so +`engine-e-2-e:sdk-contract-check` loads it by path — `dagger -m +.dagger/modules/e2e check` — inside an engine that can. diff --git a/dagger-module.toml b/dagger-module.toml index 94d56da..c067ae8 100644 --- a/dagger-module.toml +++ b/dagger-module.toml @@ -3,3 +3,8 @@ engineVersion = "v1.0.0-beta.11" [runtime] source = "dang" + +[[dependencies]] + name = "sdk-helpers" + source = "github.com/dagger/sdk-helpers" + pin = "64645f1967d3dba6fce951dd61ae4acd8d9b0861" diff --git a/dagger.json b/dagger.json index 25cec9c..9673fd8 100644 --- a/dagger.json +++ b/dagger.json @@ -3,5 +3,12 @@ "engineVersion": "v1.0.0-beta.11", "sdk": { "source": "dang" - } + }, + "dependencies": [ + { + "name": "sdk-helpers", + "source": "github.com/dagger/sdk-helpers", + "pin": "64645f1967d3dba6fce951dd61ae4acd8d9b0861" + } + ] } diff --git a/dagger.lock b/dagger.lock index 6528205..fa2eb6e 100644 --- a/dagger.lock +++ b/dagger.lock @@ -1,9 +1,13 @@ [["version","2"]] ["","git-latest",["https://github.com/dagger/dang-sdk"],"refs/heads/main"] -["","git-latest",["https://github.com/dagger/sdk-sdk"],"refs/heads/main"] +["","git-latest",["https://github.com/dagger/sdk-helpers"],"refs/heads/main"] +["","git-sha",["https://github.com/containernetworking/plugins","refs/tags/v1.9.0"],"9b3772e1a7abf93cbb7c6526a28bc0d27b830e02"] ["","git-sha",["https://github.com/dagger/dagger","refs/tags/v1.0.0-beta.11"],"a4e1e4ff663e5e51c2b96c2c0772f3d2f00cfb94"] -["","git-sha",["https://github.com/dagger/dang-sdk","refs/heads/main"],"c724eec4270870aae489daa9f3cb8cbacfb44680"] -["","git-sha",["https://github.com/dagger/sdk-sdk","refs/heads/main"],"00bb06748bcf22d724ed467f2298d31f1fb49be0"] +["","git-sha",["https://github.com/dagger/dang-sdk","refs/heads/main"],"9c8e5c6f1927b9adc325846d47e66e697991797d"] +["","git-sha",["https://github.com/dagger/sdk-helpers","refs/heads/main"],"64645f1967d3dba6fce951dd61ae4acd8d9b0861"] +["","git-sha",["https://github.com/libfuse/sshfs.git","refs/tags/sshfs-3.7.6"],"7a2d988775446ebe7af9b01c99b3b8e86bddb05a"] +["","git-sha",["https://github.com/opencontainers/runc","refs/tags/v1.4.2"],"c241c0bb5e60a8e8c1b2e53d4eca8d0068d8d57e"] ["","oci-sha",["docker.io/library/alpine:3.22"],"sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce"] ["","oci-sha",["docker.io/library/golang:1.25-alpine"],"sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59"] -["","oci-sha",["docker.io/library/golang:1.26-alpine"],"sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468"] \ No newline at end of file +["","oci-sha",["docker.io/library/golang:1.26-alpine"],"sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628"] +["","oci-sha",["docker.io/tonistiigi/xx:1.2.1"],"sha256:8879a398dedf0aadaacfbd332b29ff2f84bc39ae6d4e9c0a1109db27ac5ba012"] \ No newline at end of file diff --git a/dagger.toml b/dagger.toml index a3b65d4..2bf887a 100644 --- a/dagger.toml +++ b/dagger.toml @@ -1,5 +1,10 @@ -[modules.e2e] -source = ".dagger/modules/e2e" +# Dagger workspace configuration +# Install modules with: dagger module install +# Example: +# dagger module install github.com/dagger/dagger/modules/wolfi + +[modules.engine-e2e] +source = ".dagger/modules/engine-e2e" [modules.packager] source = ".dagger/modules/packager" @@ -7,17 +12,26 @@ source = ".dagger/modules/packager" [modules.dagger-dang-sdk] source = "github.com/dagger/dang-sdk" +# dagger/dagger#13992 removes as-sdk, and the module-max engine ignores this +# table. It stays because dang-sdk has not adopted the replacement yet +# (dagger/dang-sdk#13) and its generator now fails outright — "current module is +# not installed as an SDK in this workspace" — when it finds no registration. [modules.dagger-dang-sdk.as-sdk] name = "dang" -[modules.templates] -source = ".dagger/modules/templates" - [[modules.dagger-dang-sdk.as-sdk.modules]] path = ".dagger/modules/templates" [[modules.dagger-dang-sdk.as-sdk.modules]] path = "." -[modules.sdk-sdk] -source = "github.com/dagger/sdk-sdk" +[modules.templates] +source = ".dagger/modules/templates" + +[modules.java-sdk] +source = "." +check.skip = ["*"] + +[sdks.java] +module = "java-sdk" + diff --git a/hack/designs/2026-09-04-sdk-module-interface.md b/hack/designs/2026-09-04-sdk-module-interface.md new file mode 100644 index 0000000..2c2449f --- /dev/null +++ b/hack/designs/2026-09-04-sdk-module-interface.md @@ -0,0 +1,655 @@ +# Adopt the module-max SDK interface + +Status: proposed +Date: 2026-09-04 + +## Reviewed baselines + +Every claim in this document was checked against these exact revisions. + +| What | Revision | +| --- | --- | +| This repository (`dagger/java-sdk`), base of the change | `be18cc2d64951628a79ae7da626ab2427b6a2436` | +| The engine change, `dagger/dagger#13992`, branch `sdk-ux-module-max` | `8fd9b22b5416f8dc7cb420ba37769adef6e874d2` | +| The manifest builder, `github.com/dagger/sdk-helpers`, `main` | `fec4ded81c0565ede1d3bc319d60c985ef95ee84` | +| The reference SDK, `dagger/python-sdk#26` ("Update the Python SDK scope interface"), head | `39550254e0152949cb1d5bcb1dff403110c4b00d` (open) | +| The earlier merged Python adoption, `dagger/python-sdk#25`, head | `c05426e0fbef5d758184667d62ddc406591b8192` (merged as `d8f8eca33c75c1113ba8412b3d3ad626a0c9b0ef`) | +| The released engine and CLI this repository's CI runs | `v1.0.0-beta.11` | + +`sdk-ux-module-max` is force-pushed regularly, and it has been force-pushed +twice during this work. Every reference to it below means the commit in this +table, not the branch head at the time of reading. "Tracking the branch" records +what each rewrite changed. + +## Problem + +`dagger/dagger#13992` changes the contract between the engine and an SDK module. +It ships no compatibility adapter. On an engine built from that change, this +repository's root module cannot serve as the Java SDK at all. + +Three removals break it: + +- `CurrentModule.asSDK` is gone. `JavaSdk.modules` selects it, so module + discovery has no source. +- The beta SDK-module interface — `initModule`, `targetRuntime`, and the + `@generate` hook — is gone. `JavaSdk.initModule`, `JavaSdk.targetRuntime`, and + `JavaSdk.generateAll` implement exactly that interface. +- `ModuleSource.generateLocalDependencies` is gone. `Mod.generateModule` selects + it to stage a module's local dependencies before code generation. + +The replacement is two required functions and one optional one, declared in +`core/sdkmodule/provider.go`: + +- `findClientRoot(ws: Workspace!): String` returns the workspace-relative path + of the nearest client root that contains the workspace cwd, or null when there + is none. The result is nullable, and the engine reads it through + `dagql.Nullable`: an SDK reports "no root here" with null, not with the empty + string. The empty string would name a root at the workspace root. +- `generateScope(ws: Workspace!, isModule: Boolean!, name: String!, clients: [ModuleSource!]!): Workspace!` + receives a workspace whose cwd is already the scope, and returns the complete + scope: the starter template and a module manifest when the scope is new, and + freshly generated bindings always. The `clients` list becomes the scope's + dependency set. +- `defaultModulePath(ws: Workspace!, name: String!): String!` is optional. See + Non-goals. + +The engine validates the names, order, and types of those arguments exactly, so +they are not negotiable. + +Registration moves with the interface. An SDK is recorded as +`[sdks.] module = ""` in `dagger.toml`, and each +managed scope as `[sdks..scopes.""]`. + +Three sibling SDKs have adopted the same interface the same way: +`dagger/python-sdk#25` and its follow-up `dagger/python-sdk#26`, +`dagger/go-sdk#37`, and `dagger/dang-sdk#13`. + +## Goals + +1. Implement `findClientRoot` and `generateScope`, and delete the beta interface + they replace. +2. Write a module's `dagger-module.toml` through the shared manifest builder, so + dependency editing stays out of this repository. +3. Turn a module scope's client list into the module's dependency set, so the + generated Java bindings carry each client's types. +4. Re-register this SDK under `[sdks.java]`. +5. Migrate a pre-1.0 `dagger.json` module onto `dagger-module.toml`, preserving + the runtime it already names. +6. Prove the result against a real engine built from `sdk-ux-module-max`, in CI. +7. Leave the pull request's CI green, rather than merging it red as + `dagger/python-sdk#25` did. + +## Non-goals + +- **Standalone clients.** A scope with clients but no module (`isModule: false`) + is refused with an error. This SDK has no mechanism to serve a client outside + a Java module: every generated binding is vendored under a module's `sdk/` + directory and compiled by that module's `pom.xml`. `dagger/python-sdk#26` + refuses the same case for the same reason. Serving standalone clients needs a + separate design — where the generated code goes, what builds it, what depends + on it — not a branch in this change. +- **`defaultModulePath`.** The engine's own default for a `dagger module init` + with no `--path` is `/.dagger/modules/` + (`core/schema/workspace_sdk_module.go`). That is the same convention this + repository already used, so implementing the hook would only restate it. +- **A public single-module generate entry point.** `dagger generate` regenerates + the recorded scopes, and the engine narrows that set to the scopes containing + the caller's cwd, so running it inside a module regenerates that module. A + separate `dagger call java-sdk mod --path … generate` command shape would be + new public surface with its own CLI contract to document and test, and the + engine interface does not need it. `Mod` stays internal. +- **Manifest v2 and generated entrypoints** (`dagger/dagger#14038`). That is a + different engine change, prototyped separately in `dagger/java-sdk#19`. +- **The unified-clients redesign** (`dagger/java-sdk#17`), which replaces module + dependencies with generated clients throughout the Java SDK. This change + adopts one engine interface; it does not redesign the Java client model. +- **Keeping the SDK usable on the released engine.** See "Alternatives + considered". +- **Changing what generation produces.** The vendored SDK sources, the generated + bindings, and the generated entrypoint keep their current layout and build. + +## Proposed approach + +### `findClientRoot` + +A Java module always has a `pom.xml` at its root: the starter template writes +one, and the module's build needs one. Nothing else in a generated module is a +project marker. The vendored SDK under `/sdk` is added to the module's +build as extra source roots and carries no `pom.xml` of its own; the optional +committed SDK jar under `/sdk/repo` is accompanied by a `*.pom` file, +which is not named `pom.xml` and so is not a marker either. + +So `findClientRoot` answers with the directory of the nearest `pom.xml` at or +above the workspace cwd, as a path relative to the workspace root, and with null +when there is none. + +This is the direct analogue of python-sdk's rule — the nearest `pyproject.toml` — +minus the correction python needs. A Python module's vendored client library is +itself an installable Python project with its own `pyproject.toml`, so +python-sdk must lift a hit inside `sdk/` back to the owning module. Java has no +such hit to lift, and `find-client-root-check` pins that: it plants a file +inside a module's `sdk/` tree and asserts the module itself still answers. + +Consequences worth stating: + +- In a Maven multi-module project, the nearest `pom.xml` wins, so a Dagger + module nested inside an aggregator resolves to itself, not to the aggregator. +- A Gradle project has no `pom.xml`, so `findClientRoot` returns null and the + engine reports that client generation is unavailable there. This SDK builds + modules with Maven; that is the correct answer, and the README says so. + +### `generateScope` + +```mermaid +flowchart TD + A["generateScope(ws, isModule, name, clients)"] --> B{isModule} + B -- "no, and clients is empty" --> C["return ws unchanged"] + B -- "no, and clients is not empty" --> D["raise: standalone clients unsupported"] + B -- yes --> E["move the cwd to the workspace root"] + E --> F{"scope already has\ndagger-module.toml\nor dagger.json?"} + F -- no --> G["overlay the starter template"] + F -- yes --> H["keep the scope's files"] + G --> I{"which manifest\nto build from?"} + H --> I + I -- "dagger-module.toml" --> J["load it"] + I -- "dagger.json" --> K["load it"] + I -- neither --> L["load a seed naming\ngithub.com/dagger/java-sdk/runtime"] + J --> M["set the name, clear the dependencies,\nadd one entry per client"] + K --> M + L --> M + M --> N["write dagger-module.toml"] + N --> O{"was there\na dagger.json?"} + O -- yes --> P["delete it"] + O -- no --> Q["vendor the SDK, generate\nthe bindings and the entrypoint"] + P --> Q + Q --> R["restore the scope cwd, return the workspace"] +``` + +`dagger generate` never reaches the `isModule: false, clients empty` branch: the +engine's scope planner skips a scope that is neither a module nor a client +holder (`core/schema/workspace_sdk_generator.go`). The branch exists because +`generateScope` is also callable directly, which is how the end-to-end checks +drive it. + +Five properties of that flow are worth stating separately. + +**Every module scope is generated.** There is no opt-out. An earlier revision of +this design carried one — a `.dagger-java-sdk-skip-generate` marker file that +held an existing module out of bulk regeneration — and it is gone: the marker, +the `skipGenerateFilename` setting, `Mod.skipGenerate`, the marker fixtures, and +the README section that described it. It existed to keep the end-to-end fixtures +out of `dagger generate`, and the fixtures no longer need it, because no fixture +is registered as a scope any more. Removing it also removes the question of what +a half-generated module means: after `generateScope` returns, a module's +manifest, vendored SDK, and entrypoint are always the ones this run produced. + +**The manifest comes from `github.com/dagger/sdk-helpers`.** That module is the +manifest builder, extracted from the engine by #13992; the SDK depends on it, +recorded in both `dagger-module.toml` and `dagger.json` at the repository root, +and reaches it as `sdkHelpers.moduleManifest(loadToml:)` and +`sdkHelpers.moduleManifest(loadJson:)`. The builder parses and serializes both +manifest formats (`tomlFile`, `legacyJSONFile`) and edits dependency entries +(`withLegacyRuntimeDependency`, `withoutLegacyRuntimeDependency`, +`withoutLegacyRuntimeDependencies`). Dependency editing is the part this +repository would otherwise have to implement itself, and the part it must not: +rewriting an existing TOML manifest by hand means parsing and re-emitting a +format the engine owns. + +Depending on `sdk-helpers` does not raise this repository's engine floor. That +module declares `engineVersion = "v0.21.9"`, which the released engine +`v1.0.0-beta.11` satisfies, so the dependency does not stop the SDK module from +loading there. This was checked against the module's own `dagger.json`, not +assumed. + +**A new module's runtime is named by a seed manifest.** The builder's runtime +setters are one per builtin runtime (`withLegacyJavaRuntime`, +`withLegacyGoRuntime`, …) and they write the builtin short name, so +`withLegacyJavaRuntime` writes `source = "java"` — the engine's own Java runtime. +This SDK targets its own repository's build-and-package-only runtime, +`github.com/dagger/java-sdk/runtime`, for which the builder has no setter. +`sdk-helpers` kept the engine's rule: a non-builtin runtime is rejected on a +manifest built from nothing and accepted on one loaded from a config file. So +`seedManifestFile` renders a three-key `dagger-module.toml` — the module name, +the live engine version, and the runtime source — as a `File`, and +`scopeManifest` loads that file through the builder. What lands on disk is +therefore the builder's own rendering, not the seed bytes. + +**Clients become dependencies.** In a module scope, the complete client set +replaces the module's dependency list: the manifest's dependencies are cleared +structurally with `withoutLegacyRuntimeDependencies`, then one entry is added +per client — a git client by its ref as is, a local client by its path relative +to the module, computed with `Path(...).relativeTo(...)`. Clearing by name would +not do. `withoutLegacyRuntimeDependency` matches an unnamed dependency on its +*source*, and reading the recorded names means selecting +`ModuleSource.dependencies`, which resolves every one of them, so a single stale +or unreachable entry would fail generation instead of being dropped. Because the +Java bindings are generated from the module's introspection schema, and that +schema includes its dependencies' types, this is all it takes for a client's +types to appear in the generated bindings. + +**A module with dependencies and no clients loses those dependencies.** That is +the contrapositive of the rule above, it is deliberate, and it is the module-max +model: the client set *is* the dependency set. It is also a real migration +hazard, because the engine's own config migration records only `is-module` and +`name` on a scope and never seeds `clients` from an existing dependency list +(`core/workspace/migrate.go`). A module that has dependencies today therefore +needs each of them re-registered as a client before the first `dagger generate` +under the new interface. The README says so, and `generate-scope-clients-check` +pins the behaviour. + +**Generation runs with the cwd at the workspace root.** The engine resolves a +module's local dependency to a workspace-root-relative path and then reads it +relative to `Workspace.cwd` (`ResolveDepToSource` in `core/modulesource.go`). +With the cwd at the scope, as it is on entry to `generateScope`, a dependency +`../../dep` of `mods/app` is looked up under `mods/app/../../dep` resolved from +`mods/app` — the wrong place. Moving the cwd to the workspace root for the work +and restoring the scope cwd on the result avoids it. The engine requires the +restore in any case: it rejects a `generateScope` result whose cwd is not the +scope. + +### Migrating a pre-1.0 module + +A module configured by `dagger.json` is migrated the first time it is generated. +Its `dagger.json` is loaded through the builder, the resulting manifest is +written as `dagger-module.toml`, and the `dagger.json` is then deleted. The two +files never coexist, so they cannot disagree. + +The migration preserves the runtime the module already names. A module on the +engine's builtin `java` runtime stays on it and does not silently move onto +`github.com/dagger/java-sdk/runtime`; the seed manifest is used only when the +scope has no manifest at all. `generate-scope-migrate-check` asserts exactly +that: the `dagger-module.toml` it produces still carries `source = "java"`. + +### What survives from `mod.dang` + +`Mod` holds everything that is not part of the engine interface: the Maven +codegen containers, the vendored SDK build, the entrypoint compilation, and the +module-relative path arithmetic. None of it is touched by #13992 and all of it is +kept. + +The changes needed there: + +- `Mod.generateModule` selects the removed `ModuleSource.generateLocalDependencies` + to stage local dependencies before reading the module's introspection schema. + The engine now generates scopes in dependency order itself, so the staging + step is removed rather than replaced. +- `generateScope` must return a `Workspace`, not a `Changeset`. `Mod` gains + `generated: Workspace!` — the workspace with this module's generated files + merged in, mirroring python-sdk — and `generate: Changeset!` is deleted rather + than rewritten, because nothing else called it. `Mod.path` goes with it. +- `Mod` took `ws` both as a constructor field and as an argument to `generate`. + The argument goes away, so `Mod` has one workspace. +- `skipGenerateFilename` and `skipGenerate` are deleted with the skip marker. +- The unused `defaultModulePath` and `cleanModulePath` helpers, both left over + from the beta init contract, are deleted. +- The Maven repository cache volume is mounted `LOCKED` rather than shared. + Seeding it from the committed prebuilt repository is a plain recursive copy, + and two generations running at once race on it — + `cp: cannot create directory ...: File exists`. Generating two Java modules + concurrently is ordinary, so the mount serializes rather than the callers. + +`generateScope` constructs `Mod` directly from the scope the engine handed it. +Nothing needs to read the registered scope list, so this SDK never selects +`Workspace.sdk`. python-sdk does, because it keeps a public `mod` that resolves +a module by path; the corresponding fragility — the lookup keys on the SDK's +*install* name in `dagger.toml`, not on its SDK name — does not arise here. + +### Registration + +`dagger.toml` at the repository root gains: + +```toml +[modules.java-sdk] +source = "." +check.skip = ["*"] + +[sdks.java] +module = "java-sdk" +``` + +`[modules.java-sdk]` is required, not decorative: the engine rejects a +`[sdks.]` entry whose `module` is not an installed module. `check.skip` +keeps a released-engine `dagger check` from calling into a module that engine +cannot serve. + +No fixture is registered as a scope. The end-to-end fixtures used to carry their +own nested workspace config, `.dagger/modules/e2e/fixtures/dagger.toml`, and an +intermediate revision of this change moved them into +`[sdks.java.scopes.""]` blocks in the root `dagger.toml`. Both are gone. +The checks build the scopes they drive in memory instead, so there is nothing +for `dagger generate` to walk into and nothing to hold out of it. + +Two registrations go away: + +- `[modules.sdk-sdk]` and the checks it contributes. `github.com/dagger/sdk-sdk` + validates the beta contract this change removes: it asserts that `initModule` + seeds files without writing config, that `dagger sdk install` writes an + `as-sdk` marker, that `dagger module deps list` works. Every one of those + statements is false after this change. `dagger/python-sdk#25` dropped the same + dependency. +- `[modules.e2e]`. The end-to-end module is no longer installed at all; see + "Two engines, two check sets". + +One registration deliberately stays: `[modules.dagger-dang-sdk.as-sdk]`, which +registers this repository's own Dang modules (the root module and +`.dagger/modules/templates`) with the Dang SDK. + +An earlier revision of this design removed it, reasoning that `as-sdk` is +removed by #13992, that dang-sdk has not adopted the replacement +(`dagger/dang-sdk#13` is open), and that silently ignored configuration is worse +than none. That was wrong, and CI said so. dang-sdk's generator fails outright +when it finds no registration — `current module is not installed as an SDK in +this workspace` — rather than reporting an empty module set, so removing the +table turns `dagger-dang-sdk:generate` red. The module-max engine ignores the +table, because its config parser ignores unknown keys, so keeping it costs +nothing there. It comes out when dang-sdk adopts the new interface and can be +registered under `[sdks.dang]` instead. + +## Testing + +### What the released engine can and cannot do + +Every check in `.dagger/modules/e2e` calls into this SDK module, and this SDK +module implements an interface the released engine `v1.0.0-beta.11` does not +have. Those checks therefore run only in an engine built from +`sdk-ux-module-max`. + +Which symbol stops it has moved as the branch moved, so it is worth naming what +was measured rather than repeating an older cause. On `v1.0.0-beta.11`, +`dagger call java-sdk find-client-root --ws .` fails with +`field "withFile" not found in Dagger.Workspace`: `generateScope` writes the +manifest through `Workspace.withFile` and removes a migrated `dagger.json` +through `Workspace.withoutFile`, and that engine has neither. An earlier +revision of this design named the engine's builtin `moduleManifest` instead; +that symbol is no longer selected at all, because the builder moved to +`sdk-helpers`, which the released engine loads happily. + +An earlier revision of this design gated them with `check.skip` instead, and +measured that the gate was sufficient. On `v1.0.0-beta.11`, in a scratch +workspace with two Dang modules where module `ok` depends on module `bad`, and +`bad` has one function selecting a symbol the engine does not have: + +- the workspace loads and `dagger check` enumerates every check; +- `ok:independent`, which does not touch `bad`, passes; +- `ok:touches-bad`, which selects one unrelated field of `bad`, fails; +- with `check.skip = ["*"]` on the module that owns a failing check, the run is + green. + +Two conclusions from that measurement still hold. A failing selection anywhere +in a Dang module poisons every call into it, because Dang infers a whole program +on each call. And `check.skip` suppresses only the `checks` resolver, so +`dagger call` is unaffected by it — which is why `[modules.java-sdk]` can carry +`check.skip = ["*"]` and still serve as the `java` SDK. + +The `e2e` module went further and is not installed in `dagger.toml` at all. An +uninstalled module is never enumerated, so no skip list has to keep up with the +checks it holds, and no future check can be added outside the gate by accident. +`engine-e-2-e:sdk-contract-check` loads it by path instead. + +`dagger/python-sdk#25` had no equivalent gate. Its merged head +(`c05426e0fbef5d758184667d62ddc406591b8192`) carries 19 commit statuses, of +which 11 are red: every `e-2-e:*` check that calls the python-sdk module. It has +one green development-engine check, `engine-e-2-e:dev-sdk-check`, an +initialization smoke test. Its remaining new-interface checks were run by hand +in a development engine and are not covered by CI at all. + +### Two engines, two check sets + +| Where | Engine | What it covers | +| --- | --- | --- | +| `packager:*`, `templates:generate`, `dagger-dang-sdk:generate` | released, `v1.0.0-beta.11` | the SDK library build, its unit tests, the prebuilt assets, the templates | +| `engine-e-2-e:*` | built from `sdk-ux-module-max` at `8fd9b22b5416f8dc7cb420ba37769adef6e874d2` | the whole `findClientRoot` / `generateScope` contract | + +`.dagger/modules/engine-e2e` depends on +`github.com/dagger/dagger/.dagger/modules/engine-dev`, builds the engine from +the pinned commit, and runs it as a playground container with this checkout +mounted inside. + +`engine-e-2-e:dev-sdk-check` is the deliverable, and mirrors python-sdk's: + +1. `dagger sdk list` reports `java`. This proves the registration parses; it + loads no module, so it proves nothing more. +2. `dagger module init java --name … --path …` succeeds. This is the check that + proves the interface: it loads the SDK module, validates its function + signatures against `core/sdkmodule/provider.go`, and calls `generateScope`. + It then asserts the files that call produced — the `dagger-module.toml`, the + absence of a `dagger.json`, the `pom.xml`, the module class, the vendored + bindings, and the generated entrypoint — and that the manifest names + `github.com/dagger/java-sdk/runtime`. +3. `dagger call` against the initialized module proves the generated module + builds with Maven and serves its API. + +`engine-e-2-e:sdk-contract-check` runs the end-to-end checks inside the same +playground, as a single `dagger -m .dagger/modules/e2e check`. Running the whole +module in one invocation, rather than a list of per-check `dagger call` +invocations, means a check added to `.dagger/modules/e2e` is covered without +touching `engine-e2e`. This is coverage `dagger/python-sdk#25` does not have. It +is also the expensive part: the playground engine starts with cold Maven caches, +and this SDK installs its jars under a per-module Maven version on purpose, so +two scopes with different module names share no build. + +The end-to-end checks are: + +| Check | Asserts | +| --- | --- | +| `find-client-root-check` | the nearest `pom.xml` wins; a nested directory resolves to its module; a directory inside a module's vendored `sdk/` resolves to that module; a module of another SDK with no `pom.xml` gives null; the workspace root gives null | +| `generate-scope-init-check` | a config-less scope gets the template, a `dagger-module.toml` naming this repository's runtime, the vendored bindings, and the entrypoint; no `dagger.json` is written; existing files survive; nothing is modified or removed; the cwd is unchanged; regenerating the result changes nothing | +| `generate-scope-migrate-check` | a module whose manifest is a pre-1.0 `dagger.json` gets a `dagger-module.toml` carrying the runtime it already named, and the `dagger.json` is removed | +| `generate-scope-clients-check` | a client is recorded as a dependency in the manifest and its type appears in the generated bindings; dropping the client removes it from both; a scope with no module is untouched; a standalone client raises | +| `nullable-return-check` | a module function returning `Optional` registers an optional return type and unwraps the `Optional` before serialization | + +Those checks build their scopes in memory from the starter template rather than +from committed stubs. With generation always on, a scope a check drives has to +be a module Maven can build, and a config-only stub is not one. They also share +a single module name wherever they need only *a* module, because a second name +costs a second vendored SDK build. + +That cost was measured rather than guessed, on a developer machine with a warm +outer engine. `engine-e-2-e:dev-sdk-check` passes against +`8fd9b22b5416f8dc7cb420ba37769adef6e874d2` in 2m27s with warm caches; earlier +cold runs of the same check took 5–8 minutes. +`engine-e-2-e:sdk-contract-check` was last measured against an earlier pin and +an earlier check set, at 7m16s on a first full pass and 3m29s once the vendored +SDK build was cached; it has not been re-measured against the check set above. +CI starts colder than any of these figures and will be slower; they bound the +shape of the cost, not its exact value. + +Client handling stays inside Dang throughout: the checks call +`javaSdk.generateScope(...)` and diff the result, and never go through +`dagger module client add`. That CLI command is broken on `sdk-ux-module-max` — +it loses the workspace overlay on reload and silently writes nothing, on every +SDK — and the fault is in the CLI (`internal/cmd/dagger/module_sdk.go`), not in +any SDK's `generateScope`. python-sdk's checks avoid it the same way. + +### Known gaps + +One behaviour ships unchecked, deliberately: `dependencySource`'s `GIT_SOURCE` +arm. Recording a git client by its ref as is is not exercised by any check, +because a git `ModuleSource` needs a real remote, which no check here can +produce hermetically. + +An earlier revision of this design listed a second gap — no full generation of a +pre-1.0 `dagger.json` module — because the fixture that drove the `loadJson` +manifest branch sat under the skip marker and never reached Maven. +`generate-scope-migrate-check` closes it: it generates a real module, replaces +its manifest with a `dagger.json`, and generates again. + +### The engine pin + +Both the `engine-dev` dependency and the engine source name a commit of +`sdk-ux-module-max`, so CI does not float with a branch that force-pushes. +Following the branch means bumping both, plus `dagger.lock`. + +`github.com/dagger/sdk-helpers` is depended on without a pin, so it resolves to +whatever its `main` is when the SDK module is loaded. + +## Tracking the branch + +`sdk-ux-module-max` was force-pushed twice while this change was being written, +and each rewrite moved the interface under it. A reader should treat the pinned +commit as a moment in time, not as the definition of the contract. + +**`78c241b6` → `7e6fc93c`.** Two renames, not a re-pin. `detectScope` became +`findClientRoot`, and its result became nullable: the engine reads it through +`dagql.Nullable` and treats an invalid result as absent, so the previous +convention of reporting "no root here" with the empty string would have named a +root at the workspace root. The manifest builder's dependency verbs became +explicit about whose dependencies they are: `withDependency`, +`withoutDependency`, and `withoutDependencies` became +`withLegacyRuntimeDependency`, `withoutLegacyRuntimeDependency`, and +`withoutLegacyRuntimeDependencies`, matching the `LegacyRuntimeDependencies` +field they write. `generateScope`'s signature and the `[sdks..scopes]` +config shape were unchanged. + +**`7e6fc93c` → `8fd9b22b`.** The manifest builder left the engine. `moduleManifest` +is no longer a builtin; it lives in a separate Dang module, +`github.com/dagger/sdk-helpers`, which every SDK adopting this interface now +depends on. The builder's API is otherwise the same, including the rule that a +non-builtin runtime is accepted only on a manifest loaded from a config file. +`generateScope`'s signature and the `[sdks..scopes]` config shape were +again unchanged. + +## Alternatives considered + +**Keep the SDK usable on the released engine.** `generateScope` could render +`dagger-module.toml` as a string instead of going through the manifest builder, +and the module would keep working on `v1.0.0-beta.11`. Rejected: replacing the +complete dependency set of an *existing* manifest means parsing and rewriting +TOML, which Dang cannot do and which would put manifest editing back into this +repository. Seeding a manifest is a different matter — the seed for a *new* +module is hand-rendered TOML, because the builder has no setter for this SDK's +runtime — but a fixed three-key seed is not a TOML editor. The other three SDKs +all take the builder. + +**Regenerate the manifest from scratch instead of loading it.** `dagger/dang-sdk#13` +builds each manifest from a fresh builder and does not merge existing content, +for deterministic output. Rejected here: a Java module's manifest can carry +`include` paths and settings that this SDK did not write and has no business +dropping. + +**Leave a pre-1.0 `dagger.json` in place next to the new `dagger-module.toml`.** +Rejected: two manifest files for one module can disagree, and nothing would say +which one wins. Migrating the contents across and deleting the `dagger.json` +makes the module single-sourced again in one step. + +**Keep a skip marker for opting a module out of generation.** Rejected: its only +consumer was the end-to-end fixture tree, and that tree is no longer registered +as scopes, so the marker protected nothing while adding a second way for a +module's generated state to be stale. + +**Migrate the Dang SDK registration to `[sdks.dang]` at the same time.** It +would replace the `as-sdk` table this change keeps. Rejected: it names dang-sdk +as the provider of an interface dang-sdk does not implement yet, so it would +fail on the very engine it is meant to serve. Removing the table outright was +tried and rejected too — see Registration. + +**Use `withLegacyJavaRuntime` and accept the engine's builtin `java` runtime.** +Rejected: it would silently move every newly created module off this +repository's runtime and onto the engine's, undoing the self-contained layout +that is the point of this SDK. + +**Gate the end-to-end checks with `check.skip` rather than uninstalling the +module.** Rejected: the module has no check that a released engine can run, so +the skip list would always be `["*"]`, and an uninstalled module says the same +thing without a list to maintain. This repository has no CI configuration of its +own, so an explicit include list (`dagger check packager:* templates:*`) was not +available either — the `dagger check` invocation is not ours to change. + +## Affected components + +| Path | Change | +| --- | --- | +| `main.dang`, `main.dang.tmpl` | `findClientRoot`, `generateScope`, `scopeManifest`, `seedManifestFile`, `dependencySource`; `initModule`, `targetRuntime`, `modules`, `generateAll`, `skipGenerateFilename` removed | +| `mod.dang` | `generated: Workspace!`; local-dependency staging removed; single workspace field; skip-marker and dead init helpers removed; `LOCKED` Maven cache mount | +| `dagger-module.toml`, `dagger.json` | the `github.com/dagger/sdk-helpers` dependency | +| `dagger.toml` | `[modules.java-sdk]`, `[sdks.java]`, `[modules.engine-e2e]`; `[modules.sdk-sdk]` and `[modules.e2e]` removed, the dang-sdk `as-sdk` table kept | +| `dagger.lock` | the `engine-dev` dependency closure | +| `.dagger/modules/e2e/main.dang` | checks rewritten against the new interface, driving scopes built in memory | +| `.dagger/modules/e2e/fixtures/**` | `lookup/app` (with `pom.xml` and `nested/`), `lookup/not-java`, `clients/dep`; the config-only stubs and the skip markers removed | +| `.dagger/modules/engine-e2e/` | new: builds the branch engine and checks against it | +| `README.md` | new command shapes, the module-scope model, the dependency migration step, the pre-1.0 migration | + +## Risks + +- **Existing modules lose their dependencies on the first generate.** Described + under "Clients become dependencies" above. Mitigated by a README migration + step and a check, not by code: re-deriving clients from an existing dependency + list is the engine's migration to make, not this SDK's. +- **The branch moves.** `sdk-ux-module-max` force-pushes, twice already during + this work. The pin makes CI reproducible, but it also means the checks + validate a commit, not the branch head. A later engine change can break this + SDK without CI noticing until the pin is bumped. +- **`sdk-helpers` moves too, and is not pinned.** The dependency records no + `pin`, and `dagger.lock` records no commit for it, so a change to its `main` + reaches this SDK without a commit here. Pinning it is the obvious mitigation + and is not done. +- **Nested Java builds are slow.** The `engine-e2e` checks run Maven inside a + development engine inside the outer engine, with cold caches. Java code + generation is the heaviest operation this repository has. A check added later + that generates under a *new module name* pays for a whole vendored SDK build + of its own, because this SDK installs its jars under a per-module Maven + version on purpose; re-measure when one is added. +- **The seed-manifest path depends on a validation detail.** Loading a config + file is what lets a non-builtin runtime through the builder's validation. If + `sdk-helpers` later rejects non-builtin runtimes outright, new Java modules can + no longer name `github.com/dagger/java-sdk/runtime`, and this SDK needs a + builder API for an arbitrary runtime source. That is worth raising on #13992 + independently of this change. +- **`dagger module client add` is broken on the branch.** Client handling is + therefore verified at the API level only. When the CLI is fixed, the + playground checks should drive it end to end. +- **Standalone clients are refused.** A user who runs `dagger module client add` + from a directory that is not a Java module gets an error rather than a + generated client. This matches python-sdk, and is the honest answer while the + Java SDK has nowhere to put such a client. + +## What shipped + +Four commits on top of `be18cc2d64951628a79ae7da626ab2427b6a2436`, then two +rounds of tracking the branch. Each commit leaves the tree in a state that +loads, and none of them registers a module whose source does not yet exist. + +1. **`java-sdk: implement the module-max SDK interface`.** The interface cutover + is one commit because its parts cannot be separated: the moment `main.dang` + drops `initModule`, the end-to-end module that calls it stops compiling. + `main.dang` and `main.dang.tmpl` — kept identical apart from the template + placeholder — lose `targetRuntime`, `initModule`, `modules`, and + `generateAll`, and gain `findClientRoot`, `generateScope`, the + `template: String! = "default"` constructor setting that replaces + `initModule`'s `template` argument, and the private manifest helpers. + `mod.dang` gains `generated: Workspace!` and loses the local-dependency + staging, the duplicated workspace argument, and the dead init helpers. The + root `dagger.toml` gains `[modules.java-sdk]` and `[sdks.java]` and loses + `[modules.sdk-sdk]`. The end-to-end checks are rewritten against the new + interface. +2. **`e2e: check the SDK against an engine built from sdk-ux-module-max`.** Adds + `.dagger/modules/engine-e2e`, its `[modules.engine-e2e]` registration, and the + regenerated `dagger.lock`. +3. **`README: document the module-scope model`.** Rewrites the install, create, + generate, and client sections around `dagger module install`, + `dagger module init java --name … --path …`, and `dagger generate`, and states + the migration obligations. +4. **`workspace: keep the dang-sdk as-sdk registration`.** Restores the + `[modules.dagger-dang-sdk.as-sdk]` table an earlier revision removed, after + `dagger-dang-sdk:generate` failed without it. + +Then, tracking `sdk-ux-module-max`: + +5. **`java-sdk: track sdk-ux-module-max after its force-push`.** Adopts + `findClientRoot` and its nullable result, and the renamed dependency verbs; + re-pins the engine to `7e6fc93c`. +6. **The move to `8fd9b22b`.** Adopts the `github.com/dagger/sdk-helpers` + dependency in place of the removed engine builtin, always writes the manifest + and migrates a pre-1.0 `dagger.json`, deletes the skip marker and everything + that referenced it, replaces the hand-rolled path arithmetic with + `Path(...).relativeTo(...)`, uninstalls the `e2e` module in favour of + `dagger -m .dagger/modules/e2e check`, and rebuilds the fixtures and the check + set around always-on generation. + +Verification: `dagger check` on the released engine for the ungated checks, +`engine-e-2-e:dev-sdk-check` and `engine-e-2-e:sdk-contract-check` against the +pinned engine. This document moves to `hack/designs/done/` once that run is +green, together with `hack/designs/2026-08-17-nullable-object-returns.md`, which +is implemented but still reads as proposed and still describes verification +through `generateAll` and `sdk-sdk:*` — both of which this change removes, and +both of which `nullable-return-check` replaces. diff --git a/main.dang b/main.dang index 061cc6a..b432ef6 100644 --- a/main.dang +++ b/main.dang @@ -1,23 +1,18 @@ """ Manage Dagger modules that use the Java SDK (new self-contained code organisation). -Modules created by `init` use the self-contained layout: the Java SDK is vendored -as source and code is generated into the module (see `generate`), and the module -runtime is this repository's build/package-only runtime +Modules created by this SDK use the self-contained layout: the Java SDK is +vendored as source and code is generated into the module, and the module runtime +is this repository's build/package-only runtime (`github.com/dagger/java-sdk/runtime`). Because the generated files are committed, the runtime skips codegen at module load and just builds and packages the committed sources. """ type JavaSdk { """ - Runtime source written into the dagger-module.toml of new Java modules. - """ - targetRuntime: String! { "github.com/dagger/java-sdk/runtime" } - - """ - Marker filename that skips generate when found at or above a Java SDK module root. + Starter under templates/