diff --git a/contracts/go/seats/nodebuildgo/contract.go b/contracts/go/seats/nodebuildgo/contract.go index 3a31416a..d1a9288c 100644 --- a/contracts/go/seats/nodebuildgo/contract.go +++ b/contracts/go/seats/nodebuildgo/contract.go @@ -233,7 +233,7 @@ type Capacity struct { // CheckAnswer is a type of the contract. type CheckAnswer struct { - Fetch Fetch `json:"fetch"` + Fetch FetchAnswer `json:"fetch"` Levels []LevelAnswer `json:"levels,omitempty"` } @@ -271,6 +271,30 @@ type Fetch struct { Repository string `json:"repository"` } +// FetchAnswer is what a build module's fetch tool answers, whole: the workspace it made, the commit in it, the module directories found; and, when the call gave a base, the merge preview (the trunk commit, the merge commit, the files that conflict, the paths the merge changes, the compiled programs among them, the tree of each module directory). A head that does not merge cleanly is an answer, not an error: merge is empty and conflicts names the files. The Go module adds the sibling repositories placed and the package versions taken; the TypeScript module the directories installed and refused and the group's package. +type FetchAnswer struct { + Base string `json:"base,omitempty"` + Beside []PlacedBeside `json:"beside,omitempty"` + Changed []string `json:"changed,omitempty"` + Commit string `json:"commit"` + Conflicts []string `json:"conflicts,omitempty"` + Group string `json:"group,omitempty"` + Installed []string `json:"installed,omitempty"` + Merge string `json:"merge,omitempty"` + Modules []string `json:"modules"` + Packages []TakenVersion `json:"packages,omitempty"` + Programs []Program `json:"programs,omitempty"` + Refused []FetchRefusal `json:"refused,omitempty"` + Trees map[string]ModuleTree `json:"trees,omitempty"` + Workspace string `json:"workspace"` +} + +// FetchRefusal is a TypeScript module whose packages the fetch did not install, with why. +type FetchRefusal struct { + Module string `json:"module"` + Reason string `json:"reason"` +} + // LevelAnswerDigestNeedsItem is a type of the contract. type LevelAnswerDigestNeedsItem struct { Needs []string `json:"needs,omitempty"` @@ -316,6 +340,16 @@ type LevelAnswer struct { Unmet []LevelAnswerUnmetItem `json:"unmet,omitempty"` } +// ModuleTree is the tree of one module directory in a merge preview: its git tree, the digest of its manifest, the lock files' digests and the digests of the named paths. +type ModuleTree struct { + Locks map[string]string `json:"locks,omitempty"` + Manifest string `json:"manifest"` + // no longer filled (ADR 0593 section 2); kept so a preview written before still reads + Packages []string `json:"packages,omitempty"` + Paths map[string]string `json:"paths,omitempty"` + Tree string `json:"tree"` +} + // Pause is whether the node takes no new ask. type Pause struct { // when (RFC 3339 time) @@ -326,6 +360,21 @@ type Pause struct { V int64 `json:"v,omitempty"` } +// PlacedBeside is a sibling repository placed as a plain directory under the workspace's beside directory. +type PlacedBeside struct { + Commit string `json:"commit"` + Dir string `json:"dir"` + Repository string `json:"repository"` +} + +// Program is a changed file that is a compiled program (issue 1577): its path, its executable format, and the commit of the branch that added it when the merge's tree no longer holds it. +type Program struct { + Commit string `json:"commit,omitempty"` + // ELF, Mach-O or PE + Kind string `json:"kind"` + Path string `json:"path"` +} + // Step is one step a build module runs on a node. type Step struct { // when (RFC 3339 time) @@ -361,13 +410,17 @@ type StepAskBuild struct { // StepAskCheck is a type of the contract. type StepAskCheck struct { - Fetch Fetch `json:"fetch"` - Kept bool `json:"kept,omitempty"` - Levels []string `json:"levels"` + Fetch Fetch `json:"fetch"` + Kept bool `json:"kept,omitempty"` + Levels []string `json:"levels"` + // the node names the check's tests run against + NodeNames []string `json:"node-names,omitempty"` Packages []string `json:"packages,omitempty"` Run string `json:"run,omitempty"` Runner string `json:"runner,omitempty"` RunnerWhen string `json:"runner_when,omitempty"` + // the toolchain the checker agreed for the language (ADR 0584 clause 2): a node on another declines the check + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` } // StepAskCompose is a type of the contract. @@ -405,10 +458,10 @@ type StepResult struct { Artifacts []BuiltArtifact `json:"artifacts,omitempty"` Built bool `json:"built,omitempty"` // the holder declined the ask: its node lacks what the ask needs - Declined bool `json:"declined,omitempty"` - Fetch *Fetch `json:"fetch,omitempty"` - Imports []string `json:"imports,omitempty"` - Key string `json:"key,omitempty"` + Declined bool `json:"declined,omitempty"` + Fetch *FetchAnswer `json:"fetch,omitempty"` + Imports []string `json:"imports,omitempty"` + Key string `json:"key,omitempty"` // what the holder's node lacks of it Lacks []string `json:"lacks,omitempty"` Levels []LevelAnswer `json:"levels,omitempty"` @@ -420,6 +473,17 @@ type StepResult struct { Verdict any `json:"verdict,omitempty"` } +// TakenVersion is of a directory whose go.mod requires an asked mesh package, the version it is built against: the asked one, or go.mod's when it names a newer one, said. +type TakenVersion struct { + Asked string `json:"asked"` + Built string `json:"built"` + Dir string `json:"dir"` + Named string `json:"named"` + Package string `json:"package"` + Said string `json:"said,omitempty"` + Sum string `json:"sum,omitempty"` +} + // Toolchain is the toolchain a node reports. type Toolchain struct { // when (RFC 3339 time) diff --git a/contracts/go/seats/nodebuildgo/contract_test.go b/contracts/go/seats/nodebuildgo/contract_test.go index 4aabcc73..9148353a 100644 --- a/contracts/go/seats/nodebuildgo/contract_test.go +++ b/contracts/go/seats/nodebuildgo/contract_test.go @@ -39,14 +39,16 @@ func TestFixtures(t *testing.T) { }, subjectIs: nil}, {name: "a build ask", subject: "mesh.seat.node-build-go.accept.delivery", given: "{\"asker\":\"mesh-delivery-builder\",\"build\":{\"artifacts\":[{\"artifact\":\"go\"}],\"module\":\"nats\"},\"id\":\"a1b2\",\"kind\":\"build\",\"language\":\"go\"}", wire: "{\"asker\":\"mesh-delivery-builder\",\"build\":{\"artifacts\":[{\"artifact\":\"go\"}],\"module\":\"nats\"},\"id\":\"a1b2\",\"kind\":\"build\",\"language\":\"go\"}", decode: func(b []byte) (any, error) { var v DeliveryAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-go\\.accept\\.delivery$")}, - {name: "a check ask with its fetch, siblings and levels", subject: "mesh.seat.node-build-go.accept.check", given: "{\"asker\":\"mesh-delivery-checker\",\"at\":\"2026-11-05T10:00:00Z\",\"check\":{\"fetch\":{\"base\":\"89abcdef0123456789abcdef0123456789abcdef\",\"beside\":[{\"commit\":\"fedcba9876543210fedcba9876543210fedcba98\",\"repository\":\"novox/mesh-sdk\"}],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"packages\":{\"git.novox.be/novox/mesh-sdk/go\":\"fedcba9876543210fedcba9876543210fedcba98\"},\"ref\":\"refs/pull/7/head\",\"repository\":\"novox/mesh-catalog\"},\"kept\":true,\"levels\":[\"unit\",\"integration\"]},\"id\":\"c3d4\",\"kind\":\"check\",\"language\":\"go\",\"v\":1}", wire: "{\"asker\":\"mesh-delivery-checker\",\"at\":\"2026-11-05T10:00:00Z\",\"check\":{\"fetch\":{\"base\":\"89abcdef0123456789abcdef0123456789abcdef\",\"beside\":[{\"commit\":\"fedcba9876543210fedcba9876543210fedcba98\",\"repository\":\"novox/mesh-sdk\"}],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"packages\":{\"git.novox.be/novox/mesh-sdk/go\":\"fedcba9876543210fedcba9876543210fedcba98\"},\"ref\":\"refs/pull/7/head\",\"repository\":\"novox/mesh-catalog\"},\"kept\":true,\"levels\":[\"unit\",\"integration\"]},\"id\":\"c3d4\",\"kind\":\"check\",\"language\":\"go\",\"v\":1}", + {name: "a check ask with its fetch, siblings and levels", subject: "mesh.seat.node-build-go.accept.check", given: "{\"asker\":\"mesh-delivery-checker\",\"at\":\"2026-11-05T10:00:00Z\",\"check\":{\"fetch\":{\"base\":\"89abcdef0123456789abcdef0123456789abcdef\",\"beside\":[{\"commit\":\"fedcba9876543210fedcba9876543210fedcba98\",\"repository\":\"novox/mesh-sdk\"}],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"packages\":{\"git.novox.be/novox/mesh-sdk/go\":\"fedcba9876543210fedcba9876543210fedcba98\"},\"ref\":\"refs/pull/7/head\",\"repository\":\"novox/mesh-catalog\"},\"kept\":true,\"levels\":[\"unit\",\"integration\"],\"node-names\":[\"ace\",\"g14\"],\"toolchain\":{\"language\":\"go\",\"recipe\":\"2\",\"version\":\"1.26.0\"}},\"id\":\"c3d4\",\"kind\":\"check\",\"language\":\"go\",\"v\":1}", wire: "{\"asker\":\"mesh-delivery-checker\",\"at\":\"2026-11-05T10:00:00Z\",\"check\":{\"fetch\":{\"base\":\"89abcdef0123456789abcdef0123456789abcdef\",\"beside\":[{\"commit\":\"fedcba9876543210fedcba9876543210fedcba98\",\"repository\":\"novox/mesh-sdk\"}],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"packages\":{\"git.novox.be/novox/mesh-sdk/go\":\"fedcba9876543210fedcba9876543210fedcba98\"},\"ref\":\"refs/pull/7/head\",\"repository\":\"novox/mesh-catalog\"},\"kept\":true,\"levels\":[\"unit\",\"integration\"],\"node-names\":[\"ace\",\"g14\"],\"toolchain\":{\"language\":\"go\",\"recipe\":\"2\",\"version\":\"1.26.0\"}},\"id\":\"c3d4\",\"kind\":\"check\",\"language\":\"go\",\"v\":1}", decode: func(b []byte) (any, error) { var v CheckAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-go\\.accept\\.check$")}, {name: "a finished build step with its artifacts", subject: "", given: "{\"ended\":\"2026-11-05T10:03:00Z\",\"id\":\"a1b2\",\"node\":\"ace\",\"result\":{\"artifacts\":[{\"artifact\":\"go\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"reference\":\"container-image-registry/nats/go@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}],\"imports\":[\"git.novox.be/novox/mesh-sdk/go\"],\"toolchain\":{\"language\":\"go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"build\",\"v\":1}", wire: "{\"ended\":\"2026-11-05T10:03:00Z\",\"id\":\"a1b2\",\"node\":\"ace\",\"result\":{\"artifacts\":[{\"artifact\":\"go\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"reference\":\"container-image-registry/nats/go@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}],\"imports\":[\"git.novox.be/novox/mesh-sdk/go\"],\"toolchain\":{\"language\":\"go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"build\",\"v\":1}", decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, - {name: "a finished check step with its levels", subject: "", given: "{\"ended\":\"2026-11-05T10:06:00Z\",\"id\":\"c3d4\",\"node\":\"ace\",\"result\":{\"fetch\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"repository\":\"novox/mesh-catalog\"},\"levels\":[{\"digest\":{\"level\":\"unit\",\"packages\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"package\":\"./modules/a\"}]},\"kept\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"package\":\"./modules/a\",\"tests\":12}],\"level\":\"unit\",\"tests\":[{\"package\":\"./modules/b\",\"pass\":true}],\"unmet\":[{\"needs\":[\"docker\"],\"package\":\"./modules/c\"}]}]},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", wire: "{\"ended\":\"2026-11-05T10:06:00Z\",\"id\":\"c3d4\",\"node\":\"ace\",\"result\":{\"fetch\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"repository\":\"novox/mesh-catalog\"},\"levels\":[{\"digest\":{\"level\":\"unit\",\"packages\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"package\":\"./modules/a\"}]},\"kept\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"package\":\"./modules/a\",\"tests\":12}],\"level\":\"unit\",\"tests\":[{\"package\":\"./modules/b\",\"pass\":true}],\"unmet\":[{\"needs\":[\"docker\"],\"package\":\"./modules/c\"}]}]},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", + {name: "a finished check step with its levels", subject: "", given: "{\"ended\":\"2026-11-05T10:06:00Z\",\"id\":\"c3d4\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"changed\":[\"a.go\",\"modules/m/main.go\"],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"merge\":\"fedcba9876543210fedcba9876543210fedcba98\",\"modules\":[\".\"],\"trees\":{\"modules/m\":{\"locks\":{\"go.sum\":\"c5fc83c01e92404452b986527d239140ccf9a48b88e0c268fbf38c2e1429e9c9\"},\"manifest\":\"ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356\",\"tree\":\"1111111111111111111111111111111111111111\"}},\"workspace\":\"mvii1iwu5414e4\"},\"levels\":[{\"digest\":{\"level\":\"unit\",\"packages\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"package\":\"./modules/a\"}]},\"kept\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"package\":\"./modules/a\",\"tests\":12}],\"level\":\"unit\",\"tests\":[{\"package\":\"./modules/b\",\"pass\":true}],\"unmet\":[{\"needs\":[\"docker\"],\"package\":\"./modules/c\"}]}]},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", wire: "{\"ended\":\"2026-11-05T10:06:00Z\",\"id\":\"c3d4\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"changed\":[\"a.go\",\"modules/m/main.go\"],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"merge\":\"fedcba9876543210fedcba9876543210fedcba98\",\"modules\":[\".\"],\"trees\":{\"modules/m\":{\"locks\":{\"go.sum\":\"c5fc83c01e92404452b986527d239140ccf9a48b88e0c268fbf38c2e1429e9c9\"},\"manifest\":\"ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356\",\"tree\":\"1111111111111111111111111111111111111111\"}},\"workspace\":\"mvii1iwu5414e4\"},\"levels\":[{\"digest\":{\"level\":\"unit\",\"packages\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"package\":\"./modules/a\"}]},\"kept\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"package\":\"./modules/a\",\"tests\":12}],\"level\":\"unit\",\"tests\":[{\"package\":\"./modules/b\",\"pass\":true}],\"unmet\":[{\"needs\":[\"docker\"],\"package\":\"./modules/c\"}]}]},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, {name: "a declined step", subject: "", given: "{\"id\":\"c3d5\",\"node\":\"ace\",\"result\":{\"declined\":true,\"lacks\":[\"docker\"],\"needs\":[\"docker\"]},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"refused\",\"tool\":\"check\",\"v\":1}", wire: "{\"id\":\"c3d5\",\"node\":\"ace\",\"result\":{\"declined\":true,\"lacks\":[\"docker\"],\"needs\":[\"docker\"]},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"refused\",\"tool\":\"check\",\"v\":1}", decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a finished check step whose head does not merge", subject: "", given: "{\"ended\":\"2026-11-05T10:01:00Z\",\"id\":\"c3d5\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"conflicts\":[\"a.go\",\"sub/b.go\"],\"modules\":[\".\"],\"workspace\":\"mvii1iwz5050eb\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", wire: "{\"ended\":\"2026-11-05T10:01:00Z\",\"id\":\"c3d5\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"conflicts\":[\"a.go\",\"sub/b.go\"],\"modules\":[\".\"],\"workspace\":\"mvii1iwz5050eb\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", + decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, } for _, c := range cases { v, err := c.decode([]byte(c.given)) diff --git a/contracts/go/seats/nodebuildimage/contract.go b/contracts/go/seats/nodebuildimage/contract.go index e5445d8a..69fc44b7 100644 --- a/contracts/go/seats/nodebuildimage/contract.go +++ b/contracts/go/seats/nodebuildimage/contract.go @@ -233,7 +233,7 @@ type Capacity struct { // CheckAnswer is a type of the contract. type CheckAnswer struct { - Fetch Fetch `json:"fetch"` + Fetch FetchAnswer `json:"fetch"` Levels []LevelAnswer `json:"levels,omitempty"` } @@ -271,6 +271,30 @@ type Fetch struct { Repository string `json:"repository"` } +// FetchAnswer is what a build module's fetch tool answers, whole: the workspace it made, the commit in it, the module directories found; and, when the call gave a base, the merge preview (the trunk commit, the merge commit, the files that conflict, the paths the merge changes, the compiled programs among them, the tree of each module directory). A head that does not merge cleanly is an answer, not an error: merge is empty and conflicts names the files. The Go module adds the sibling repositories placed and the package versions taken; the TypeScript module the directories installed and refused and the group's package. +type FetchAnswer struct { + Base string `json:"base,omitempty"` + Beside []PlacedBeside `json:"beside,omitempty"` + Changed []string `json:"changed,omitempty"` + Commit string `json:"commit"` + Conflicts []string `json:"conflicts,omitempty"` + Group string `json:"group,omitempty"` + Installed []string `json:"installed,omitempty"` + Merge string `json:"merge,omitempty"` + Modules []string `json:"modules"` + Packages []TakenVersion `json:"packages,omitempty"` + Programs []Program `json:"programs,omitempty"` + Refused []FetchRefusal `json:"refused,omitempty"` + Trees map[string]ModuleTree `json:"trees,omitempty"` + Workspace string `json:"workspace"` +} + +// FetchRefusal is a TypeScript module whose packages the fetch did not install, with why. +type FetchRefusal struct { + Module string `json:"module"` + Reason string `json:"reason"` +} + // LevelAnswerDigestNeedsItem is a type of the contract. type LevelAnswerDigestNeedsItem struct { Needs []string `json:"needs,omitempty"` @@ -316,6 +340,16 @@ type LevelAnswer struct { Unmet []LevelAnswerUnmetItem `json:"unmet,omitempty"` } +// ModuleTree is the tree of one module directory in a merge preview: its git tree, the digest of its manifest, the lock files' digests and the digests of the named paths. +type ModuleTree struct { + Locks map[string]string `json:"locks,omitempty"` + Manifest string `json:"manifest"` + // no longer filled (ADR 0593 section 2); kept so a preview written before still reads + Packages []string `json:"packages,omitempty"` + Paths map[string]string `json:"paths,omitempty"` + Tree string `json:"tree"` +} + // Pause is whether the node takes no new ask. type Pause struct { // when (RFC 3339 time) @@ -326,6 +360,21 @@ type Pause struct { V int64 `json:"v,omitempty"` } +// PlacedBeside is a sibling repository placed as a plain directory under the workspace's beside directory. +type PlacedBeside struct { + Commit string `json:"commit"` + Dir string `json:"dir"` + Repository string `json:"repository"` +} + +// Program is a changed file that is a compiled program (issue 1577): its path, its executable format, and the commit of the branch that added it when the merge's tree no longer holds it. +type Program struct { + Commit string `json:"commit,omitempty"` + // ELF, Mach-O or PE + Kind string `json:"kind"` + Path string `json:"path"` +} + // Step is one step a build module runs on a node. type Step struct { // when (RFC 3339 time) @@ -361,13 +410,17 @@ type StepAskBuild struct { // StepAskCheck is a type of the contract. type StepAskCheck struct { - Fetch Fetch `json:"fetch"` - Kept bool `json:"kept,omitempty"` - Levels []string `json:"levels"` + Fetch Fetch `json:"fetch"` + Kept bool `json:"kept,omitempty"` + Levels []string `json:"levels"` + // the node names the check's tests run against + NodeNames []string `json:"node-names,omitempty"` Packages []string `json:"packages,omitempty"` Run string `json:"run,omitempty"` Runner string `json:"runner,omitempty"` RunnerWhen string `json:"runner_when,omitempty"` + // the toolchain the checker agreed for the language (ADR 0584 clause 2): a node on another declines the check + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` } // StepAskCompose is a type of the contract. @@ -405,10 +458,10 @@ type StepResult struct { Artifacts []BuiltArtifact `json:"artifacts,omitempty"` Built bool `json:"built,omitempty"` // the holder declined the ask: its node lacks what the ask needs - Declined bool `json:"declined,omitempty"` - Fetch *Fetch `json:"fetch,omitempty"` - Imports []string `json:"imports,omitempty"` - Key string `json:"key,omitempty"` + Declined bool `json:"declined,omitempty"` + Fetch *FetchAnswer `json:"fetch,omitempty"` + Imports []string `json:"imports,omitempty"` + Key string `json:"key,omitempty"` // what the holder's node lacks of it Lacks []string `json:"lacks,omitempty"` Levels []LevelAnswer `json:"levels,omitempty"` @@ -420,6 +473,17 @@ type StepResult struct { Verdict any `json:"verdict,omitempty"` } +// TakenVersion is of a directory whose go.mod requires an asked mesh package, the version it is built against: the asked one, or go.mod's when it names a newer one, said. +type TakenVersion struct { + Asked string `json:"asked"` + Built string `json:"built"` + Dir string `json:"dir"` + Named string `json:"named"` + Package string `json:"package"` + Said string `json:"said,omitempty"` + Sum string `json:"sum,omitempty"` +} + // Toolchain is the toolchain a node reports. type Toolchain struct { // when (RFC 3339 time) diff --git a/contracts/go/seats/nodebuildnode/contract.go b/contracts/go/seats/nodebuildnode/contract.go index 9f2abc00..51a5a76e 100644 --- a/contracts/go/seats/nodebuildnode/contract.go +++ b/contracts/go/seats/nodebuildnode/contract.go @@ -233,7 +233,7 @@ type Capacity struct { // CheckAnswer is a type of the contract. type CheckAnswer struct { - Fetch Fetch `json:"fetch"` + Fetch FetchAnswer `json:"fetch"` Levels []LevelAnswer `json:"levels,omitempty"` } @@ -271,6 +271,30 @@ type Fetch struct { Repository string `json:"repository"` } +// FetchAnswer is what a build module's fetch tool answers, whole: the workspace it made, the commit in it, the module directories found; and, when the call gave a base, the merge preview (the trunk commit, the merge commit, the files that conflict, the paths the merge changes, the compiled programs among them, the tree of each module directory). A head that does not merge cleanly is an answer, not an error: merge is empty and conflicts names the files. The Go module adds the sibling repositories placed and the package versions taken; the TypeScript module the directories installed and refused and the group's package. +type FetchAnswer struct { + Base string `json:"base,omitempty"` + Beside []PlacedBeside `json:"beside,omitempty"` + Changed []string `json:"changed,omitempty"` + Commit string `json:"commit"` + Conflicts []string `json:"conflicts,omitempty"` + Group string `json:"group,omitempty"` + Installed []string `json:"installed,omitempty"` + Merge string `json:"merge,omitempty"` + Modules []string `json:"modules"` + Packages []TakenVersion `json:"packages,omitempty"` + Programs []Program `json:"programs,omitempty"` + Refused []FetchRefusal `json:"refused,omitempty"` + Trees map[string]ModuleTree `json:"trees,omitempty"` + Workspace string `json:"workspace"` +} + +// FetchRefusal is a TypeScript module whose packages the fetch did not install, with why. +type FetchRefusal struct { + Module string `json:"module"` + Reason string `json:"reason"` +} + // LevelAnswerDigestNeedsItem is a type of the contract. type LevelAnswerDigestNeedsItem struct { Needs []string `json:"needs,omitempty"` @@ -316,6 +340,16 @@ type LevelAnswer struct { Unmet []LevelAnswerUnmetItem `json:"unmet,omitempty"` } +// ModuleTree is the tree of one module directory in a merge preview: its git tree, the digest of its manifest, the lock files' digests and the digests of the named paths. +type ModuleTree struct { + Locks map[string]string `json:"locks,omitempty"` + Manifest string `json:"manifest"` + // no longer filled (ADR 0593 section 2); kept so a preview written before still reads + Packages []string `json:"packages,omitempty"` + Paths map[string]string `json:"paths,omitempty"` + Tree string `json:"tree"` +} + // Pause is whether the node takes no new ask. type Pause struct { // when (RFC 3339 time) @@ -326,6 +360,21 @@ type Pause struct { V int64 `json:"v,omitempty"` } +// PlacedBeside is a sibling repository placed as a plain directory under the workspace's beside directory. +type PlacedBeside struct { + Commit string `json:"commit"` + Dir string `json:"dir"` + Repository string `json:"repository"` +} + +// Program is a changed file that is a compiled program (issue 1577): its path, its executable format, and the commit of the branch that added it when the merge's tree no longer holds it. +type Program struct { + Commit string `json:"commit,omitempty"` + // ELF, Mach-O or PE + Kind string `json:"kind"` + Path string `json:"path"` +} + // Step is one step a build module runs on a node. type Step struct { // when (RFC 3339 time) @@ -361,13 +410,17 @@ type StepAskBuild struct { // StepAskCheck is a type of the contract. type StepAskCheck struct { - Fetch Fetch `json:"fetch"` - Kept bool `json:"kept,omitempty"` - Levels []string `json:"levels"` + Fetch Fetch `json:"fetch"` + Kept bool `json:"kept,omitempty"` + Levels []string `json:"levels"` + // the node names the check's tests run against + NodeNames []string `json:"node-names,omitempty"` Packages []string `json:"packages,omitempty"` Run string `json:"run,omitempty"` Runner string `json:"runner,omitempty"` RunnerWhen string `json:"runner_when,omitempty"` + // the toolchain the checker agreed for the language (ADR 0584 clause 2): a node on another declines the check + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` } // StepAskCompose is a type of the contract. @@ -405,10 +458,10 @@ type StepResult struct { Artifacts []BuiltArtifact `json:"artifacts,omitempty"` Built bool `json:"built,omitempty"` // the holder declined the ask: its node lacks what the ask needs - Declined bool `json:"declined,omitempty"` - Fetch *Fetch `json:"fetch,omitempty"` - Imports []string `json:"imports,omitempty"` - Key string `json:"key,omitempty"` + Declined bool `json:"declined,omitempty"` + Fetch *FetchAnswer `json:"fetch,omitempty"` + Imports []string `json:"imports,omitempty"` + Key string `json:"key,omitempty"` // what the holder's node lacks of it Lacks []string `json:"lacks,omitempty"` Levels []LevelAnswer `json:"levels,omitempty"` @@ -420,6 +473,17 @@ type StepResult struct { Verdict any `json:"verdict,omitempty"` } +// TakenVersion is of a directory whose go.mod requires an asked mesh package, the version it is built against: the asked one, or go.mod's when it names a newer one, said. +type TakenVersion struct { + Asked string `json:"asked"` + Built string `json:"built"` + Dir string `json:"dir"` + Named string `json:"named"` + Package string `json:"package"` + Said string `json:"said,omitempty"` + Sum string `json:"sum,omitempty"` +} + // Toolchain is the toolchain a node reports. type Toolchain struct { // when (RFC 3339 time) diff --git a/contracts/go/seats/nodebuildnode/contract_test.go b/contracts/go/seats/nodebuildnode/contract_test.go index 9bfd4c18..70e0c3a9 100644 --- a/contracts/go/seats/nodebuildnode/contract_test.go +++ b/contracts/go/seats/nodebuildnode/contract_test.go @@ -39,6 +39,8 @@ func TestFixtures(t *testing.T) { }, subjectIs: nil}, {name: "a build ask", subject: "mesh.seat.node-build-node.accept.delivery", given: "{\"asker\":\"mesh-delivery-builder\",\"build\":{\"artifacts\":[{\"artifact\":\"go\"}],\"module\":\"nats\"},\"id\":\"a1b2\",\"kind\":\"build\",\"language\":\"go\"}", wire: "{\"asker\":\"mesh-delivery-builder\",\"build\":{\"artifacts\":[{\"artifact\":\"go\"}],\"module\":\"nats\"},\"id\":\"a1b2\",\"kind\":\"build\",\"language\":\"go\"}", decode: func(b []byte) (any, error) { var v DeliveryAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-node\\.accept\\.delivery$")}, + {name: "a finished check step of a TypeScript merge preview", subject: "", given: "{\"ended\":\"2026-11-05T10:01:00Z\",\"id\":\"c3d6\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"changed\":[\"modules/m/package.json\"],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"merge\":\"0123456789abcdef0123456789abcdef01234567\",\"modules\":[\"modules/m\"],\"trees\":{\"modules/m\":{\"manifest\":\"0581b699d9d05517e69b21f5cedbeb2eee8aba2b205280b561be731594d7feef\",\"tree\":\"0123456789abcdef0123456789abcdef01234567\"}},\"workspace\":\"mvii1sxt979f67\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", wire: "{\"ended\":\"2026-11-05T10:01:00Z\",\"id\":\"c3d6\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"changed\":[\"modules/m/package.json\"],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"merge\":\"0123456789abcdef0123456789abcdef01234567\",\"modules\":[\"modules/m\"],\"trees\":{\"modules/m\":{\"manifest\":\"0581b699d9d05517e69b21f5cedbeb2eee8aba2b205280b561be731594d7feef\",\"tree\":\"0123456789abcdef0123456789abcdef01234567\"}},\"workspace\":\"mvii1sxt979f67\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", + decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, } for _, c := range cases { v, err := c.decode([]byte(c.given)) diff --git a/contracts/parts/build-step.json b/contracts/parts/build-step.json index 55920376..c272a8c2 100644 --- a/contracts/parts/build-step.json +++ b/contracts/parts/build-step.json @@ -130,6 +130,17 @@ "items": { "type": "string" } + }, + "toolchain": { + "$ref": "#/defs/ToolchainUsed", + "description": "the toolchain the checker agreed for the language (ADR 0584 clause 2): a node on another declines the check" + }, + "node-names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the node names the check's tests run against" } }, "required": [ @@ -218,7 +229,7 @@ "type": "object", "properties": { "fetch": { - "$ref": "#/defs/Fetch" + "$ref": "#/defs/FetchAnswer" }, "levels": { "type": "array", @@ -680,7 +691,7 @@ } }, "fetch": { - "$ref": "#/defs/Fetch" + "$ref": "#/defs/FetchAnswer" }, "levels": { "type": "array", @@ -716,6 +727,211 @@ "description": "what the holder's node lacks of it" } } + }, + "ModuleTree": { + "type": "object", + "description": "the tree of one module directory in a merge preview: its git tree, the digest of its manifest, the lock files' digests and the digests of the named paths", + "properties": { + "tree": { + "type": "string" + }, + "manifest": { + "type": "string" + }, + "locks": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "paths": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "packages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "no longer filled (ADR 0593 section 2); kept so a preview written before still reads" + } + }, + "required": [ + "tree", + "manifest" + ] + }, + "Program": { + "type": "object", + "description": "a changed file that is a compiled program (issue 1577): its path, its executable format, and the commit of the branch that added it when the merge's tree no longer holds it", + "properties": { + "path": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "ELF, Mach-O or PE" + }, + "commit": { + "type": "string" + } + }, + "required": [ + "path", + "kind" + ] + }, + "PlacedBeside": { + "type": "object", + "description": "a sibling repository placed as a plain directory under the workspace's beside directory", + "properties": { + "repository": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "dir": { + "type": "string" + } + }, + "required": [ + "repository", + "commit", + "dir" + ] + }, + "TakenVersion": { + "type": "object", + "description": "of a directory whose go.mod requires an asked mesh package, the version it is built against: the asked one, or go.mod's when it names a newer one, said", + "properties": { + "dir": { + "type": "string" + }, + "package": { + "type": "string" + }, + "asked": { + "type": "string" + }, + "named": { + "type": "string" + }, + "built": { + "type": "string" + }, + "sum": { + "type": "string" + }, + "said": { + "type": "string" + } + }, + "required": [ + "dir", + "package", + "asked", + "named", + "built" + ] + }, + "FetchRefusal": { + "type": "object", + "description": "a TypeScript module whose packages the fetch did not install, with why", + "properties": { + "module": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "module", + "reason" + ] + }, + "FetchAnswer": { + "type": "object", + "description": "what a build module's fetch tool answers, whole: the workspace it made, the commit in it, the module directories found; and, when the call gave a base, the merge preview (the trunk commit, the merge commit, the files that conflict, the paths the merge changes, the compiled programs among them, the tree of each module directory). A head that does not merge cleanly is an answer, not an error: merge is empty and conflicts names the files. The Go module adds the sibling repositories placed and the package versions taken; the TypeScript module the directories installed and refused and the group's package", + "properties": { + "workspace": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "modules": { + "type": "array", + "items": { + "type": "string" + } + }, + "base": { + "type": "string" + }, + "merge": { + "type": "string" + }, + "conflicts": { + "type": "array", + "items": { + "type": "string" + } + }, + "changed": { + "type": "array", + "items": { + "type": "string" + } + }, + "programs": { + "type": "array", + "items": { + "$ref": "#/defs/Program" + } + }, + "trees": { + "type": "object", + "additionalProperties": { + "$ref": "#/defs/ModuleTree" + } + }, + "beside": { + "type": "array", + "items": { + "$ref": "#/defs/PlacedBeside" + } + }, + "packages": { + "type": "array", + "items": { + "$ref": "#/defs/TakenVersion" + } + }, + "installed": { + "type": "array", + "items": { + "type": "string" + } + }, + "refused": { + "type": "array", + "items": { + "$ref": "#/defs/FetchRefusal" + } + }, + "group": { + "type": "string" + } + }, + "required": [ + "workspace", + "commit", + "modules" + ] } } } diff --git a/contracts/seats/node-build-go/fixtures/states.json b/contracts/seats/node-build-go/fixtures/states.json index 5193a6af..1a231fd5 100644 --- a/contracts/seats/node-build-go/fixtures/states.json +++ b/contracts/seats/node-build-go/fixtures/states.json @@ -91,7 +91,16 @@ "unit", "integration" ], - "kept": true + "kept": true, + "toolchain": { + "language": "go", + "version": "1.26.0", + "recipe": "2" + }, + "node-names": [ + "ace", + "g14" + ] } } }, @@ -138,8 +147,26 @@ "ended": "2026-11-05T10:06:00Z", "result": { "fetch": { - "repository": "novox/mesh-catalog", - "commit": "0123456789abcdef0123456789abcdef01234567" + "workspace": "mvii1iwu5414e4", + "commit": "0123456789abcdef0123456789abcdef01234567", + "modules": [ + "." + ], + "base": "abcdef0123456789abcdef0123456789abcdef01", + "merge": "fedcba9876543210fedcba9876543210fedcba98", + "changed": [ + "a.go", + "modules/m/main.go" + ], + "trees": { + "modules/m": { + "tree": "1111111111111111111111111111111111111111", + "manifest": "ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356", + "locks": { + "go.sum": "c5fc83c01e92404452b986527d239140ccf9a48b88e0c268fbf38c2e1429e9c9" + } + } + } }, "levels": [ { @@ -199,5 +226,32 @@ ] } } + }, + { + "name": "a finished check step whose head does not merge", + "of": "state:steps/.", + "given": { + "v": 1, + "id": "c3d5", + "tool": "check", + "node": "ace", + "state": "passed", + "started": "2026-11-05T10:00:00Z", + "ended": "2026-11-05T10:01:00Z", + "result": { + "fetch": { + "workspace": "mvii1iwz5050eb", + "commit": "0123456789abcdef0123456789abcdef01234567", + "modules": [ + "." + ], + "base": "abcdef0123456789abcdef0123456789abcdef01", + "conflicts": [ + "a.go", + "sub/b.go" + ] + } + } + } } ] diff --git a/contracts/seats/node-build-node/fixtures/states.json b/contracts/seats/node-build-node/fixtures/states.json index 9a80337a..a9f265f5 100644 --- a/contracts/seats/node-build-node/fixtures/states.json +++ b/contracts/seats/node-build-node/fixtures/states.json @@ -59,5 +59,38 @@ ] } } + }, + { + "name": "a finished check step of a TypeScript merge preview", + "of": "state:steps/.", + "given": { + "v": 1, + "id": "c3d6", + "tool": "check", + "node": "ace", + "state": "passed", + "started": "2026-11-05T10:00:00Z", + "ended": "2026-11-05T10:01:00Z", + "result": { + "fetch": { + "workspace": "mvii1sxt979f67", + "commit": "0123456789abcdef0123456789abcdef01234567", + "modules": [ + "modules/m" + ], + "base": "abcdef0123456789abcdef0123456789abcdef01", + "merge": "0123456789abcdef0123456789abcdef01234567", + "changed": [ + "modules/m/package.json" + ], + "trees": { + "modules/m": { + "tree": "0123456789abcdef0123456789abcdef01234567", + "manifest": "0581b699d9d05517e69b21f5cedbeb2eee8aba2b205280b561be731594d7feef" + } + } + } + } + } } ] diff --git a/contracts/ts/seats/nodebuildgo/src/index.ts b/contracts/ts/seats/nodebuildgo/src/index.ts index 48102965..83d2a60a 100644 --- a/contracts/ts/seats/nodebuildgo/src/index.ts +++ b/contracts/ts/seats/nodebuildgo/src/index.ts @@ -111,16 +111,21 @@ export type ComposeAnswer = buildStep.ComposeAnswer; export type CurrentAnswer = buildStep.CurrentAnswer; export type CurrentInput = buildStep.CurrentInput; export type Fetch = buildStep.Fetch; +export type FetchAnswer = buildStep.FetchAnswer; export type FetchBesideItem = buildStep.FetchBesideItem; +export type FetchRefusal = buildStep.FetchRefusal; export type LevelAnswer = buildStep.LevelAnswer; export type LevelAnswerDigest = buildStep.LevelAnswerDigest; export type LevelAnswerDigestNeedsItem = buildStep.LevelAnswerDigestNeedsItem; export type LevelAnswerDigestPackagesItem = buildStep.LevelAnswerDigestPackagesItem; export type LevelAnswerKeptItem = buildStep.LevelAnswerKeptItem; export type LevelAnswerUnmetItem = buildStep.LevelAnswerUnmetItem; +export type ModuleTree = buildStep.ModuleTree; export type Pause = buildStep.Pause; export type PauseAnswer = buildStep.PauseAnswer; export type PauseInput = buildStep.PauseInput; +export type PlacedBeside = buildStep.PlacedBeside; +export type Program = buildStep.Program; export type ResumeAnswer = buildStep.ResumeAnswer; export type ResumeInput = buildStep.ResumeInput; export type Step = buildStep.Step; @@ -129,6 +134,7 @@ export type StepAskBuild = buildStep.StepAskBuild; export type StepAskCheck = buildStep.StepAskCheck; export type StepAskCompose = buildStep.StepAskCompose; export type StepResult = buildStep.StepResult; +export type TakenVersion = buildStep.TakenVersion; export type Toolchain = buildStep.Toolchain; export type ToolchainUsed = buildStep.ToolchainUsed; diff --git a/contracts/ts/seats/nodebuildgo/src/parts/build-step.ts b/contracts/ts/seats/nodebuildgo/src/parts/build-step.ts index 4ca1eb95..c61ca3bc 100644 --- a/contracts/ts/seats/nodebuildgo/src/parts/build-step.ts +++ b/contracts/ts/seats/nodebuildgo/src/parts/build-step.ts @@ -51,7 +51,7 @@ export interface Capacity { } export interface CheckAnswer { - "fetch": Fetch; + "fetch": FetchAnswer; "levels"?: LevelAnswer[]; } @@ -87,6 +87,30 @@ export interface Fetch { "repository": string; } +/** what a build module's fetch tool answers, whole: the workspace it made, the commit in it, the module directories found; and, when the call gave a base, the merge preview (the trunk commit, the merge commit, the files that conflict, the paths the merge changes, the compiled programs among them, the tree of each module directory). A head that does not merge cleanly is an answer, not an error: merge is empty and conflicts names the files. The Go module adds the sibling repositories placed and the package versions taken; the TypeScript module the directories installed and refused and the group's package */ +export interface FetchAnswer { + "base"?: string; + "beside"?: PlacedBeside[]; + "changed"?: string[]; + "commit": string; + "conflicts"?: string[]; + "group"?: string; + "installed"?: string[]; + "merge"?: string; + "modules": string[]; + "packages"?: TakenVersion[]; + "programs"?: Program[]; + "refused"?: FetchRefusal[]; + "trees"?: Record; + "workspace": string; +} + +/** a TypeScript module whose packages the fetch did not install, with why */ +export interface FetchRefusal { + "module": string; + "reason": string; +} + export interface LevelAnswerDigestNeedsItem { "needs"?: string[]; "package"?: string; @@ -127,6 +151,16 @@ export interface LevelAnswer { "unmet"?: LevelAnswerUnmetItem[]; } +/** the tree of one module directory in a merge preview: its git tree, the digest of its manifest, the lock files' digests and the digests of the named paths */ +export interface ModuleTree { + "locks"?: Record; + "manifest": string; + /** no longer filled (ADR 0593 section 2); kept so a preview written before still reads */ + "packages"?: string[]; + "paths"?: Record; + "tree": string; +} + /** whether the node takes no new ask */ export interface Pause { /** when (RFC 3339 time) */ @@ -137,6 +171,21 @@ export interface Pause { "v"?: number; } +/** a sibling repository placed as a plain directory under the workspace's beside directory */ +export interface PlacedBeside { + "commit": string; + "dir": string; + "repository": string; +} + +/** a changed file that is a compiled program (issue 1577): its path, its executable format, and the commit of the branch that added it when the merge's tree no longer holds it */ +export interface Program { + "commit"?: string; + /** ELF, Mach-O or PE */ + "kind": string; + "path": string; +} + /** one step a build module runs on a node */ export interface Step { /** when (RFC 3339 time) */ @@ -173,10 +222,14 @@ export interface StepAskCheck { "fetch": Fetch; "kept"?: boolean; "levels": string[]; + /** the node names the check's tests run against */ + "node-names"?: string[]; "packages"?: string[]; "run"?: string; "runner"?: string; "runner_when"?: string; + /** the toolchain the checker agreed for the language (ADR 0584 clause 2): a node on another declines the check */ + "toolchain"?: ToolchainUsed; } export interface StepAskCompose { @@ -214,7 +267,7 @@ export interface StepResult { "built"?: boolean; /** the holder declined the ask: its node lacks what the ask needs */ "declined"?: boolean; - "fetch"?: Fetch; + "fetch"?: FetchAnswer; "imports"?: string[]; "key"?: string; /** what the holder's node lacks of it */ @@ -228,6 +281,17 @@ export interface StepResult { "verdict"?: unknown; } +/** of a directory whose go.mod requires an asked mesh package, the version it is built against: the asked one, or go.mod's when it names a newer one, said */ +export interface TakenVersion { + "asked": string; + "built": string; + "dir": string; + "named": string; + "package": string; + "said"?: string; + "sum"?: string; +} + /** the toolchain a node reports */ export interface Toolchain { /** when (RFC 3339 time) */ diff --git a/contracts/ts/seats/nodebuildimage/src/index.ts b/contracts/ts/seats/nodebuildimage/src/index.ts index f0312a18..d0745fcc 100644 --- a/contracts/ts/seats/nodebuildimage/src/index.ts +++ b/contracts/ts/seats/nodebuildimage/src/index.ts @@ -111,16 +111,21 @@ export type ComposeAnswer = buildStep.ComposeAnswer; export type CurrentAnswer = buildStep.CurrentAnswer; export type CurrentInput = buildStep.CurrentInput; export type Fetch = buildStep.Fetch; +export type FetchAnswer = buildStep.FetchAnswer; export type FetchBesideItem = buildStep.FetchBesideItem; +export type FetchRefusal = buildStep.FetchRefusal; export type LevelAnswer = buildStep.LevelAnswer; export type LevelAnswerDigest = buildStep.LevelAnswerDigest; export type LevelAnswerDigestNeedsItem = buildStep.LevelAnswerDigestNeedsItem; export type LevelAnswerDigestPackagesItem = buildStep.LevelAnswerDigestPackagesItem; export type LevelAnswerKeptItem = buildStep.LevelAnswerKeptItem; export type LevelAnswerUnmetItem = buildStep.LevelAnswerUnmetItem; +export type ModuleTree = buildStep.ModuleTree; export type Pause = buildStep.Pause; export type PauseAnswer = buildStep.PauseAnswer; export type PauseInput = buildStep.PauseInput; +export type PlacedBeside = buildStep.PlacedBeside; +export type Program = buildStep.Program; export type ResumeAnswer = buildStep.ResumeAnswer; export type ResumeInput = buildStep.ResumeInput; export type Step = buildStep.Step; @@ -129,6 +134,7 @@ export type StepAskBuild = buildStep.StepAskBuild; export type StepAskCheck = buildStep.StepAskCheck; export type StepAskCompose = buildStep.StepAskCompose; export type StepResult = buildStep.StepResult; +export type TakenVersion = buildStep.TakenVersion; export type Toolchain = buildStep.Toolchain; export type ToolchainUsed = buildStep.ToolchainUsed; diff --git a/contracts/ts/seats/nodebuildimage/src/parts/build-step.ts b/contracts/ts/seats/nodebuildimage/src/parts/build-step.ts index 4ca1eb95..c61ca3bc 100644 --- a/contracts/ts/seats/nodebuildimage/src/parts/build-step.ts +++ b/contracts/ts/seats/nodebuildimage/src/parts/build-step.ts @@ -51,7 +51,7 @@ export interface Capacity { } export interface CheckAnswer { - "fetch": Fetch; + "fetch": FetchAnswer; "levels"?: LevelAnswer[]; } @@ -87,6 +87,30 @@ export interface Fetch { "repository": string; } +/** what a build module's fetch tool answers, whole: the workspace it made, the commit in it, the module directories found; and, when the call gave a base, the merge preview (the trunk commit, the merge commit, the files that conflict, the paths the merge changes, the compiled programs among them, the tree of each module directory). A head that does not merge cleanly is an answer, not an error: merge is empty and conflicts names the files. The Go module adds the sibling repositories placed and the package versions taken; the TypeScript module the directories installed and refused and the group's package */ +export interface FetchAnswer { + "base"?: string; + "beside"?: PlacedBeside[]; + "changed"?: string[]; + "commit": string; + "conflicts"?: string[]; + "group"?: string; + "installed"?: string[]; + "merge"?: string; + "modules": string[]; + "packages"?: TakenVersion[]; + "programs"?: Program[]; + "refused"?: FetchRefusal[]; + "trees"?: Record; + "workspace": string; +} + +/** a TypeScript module whose packages the fetch did not install, with why */ +export interface FetchRefusal { + "module": string; + "reason": string; +} + export interface LevelAnswerDigestNeedsItem { "needs"?: string[]; "package"?: string; @@ -127,6 +151,16 @@ export interface LevelAnswer { "unmet"?: LevelAnswerUnmetItem[]; } +/** the tree of one module directory in a merge preview: its git tree, the digest of its manifest, the lock files' digests and the digests of the named paths */ +export interface ModuleTree { + "locks"?: Record; + "manifest": string; + /** no longer filled (ADR 0593 section 2); kept so a preview written before still reads */ + "packages"?: string[]; + "paths"?: Record; + "tree": string; +} + /** whether the node takes no new ask */ export interface Pause { /** when (RFC 3339 time) */ @@ -137,6 +171,21 @@ export interface Pause { "v"?: number; } +/** a sibling repository placed as a plain directory under the workspace's beside directory */ +export interface PlacedBeside { + "commit": string; + "dir": string; + "repository": string; +} + +/** a changed file that is a compiled program (issue 1577): its path, its executable format, and the commit of the branch that added it when the merge's tree no longer holds it */ +export interface Program { + "commit"?: string; + /** ELF, Mach-O or PE */ + "kind": string; + "path": string; +} + /** one step a build module runs on a node */ export interface Step { /** when (RFC 3339 time) */ @@ -173,10 +222,14 @@ export interface StepAskCheck { "fetch": Fetch; "kept"?: boolean; "levels": string[]; + /** the node names the check's tests run against */ + "node-names"?: string[]; "packages"?: string[]; "run"?: string; "runner"?: string; "runner_when"?: string; + /** the toolchain the checker agreed for the language (ADR 0584 clause 2): a node on another declines the check */ + "toolchain"?: ToolchainUsed; } export interface StepAskCompose { @@ -214,7 +267,7 @@ export interface StepResult { "built"?: boolean; /** the holder declined the ask: its node lacks what the ask needs */ "declined"?: boolean; - "fetch"?: Fetch; + "fetch"?: FetchAnswer; "imports"?: string[]; "key"?: string; /** what the holder's node lacks of it */ @@ -228,6 +281,17 @@ export interface StepResult { "verdict"?: unknown; } +/** of a directory whose go.mod requires an asked mesh package, the version it is built against: the asked one, or go.mod's when it names a newer one, said */ +export interface TakenVersion { + "asked": string; + "built": string; + "dir": string; + "named": string; + "package": string; + "said"?: string; + "sum"?: string; +} + /** the toolchain a node reports */ export interface Toolchain { /** when (RFC 3339 time) */ diff --git a/contracts/ts/seats/nodebuildnode/src/index.ts b/contracts/ts/seats/nodebuildnode/src/index.ts index 99f5963c..0c66706f 100644 --- a/contracts/ts/seats/nodebuildnode/src/index.ts +++ b/contracts/ts/seats/nodebuildnode/src/index.ts @@ -111,16 +111,21 @@ export type ComposeAnswer = buildStep.ComposeAnswer; export type CurrentAnswer = buildStep.CurrentAnswer; export type CurrentInput = buildStep.CurrentInput; export type Fetch = buildStep.Fetch; +export type FetchAnswer = buildStep.FetchAnswer; export type FetchBesideItem = buildStep.FetchBesideItem; +export type FetchRefusal = buildStep.FetchRefusal; export type LevelAnswer = buildStep.LevelAnswer; export type LevelAnswerDigest = buildStep.LevelAnswerDigest; export type LevelAnswerDigestNeedsItem = buildStep.LevelAnswerDigestNeedsItem; export type LevelAnswerDigestPackagesItem = buildStep.LevelAnswerDigestPackagesItem; export type LevelAnswerKeptItem = buildStep.LevelAnswerKeptItem; export type LevelAnswerUnmetItem = buildStep.LevelAnswerUnmetItem; +export type ModuleTree = buildStep.ModuleTree; export type Pause = buildStep.Pause; export type PauseAnswer = buildStep.PauseAnswer; export type PauseInput = buildStep.PauseInput; +export type PlacedBeside = buildStep.PlacedBeside; +export type Program = buildStep.Program; export type ResumeAnswer = buildStep.ResumeAnswer; export type ResumeInput = buildStep.ResumeInput; export type Step = buildStep.Step; @@ -129,6 +134,7 @@ export type StepAskBuild = buildStep.StepAskBuild; export type StepAskCheck = buildStep.StepAskCheck; export type StepAskCompose = buildStep.StepAskCompose; export type StepResult = buildStep.StepResult; +export type TakenVersion = buildStep.TakenVersion; export type Toolchain = buildStep.Toolchain; export type ToolchainUsed = buildStep.ToolchainUsed; diff --git a/contracts/ts/seats/nodebuildnode/src/parts/build-step.ts b/contracts/ts/seats/nodebuildnode/src/parts/build-step.ts index 4ca1eb95..c61ca3bc 100644 --- a/contracts/ts/seats/nodebuildnode/src/parts/build-step.ts +++ b/contracts/ts/seats/nodebuildnode/src/parts/build-step.ts @@ -51,7 +51,7 @@ export interface Capacity { } export interface CheckAnswer { - "fetch": Fetch; + "fetch": FetchAnswer; "levels"?: LevelAnswer[]; } @@ -87,6 +87,30 @@ export interface Fetch { "repository": string; } +/** what a build module's fetch tool answers, whole: the workspace it made, the commit in it, the module directories found; and, when the call gave a base, the merge preview (the trunk commit, the merge commit, the files that conflict, the paths the merge changes, the compiled programs among them, the tree of each module directory). A head that does not merge cleanly is an answer, not an error: merge is empty and conflicts names the files. The Go module adds the sibling repositories placed and the package versions taken; the TypeScript module the directories installed and refused and the group's package */ +export interface FetchAnswer { + "base"?: string; + "beside"?: PlacedBeside[]; + "changed"?: string[]; + "commit": string; + "conflicts"?: string[]; + "group"?: string; + "installed"?: string[]; + "merge"?: string; + "modules": string[]; + "packages"?: TakenVersion[]; + "programs"?: Program[]; + "refused"?: FetchRefusal[]; + "trees"?: Record; + "workspace": string; +} + +/** a TypeScript module whose packages the fetch did not install, with why */ +export interface FetchRefusal { + "module": string; + "reason": string; +} + export interface LevelAnswerDigestNeedsItem { "needs"?: string[]; "package"?: string; @@ -127,6 +151,16 @@ export interface LevelAnswer { "unmet"?: LevelAnswerUnmetItem[]; } +/** the tree of one module directory in a merge preview: its git tree, the digest of its manifest, the lock files' digests and the digests of the named paths */ +export interface ModuleTree { + "locks"?: Record; + "manifest": string; + /** no longer filled (ADR 0593 section 2); kept so a preview written before still reads */ + "packages"?: string[]; + "paths"?: Record; + "tree": string; +} + /** whether the node takes no new ask */ export interface Pause { /** when (RFC 3339 time) */ @@ -137,6 +171,21 @@ export interface Pause { "v"?: number; } +/** a sibling repository placed as a plain directory under the workspace's beside directory */ +export interface PlacedBeside { + "commit": string; + "dir": string; + "repository": string; +} + +/** a changed file that is a compiled program (issue 1577): its path, its executable format, and the commit of the branch that added it when the merge's tree no longer holds it */ +export interface Program { + "commit"?: string; + /** ELF, Mach-O or PE */ + "kind": string; + "path": string; +} + /** one step a build module runs on a node */ export interface Step { /** when (RFC 3339 time) */ @@ -173,10 +222,14 @@ export interface StepAskCheck { "fetch": Fetch; "kept"?: boolean; "levels": string[]; + /** the node names the check's tests run against */ + "node-names"?: string[]; "packages"?: string[]; "run"?: string; "runner"?: string; "runner_when"?: string; + /** the toolchain the checker agreed for the language (ADR 0584 clause 2): a node on another declines the check */ + "toolchain"?: ToolchainUsed; } export interface StepAskCompose { @@ -214,7 +267,7 @@ export interface StepResult { "built"?: boolean; /** the holder declined the ask: its node lacks what the ask needs */ "declined"?: boolean; - "fetch"?: Fetch; + "fetch"?: FetchAnswer; "imports"?: string[]; "key"?: string; /** what the holder's node lacks of it */ @@ -228,6 +281,17 @@ export interface StepResult { "verdict"?: unknown; } +/** of a directory whose go.mod requires an asked mesh package, the version it is built against: the asked one, or go.mod's when it names a newer one, said */ +export interface TakenVersion { + "asked": string; + "built": string; + "dir": string; + "named": string; + "package": string; + "said"?: string; + "sum"?: string; +} + /** the toolchain a node reports */ export interface Toolchain { /** when (RFC 3339 time) */ diff --git a/sdk/buildstep/ask.go b/sdk/buildstep/ask.go index be85a8af..7e46704e 100644 --- a/sdk/buildstep/ask.go +++ b/sdk/buildstep/ask.go @@ -258,6 +258,9 @@ type CheckJob struct { Run string `json:"run,omitempty"` Packages []string `json:"packages,omitempty"` NodeNames []string `json:"node-names,omitempty"` + // Toolchain is the toolchain the checker agreed for the language (ADR 0584 clause 2): a holder whose node reports + // another version declines the check. + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` } // ComposeJob asks one compose-candidate build. @@ -409,6 +412,9 @@ func (c CheckJob) check(id string) error { return fmt.Errorf("buildstep: the check ask %s names a package %q that is no import path", id, p) } } + if t := c.Toolchain; t != nil && (t.Language == "" || t.Version == "" || t.Recipe == "") { + return fmt.Errorf("buildstep: the check ask %s names a toolchain without its language, version and recipe", id) + } seen := map[string]bool{} for _, n := range c.NodeNames { if err := meshkeys.CheckName("node", n); err != nil || strings.Contains(n, ",") || seen[n] { @@ -520,10 +526,81 @@ type AskBuilt struct { // CheckAnswer is what a check ask's step answers. type CheckAnswer struct { - Fetch Fetch `json:"fetch"` + Fetch FetchAnswer `json:"fetch"` Levels []LevelAnswer `json:"levels,omitempty"` } +// FetchAnswer is what a build module's fetch tool answers, whole: the part's `FetchAnswer`. A check keeps it as the +// answer's fetch, so the checker reads a conflict (a head that does not merge is an answer, not an error), the changed +// paths, the programs and the trees from it. +type FetchAnswer struct { + Workspace string `json:"workspace"` + Commit string `json:"commit"` + Modules []string `json:"modules"` + // The merge preview, only when the call gave a base. + Base string `json:"base,omitempty"` + Merge string `json:"merge,omitempty"` + Conflicts []string `json:"conflicts,omitempty"` + Changed []string `json:"changed,omitempty"` + Programs []Program `json:"programs,omitempty"` + Trees map[string]ModuleTree `json:"trees,omitempty"` + // The Go module's. + Beside []PlacedBeside `json:"beside,omitempty"` + Packages []TakenVersion `json:"packages,omitempty"` + // The TypeScript module's. + Installed []string `json:"installed,omitempty"` + Refused []FetchRefusal `json:"refused,omitempty"` + Group string `json:"group,omitempty"` +} + +// Program is a changed file that is a compiled program (issue 1577). +type Program struct { + Path string `json:"path"` + Kind string `json:"kind"` // ELF, Mach-O or PE + Commit string `json:"commit,omitempty"` +} + +// PlacedBeside is a sibling repository placed as a plain directory under the workspace's beside directory. +type PlacedBeside struct { + Repository string `json:"repository"` + Commit string `json:"commit"` + Dir string `json:"dir"` +} + +// TakenVersion is, of a directory whose go.mod requires an asked mesh package, the version it is built against. +type TakenVersion struct { + Dir string `json:"dir"` + Package string `json:"package"` + Asked string `json:"asked"` + Named string `json:"named"` + Built string `json:"built"` + Sum string `json:"sum,omitempty"` + Said string `json:"said,omitempty"` +} + +// FetchRefusal is a TypeScript module whose packages the fetch did not install, with why. +type FetchRefusal struct { + Module string `json:"module"` + Reason string `json:"reason"` +} + +// StepResult is what any step answered: one layout for the four kinds, as the seats' `StepResult` (the fields of the +// kinds do not meet). A step's result bytes read into it strictly. +type StepResult struct { + Artifacts []AskBuilt `json:"artifacts,omitempty"` + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` + Imports []string `json:"imports,omitempty"` + OutOfStep []string `json:"out-of-step,omitempty"` + Fetch *FetchAnswer `json:"fetch,omitempty"` + Levels []LevelAnswer `json:"levels,omitempty"` + Built bool `json:"built,omitempty"` + Key string `json:"key,omitempty"` + Verdict json.RawMessage `json:"verdict,omitempty"` + Declined bool `json:"declined,omitempty"` + Needs []string `json:"needs,omitempty"` + Lacks []string `json:"lacks,omitempty"` +} + // ComposeAnswer is what a compose ask's step answers. type ComposeAnswer struct { Built bool `json:"built"` diff --git a/sdk/buildstep/ask_test.go b/sdk/buildstep/ask_test.go index 07b2d67a..e75ef4a6 100644 --- a/sdk/buildstep/ask_test.go +++ b/sdk/buildstep/ask_test.go @@ -72,6 +72,19 @@ func TestAskCheckAndRoundTrip(t *testing.T) { t.Errorf("a level: %v", err) } chk = goodCheck() + chk.Check.Toolchain = &ToolchainUsed{Language: "go", Version: "1.26.0", Recipe: "2"} + raw, err := EncodeStepAsk(chk) + if err != nil { + t.Fatal(err) + } + if back, err := DecodeStepAsk(raw); err != nil || back.Check.Toolchain == nil || back.Check.Toolchain.Version != "1.26.0" { + t.Errorf("the check's toolchain is lost in a round trip: %+v, %v", back.Check, err) + } + chk.Check.Toolchain.Recipe = "" + if _, err := EncodeStepAsk(chk); err == nil || !strings.Contains(err.Error(), "toolchain") { + t.Errorf("a toolchain without a recipe: %v", err) + } + chk = goodCheck() chk.Language = AskImage if _, err := EncodeStepAsk(chk); err == nil || !strings.Contains(err.Error(), "no tests") { t.Errorf("an image check: %v", err) @@ -251,6 +264,11 @@ func TestTheThreeSeatsFixturesFitTheNeutralForms(t *testing.T) { case strings.HasPrefix(f.Of, "state:steps/"): seen++ strict(t, f.Given, &Step{}) + var st Step + if err := json.Unmarshal(f.Given, &st); err == nil && len(st.Result) > 0 { + // a step's result keeps what the tool answered, whole (the fetch tool's answer too) + strict(t, st.Result, &StepResult{}) + } } }) } diff --git a/sdk/buildstep/toolchain.go b/sdk/buildstep/toolchain.go new file mode 100644 index 00000000..9b655926 --- /dev/null +++ b/sdk/buildstep/toolchain.go @@ -0,0 +1,81 @@ +package buildstep + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "time" +) + +// ToolchainVersion is the layout version of a toolchains value. +const ToolchainVersion = 1 + +// Toolchain is the value of a build module's state toolchains under the key .: the version of the +// language's toolchain installed on the node, the build module's recipe version, and when the module saw them. It is +// the part build-step's `Toolchain`, the layout every node-build seat gives that state; the three build modules share +// this neutral form (a seat's generated type is one package per seat). A test reads each seat's fixture strictly into it. +type Toolchain struct { + V int `json:"v,omitempty"` + Language string `json:"language"` + Version string `json:"version"` + Recipe string `json:"recipe"` + At time.Time `json:"at,omitzero"` +} + +func checkToolchainLanguage(language string) error { + switch language { + case "go", "typescript": + return nil + } + return fmt.Errorf("buildstep: the toolchain language %q is not go or typescript", language) +} + +// ToolchainKey is the key of a node's toolchain of one language: .. It refuses an empty node, a node +// with a dot and a language other than go or typescript. +func ToolchainKey(node, language string) (string, error) { + if node == "" || strings.ContainsAny(node, ". ") { + return "", fmt.Errorf("buildstep: %q is not a node name", node) + } + if err := checkToolchainLanguage(language); err != nil { + return "", err + } + return node + "." + language, nil +} + +func (t Toolchain) check() error { + if err := checkToolchainLanguage(t.Language); err != nil { + return err + } + if t.Version == "" || t.Recipe == "" { + return fmt.Errorf("buildstep: a %s toolchain needs its version and its recipe", t.Language) + } + return nil +} + +// EncodeToolchain is t as JSON; it refuses a language other than go or typescript and an empty version or recipe. +func EncodeToolchain(t Toolchain) ([]byte, error) { + if err := t.check(); err != nil { + return nil, err + } + t.V = ToolchainVersion + return json.Marshal(t) +} + +// DecodeToolchain reads a toolchains value, refusing an unknown field, another layout version and what +// EncodeToolchain refuses. +func DecodeToolchain(data []byte) (Toolchain, error) { + var t Toolchain + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&t); err != nil { + return Toolchain{}, fmt.Errorf("buildstep: a toolchain does not read: %w", err) + } + if t.V != ToolchainVersion { + return Toolchain{}, fmt.Errorf("buildstep: a toolchain of version %d, this reads %d", t.V, ToolchainVersion) + } + if err := t.check(); err != nil { + return Toolchain{}, err + } + return t, nil +} diff --git a/sdk/buildstep/toolchain_test.go b/sdk/buildstep/toolchain_test.go new file mode 100644 index 00000000..3cff6eea --- /dev/null +++ b/sdk/buildstep/toolchain_test.go @@ -0,0 +1,80 @@ +package buildstep + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// The toolchains value and key are the layout all three node-build seats generate: each seat's fixture of the state +// reads strictly into Toolchain, and the key is the seat's key form .. +func TestTheToolchainsValueAndKeyAreTheSeatsLayout(t *testing.T) { + read := 0 + for _, seat := range []string{"node-build-go", "node-build-image", "node-build-node"} { + raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "seats", seat, "fixtures", "states.json")) + if err != nil { + t.Fatal(err) + } + var fixtures []struct { + Of string `json:"of"` + Given json.RawMessage `json:"given"` + } + if err := json.Unmarshal(raw, &fixtures); err != nil { + t.Fatal(err) + } + for _, f := range fixtures { + if f.Of != "state:toolchains/." { + continue + } + if back, err := DecodeToolchain(f.Given); err != nil || back.Version == "" { + t.Errorf("%s: the fixture reads as %+v, %v", seat, back, err) + } + if _, err := EncodeToolchain(mustToolchain(t, f.Given)); err != nil { + t.Errorf("%s: %v", seat, err) + } + dec := json.NewDecoder(bytes.NewReader(f.Given)) + dec.DisallowUnknownFields() + if err := dec.Decode(&Toolchain{}); err != nil { + t.Errorf("%s: %v", seat, err) + } + read++ + } + } + if read != 3 { + t.Errorf("%d toolchain fixtures read, want 3", read) + } + raw, err := EncodeToolchain(Toolchain{Language: "go", Version: "go1.27.1", Recipe: "3", At: time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC)}) + if err != nil { + t.Fatal(err) + } + if back, err := DecodeToolchain(raw); err != nil || back.Version != "go1.27.1" || back.At.IsZero() { + t.Errorf("the value reads back as %+v, %v", back, err) + } + if key, err := ToolchainKey("ace", "typescript"); err != nil || key != "ace.typescript" { + t.Errorf("key %q, %v", key, err) + } + for _, bad := range [][2]string{{"", "go"}, {"a.b", "go"}, {"ace", "rust"}} { + if _, err := ToolchainKey(bad[0], bad[1]); err == nil { + t.Errorf("the key of %v was made", bad) + } + } + if _, err := EncodeToolchain(Toolchain{Language: "go", Version: "go1.27"}); err == nil { + t.Error("a toolchain without its recipe was written") + } + if _, err := DecodeToolchain([]byte(`{"v":1,"language":"go","version":"x","recipe":"1","extra":1}`)); err == nil || !strings.Contains(err.Error(), "extra") { + t.Errorf("a field the layout lacks was read: %v", err) + } +} + +func mustToolchain(t *testing.T, raw []byte) Toolchain { + t.Helper() + var tc Toolchain + if err := json.Unmarshal(raw, &tc); err != nil { + t.Fatal(err) + } + return tc +}