From 8f520a5a9086078a4001f1555663a5b021ea959a Mon Sep 17 00:00:00 2001 From: Rick Guo Date: Sun, 12 Jul 2026 16:11:15 +0800 Subject: [PATCH 1/4] feat(formula): discover matrix keys with SSA tracker --- AGENTS.md | 63 +++++ doc/matrix-options-tracker.md | 53 ++++ internal/formula/formula.go | 12 +- internal/formula/formula_test.go | 26 ++ internal/formula/probe.go | 67 +++++ .../formula/testdata/formula/matrix_llar.gox | 20 ++ .../testdata/formula/trackcases_llar.gox | 77 ++++++ .../testdata/formula/trackfilter_llar.gox | 8 + .../testdata/formula/trackpanic_llar.gox | 12 + internal/formula/tracker.go | 249 ++++++++++++++++++ internal/formula/tracker_test.go | 78 ++++++ 11 files changed, 663 insertions(+), 2 deletions(-) create mode 100644 doc/matrix-options-tracker.md create mode 100644 internal/formula/probe.go create mode 100644 internal/formula/testdata/formula/matrix_llar.gox create mode 100644 internal/formula/testdata/formula/trackcases_llar.gox create mode 100644 internal/formula/testdata/formula/trackfilter_llar.gox create mode 100644 internal/formula/testdata/formula/trackpanic_llar.gox create mode 100644 internal/formula/tracker.go create mode 100644 internal/formula/tracker_test.go diff --git a/AGENTS.md b/AGENTS.md index 219b0c45..efd68cba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,69 @@ func main() { 3. **Error Handling**: If a file doesn't match any registered classfile pattern (wrong suffix), `BuildFile` returns "undefined" errors for DSL functions +### Classfile `var` Fields + +In an XGo classfile, the first top-level `var (...)` declaration is the class +fields declaration. These names compile into fields on the generated struct, +not package-level variables. If the fields have initializers, XGo generates an +`XGo_Init()` method and calls it before the classfile body runs. + +Source file `hello_llar.gox`: +```gox +var ( + seen map[string]bool = {} +) + +func mark(key string) { + seen[key] = true +} + +id "DaveGamble/cJSON" +fromVer "v1.0.0" + +onRequire (proj, deps) => { + mark "zlib" +} +``` + +Generated Go code: +```go +package main + +import "github.com/goplus/llar/formula" + +type hello struct { + formula.ModuleF + seen map[string]bool +} + +func (this *hello) mark(key string) { + this.seen[key] = true +} + +func (this *hello) MainEntry() { + this.XGo_Init() + this.Id("DaveGamble/cJSON") + this.FromVer("v1.0.0") + this.OnRequire(func(proj *formula.Project, deps *formula.ModuleDeps) { + this.mark("zlib") + }) +} + +func (this *hello) Main() { + formula.Gopt_ModuleF_Main(this) +} + +func (this *hello) XGo_Init() *hello { + this.seen = map[string]bool{} + return this +} + +func main() { + new(hello).Main() +} +``` + ## Build & Test ```bash diff --git a/doc/matrix-options-tracker.md b/doc/matrix-options-tracker.md new file mode 100644 index 00000000..61e39f8b --- /dev/null +++ b/doc/matrix-options-tracker.md @@ -0,0 +1,53 @@ +# Proposal: Discover Formula Matrix with SSA Instrumentation + +## Summary + +LLAR discovers matrix keys by observing formula execution. Formula code remains +unchanged; matrix reads are instrumented in Go SSA after ixgo has built the +package and before ixgo translates it for execution. + +## Pipeline + +```text +_llar.gox source + -> generated Go source + -> x/tools SSA and built-in optimization + -> tracker call insertion + -> ixgo interpreter +``` + +The tracker does not rewrite XGo AST or modify ixgo. + +## Instrumentation + +XGo lowers a matrix read into a method call followed by `ssa.Lookup`: + +```text +target.options[key] + -> matrixTarget.Options() + -> map lookup +``` + +The tracker inserts no-result calls at two points: + +1. After `matrixTarget.Options()` or `matrixTarget.Require()`, register the + returned map as options or require. +2. Before each `ssa.Lookup` on `map[string][]string`, record the key only when + the map was registered in step 1. + +Map identity follows normal assignments and function arguments, so aliases and +helpers do not require data-flow reconstruction. + +## Probe + +`LoadFS` executes the formula hooks with empty probe inputs, then copies the +observed require and options keys into `Formula.Matrix`. Values are filled later +from the selected matrix. Tracker calls are disabled after the probe and do not +affect normal builds. + +## Boundaries + +- Instrumentation runs after all x/tools SSA builder passes. +- Inserted calls do not produce values or change control flow. +- Only code represented in the ixgo SSA program can be instrumented. +- Additional probe rounds and candidate generation are separate concerns. diff --git a/internal/formula/formula.go b/internal/formula/formula.go index 1d8879b7..542d983d 100644 --- a/internal/formula/formula.go +++ b/internal/formula/formula.go @@ -28,6 +28,7 @@ type Formula struct { // the method declaration of ModuleF in formula/classfile.go ModPath string FromVer string + Matrix formula.Matrix OnRequire func(proj *formula.Project, deps *formula.ModuleDeps) OnBuild func(ctx *formula.Context, proj *formula.Project, out *formula.BuildResult) OnTest func(ctx *formula.Context, proj *formula.Project, out *formula.TestResult) @@ -94,6 +95,8 @@ func loadFS(fs fs.ReadFileFS, path string) (*Formula, error) { if err != nil { return nil, err } + tr := newTracker() + tracked := tr.track(ctx, pkgs) // Create a new interpreter for the loaded package interp, err := ctx.NewInterp(pkgs) @@ -132,7 +135,7 @@ func loadFS(fs fs.ReadFileFS, path string) (*Formula, error) { val.Interface().(interface{ Main() }).Main() // Extract the populated fields from the struct and return the Formula - return &Formula{ + loaded := &Formula{ structElem: class, ModPath: valueOf(class, "modPath").(string), FromVer: valueOf(class, "modFromVer").(string), @@ -140,7 +143,12 @@ func loadFS(fs fs.ReadFileFS, path string) (*Formula, error) { OnTest: valueOf(class, "fOnTest").(func(*formula.Context, *formula.Project, *formula.TestResult)), OnRequire: valueOf(class, "fOnRequire").(func(*formula.Project, *formula.ModuleDeps)), Filter: valueOf(class, "fFilter").(func() bool), - }, nil + } + if tracked { + probeFormula(loaded) + } + loaded.Matrix = tr.matrix() + return loaded, nil } // LoadFS loads a formula from a filesystem interface. diff --git a/internal/formula/formula_test.go b/internal/formula/formula_test.go index 49220414..6c1839e4 100644 --- a/internal/formula/formula_test.go +++ b/internal/formula/formula_test.go @@ -7,6 +7,7 @@ package formula import ( "io/fs" "os" + "reflect" "testing" formulapkg "github.com/goplus/llar/formula" @@ -79,6 +80,14 @@ func TestLoadFS_TargetSurface(t *testing.T) { if !f.Filter() { t.Fatal("Filter() = false, want true") } + wantRequire := map[string][]string{"os": nil} + if !reflect.DeepEqual(f.Matrix.Require, wantRequire) { + t.Fatalf("Matrix.Require = %#v, want %#v", f.Matrix.Require, wantRequire) + } + wantOptions := map[string][]string{"debug": nil, "zlib": nil} + if !reflect.DeepEqual(f.Matrix.Options, wantOptions) { + t.Fatalf("Matrix.Options = %#v, want %#v", f.Matrix.Options, wantOptions) + } var deps formulapkg.ModuleDeps f.OnRequire(&formulapkg.Project{}, &deps) @@ -89,6 +98,23 @@ func TestLoadFS_TargetSurface(t *testing.T) { f.OnBuild(&formulapkg.Context{}, &formulapkg.Project{}, &formulapkg.BuildResult{}) } +func TestLoadFSProbesMatrixKeys(t *testing.T) { + fsys := os.DirFS("testdata/formula").(fs.ReadFileFS) + f, err := LoadFS(fsys, "matrix_llar.gox") + if err != nil { + t.Fatalf("LoadFS failed: %v", err) + } + + wantRequire := map[string][]string{"os": nil} + if !reflect.DeepEqual(f.Matrix.Require, wantRequire) { + t.Fatalf("Matrix.Require = %#v, want %#v", f.Matrix.Require, wantRequire) + } + wantOptions := map[string][]string{"debug": nil, "ssl": nil} + if !reflect.DeepEqual(f.Matrix.Options, wantOptions) { + t.Fatalf("Matrix.Options = %#v, want %#v", f.Matrix.Options, wantOptions) + } +} + func TestFormula_SetStdout(t *testing.T) { formula, err := loadFS(os.DirFS("testdata/formula").(fs.ReadFileFS), "hello_llar.gox") if err != nil { diff --git a/internal/formula/probe.go b/internal/formula/probe.go new file mode 100644 index 00000000..3424f177 --- /dev/null +++ b/internal/formula/probe.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package formula + +import ( + "io/fs" + + classfile "github.com/goplus/llar/formula" + "github.com/goplus/llar/mod/module" +) + +type probeFS struct{} + +func (probeFS) Open(string) (fs.File, error) { + return nil, fs.ErrNotExist +} + +func (probeFS) ReadFile(string) ([]byte, error) { + return nil, fs.ErrNotExist +} + +func probeFormula(f *Formula) { + originalTarget := valueOf(f.structElem, "target").(classfile.Matrix) + // Probe with empty maps because discovery does not require a valid matrix. + // A formula may read several independent options while configuring a build: + // + // if has(target.options["zlib"], "ON") { ... } + // shared := has(target.options["shared"], "ON") + // debug := has(target.options["debug"], "ON") + // + // Each lookup returns an empty value, but the SSA tracker still records zlib, + // shared, and debug before the formula eventually succeeds or fails. The maps + // must be non-nil because their runtime identities also let the tracker follow + // aliases and helper arguments. + fakeTarget := classfile.Matrix{ + Require: make(map[string][]string), + Options: make(map[string][]string), + } + setValue(f.structElem, "target", fakeTarget) + defer setValue(f.structElem, "target", originalTarget) + + project := &classfile.Project{SourceFS: probeFS{}} + if f.OnRequire != nil { + var deps classfile.ModuleDeps + safeProbeCall(func() { + f.OnRequire(project, &deps) + }) + } + if f.OnBuild != nil { + ctx := classfile.NewContext("", "", "", func(string, module.Version) (string, error) { + return "", nil + }) + var out classfile.BuildResult + safeProbeCall(func() { + f.OnBuild(ctx, project, &out) + }) + } +} + +func safeProbeCall(call func()) { + defer func() { + _ = recover() + }() + call() +} diff --git a/internal/formula/testdata/formula/matrix_llar.gox b/internal/formula/testdata/formula/matrix_llar.gox new file mode 100644 index 00000000..f37f4b9d --- /dev/null +++ b/internal/formula/testdata/formula/matrix_llar.gox @@ -0,0 +1,20 @@ +func lookup(values map[string][]string, key string) []string { + return values[key] +} + +id "test/matrix" + +fromVer "v1.0.0" + +onRequire (proj, deps) => { + require := target.require + if len(lookup(require, "os")) == 0 { + options := target.options + _ = lookup(options, "ssl") + } +} + +onBuild (ctx, proj, out) => { + options := target.options + _ = len(lookup(options, "debug")) +} diff --git a/internal/formula/testdata/formula/trackcases_llar.gox b/internal/formula/testdata/formula/trackcases_llar.gox new file mode 100644 index 00000000..20a9ab79 --- /dev/null +++ b/internal/formula/testdata/formula/trackcases_llar.gox @@ -0,0 +1,77 @@ +type namedOptions map[string][]string + +type aliasedOptions = map[string][]string + +type matrixHolder struct { + values map[string][]string +} + +func matrixLookup(values map[string][]string, key string) []string { + return values[key] +} + +func matrixPassthrough(values map[string][]string) map[string][]string { + return values +} + +func dynamicMatrixKey() string { + return "dynamic" +} + +id "test/track-cases" + +fromVer "v1.0.0" + +onRequire (proj, deps) => { + _ = target.require["direct-require"] + + require := target.require + _ = matrixLookup(require, "helper-require") + _ = require["same"] + + unrelated := map[string][]string{"ignored-require": []string{"value"}} + _ = matrixLookup(unrelated, "ignored-require") +} + +onBuild (ctx, proj, out) => { + _ = target.options["direct-option"] + + options := target.options + alias := options + _ = alias["alias"] + _ = alias["alias"] + _ = matrixLookup(options, "helper-option") + _ = matrixLookup(matrixPassthrough(options), "returned") + + named := namedOptions(options) + _ = named["named"] + aliased := aliasedOptions(options) + _ = aliased["type-alias"] + + optionsPtr := &options + _ = (*optionsPtr)["pointer"] + + key := dynamicMatrixKey() + _ = options[key] + + _, ok := options["comma-ok"] + _ = ok + + holder := matrixHolder{values: options} + _ = holder.values["struct"] + + var boxed any = options + fromInterface := boxed.(map[string][]string) + _ = fromInterface["interface"] + + func() { + _ = options["closure"] + }() + + _ = options["same"] + + unrelated := map[string][]string{"ignored-option": []string{"value"}} + _ = matrixLookup(unrelated, "ignored-option") + var nilMap map[string][]string + _ = nilMap["ignored-nil"] +} diff --git a/internal/formula/testdata/formula/trackfilter_llar.gox b/internal/formula/testdata/formula/trackfilter_llar.gox new file mode 100644 index 00000000..1779346e --- /dev/null +++ b/internal/formula/testdata/formula/trackfilter_llar.gox @@ -0,0 +1,8 @@ +id "test/track-filter" + +fromVer "v1.0.0" + +filter => { + _ = target.options["filter-only"] + return false +} diff --git a/internal/formula/testdata/formula/trackpanic_llar.gox b/internal/formula/testdata/formula/trackpanic_llar.gox new file mode 100644 index 00000000..7215e1ce --- /dev/null +++ b/internal/formula/testdata/formula/trackpanic_llar.gox @@ -0,0 +1,12 @@ +id "test/track-panic" + +fromVer "v1.0.0" + +onRequire (proj, deps) => { + _ = target.options["before-panic"] + panic("stop require probe") +} + +onBuild (ctx, proj, out) => { + _ = target.options["after-panic"] +} diff --git a/internal/formula/tracker.go b/internal/formula/tracker.go new file mode 100644 index 00000000..eda450f3 --- /dev/null +++ b/internal/formula/tracker.go @@ -0,0 +1,249 @@ +// Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package formula + +import ( + "go/token" + "go/types" + "reflect" + "sync" + "unsafe" + + "github.com/goplus/ixgo" + classfile "github.com/goplus/llar/formula" + "golang.org/x/tools/go/ssa" + "golang.org/x/tools/go/ssa/ssautil" +) + +const ( + optionsTrackerFunc = "__internalOptionsTracker" + requireTrackerFunc = "__internalRequireTracker" + lookupTrackerFunc = "__internalLookupTracker" + formulaPackagePath = "github.com/goplus/llar/formula" +) + +type matrixKind uint8 + +const ( + unknownMatrix matrixKind = iota + requireMatrix + optionsMatrix +) + +var matrixMapType = types.NewMap(types.Typ[types.String], types.NewSlice(types.Typ[types.String])) + +type trackedMap struct { + kind matrixKind + // Keep the map alive so its runtime identity cannot be reused during probe. + values map[string][]string +} + +// tracker discovers matrix reads by instrumenting the completed Go SSA program. +// A target accessor registers the returned map, and each map lookup reports its +// map and key before the original lookup executes. For example: +// +// options := target.Options() // register options by map identity +// lookup(options, "shared") // report the lookup inside lookup +// +// Map identity survives assignment and argument passing, so lookups in helpers +// remain associated with Options or Require. Lookups on maps that were never +// returned by a target accessor are ignored at runtime. +type tracker struct { + mu sync.Mutex + + active bool + maps map[uintptr]trackedMap + require map[string]struct{} + options map[string]struct{} +} + +func newTracker() *tracker { + return &tracker{ + active: true, + maps: make(map[uintptr]trackedMap), + require: make(map[string]struct{}), + options: make(map[string]struct{}), + } +} + +// track instruments the completed SSA program before ixgo translates it. +func (t *tracker) track(ctx *ixgo.Context, pkg *ssa.Package) bool { + functions := ssautil.AllFunctions(pkg.Prog) + + valuesParam := types.NewVar(token.NoPos, nil, "values", matrixMapType) + registerSignature := types.NewSignatureType( + nil, nil, nil, + types.NewTuple(valuesParam), + types.NewTuple(), + false, + ) + keyParam := types.NewVar(token.NoPos, nil, "key", types.Typ[types.String]) + lookupSignature := types.NewSignatureType( + nil, nil, nil, + types.NewTuple(valuesParam, keyParam), + types.NewTuple(), + false, + ) + optionsTracker := pkg.Prog.NewFunction(optionsTrackerFunc, registerSignature, "llar matrix tracker") + requireTracker := pkg.Prog.NewFunction(requireTrackerFunc, registerSignature, "llar matrix tracker") + lookupTracker := pkg.Prog.NewFunction(lookupTrackerFunc, lookupSignature, "llar matrix tracker") + + ctx.RegisterExternal(optionsTracker.String(), func(values map[string][]string) { + t.trackMap(optionsMatrix, values) + }) + ctx.RegisterExternal(requireTracker.String(), func(values map[string][]string) { + t.trackMap(requireMatrix, values) + }) + ctx.RegisterExternal(lookupTracker.String(), t.trackLookup) + + tracked := false + for fn := range functions { + if fn.Blocks == nil { + continue + } + for _, block := range fn.Blocks { + instrs := make([]ssa.Instruction, 0, len(block.Instrs)) + for _, instr := range block.Instrs { + // Observe every matching lookup. trackLookup filters unrelated maps + // using the identities registered by Options or Require. + if lookup, ok := instr.(*ssa.Lookup); ok && isMatrixMap(lookup.X.Type()) { + instrs = append(instrs, trackerCall(block, lookupTracker, lookup.X, lookup.Index)) + } + instrs = append(instrs, instr) + if call, ok := instr.(*ssa.Call); ok { + // Imported wrappers may call the same target methods. Only + // accessors called by the formula package start matrix tracking. + switch matrixTargetCall(call) { + case optionsMatrix: + if fn.Pkg == pkg { + tracked = true + instrs = append(instrs, trackerCall(block, optionsTracker, call)) + } + case requireMatrix: + if fn.Pkg == pkg { + tracked = true + instrs = append(instrs, trackerCall(block, requireTracker, call)) + } + } + } + } + block.Instrs = instrs + } + } + return tracked +} + +func (t *tracker) matrix() classfile.Matrix { + t.mu.Lock() + defer t.mu.Unlock() + + t.active = false + t.maps = nil + return classfile.Matrix{ + Require: cloneKeys(t.require), + Options: cloneKeys(t.options), + } +} + +func (t *tracker) trackMap(kind matrixKind, values map[string][]string) { + if values == nil { + return + } + + t.mu.Lock() + defer t.mu.Unlock() + if !t.active { + return + } + t.maps[reflect.ValueOf(values).Pointer()] = trackedMap{kind: kind, values: values} +} + +func (t *tracker) trackLookup(values map[string][]string, key string) { + if values == nil { + return + } + + t.mu.Lock() + defer t.mu.Unlock() + if !t.active { + return + } + + tracked, ok := t.maps[reflect.ValueOf(values).Pointer()] + if !ok { + return + } + if tracked.kind == requireMatrix { + t.require[key] = struct{}{} + } else { + t.options[key] = struct{}{} + } +} + +func matrixTargetCall(call *ssa.Call) matrixKind { + callee := call.Call.StaticCallee() + if callee == nil || callee.Signature.Recv() == nil { + return unknownMatrix + } + + typ := types.Unalias(callee.Signature.Recv().Type()) + if ptr, ok := typ.(*types.Pointer); ok { + typ = types.Unalias(ptr.Elem()) + } + named, ok := typ.(*types.Named) + if !ok { + return unknownMatrix + } + obj := named.Obj() + if obj.Pkg() == nil || obj.Name() != "matrixTarget" || obj.Pkg().Path() != formulaPackagePath { + return unknownMatrix + } + + switch callee.Name() { + case "Options": + return optionsMatrix + case "Require": + return requireMatrix + default: + return unknownMatrix + } +} + +func isMatrixMap(typ types.Type) bool { + return types.Identical(types.Unalias(typ).Underlying(), matrixMapType) +} + +func trackerCall(block *ssa.BasicBlock, fn *ssa.Function, args ...ssa.Value) *ssa.Call { + call := &ssa.Call{Call: ssa.CallCommon{Value: fn, Args: args}} + // x/tools does not expose constructors for post-build instructions. + // Populate the metadata ixgo reads before appending the call to the block. + callValue := reflect.ValueOf(call).Elem() + register := callValue.FieldByName("register") + setUnexported(register.FieldByName("typ"), reflect.ValueOf(fn.Signature.Results())) + instruction := register.FieldByName("anInstruction") + setUnexported(instruction.FieldByName("block"), reflect.ValueOf(block)) + + for _, arg := range args { + if refs := arg.Referrers(); refs != nil { + *refs = append(*refs, call) + } + } + return call +} + +func setUnexported(dst, src reflect.Value) { + reflect.NewAt(dst.Type(), unsafe.Pointer(dst.UnsafeAddr())).Elem().Set(src) +} + +func cloneKeys(keys map[string]struct{}) map[string][]string { + if len(keys) == 0 { + return nil + } + matrix := make(map[string][]string, len(keys)) + for key := range keys { + matrix[key] = nil + } + return matrix +} diff --git a/internal/formula/tracker_test.go b/internal/formula/tracker_test.go new file mode 100644 index 00000000..65e57209 --- /dev/null +++ b/internal/formula/tracker_test.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package formula + +import ( + "io/fs" + "os" + "reflect" + "testing" +) + +func TestLoadFSMatrixTracker(t *testing.T) { + tests := []struct { + name string + path string + wantRequire map[string][]string + wantOptions map[string][]string + }{ + { + name: "data flow", + path: "trackcases_llar.gox", + wantRequire: map[string][]string{ + "direct-require": nil, + "helper-require": nil, + "same": nil, + }, + wantOptions: map[string][]string{ + "alias": nil, + "closure": nil, + "comma-ok": nil, + "direct-option": nil, + "dynamic": nil, + "helper-option": nil, + "interface": nil, + "named": nil, + "pointer": nil, + "returned": nil, + "same": nil, + "struct": nil, + "type-alias": nil, + }, + }, + { + name: "panic isolation", + path: "trackpanic_llar.gox", + wantOptions: map[string][]string{ + "after-panic": nil, + "before-panic": nil, + }, + }, + { + name: "filter is not probed", + path: "trackfilter_llar.gox", + }, + { + name: "no matrix access", + path: "hello_llar.gox", + }, + } + + fsys := os.DirFS("testdata/formula").(fs.ReadFileFS) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := LoadFS(fsys, tt.path) + if err != nil { + t.Fatalf("LoadFS(%q) failed: %v", tt.path, err) + } + if !reflect.DeepEqual(f.Matrix.Require, tt.wantRequire) { + t.Fatalf("Matrix.Require = %#v, want %#v", f.Matrix.Require, tt.wantRequire) + } + if !reflect.DeepEqual(f.Matrix.Options, tt.wantOptions) { + t.Fatalf("Matrix.Options = %#v, want %#v", f.Matrix.Options, tt.wantOptions) + } + }) + } +} From 14b1b1f45e29721f5da8801ceebc9de266d4da3b Mon Sep 17 00:00:00 2001 From: Rick Guo Date: Sun, 12 Jul 2026 16:14:27 +0800 Subject: [PATCH 2/4] chore: keep tracker branch code-only --- AGENTS.md | 63 ----------------------------------- doc/matrix-options-tracker.md | 53 ----------------------------- 2 files changed, 116 deletions(-) delete mode 100644 doc/matrix-options-tracker.md diff --git a/AGENTS.md b/AGENTS.md index efd68cba..219b0c45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,69 +107,6 @@ func main() { 3. **Error Handling**: If a file doesn't match any registered classfile pattern (wrong suffix), `BuildFile` returns "undefined" errors for DSL functions -### Classfile `var` Fields - -In an XGo classfile, the first top-level `var (...)` declaration is the class -fields declaration. These names compile into fields on the generated struct, -not package-level variables. If the fields have initializers, XGo generates an -`XGo_Init()` method and calls it before the classfile body runs. - -Source file `hello_llar.gox`: -```gox -var ( - seen map[string]bool = {} -) - -func mark(key string) { - seen[key] = true -} - -id "DaveGamble/cJSON" -fromVer "v1.0.0" - -onRequire (proj, deps) => { - mark "zlib" -} -``` - -Generated Go code: -```go -package main - -import "github.com/goplus/llar/formula" - -type hello struct { - formula.ModuleF - seen map[string]bool -} - -func (this *hello) mark(key string) { - this.seen[key] = true -} - -func (this *hello) MainEntry() { - this.XGo_Init() - this.Id("DaveGamble/cJSON") - this.FromVer("v1.0.0") - this.OnRequire(func(proj *formula.Project, deps *formula.ModuleDeps) { - this.mark("zlib") - }) -} - -func (this *hello) Main() { - formula.Gopt_ModuleF_Main(this) -} - -func (this *hello) XGo_Init() *hello { - this.seen = map[string]bool{} - return this -} - -func main() { - new(hello).Main() -} -``` - ## Build & Test ```bash diff --git a/doc/matrix-options-tracker.md b/doc/matrix-options-tracker.md deleted file mode 100644 index 61e39f8b..00000000 --- a/doc/matrix-options-tracker.md +++ /dev/null @@ -1,53 +0,0 @@ -# Proposal: Discover Formula Matrix with SSA Instrumentation - -## Summary - -LLAR discovers matrix keys by observing formula execution. Formula code remains -unchanged; matrix reads are instrumented in Go SSA after ixgo has built the -package and before ixgo translates it for execution. - -## Pipeline - -```text -_llar.gox source - -> generated Go source - -> x/tools SSA and built-in optimization - -> tracker call insertion - -> ixgo interpreter -``` - -The tracker does not rewrite XGo AST or modify ixgo. - -## Instrumentation - -XGo lowers a matrix read into a method call followed by `ssa.Lookup`: - -```text -target.options[key] - -> matrixTarget.Options() - -> map lookup -``` - -The tracker inserts no-result calls at two points: - -1. After `matrixTarget.Options()` or `matrixTarget.Require()`, register the - returned map as options or require. -2. Before each `ssa.Lookup` on `map[string][]string`, record the key only when - the map was registered in step 1. - -Map identity follows normal assignments and function arguments, so aliases and -helpers do not require data-flow reconstruction. - -## Probe - -`LoadFS` executes the formula hooks with empty probe inputs, then copies the -observed require and options keys into `Formula.Matrix`. Values are filled later -from the selected matrix. Tracker calls are disabled after the probe and do not -affect normal builds. - -## Boundaries - -- Instrumentation runs after all x/tools SSA builder passes. -- Inserted calls do not produce values or change control flow. -- Only code represented in the ixgo SSA program can be instrumented. -- Additional probe rounds and candidate generation are separate concerns. From 11a8363ccd7cf28cc6c7b8292979a86b3bae2b4e Mon Sep 17 00:00:00 2001 From: Rick Guo Date: Sun, 12 Jul 2026 16:52:19 +0800 Subject: [PATCH 3/4] fix(formula): isolate matrix probe SSA --- internal/formula/formula.go | 88 ++++++++++++++++++++++++++------ internal/formula/probe.go | 27 +++++++++- internal/formula/tracker_test.go | 48 +++++++++++++++++ 3 files changed, 147 insertions(+), 16 deletions(-) diff --git a/internal/formula/formula.go b/internal/formula/formula.go index 542d983d..b697dad2 100644 --- a/internal/formula/formula.go +++ b/internal/formula/formula.go @@ -10,11 +10,14 @@ import ( "io/fs" "path/filepath" "reflect" + "slices" "strings" "github.com/goplus/ixgo" "github.com/goplus/ixgo/xgobuild" "github.com/goplus/llar/formula" + "golang.org/x/tools/go/ssa" + "golang.org/x/tools/go/ssa/ssautil" _ "github.com/goplus/llar/internal/ixgo" ) @@ -35,6 +38,52 @@ type Formula struct { Filter func() bool } +type ssaState struct { + blocks map[*ssa.BasicBlock][]ssa.Instruction + referrers map[ssa.Value][]ssa.Instruction +} + +func saveSSAState(prog *ssa.Program) ssaState { + state := ssaState{ + blocks: make(map[*ssa.BasicBlock][]ssa.Instruction), + referrers: make(map[ssa.Value][]ssa.Instruction), + } + for fn := range ssautil.AllFunctions(prog) { + for _, block := range fn.Blocks { + state.blocks[block] = slices.Clone(block.Instrs) + for _, instr := range block.Instrs { + if value, ok := instr.(ssa.Value); ok { + state.saveReferrers(value) + } + for _, operand := range instr.Operands(nil) { + if operand != nil && *operand != nil { + state.saveReferrers(*operand) + } + } + } + } + } + return state +} + +func (s ssaState) saveReferrers(value ssa.Value) { + if _, ok := s.referrers[value]; ok { + return + } + if refs := value.Referrers(); refs != nil { + s.referrers[value] = slices.Clone(*refs) + } +} + +func (s ssaState) restore() { + for block, instrs := range s.blocks { + block.Instrs = instrs + } + for value, refs := range s.referrers { + *value.Referrers() = refs + } +} + // loadFS is the internal implementation for loading a formula from a filesystem. // It builds and interprets the formula file using the xgo classfile mechanism, // then extracts the struct fields containing module metadata and callbacks. @@ -95,10 +144,30 @@ func loadFS(fs fs.ReadFileFS, path string) (*Formula, error) { if err != nil { return nil, err } + state := saveSSAState(pkgs.Prog) tr := newTracker() tracked := tr.track(ctx, pkgs) - // Create a new interpreter for the loaded package + // Extract struct name from filename: "hello_llar.gox" -> "hello" + // The classfile mechanism generates a struct with this name + structName, _, ok := strings.Cut(filepath.Base(path), "_") + if !ok { + return nil, fmt.Errorf("failed to load formula: file name is not valid: %s", path) + } + + var matrix formula.Matrix + if tracked { + matrix, err = probeFormula(ctx, pkgs, structName, tr) + if err != nil { + return nil, err + } + } + + // NewInterp translates SSA eagerly. The probe interpreter retains the + // instrumented instructions, while the production interpreter below sees + // the original SSA restored from this snapshot. + state.restore() + interp, err := ctx.NewInterp(pkgs) if err != nil { return nil, err @@ -109,13 +178,6 @@ func loadFS(fs fs.ReadFileFS, path string) (*Formula, error) { return nil, err } - // Extract struct name from filename: "hello_llar.gox" -> "hello" - // The classfile mechanism generates a struct with this name - structName, _, ok := strings.Cut(filepath.Base(path), "_") - if !ok { - return nil, fmt.Errorf("failed to load formula: file name is not valid: %s", path) - } - // Get the generated struct type from the interpreter typ, ok := interp.GetType(structName) if !ok { @@ -135,20 +197,16 @@ func loadFS(fs fs.ReadFileFS, path string) (*Formula, error) { val.Interface().(interface{ Main() }).Main() // Extract the populated fields from the struct and return the Formula - loaded := &Formula{ + return &Formula{ structElem: class, ModPath: valueOf(class, "modPath").(string), FromVer: valueOf(class, "modFromVer").(string), + Matrix: matrix, OnBuild: valueOf(class, "fOnBuild").(func(*formula.Context, *formula.Project, *formula.BuildResult)), OnTest: valueOf(class, "fOnTest").(func(*formula.Context, *formula.Project, *formula.TestResult)), OnRequire: valueOf(class, "fOnRequire").(func(*formula.Project, *formula.ModuleDeps)), Filter: valueOf(class, "fFilter").(func() bool), - } - if tracked { - probeFormula(loaded) - } - loaded.Matrix = tr.matrix() - return loaded, nil + }, nil } // LoadFS loads a formula from a filesystem interface. diff --git a/internal/formula/probe.go b/internal/formula/probe.go index 3424f177..f7e0eea4 100644 --- a/internal/formula/probe.go +++ b/internal/formula/probe.go @@ -5,10 +5,14 @@ package formula import ( + "fmt" "io/fs" + "reflect" + "github.com/goplus/ixgo" classfile "github.com/goplus/llar/formula" "github.com/goplus/llar/mod/module" + "golang.org/x/tools/go/ssa" ) type probeFS struct{} @@ -21,7 +25,27 @@ func (probeFS) ReadFile(string) ([]byte, error) { return nil, fs.ErrNotExist } -func probeFormula(f *Formula) { +func probeFormula(ctx *ixgo.Context, pkg *ssa.Package, structName string, tr *tracker) (classfile.Matrix, error) { + interp, err := ctx.NewInterp(pkg) + if err != nil { + return classfile.Matrix{}, err + } + if err = interp.RunInit(); err != nil { + return classfile.Matrix{}, err + } + typ, ok := interp.GetType(structName) + if !ok { + return classfile.Matrix{}, fmt.Errorf("failed to load formula: struct name not found: %s", structName) + } + val := reflect.New(typ) + class := val.Elem() + val.Interface().(interface{ Main() }).Main() + f := &Formula{ + structElem: class, + OnBuild: valueOf(class, "fOnBuild").(func(*classfile.Context, *classfile.Project, *classfile.BuildResult)), + OnRequire: valueOf(class, "fOnRequire").(func(*classfile.Project, *classfile.ModuleDeps)), + } + originalTarget := valueOf(f.structElem, "target").(classfile.Matrix) // Probe with empty maps because discovery does not require a valid matrix. // A formula may read several independent options while configuring a build: @@ -57,6 +81,7 @@ func probeFormula(f *Formula) { f.OnBuild(ctx, project, &out) }) } + return tr.matrix(), nil } func safeProbeCall(call func()) { diff --git a/internal/formula/tracker_test.go b/internal/formula/tracker_test.go index 65e57209..4ce269e7 100644 --- a/internal/formula/tracker_test.go +++ b/internal/formula/tracker_test.go @@ -8,7 +8,11 @@ import ( "io/fs" "os" "reflect" + "slices" "testing" + + "github.com/goplus/ixgo" + "github.com/goplus/ixgo/xgobuild" ) func TestLoadFSMatrixTracker(t *testing.T) { @@ -76,3 +80,47 @@ func TestLoadFSMatrixTracker(t *testing.T) { }) } } + +func TestSSAStateRestore(t *testing.T) { + ctx := ixgo.NewContext(0) + content, err := os.ReadFile("testdata/formula/matrix_llar.gox") + if err != nil { + t.Fatal(err) + } + source, err := xgobuild.BuildFile(ctx, "matrix_llar.gox", content) + if err != nil { + t.Fatal(err) + } + pkg, err := ctx.LoadFile("main.go", source) + if err != nil { + t.Fatal(err) + } + + state := saveSSAState(pkg.Prog) + if !newTracker().track(ctx, pkg) { + t.Fatal("track returned false") + } + + changed := false + for block, instrs := range state.blocks { + if !slices.Equal(block.Instrs, instrs) { + changed = true + break + } + } + if !changed { + t.Fatal("tracker did not change SSA instructions") + } + + state.restore() + for block, instrs := range state.blocks { + if !slices.Equal(block.Instrs, instrs) { + t.Fatalf("block %d instructions were not restored", block.Index) + } + } + for value, refs := range state.referrers { + if !slices.Equal(*value.Referrers(), refs) { + t.Fatalf("referrers for %s were not restored", value.Name()) + } + } +} From 143f7d65170c41ce8ab06a7d3f3b759ff5f83383 Mon Sep 17 00:00:00 2001 From: Rick Guo Date: Sun, 12 Jul 2026 19:05:59 +0800 Subject: [PATCH 4/4] feat(build): scope cache target by formula matrix --- cmd/llar/internal/make.go | 7 +-- internal/build/build.go | 57 ++++++++++++++--- internal/build/build_test.go | 116 ++++++++++++++++++++++++++++++++--- internal/build/cache.go | 4 +- internal/build/cache_test.go | 36 +++++------ internal/build/e2e_test.go | 29 +++++---- testdata/kodo-e2e/main.go | 2 +- 7 files changed, 197 insertions(+), 54 deletions(-) diff --git a/cmd/llar/internal/make.go b/cmd/llar/internal/make.go index 7474fe00..6aeda735 100644 --- a/cmd/llar/internal/make.go +++ b/cmd/llar/internal/make.go @@ -169,11 +169,10 @@ func buildModule(ctx context.Context, store repo.Store, modPath, version string, } }() - matrixStr := matrix.Combinations()[0] buildOpts := build.Options{ - Store: store, - MatrixStr: matrixStr, - RunTest: runTest, + Store: store, + Target: matrix, + RunTest: runTest, } if makeOutput != "" { tmpDir, err := os.MkdirTemp("", "llar-make-*") diff --git a/internal/build/build.go b/internal/build/build.go index 71af620d..8a8690ce 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io/fs" + "maps" "os" "path/filepath" "strings" @@ -19,7 +20,7 @@ import ( type Builder struct { store repo.Store - matrix string + target classfile.Matrix runTest bool workspaceDir string cache cache.Cache @@ -32,8 +33,8 @@ type Result struct { } type Options struct { - Store repo.Store - MatrixStr string + Store repo.Store + Target classfile.Matrix // RunTest, when true, causes Build to invoke OnTest on the root target // after OnBuild (or after reusing cached build metadata). The build // cache is consulted as usual: on a cache hit the root's OnBuild is @@ -75,7 +76,7 @@ func NewBuilder(opts Options) (*Builder, error) { } return &Builder{ store: opts.Store, - matrix: opts.MatrixStr, + target: opts.Target, runTest: opts.RunTest, workspaceDir: workspaceDir, cache: c, @@ -83,6 +84,19 @@ func NewBuilder(opts Options) (*Builder, error) { }, nil } +func intersect(values, keys map[string][]string) map[string][]string { + intersection := make(map[string][]string) + for key, value := range values { + if _, ok := keys[key]; ok { + intersection[key] = value + } + } + if len(intersection) == 0 { + return nil + } + return intersection +} + // constructBuildList reorders the MVS build list into a valid build order // using DFS post-order traversal: leaves (modules with no deps) come first, // the main module (root) comes last. @@ -213,6 +227,16 @@ func (b *Builder) Build(ctx context.Context, targets []*modules.Module) ([]Resul } build := func(mod *modules.Module) (Result, error) { + modVer := module.Version{Path: mod.Path, Version: mod.Version} + target := classfile.Matrix{ + Require: maps.Clone(b.target.Require), + Options: intersect(b.target.Options, mod.Formula.Matrix.Options), + } + combinations := target.Combinations() + if len(combinations) == 0 { + return Result{}, fmt.Errorf("%s@%s has no matrix dimensions matching target", mod.Path, mod.Version) + } + targetStr := combinations[0] isRoot := mod.Path == rootID.Path && mod.Version == rootID.Version testThisMod := b.runTest && isRoot && mod.OnTest != nil @@ -222,17 +246,16 @@ func (b *Builder) Build(ctx context.Context, targets []*modules.Module) ([]Resul } defer unlock() - installDir, err := b.installDir(mod.Path, mod.Version) + installDir, err := b.installDir(mod.Path, mod.Version, targetStr) if err != nil { return Result{}, err } deps := b.resolveModTransitiveDeps(targets, mod) - modVer := module.Version{Path: mod.Path, Version: mod.Version} // Consult the build cache. A hit means we already have the // module's build metadata and its installDir is populated from a // previous successful build. - entry, cacheHit, err := b.cache.Get(ctx, cache.Key{Module: modVer, Matrix: b.matrix}) + entry, cacheHit, err := b.cache.Get(ctx, cache.Key{Module: modVer, Matrix: targetStr}) if err != nil { return Result{}, err } @@ -270,9 +293,23 @@ func (b *Builder) Build(ctx context.Context, targets []*modules.Module) ([]Resul } getOutputDir := func(_ string, m module.Version) (string, error) { - return b.installDir(m.Path, m.Version) + for _, dep := range targets { + if dep.Path != m.Path || dep.Version != m.Version { + continue + } + target := classfile.Matrix{ + Require: maps.Clone(b.target.Require), + Options: intersect(b.target.Options, dep.Formula.Matrix.Options), + } + combinations := target.Combinations() + if len(combinations) == 0 { + return "", fmt.Errorf("%s@%s has no matrix dimensions matching target", m.Path, m.Version) + } + return b.installDir(m.Path, m.Version, combinations[0]) + } + return "", fmt.Errorf("target not found for %s@%s", m.Path, m.Version) } - buildContext := classfile.NewContext(tmpSourceDir, installDir, b.matrix, getOutputDir) + buildContext := classfile.NewContext(tmpSourceDir, installDir, targetStr, getOutputDir) // Inject results of already-built dependencies for modVer, result := range builtResults { @@ -313,7 +350,7 @@ func (b *Builder) Build(ctx context.Context, targets []*modules.Module) ([]Resul // Save cache only on cache miss. A cache hit means the entry is // already present and current; OnTest does not modify metadata. if !cacheHit { - entry, err := b.cache.Put(ctx, cache.Key{Module: modVer, Matrix: b.matrix}, os.DirFS(installDir), cache.Entry{ + entry, err := b.cache.Put(ctx, cache.Key{Module: modVer, Matrix: targetStr}, os.DirFS(installDir), cache.Entry{ Metadata: metadata, Deps: deps, }) diff --git a/internal/build/build_test.go b/internal/build/build_test.go index d5a2746c..7a1b1812 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -8,6 +8,7 @@ import ( "io/fs" "os" "path/filepath" + "reflect" "slices" "strings" "testing" @@ -15,6 +16,7 @@ import ( classfile "github.com/goplus/llar/formula" "github.com/goplus/llar/internal/build/cache" + internalformula "github.com/goplus/llar/internal/formula" "github.com/goplus/llar/internal/formula/repo" "github.com/goplus/llar/internal/modules" "github.com/goplus/llar/internal/vcs" @@ -305,8 +307,10 @@ func setupBuilder(t *testing.T, store repo.Store, matrix string) *Builder { t.Helper() workspaceDir := t.TempDir() return &Builder{ - store: store, - matrix: matrix, + store: store, + target: classfile.Matrix{Require: map[string][]string{ + "matrix": {matrix}, + }}, workspaceDir: workspaceDir, cache: &localCache{workspaceDir: workspaceDir}, newRepo: func(repoPath string) (vcs.Repo, error) { @@ -351,10 +355,12 @@ type cachePut struct { type recordingCache struct { hits map[module.Version]cache.Entry + gets []cache.Key puts []cachePut } func (c *recordingCache) Get(ctx context.Context, key cache.Key) (cache.Entry, bool, error) { + c.gets = append(c.gets, key) if c.hits == nil { return cache.Entry{}, false, nil } @@ -380,8 +386,11 @@ func TestNewBuilder(t *testing.T) { tmpDir := t.TempDir() store := setupTestStore(t) b, err := NewBuilder(Options{ - Store: store, - MatrixStr: "amd64-linux", + Store: store, + Target: classfile.Matrix{Require: map[string][]string{ + "arch": {"amd64"}, + "os": {"linux"}, + }}, WorkspaceDir: tmpDir, }) if err != nil { @@ -390,8 +399,8 @@ func TestNewBuilder(t *testing.T) { if b.workspaceDir != tmpDir { t.Errorf("workspaceDir = %q, want %q", b.workspaceDir, tmpDir) } - if b.matrix != "amd64-linux" { - t.Errorf("matrix = %q, want %q", b.matrix, "amd64-linux") + if got := b.target.Combinations()[0]; got != "amd64-linux" { + t.Errorf("target = %q, want %q", got, "amd64-linux") } if b.store != store { t.Error("store not set correctly") @@ -403,7 +412,10 @@ func TestNewBuilder(t *testing.T) { t.Run("default workspace dir", func(t *testing.T) { b, err := NewBuilder(Options{ - MatrixStr: "arm64-darwin", + Target: classfile.Matrix{Require: map[string][]string{ + "arch": {"arm64"}, + "os": {"darwin"}, + }}, }) if err != nil { t.Fatalf("NewBuilder() error = %v", err) @@ -421,6 +433,94 @@ func TestNewBuilder(t *testing.T) { }) } +func TestIntersect(t *testing.T) { + values := map[string][]string{ + "debug": {"ON"}, + "shared": {"OFF"}, + "ssl": {"openssl"}, + } + keys := map[string][]string{ + "debug": nil, + "ssl": nil, + } + + got := intersect(values, keys) + want := map[string][]string{ + "debug": {"ON"}, + "ssl": {"openssl"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("intersect() = %#v, want %#v", got, want) + } +} + +func TestIntersect_NoMatch(t *testing.T) { + got := intersect(map[string][]string{"shared": {"OFF"}}, nil) + if got != nil { + t.Fatalf("intersect() = %#v, want nil", got) + } +} + +func TestBuild_UsesTargetIntersectionForCache(t *testing.T) { + store := setupTestStore(t) + modVer := module.Version{Path: "test/liba", Version: "1.0.0"} + recording := &recordingCache{ + hits: map[module.Version]cache.Entry{modVer: {Metadata: "-lA"}}, + } + b := &Builder{ + store: store, + target: classfile.Matrix{ + Require: map[string][]string{ + "arch": {"amd64"}, + "os": {"linux"}, + }, + Options: map[string][]string{ + "debug": {"ON"}, + "shared": {"OFF"}, + }, + }, + workspaceDir: t.TempDir(), + cache: recording, + } + targets := []*modules.Module{{ + Formula: &internalformula.Formula{Matrix: classfile.Matrix{ + Options: map[string][]string{"debug": nil}, + }}, + Path: modVer.Path, + Version: modVer.Version, + }} + + if _, err := b.Build(context.Background(), targets); err != nil { + t.Fatalf("Build() error = %v", err) + } + if len(recording.gets) != 1 { + t.Fatalf("cache gets = %d, want 1", len(recording.gets)) + } + if got, want := recording.gets[0].Matrix, "amd64-linux|ON"; got != want { + t.Fatalf("cache matrix = %q, want %q", got, want) + } +} + +func TestBuild_RejectsTargetWithoutIntersection(t *testing.T) { + b := &Builder{ + target: classfile.Matrix{ + Options: map[string][]string{"shared": {"OFF"}}, + }, + } + targets := []*modules.Module{{ + Formula: &internalformula.Formula{Matrix: classfile.Matrix{ + Options: map[string][]string{"debug": nil}, + }}, + Path: "test/liba", + Version: "1.0.0", + }} + + _, err := b.Build(context.Background(), targets) + if err == nil { + t.Fatal("Build() error = nil, want target intersection error") + } +} + // --------------------------------------------------------------------------- // Build error path tests // --------------------------------------------------------------------------- @@ -964,7 +1064,7 @@ func TestBuild_InstallDirConvention(t *testing.T) { main := module.Version{Path: "test/liba", Version: "1.0.0"} loadAndBuild(t, b, store, main) - installDir, _ := b.installDir("test/liba", "1.0.0") + installDir, _ := b.installDir("test/liba", "1.0.0", "amd64-linux") // Verify the path follows workspace/@- rel, err := filepath.Rel(b.workspaceDir, installDir) diff --git a/internal/build/cache.go b/internal/build/cache.go index a2311cc7..a2fb2b22 100644 --- a/internal/build/cache.go +++ b/internal/build/cache.go @@ -70,12 +70,12 @@ func (c *localCache) cacheDir(modPath string) (string, error) { } // installDir returns the build output directory: workspaceDir/@-. -func (b *Builder) installDir(modPath, version string) (string, error) { +func (b *Builder) installDir(modPath, version, target string) (string, error) { escaped, err := module.EscapePath(modPath) if err != nil { return "", err } - return filepath.Join(b.workspaceDir, fmt.Sprintf("%s@%s-%s", escaped, version, b.matrix)), nil + return filepath.Join(b.workspaceDir, fmt.Sprintf("%s@%s-%s", escaped, version, target)), nil } // loadCache reads the cache file for a module from the workspace directory. diff --git a/internal/build/cache_test.go b/internal/build/cache_test.go index d6de8e7a..303dc3a6 100644 --- a/internal/build/cache_test.go +++ b/internal/build/cache_test.go @@ -97,9 +97,9 @@ func TestBuildCache_Overwrite(t *testing.T) { } func TestBuilder_InstallDir(t *testing.T) { - b := &Builder{workspaceDir: "/tmp/ws", matrix: "amd64-linux"} + b := &Builder{workspaceDir: "/tmp/ws"} - dir, err := b.installDir("madler/zlib", "1.0.0") + dir, err := b.installDir("madler/zlib", "1.0.0", "amd64-linux") if err != nil { t.Fatalf("installDir() failed: %v", err) } @@ -110,7 +110,7 @@ func TestBuilder_InstallDir(t *testing.T) { } func TestBuilder_CacheDir(t *testing.T) { - b := &Builder{workspaceDir: "/tmp/ws", matrix: "amd64-linux"} + b := &Builder{workspaceDir: "/tmp/ws"} dir, err := b.cacheDir("madler/zlib") if err != nil { @@ -124,7 +124,7 @@ func TestBuilder_CacheDir(t *testing.T) { func TestBuilder_SaveLoadCache(t *testing.T) { tmpDir := t.TempDir() - b := &Builder{workspaceDir: tmpDir, matrix: "amd64-linux"} + b := &Builder{workspaceDir: tmpDir} now := time.Now().Truncate(time.Second) original := &buildCache{} @@ -188,7 +188,7 @@ func TestBuilder_SaveLoadCache(t *testing.T) { // --------------------------------------------------------------------------- func TestBuilder_CacheDir_InvalidPath(t *testing.T) { - b := &Builder{workspaceDir: "/tmp/ws", matrix: "amd64-linux"} + b := &Builder{workspaceDir: "/tmp/ws"} // Empty path should fail EscapePath (filepath.Localize) _, err := b.cacheDir("") @@ -210,14 +210,14 @@ func TestBuilder_CacheDir_InvalidPath(t *testing.T) { } func TestBuilder_InstallDir_InvalidPath(t *testing.T) { - b := &Builder{workspaceDir: "/tmp/ws", matrix: "amd64-linux"} + b := &Builder{workspaceDir: "/tmp/ws"} - _, err := b.installDir("", "1.0.0") + _, err := b.installDir("", "1.0.0", "amd64-linux") if err == nil { t.Fatal("installDir('', ...) should fail") } - _, err = b.installDir("/abs/path", "1.0.0") + _, err = b.installDir("/abs/path", "1.0.0", "amd64-linux") if err == nil { t.Fatal("installDir('/abs/path', ...) should fail") } @@ -225,7 +225,7 @@ func TestBuilder_InstallDir_InvalidPath(t *testing.T) { func TestBuilder_LoadCache_InvalidPath(t *testing.T) { tmpDir := t.TempDir() - b := &Builder{workspaceDir: tmpDir, matrix: "amd64-linux"} + b := &Builder{workspaceDir: tmpDir} _, err := b.loadCache("") if err == nil { @@ -235,7 +235,7 @@ func TestBuilder_LoadCache_InvalidPath(t *testing.T) { func TestBuilder_SaveCache_InvalidPath(t *testing.T) { tmpDir := t.TempDir() - b := &Builder{workspaceDir: tmpDir, matrix: "amd64-linux"} + b := &Builder{workspaceDir: tmpDir} cache := &buildCache{} cache.set("1.0.0", "amd64-linux", &buildEntry{BuildTime: time.Now()}) @@ -247,11 +247,11 @@ func TestBuilder_SaveCache_InvalidPath(t *testing.T) { } func TestBuilder_InstallDir_DifferentMatrices(t *testing.T) { - b1 := &Builder{workspaceDir: "/tmp/ws", matrix: "amd64-linux"} - b2 := &Builder{workspaceDir: "/tmp/ws", matrix: "arm64-darwin"} + b1 := &Builder{workspaceDir: "/tmp/ws"} + b2 := &Builder{workspaceDir: "/tmp/ws"} - dir1, _ := b1.installDir("test/lib", "1.0.0") - dir2, _ := b2.installDir("test/lib", "1.0.0") + dir1, _ := b1.installDir("test/lib", "1.0.0", "amd64-linux") + dir2, _ := b2.installDir("test/lib", "1.0.0", "arm64-darwin") if dir1 == dir2 { t.Errorf("same installDir for different matrices: %q", dir1) @@ -265,10 +265,10 @@ func TestBuilder_InstallDir_DifferentMatrices(t *testing.T) { } func TestBuilder_InstallDir_DifferentVersions(t *testing.T) { - b := &Builder{workspaceDir: "/tmp/ws", matrix: "amd64-linux"} + b := &Builder{workspaceDir: "/tmp/ws"} - dir1, _ := b.installDir("test/lib", "1.0.0") - dir2, _ := b.installDir("test/lib", "2.0.0") + dir1, _ := b.installDir("test/lib", "1.0.0", "amd64-linux") + dir2, _ := b.installDir("test/lib", "2.0.0", "amd64-linux") if dir1 == dir2 { t.Errorf("same installDir for different versions: %q", dir1) @@ -277,7 +277,7 @@ func TestBuilder_InstallDir_DifferentVersions(t *testing.T) { func TestBuilder_SaveCache_CreatesDir(t *testing.T) { tmpDir := t.TempDir() - b := &Builder{workspaceDir: tmpDir, matrix: "amd64-linux"} + b := &Builder{workspaceDir: tmpDir} cache := &buildCache{} cache.set("1.0.0", "amd64-linux", &buildEntry{ diff --git a/internal/build/e2e_test.go b/internal/build/e2e_test.go index 6968d0dc..717f155b 100644 --- a/internal/build/e2e_test.go +++ b/internal/build/e2e_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + classfile "github.com/goplus/llar/formula" "github.com/goplus/llar/internal/modules" "github.com/goplus/llar/internal/vcs" "github.com/goplus/llar/mod/module" @@ -128,8 +129,8 @@ func TestE2E_MatrixVariation(t *testing.T) { // Verify each matrix has its own install directory for _, matrix := range matrices { - b := &Builder{workspaceDir: wsDir, matrix: matrix} - dir, _ := b.installDir("test/ctxcheck", "1.0.0") + b := &Builder{workspaceDir: wsDir} + dir, _ := b.installDir("test/ctxcheck", "1.0.0", matrix) if _, err := os.Stat(dir); err != nil { t.Errorf("installDir not created for matrix %q: %v", matrix, err) } @@ -304,8 +305,10 @@ func TestE2E_RealZlibBuild(t *testing.T) { workspaceDir := t.TempDir() b := &Builder{ - store: store, - matrix: matrix, + store: store, + target: classfile.Matrix{Require: map[string][]string{ + "matrix": {matrix}, + }}, workspaceDir: workspaceDir, cache: &localCache{workspaceDir: workspaceDir}, newRepo: func(repoPath string) (vcs.Repo, error) { @@ -333,7 +336,7 @@ func TestE2E_RealZlibBuild(t *testing.T) { } // Verify build artifacts exist in installDir - installDir, _ := b.installDir("madler/zlib", "v1.3.1") + installDir, _ := b.installDir("madler/zlib", "v1.3.1", matrix) // Check static library libDir := filepath.Join(installDir, "lib") @@ -378,8 +381,10 @@ func TestE2E_RealLibpngBuild(t *testing.T) { workspaceDir := t.TempDir() b := &Builder{ - store: store, - matrix: matrix, + store: store, + target: classfile.Matrix{Require: map[string][]string{ + "matrix": {matrix}, + }}, workspaceDir: workspaceDir, cache: &localCache{workspaceDir: workspaceDir}, newRepo: func(repoPath string) (vcs.Repo, error) { @@ -427,7 +432,7 @@ func TestE2E_RealLibpngBuild(t *testing.T) { } // Verify libpng build artifacts - pngInstallDir, _ := b.installDir("pnggroup/libpng", "v1.6.47") + pngInstallDir, _ := b.installDir("pnggroup/libpng", "v1.6.47", matrix) // Check library libDir := filepath.Join(pngInstallDir, "lib") @@ -476,8 +481,10 @@ func TestE2E_RealFreetypeBuild(t *testing.T) { workspaceDir := t.TempDir() b := &Builder{ - store: store, - matrix: matrix, + store: store, + target: classfile.Matrix{Require: map[string][]string{ + "matrix": {matrix}, + }}, workspaceDir: workspaceDir, cache: &localCache{workspaceDir: workspaceDir}, newRepo: func(repoPath string) (vcs.Repo, error) { @@ -518,7 +525,7 @@ func TestE2E_RealFreetypeBuild(t *testing.T) { t.Logf("freetype metadata (from pkg-config): %s", strings.TrimSpace(ftR.Metadata)) // Verify freetype build artifacts - ftInstallDir, _ := b.installDir("freetype/freetype", "VER-2-13-3") + ftInstallDir, _ := b.installDir("freetype/freetype", "VER-2-13-3", matrix) // Check library libDir := filepath.Join(ftInstallDir, "lib") diff --git a/testdata/kodo-e2e/main.go b/testdata/kodo-e2e/main.go index 4533c14a..38388b49 100644 --- a/testdata/kodo-e2e/main.go +++ b/testdata/kodo-e2e/main.go @@ -537,7 +537,7 @@ func (s *suite) build(ctx context.Context, target module.Version, matrix, worksp } builder, err := build.NewBuilder(build.Options{ Store: s.formulas, - MatrixStr: matrix, + Target: targetMatrix, WorkspaceDir: workspaceDir, Cache: c, })